mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-11 07:00:54 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2688dd368 | ||
|
|
13de114f39 | ||
|
|
9016982394 | ||
|
|
cad2cd366c | ||
|
|
a5820e68d7 | ||
|
|
aff939a6d8 | ||
|
|
5e725fbd08 | ||
|
|
1cb9e6f65b | ||
|
|
7a843962e8 | ||
|
|
51220a4942 | ||
|
|
981daa3c2d | ||
|
|
9781c2bb6b | ||
|
|
a7c349835c | ||
|
|
9a48eac3e6 | ||
|
|
9082c684ed | ||
|
|
84235fbc9f | ||
|
|
8accd6221a | ||
|
|
c37eebb59f | ||
|
|
5b0bd2589b | ||
|
|
231e5e50cd | ||
|
|
423b2e1c3e | ||
|
|
b6c5f7c18e | ||
|
|
74320e607f | ||
|
|
d87e3b3b5f | ||
|
|
c4153be8bc | ||
|
|
b5a16a2680 | ||
|
|
f6870da310 | ||
|
|
6c8cda5d7a | ||
|
|
7331d9320a |
No files matched your search
@@ -1 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions and ideas
|
||||
url: https://github.com/spoopyghosty0/frameport/discussions
|
||||
about: Setup help, how-to questions and feature ideas go to Discussions.
|
||||
- name: Docs and FAQ
|
||||
url: https://frameport.app/docs/
|
||||
about: Install steps, game folder layouts and common questions.
|
||||
@@ -211,7 +211,14 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
`FRAMEPORT_DESKTOP=1` skips the Steam-parent watchdog and Steam's display; ensure_host_fixes refreshes entries
|
||||
(older installs, stale ones removed), uninstall/purge remove them. Per app: library entry field `desktop_entry`
|
||||
(default on; patches don't apply to Linux apps) → game page switch → agent `desktop_entry`. Untested on device.
|
||||
- Scanning: SideQuest backups (`<package>/<time>/apk|obb|data`) and AXRB downloads (`AXRB/<app id>/<binary id>/base.apk`
|
||||
+ OBBs/assets; its `patched/`/`*-axrb.apk` PC builds are skipped) are recognised (`sources/quest_dump.py`, FAQ).
|
||||
- Quest/Rift twins stay separate entries, shown and named in Steam "Title (Quest)"/"(Rift)" (`core/titles.py`).
|
||||
- Rename… (game menu "Name and artwork", Library right-click; CLI `frameport rename <pkg> "<name>"|--reset`):
|
||||
`pipeline.rename_game` sets `title` + `title_locked` (+ `auto_title` = the automatic name for reset; rescans, Rift
|
||||
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
|
||||
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
|
||||
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
|
||||
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
|
||||
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
|
||||
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
|
||||
@@ -622,7 +629,10 @@ shortcuts.vdf and writes once more if Steam put its old copy back (`shortcuts_lo
|
||||
auto-repair (`_add_then_play`) crashed (its job got a Job, not a reporter). Uploads resume on a transient OSError.
|
||||
Lepton's Android 11 has no clipboard service (134 services; checked with `podman exec … service check clipboard`):
|
||||
SDL/LÖVE apps crashed at start → `frame.sdl_clipboard` (`apk/dex.py`: in-place dex edit, nops one invoke, fixes
|
||||
the header checksum/signature; verified with LÖVE for Android 11.5, GitHub #24 Dramatic Shape). 2D apps (vr_kind none)
|
||||
the header checksum/signature; verified with LÖVE for Android 11.5, GitHub #24 Dramatic Shape). Godot 4.2-4.4 (Kotlin `as ClipboardManager`,
|
||||
GitHub #134 EndoparasiticVR) → `frame.godot_clipboard` (nops the `Intrinsics.checkNotNull` before that check-cast; Godot's
|
||||
clipboard methods return at once; analysis `godot_clipboard`, ANALYSIS_VERSION 10; checked on real godot-lib/templates
|
||||
4.2.2-4.4.1, not on the device; 4.1 and 4.5+ don't throw). 2D apps (vr_kind none)
|
||||
keep every suggested patch that isn't about VR (`needs_vr = False`), not a fixed list. FramePort on the Frame:
|
||||
`frame/local.py` (127.0.0.1, own key authorized; "This Frame (experimental)"; app data in
|
||||
~/.local/share/frameport-app because ~/.local/share/frameport is the agent's) and Steam library changes wait while
|
||||
@@ -712,7 +722,7 @@ FrameBridge `snapshot=N` (`snapshot_gl.c`, GLES): every N s the left-eye image t
|
||||
own context and saved as `files/fb_snap_0-7.ppm` (quarter size) — headless launches show a black headset view, this
|
||||
shows what the game draws. Vader: Lucasfilm logo (an OBB mp4: video works), then its loading card (portrait + segmented
|
||||
bar) that never advances; the async loader thread sleeps and OBB reads stop (~168 MB of a 2.7 GB pak).
|
||||
**SteamVR overlay apps (agent v75, 2026-10-10):** host OpenVR overlays (VRApplication_Overlay) are composited over Lepton (Quest) games on the Frame (probe checker seen over 4XVR in the headset view, `native/vroverlay_probe`); Windows ones register through Proton's vrclient too (Temporal Reality's Windows build created its overlays; fpsVR never reaches OpenVR under Proton). Detection `analysis/vroverlay.py` (bundled `.vrmanifest` with `is_dashboard_overlay`, else OpenVR + IVROverlay + self-registration markers, engine Other; Rift fingerprint `RIFT_ANALYSIS`), patch `pcvr.vr_overlay` (param autostart), Linux entry fields `vr_overlay`/`vr_overlay_autostart` (game page switch); agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay` (IVRApplications via ctypes, live; manifest `<anchor>/frameport-overlay.vrmanifest`, the app's own key, binary = launch.sh). **SteamVR on the Frame reads only `binary_path_linux_arm`** (binary_path_linux alone is skipped). Overlay apps: no launch test, no Linux Steam-parent watchdog (`FRAMEPORT_OVERLAY`), not a "running game". A Steam overlay-app shortcut and a game shortcut run together. Autostart at the next SteamVR start: not verified yet. Details: docs/FRAME_RUNTIME.md "Overlay apps".
|
||||
**SteamVR overlay apps (agent v75, 2026-10-10):** host OpenVR overlays (VRApplication_Overlay) are composited over Lepton (Quest) games on the Frame (probe checker seen over 4XVR in the headset view, `native/vroverlay_probe`); Windows ones register through Proton's vrclient too (Temporal Reality's Windows build created its overlays; fpsVR never reaches OpenVR under Proton). Detection `analysis/vroverlay.py` (bundled `.vrmanifest` with `is_dashboard_overlay`, else OpenVR + IVROverlay + self-registration markers, engine Other; Rift fingerprint `RIFT_ANALYSIS`), patch `pcvr.vr_overlay` (param autostart), Linux entry fields `vr_overlay`/`vr_overlay_autostart` (game page switch); agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay` (IVRApplications via ctypes, live; manifest `<anchor>/frameport-overlay.vrmanifest`, the app's own key, binary = launch.sh). **SteamVR on the Frame reads only `binary_path_linux_arm`** (binary_path_linux alone is skipped). Overlay apps: no launch test, no Linux Steam-parent watchdog (`FRAMEPORT_OVERLAY`), not a "running game". A Steam overlay-app shortcut and a game shortcut run together. SteamVR's SetApplicationAutoLaunch didn't persist on the dev Frame (the watch never started by itself) → **agent v76: FramePort's own autostart**: the deployment's `overlay.autostart` is the source of truth; `ensure_host_fixes` (`ensure_overlay_service`) keeps the user service `frameport-vr-overlays.service` (`_vr_overlay_watch`, 5 s polls of the known vrserver's /proc stat) only while an overlay app has autostart on (register/unregister, uninstall, purge and the kill switch `~/.local/share/frameport/vr-overlays.disabled` / `FRAMEPORT_NO_OVERLAY_AUTOSTART` update it); a new vrserver (pid + start time, kept in `~/.cache/frameport-vr-overlays.json` across agent updates) → child `_vr_overlay_round`: wait for IVRApplications, 15 s grace, `launch_vr_overlay` for apps not running (folder process or GetApplicationProcessId); log `~/.local/share/frameport/vr-overlays.log`. Dev Frame: adopted the running vrserver, skipped the already running watch; a start after a real SteamVR start/boot is not yet seen. Details: docs/FRAME_RUNTIME.md "Overlay apps".
|
||||
**Lepton's logcat mirror dies (agent v67, 2026-10-09):** occasionally launch.log ends with `logcat: Unexpected EOF!` right after the game starts (about 1 launch in 50, Lepton 2.8.14 and 3.0.5): no dashboard auto-hide, no launch-test result. launch.sh's `_logcat_keeper` then reads `podman exec lepton-steamlaunch-<appid> logcat` itself into launch.log (up to 5 restarts while the game runs).
|
||||
**Double launch (agent v57, 2026-10-06):** a second Play while Lepton still boots (~10 s with nothing to see) made
|
||||
the second Lepton stop the first one's container ("Waiting for steamlaunch-<appid> (PID …) to exit", exit 137
|
||||
@@ -779,6 +789,7 @@ with an index naming modules, `shader_dumps` sends every module of the index's *
|
||||
index lines; no index (fp_spirv) = the old newest-by-mtime 4 MB. One JSON line (~40 MB base64) over the SSH channel
|
||||
is read whole by `Frame.agent` (fine at that size); `_Writer.fit` drops the least recently used modules last, after
|
||||
cutting logs, to stay under the 24 MB zip limit.
|
||||
**Unreal OBB check (GitHub #159, 2026-10-10):** Epic's DownloaderActivity checks OBB name+size (OBBData), then with `bVerifyOBBOnStartUp=true` CRCs the whole OBB; its screens are invisible in Lepton (stuck at `Displayed …/.DownloaderActivity`, triage `unreal-obb-check-stuck`) → `frame.unreal_skip_obb_check` (analysis `unreal_verify_obb`, ANALYSIS_VERSION 11) flips the manifest boolean (`axml.set_meta_data_bool`); skips only the CRC pass. Host-tested only.
|
||||
**Vivox API 31 (GitHub #101, 2026-10-09):** newer Vivox builds (Green Hell VR) call Android 12 AudioManager
|
||||
communication-device methods from `com.vivox.sdk.AudioChangeListener` with no SDK check → NoSuchMethodError on Lepton's
|
||||
Android 11. `frame.vivox_audio_route` (analysis `vivox_api31`, ANALYSIS_VERSION 4) makes every such method return at
|
||||
@@ -795,6 +806,21 @@ table comes from dlsym on that handle; also first DT_NEEDED for its direct gl*/e
|
||||
built single-view twin (view 0, uniforms copied per draw); eglMakeCurrent resets the per-thread cache. Host-tested only
|
||||
(rewriter on all 19 Doom3Quest shaders + glmv.c against a stand-in GL, `tests/test_gl_multiview_fbo.py`); the host's
|
||||
Mesa 23.2 llvmpipe has no OVR_multiview. Headset-verified by Xandrix1987 (2026-10-09: HUD/PDA shown, no fps drop) -> in the Doom3Quest catalog recipe.
|
||||
**BlazeRush (GitHub #57, Klownicle's verified guide, 2026-10-10; FramePort's version headless-tested only):**
|
||||
`frame.blazerush` (1.0.349 only: exec-segment SHA + every word checked, offsets from PT_LOAD) NOPs the avatar-readiness
|
||||
wait (0x4b2dbc), RETs the avatar renderer (0x4b2adc), renames `cache/shader_cache` → `cache/br_shader_v1`. GL shim
|
||||
(now also exports glShaderSource/glLinkProgram/glProgramBinary/glDeleteProgram/glDraw*: pass-through unless a setting
|
||||
is on) `gl_int_attribs` (integer inputs fed via glVertexAttribPointer re-set with glVertexAttribIPointer per draw,
|
||||
reflection cached per EGL context + program, dropped on link/binary/delete) and `gl_highp_markers` (vertex shaders with
|
||||
every '|' marker: mediump→highp except on in/out/uniform lines); host test `tests/test_blazerush.py` with a stand-in
|
||||
GL. FOV: the game reads VrApi props 7/8 right after vrapi_Initialize (no session; the Frame has no
|
||||
XR_EPIC_view_configuration_fov) → the bridge saves the live FOV (also located before tracking, headless: 118.87×120.04
|
||||
on the dev Frame) in `files/framebridge-vrapi-fov.txt` and answers with it at the next start (log `FOV properties: …
|
||||
(saved by an earlier session)`). `device.config_sync` (catalog field `config_sync`, agent v78): user_config.xml
|
||||
r_3dwidth/height, r_width/height from the runtime eye size (FrameBridge logs `runtime recommended eye size`) × scale,
|
||||
written at finalize, set_settings and by launch.sh before Lepton starts (`_config_sync`). Headless runs at scale 1.7
|
||||
end in `zink: DEVICE LOST` right after the bridge's 30 s no-tracking deadline tears the session down (gpu fault at the
|
||||
teardown; not at scale 1.0): a headless-only artifact, the headset never hits that deadline.
|
||||
**Session triage (agent v70, 2026-10-09):** launch tests never reach FOCUSED, so real play sessions are triaged
|
||||
too. list_installed gives each game `last_play` {start, end, test} from `<anchor>/plays.log` (launch tests write a
|
||||
`test <unix>` line first → test sessions are only marked); `session_log` returns the newest session's launch.log
|
||||
@@ -847,6 +873,10 @@ up to 4 s for the link file to be taken before trusting the heartbeat, the windo
|
||||
`gui.alive`, and the Windows command runs under `conhost.exe --headless` (plain `-WindowStyle Hidden` flashed a
|
||||
console). Screens: `scripts/ui_smoke.py --links --fake-frame --game <pkg>` (tall pages: `--viewport
|
||||
1280x7000`; Flutter's popup menu ignores Escape).
|
||||
Demo link: the homepage's example button = `deeplink.DEMO_MANIFEST` (https://frameport.app/demo/cool-game.json, published
|
||||
from site/public/demo/); every form (https button page, frameport://, framedrop://, pasted) → `InstallRequest.demo`,
|
||||
no network (fetch_manifest returns `demo_manifest()`, download refuses), `link_dialog.show_demo` (Install =
|
||||
`easter.demo_install`: Cool Game's cover out of the portal; eggs off → "Nice, it works!"), CLI just says so.
|
||||
|
||||
## Releases, CI, GitHub
|
||||
Maintainer-only notes (accounts, credentials, key locations) live in the git-ignored `CLAUDE.local.md`.
|
||||
|
||||
+531
-8
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 75
|
||||
AGENT_VERSION = 78
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -737,6 +737,12 @@ def ensure_host_fixes():
|
||||
overlays = []
|
||||
if overlays:
|
||||
changed.append(f"SteamVR overlay apps registered ({len(overlays)})")
|
||||
try:
|
||||
service = ensure_overlay_service()
|
||||
except Exception: # noqa: BLE001
|
||||
service = None
|
||||
if service:
|
||||
changed.append(f"overlay autostart service {service}")
|
||||
return changed
|
||||
|
||||
|
||||
@@ -944,7 +950,15 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
|
||||
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)
|
||||
# matched by Exe; a launcher many games share (Revive's injector on a PC) also by the game in its launch options,
|
||||
# or two Revive games overwrote each other's shortcut
|
||||
shared = "reviveinjector" in exe.lower()
|
||||
|
||||
def game_of(options): # the game's program in the injector's arguments (its flags may change)
|
||||
found = re.findall(r'"([^"]+\.exe)"|(\S+\.exe)', options or "", re.IGNORECASE)
|
||||
return (found[-1][0] or found[-1][1]).lower() if found else (options or "")
|
||||
entry = next((v for v in shortcuts.values() if isinstance(v, dict) and v.get("Exe") == exe
|
||||
and (not shared or game_of(v.get("LaunchOptions", "")) == game_of(launch_options))), None)
|
||||
ident = entry["appid"] if entry else shortcut_appid(exe, title)
|
||||
if entry is None:
|
||||
entry = {"appid": ident, "LastPlayTime": 0, "tags": {"0": tag}}
|
||||
@@ -2513,6 +2527,7 @@ def cmd_register_vr_overlay(args):
|
||||
out = register_overlay(pkg, bool(args.get("autostart")))
|
||||
dep["overlay"]["registered"] = out["registered"]
|
||||
write_deployment(pkg, dep)
|
||||
out["service"] = overlay_service_quiet()
|
||||
return out
|
||||
|
||||
|
||||
@@ -2523,6 +2538,7 @@ def cmd_unregister_vr_overlay(args):
|
||||
if dep and "overlay" in dep:
|
||||
dep.pop("overlay")
|
||||
write_deployment(pkg, dep)
|
||||
overlay_service_quiet()
|
||||
return {"unregistered": True, "steamvr": reached}
|
||||
|
||||
|
||||
@@ -2554,6 +2570,15 @@ def cmd_launch_vr_overlay(args):
|
||||
return {"started": not err, "pid": pid or None, "error": err}
|
||||
|
||||
|
||||
def overlay_service_quiet():
|
||||
"""ensure_overlay_service() after an overlay app's autostart flag changed; a systemd problem never fails the
|
||||
command (ensure_host_fixes tries again at the next connection)."""
|
||||
try:
|
||||
return ensure_overlay_service()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"error: {exc}"
|
||||
|
||||
|
||||
def overlay_registrations():
|
||||
"""Register overlay apps whose registration is missing (SteamVR wasn't running at install, or forgot it)."""
|
||||
done = []
|
||||
@@ -2581,6 +2606,259 @@ def overlay_registrations():
|
||||
return done
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ overlay autostart (v76)
|
||||
# SteamVR's own auto-launch flag (SetApplicationAutoLaunch) was accepted on the dev Frame but never written to
|
||||
# steamvr.vrsettings, and a registered watch didn't start by itself (2026-10-10). FramePort's deployment flag
|
||||
# (overlay.autostart) is the source of truth: a small user service (only while at least one installed overlay app has
|
||||
# autostart on) notices every new vrserver process (SteamVR start or restart, boot), waits until SteamVR answers,
|
||||
# gives SteamVR's own auto-launch a moment, then starts each autostart overlay app that isn't running yet, the same
|
||||
# way launch_vr_overlay does (LaunchApplication: SteamVR spawns it, in its own environment). Off for every app: the
|
||||
# flag file VR_OVERLAY_DISABLED or FRAMEPORT_NO_OVERLAY_AUTOSTART=1 (the service is then removed).
|
||||
VR_OVERLAY_UNIT = "frameport-vr-overlays"
|
||||
SYSTEMD_USER_DIR = os.path.join(HOME, ".config/systemd/user")
|
||||
VR_OVERLAY_UNIT_PATH = os.path.join(SYSTEMD_USER_DIR, VR_OVERLAY_UNIT + ".service")
|
||||
VR_OVERLAY_DISABLED = os.path.join(HOME, ".local/share/frameport/vr-overlays.disabled")
|
||||
VR_OVERLAY_LOG = os.path.join(HOME, ".local/share/frameport/vr-overlays.log")
|
||||
VR_OVERLAY_STATE = os.path.join(HOME, ".cache/frameport-vr-overlays.json")
|
||||
VR_OVERLAY_LOG_MAX = 128 * 1024
|
||||
VR_OVERLAY_POLL = 5 # s between /proc scans (nothing else happens while SteamVR keeps running)
|
||||
VR_OVERLAY_READY_WAIT = 180 # s for a new SteamVR to answer IVRApplications
|
||||
VR_OVERLAY_GRACE = 15 # s after SteamVR answers: its own auto-launch may start the app first
|
||||
|
||||
|
||||
def overlay_autostart_disabled():
|
||||
return os.environ.get("FRAMEPORT_NO_OVERLAY_AUTOSTART", "0") not in ("", "0") or \
|
||||
os.path.exists(VR_OVERLAY_DISABLED)
|
||||
|
||||
|
||||
def autostart_overlays():
|
||||
"""Installed overlay apps whose deployment has autostart on: [(package, deployment)]."""
|
||||
found = []
|
||||
for dep_path in sorted(glob.glob(os.path.join(ANCHORS, "*/deployment.json"))):
|
||||
try:
|
||||
dep = json.load(open(dep_path))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if isinstance(dep, dict) and dep.get("package") and (dep.get("overlay") or {}).get("autostart"):
|
||||
dep.setdefault("base", os.path.dirname(dep_path))
|
||||
found.append((dep["package"], dep))
|
||||
return found
|
||||
|
||||
|
||||
def systemd_quote(arg):
|
||||
"""One ExecStart= word (systemd's own quoting: double quotes, % and $ doubled)."""
|
||||
arg = arg.replace("%", "%%").replace("$", "$$")
|
||||
if re.fullmatch(r"[A-Za-z0-9_./+:=@-]+", arg):
|
||||
return arg
|
||||
return '"' + arg.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def vr_overlay_unit():
|
||||
"""The user service's unit text (the agent's path and Python are written in: the agent lives in AGENT_HOME)."""
|
||||
agent = os.path.abspath(__file__)
|
||||
return ("[Unit]\n"
|
||||
"Description=FramePort: start SteamVR overlay apps with SteamVR\n"
|
||||
"\n[Service]\n"
|
||||
"Type=simple\n"
|
||||
f"ExecStart={systemd_quote(sys.executable or '/usr/bin/python3')} {systemd_quote(agent)} "
|
||||
"_vr_overlay_watch\n"
|
||||
"Restart=always\n"
|
||||
"RestartSec=10\n"
|
||||
"Nice=10\n"
|
||||
"\n[Install]\n"
|
||||
"WantedBy=default.target\n")
|
||||
|
||||
|
||||
def user_systemctl(*args):
|
||||
return run(["systemctl", "--user", *args])
|
||||
|
||||
|
||||
def ensure_overlay_service():
|
||||
"""Install (or update) the overlay autostart service when an installed overlay app has autostart on, remove it
|
||||
when none has (or the kill switch is set). Returns "installed", "updated", "removed" or None (no change)."""
|
||||
want = bool(autostart_overlays()) and not overlay_autostart_disabled()
|
||||
text = vr_overlay_unit()
|
||||
try:
|
||||
current = open(VR_OVERLAY_UNIT_PATH).read()
|
||||
except OSError:
|
||||
current = None
|
||||
if not want:
|
||||
if current is None:
|
||||
return None
|
||||
user_systemctl("disable", "--now", VR_OVERLAY_UNIT + ".service")
|
||||
try:
|
||||
os.remove(VR_OVERLAY_UNIT_PATH)
|
||||
except OSError:
|
||||
pass
|
||||
user_systemctl("daemon-reload")
|
||||
user_systemctl("reset-failed", VR_OVERLAY_UNIT + ".service")
|
||||
return "removed"
|
||||
if current == text:
|
||||
if user_systemctl("is-active", "--quiet", VR_OVERLAY_UNIT + ".service").returncode == 0:
|
||||
return None
|
||||
user_systemctl("enable", "--now", VR_OVERLAY_UNIT + ".service")
|
||||
return "started"
|
||||
os.makedirs(SYSTEMD_USER_DIR, exist_ok=True)
|
||||
tmp = VR_OVERLAY_UNIT_PATH + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(text)
|
||||
os.replace(tmp, VR_OVERLAY_UNIT_PATH)
|
||||
user_systemctl("daemon-reload")
|
||||
user_systemctl("enable", VR_OVERLAY_UNIT + ".service")
|
||||
user_systemctl("restart", VR_OVERLAY_UNIT + ".service")
|
||||
return "installed" if current is None else "updated"
|
||||
|
||||
|
||||
def overlay_log(msg):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(VR_OVERLAY_LOG), exist_ok=True)
|
||||
if os.path.exists(VR_OVERLAY_LOG) and os.path.getsize(VR_OVERLAY_LOG) > VR_OVERLAY_LOG_MAX:
|
||||
os.replace(VR_OVERLAY_LOG, VR_OVERLAY_LOG + ".1")
|
||||
with open(VR_OVERLAY_LOG, "a") as f:
|
||||
f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + msg + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def vrserver_ident(pid):
|
||||
""""<pid>:<start ticks>" when process `pid` is a vrserver (the start time tells a reused pid apart), else None."""
|
||||
try:
|
||||
with open(f"/proc/{pid}/stat") as f:
|
||||
stat = f.read()
|
||||
except OSError:
|
||||
return None
|
||||
if stat[stat.find("(") + 1:stat.rfind(")")] != "vrserver":
|
||||
return None
|
||||
return f"{pid}:{stat[stat.rfind(')') + 2:].split()[19]}"
|
||||
|
||||
|
||||
def vrserver_process(known=None):
|
||||
"""The running vrserver's vrserver_ident, None if none. `known` (the last one seen) is checked first: while
|
||||
SteamVR keeps running a poll reads one file instead of scanning /proc."""
|
||||
if known and vrserver_ident(known.split(":")[0]) == known:
|
||||
return known
|
||||
for d in os.listdir("/proc"):
|
||||
if d.isdigit():
|
||||
found = vrserver_ident(d)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def overlay_running(pkg, dep, apps=None):
|
||||
"""The overlay app runs already: a process of its install folder or launcher (also when Steam or SteamVR started
|
||||
it), or SteamVR knows its process (GetApplicationProcessId)."""
|
||||
if pcvr_pids(dep.get("base") or os.path.join(ANCHORS, pkg)) or pcvr_pids(os.path.join(ANCHORS, pkg)):
|
||||
return True
|
||||
if apps is not None:
|
||||
try:
|
||||
return bool(apps.pid(dep_overlay_key(pkg, dep)))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def overlay_autostart_round():
|
||||
"""One new SteamVR: wait until it answers, give its own auto-launch a moment, then start each autostart overlay
|
||||
app that isn't running. Returns {package: "started" | "running" | error text}; {} without SteamVR."""
|
||||
deadline = time.time() + VR_OVERLAY_READY_WAIT
|
||||
apps = SteamVRApps.open()
|
||||
while apps is None and time.time() < deadline:
|
||||
time.sleep(3)
|
||||
apps = SteamVRApps.open()
|
||||
if apps is None:
|
||||
overlay_log("SteamVR didn't answer: nothing started")
|
||||
return {}
|
||||
try:
|
||||
time.sleep(VR_OVERLAY_GRACE)
|
||||
todo = [(pkg, dep) for pkg, dep in autostart_overlays() if not overlay_running(pkg, dep, apps)]
|
||||
result = {pkg: "running" for pkg, _ in autostart_overlays() if pkg not in dict(todo)}
|
||||
finally:
|
||||
apps.close()
|
||||
for pkg, _ in todo:
|
||||
try:
|
||||
r = cmd_launch_vr_overlay({"package": pkg})
|
||||
result[pkg] = "started" if r.get("started") else (r.get("error") or "not started")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result[pkg] = str(exc) or type(exc).__name__
|
||||
return result
|
||||
|
||||
|
||||
def overlay_watch_tick(state):
|
||||
"""One poll of the watcher: a vrserver not handled before (state["vrserver"]) gets one autostart round. Returns
|
||||
the round's result, or None when there was nothing to do."""
|
||||
if overlay_autostart_disabled():
|
||||
return None
|
||||
proc = vrserver_process(state.get("vrserver"))
|
||||
if not proc or proc == state.get("vrserver"):
|
||||
return None
|
||||
state["vrserver"] = proc
|
||||
overlay_save_state(state) # a restarted watcher (agent update) doesn't start apps the user closed again
|
||||
if not autostart_overlays():
|
||||
return None
|
||||
overlay_log(f"new SteamVR (vrserver {proc.split(':')[0]})")
|
||||
result = run_overlay_round()
|
||||
for pkg, what in result.items():
|
||||
overlay_log(f"{pkg}: {what}" if what in ("started", "running") else f"{pkg}: couldn't start it: {what}")
|
||||
state["last"] = {"time": time.time(), "result": result}
|
||||
overlay_save_state(state)
|
||||
return result
|
||||
|
||||
|
||||
def run_overlay_round():
|
||||
"""overlay_autostart_round() in a child process (_vr_overlay_round): OpenVR is loaded only there, and a SteamVR
|
||||
that never answers can't hang the watcher (timeout)."""
|
||||
try:
|
||||
p = subprocess.run([sys.executable, os.path.abspath(__file__), "_vr_overlay_round"], capture_output=True,
|
||||
text=True, errors="replace", timeout=VR_OVERLAY_READY_WAIT + VR_OVERLAY_GRACE + 120)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"*": "timed out"}
|
||||
try:
|
||||
out = json.loads(p.stdout.strip().splitlines()[-1])
|
||||
return out if isinstance(out, dict) else {}
|
||||
except (ValueError, IndexError):
|
||||
return {"*": (p.stderr.strip().splitlines() or [f"exit {p.returncode}"])[-1]}
|
||||
|
||||
|
||||
def overlay_save_state(state):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(VR_OVERLAY_STATE), exist_ok=True)
|
||||
with open(VR_OVERLAY_STATE + ".tmp", "w") as f:
|
||||
json.dump(state, f)
|
||||
os.replace(VR_OVERLAY_STATE + ".tmp", VR_OVERLAY_STATE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def vr_overlay_watch():
|
||||
"""The service's loop (_vr_overlay_watch): a /proc scan every VR_OVERLAY_POLL s. Exits when its own file changes
|
||||
(an agent update: systemd starts the new one)."""
|
||||
me = os.path.abspath(__file__)
|
||||
try:
|
||||
mine = os.path.getmtime(me)
|
||||
except OSError:
|
||||
mine = None
|
||||
try:
|
||||
state = json.load(open(VR_OVERLAY_STATE))
|
||||
state = state if isinstance(state, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
state = {}
|
||||
overlay_log(f"watching (agent v{AGENT_VERSION}, overlay apps with autostart: "
|
||||
f"{', '.join(p for p, _ in autostart_overlays()) or 'none'})")
|
||||
while True:
|
||||
try:
|
||||
if os.path.getmtime(me) != mine:
|
||||
overlay_log("agent updated: restarting")
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
overlay_watch_tick(state)
|
||||
except Exception as exc: # noqa: BLE001 (keep watching)
|
||||
overlay_log(f"error: {exc}")
|
||||
time.sleep(VR_OVERLAY_POLL)
|
||||
|
||||
|
||||
def cmd_prepare(args):
|
||||
"""Where to upload, and what the Frame already has (so unchanged data is not re-sent)."""
|
||||
pkg = check_pkg(args["package"])
|
||||
@@ -2724,6 +3002,8 @@ def upgrade_launchers():
|
||||
if "export LEPTON_ENV_FRAMEBRIDGE_CONFIG=" in new:
|
||||
new = set_codec_line(new, wants_hw_video(os.path.dirname(path)))
|
||||
new = set_gamepad_line(new, wants_gamepad(os.path.dirname(path)))
|
||||
new = set_config_sync_line(new, os.path.basename(os.path.dirname(path)),
|
||||
wants_config_sync(os.path.dirname(path)))
|
||||
if "a Linux app. Generated by FramePort" in new and "FRAMEPORT_DESKTOP" not in new:
|
||||
new = upgrade_linux_launcher(new)
|
||||
if OLD_WATCHDOG in new and "parent=$PPID" not in new:
|
||||
@@ -3078,7 +3358,7 @@ def upgrade_linux_launcher(text):
|
||||
return text
|
||||
|
||||
|
||||
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False):
|
||||
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False, config_sync=False):
|
||||
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
|
||||
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
|
||||
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid,
|
||||
@@ -3087,6 +3367,7 @@ def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False,
|
||||
video_codec=wrapper_lines(hw_video, gamepad),
|
||||
plays_start=plays_lines(anchor)[0],
|
||||
plays_end=plays_lines(anchor)[1])
|
||||
text = set_config_sync_line(text, pkg, config_sync)
|
||||
path = os.path.join(anchor, "launch.sh")
|
||||
with open(path + ".tmp", "w") as f:
|
||||
f.write(text)
|
||||
@@ -3098,6 +3379,187 @@ def data_files_dir(base, pkg):
|
||||
return os.path.join(base, "lepton-data/external/Android/data", pkg, "files")
|
||||
|
||||
|
||||
# ------------------------------------------------- saved game settings in step with the eye buffers (agent v78)
|
||||
# device.config_sync (per game, from its recipe; deployment.json "config_sync": {file relative to the game's
|
||||
# Android/data/<pkg>/files: {setting: expression}}). Some games save their render resolution and load it after they
|
||||
# created the headset's eye buffers: BlazeRush creates 2938x2938 buffers at scale 1.7, then loads its saved
|
||||
# user_config.xml (r_3dwidth/r_3dheight 1728, r_width/r_height 3072x1728) and draws a displaced, stretched picture with
|
||||
# black borders (Klownicle's guide, GitHub #57). The values are written while the game is stopped: at finalize, after
|
||||
# a Game settings change, and by the launcher before Lepton starts (the game rewrites its file when it exits, so a scale
|
||||
# changed while it ran is caught at the next start). The eye buffer = the runtime's recommended size (learned from the
|
||||
# game's last launch.log: FrameBridge logs it; older builds: the bridge's stereo= size divided by the logged scale)
|
||||
# times settings.conf's scale, rounded like FrameBridge does. Only existing numeric entries change; the previous file
|
||||
# is kept as <file>.frameport-backup and the new one replaces it atomically.
|
||||
CONFIG_EXPR_RE = re.compile(r"\s*([wh])\s*(?:\*\s*(\d+(?:\.\d+)?))?\s*(?:/\s*(\d+(?:\.\d+)?))?\s*")
|
||||
CONFIG_SYNC_LINE = 'python3 {agent_q} _config_sync {pkg_q} >"$app_dir/config-sync.log" 2>&1 9>&- || true'
|
||||
CONFIG_BACKUP = ".frameport-backup"
|
||||
RUNTIME_EYE_RE = re.compile(r"FrameBridge: runtime recommended eye size (\d+)x(\d+)")
|
||||
BRIDGE_STEREO_RE = re.compile(r"OVRPortVrApi: Initialized .*?stereo=(\d+)x(\d+)")
|
||||
ADAPTER_VIEW_RE = re.compile(r"FrameBridge: view 0 recommended (\d+)x(\d+)")
|
||||
ADAPTER_SCALE_RE = re.compile(r"FrameBridge: scale=([0-9.]+) ")
|
||||
|
||||
|
||||
def config_value(expr, w, h):
|
||||
"""An expression of the eye-buffer size: w, h, optionally *a and/or /b (for example h*16/9), rounded."""
|
||||
m = CONFIG_EXPR_RE.fullmatch(str(expr))
|
||||
if not m:
|
||||
raise AgentError(f"bad config_sync expression {expr!r}")
|
||||
value = (w if m.group(1) == "w" else h) * float(m.group(2) or 1) / float(m.group(3) or 1)
|
||||
if not 0 < value < 1e6:
|
||||
raise AgentError(f"config_sync expression {expr!r} out of range")
|
||||
return int(value + 0.5)
|
||||
|
||||
|
||||
def settings_scale(base):
|
||||
"""FrameBridge's resolution scale for this game (settings.conf; out-of-range values are ignored like FrameBridge
|
||||
does)."""
|
||||
scale = 1.0
|
||||
try:
|
||||
for line in open(os.path.join(base, "settings.conf")):
|
||||
k, _, v = line.strip().partition("=")
|
||||
if k == "scale":
|
||||
try:
|
||||
value = float(v)
|
||||
except ValueError:
|
||||
continue
|
||||
if 0.5 <= value <= 2.0:
|
||||
scale = value
|
||||
except OSError:
|
||||
pass
|
||||
return scale
|
||||
|
||||
|
||||
def runtime_eye_size(base):
|
||||
"""(width, height) the Frame's runtime recommends per eye before FrameBridge's scale, from the game's last
|
||||
launch.log / session.log, or None."""
|
||||
for name in ("launch.log", "session.log"):
|
||||
try:
|
||||
with open(os.path.join(base, name), errors="replace") as f:
|
||||
text = f.read(16 << 20)
|
||||
except OSError:
|
||||
continue
|
||||
found = RUNTIME_EYE_RE.findall(text)
|
||||
if found:
|
||||
return int(found[-1][0]), int(found[-1][1])
|
||||
scales = ADAPTER_SCALE_RE.findall(text)
|
||||
sized = BRIDGE_STEREO_RE.findall(text) or ADAPTER_VIEW_RE.findall(text)
|
||||
if sized and scales:
|
||||
try:
|
||||
scale = float(scales[-1])
|
||||
except ValueError:
|
||||
continue
|
||||
if 0.5 <= scale <= 2.0:
|
||||
return int(int(sized[-1][0]) / scale + 0.5), int(int(sized[-1][1]) / scale + 0.5)
|
||||
return None
|
||||
|
||||
|
||||
def sync_config_text(text, values):
|
||||
"""(new text, {name: (old, new)}, [names not found]) with the numeric value of every named setting replaced; the
|
||||
rest of the text stays byte for byte. Matches <x name="NAME" ... value="V">, <x value="V" ... name="NAME">,
|
||||
<NAME value="V"/> and <NAME>V</NAME>."""
|
||||
changed, missing = {}, []
|
||||
num = r"-?\d+(?:\.\d+)?"
|
||||
for name, new in values.items():
|
||||
n = re.escape(name)
|
||||
patterns = (rf"""(<[^<>]*\bname\s*=\s*["']{n}["'][^<>]*?\bvalue\s*=\s*["'])({num})(["'])""",
|
||||
rf"""(<[^<>]*?\bvalue\s*=\s*["'])({num})(["'][^<>]*\bname\s*=\s*["']{n}["'])""",
|
||||
rf"""(<{n}\b[^<>]*?\bvalue\s*=\s*["'])({num})(["'])""",
|
||||
rf"""(<{n}>\s*)({num})(\s*</{n}>)""")
|
||||
for pattern in patterns:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
old = m.group(2)
|
||||
if old != str(new):
|
||||
text = text[:m.start(2)] + str(new) + text[m.end(2):]
|
||||
changed[name] = (old, str(new))
|
||||
break
|
||||
else:
|
||||
missing.append(name)
|
||||
return text, changed, missing
|
||||
|
||||
|
||||
def sync_game_config(pkg, dep, check_running=True):
|
||||
"""Write the eye-buffer size into the game's saved settings (device.config_sync). Returns a report (None when the
|
||||
game has no rules)."""
|
||||
rules = (dep or {}).get("config_sync") or {}
|
||||
if not isinstance(rules, dict) or not rules:
|
||||
return None
|
||||
base = dep["base"]
|
||||
if check_running and dep.get("appid") and container_running(dep["appid"]):
|
||||
return {"skipped": "the game is running (its next start corrects it)"}
|
||||
runtime = runtime_eye_size(base)
|
||||
if runtime and runtime != tuple(dep.get("eye_base") or ()):
|
||||
dep["eye_base"] = list(runtime)
|
||||
try:
|
||||
write_deployment(pkg, dep)
|
||||
except OSError:
|
||||
pass
|
||||
runtime = runtime or tuple(dep.get("eye_base") or ())
|
||||
if len(runtime) != 2:
|
||||
return {"skipped": "the Frame's eye-buffer size isn't known yet (it is after the game's first start)"}
|
||||
scale = settings_scale(base)
|
||||
w, h = int(runtime[0] * scale + 0.5), int(runtime[1] * scale + 0.5)
|
||||
files_dir = os.path.realpath(data_files_dir(base, pkg))
|
||||
out = {"eye": [w, h], "runtime": list(runtime), "scale": scale, "changed": {}, "missing": [], "files": []}
|
||||
for rel, exprs in rules.items():
|
||||
target = os.path.realpath(os.path.join(files_dir, str(rel)))
|
||||
if not target.startswith(files_dir + os.sep) or not isinstance(exprs, dict):
|
||||
out["missing"].append(str(rel))
|
||||
continue
|
||||
if not os.path.isfile(target):
|
||||
out["files"].append({"file": rel, "exists": False}) # the game writes it itself, from the right size
|
||||
continue
|
||||
values = {str(k): config_value(v, w, h) for k, v in exprs.items()}
|
||||
with open(target, encoding="utf-8", errors="surrogateescape", newline="") as f:
|
||||
text = f.read()
|
||||
new, changed, missing = sync_config_text(text, values)
|
||||
out["missing"] += [f"{rel}:{k}" for k in missing]
|
||||
out["files"].append({"file": rel, "exists": True, "changed": sorted(changed)})
|
||||
if not changed:
|
||||
continue
|
||||
shutil.copy2(target, target + CONFIG_BACKUP)
|
||||
tmp = target + ".frameport-tmp"
|
||||
with open(tmp, "w", encoding="utf-8", errors="surrogateescape", newline="") as f:
|
||||
f.write(new)
|
||||
shutil.copymode(target, tmp)
|
||||
os.replace(tmp, target)
|
||||
out["changed"].update(changed)
|
||||
return out
|
||||
|
||||
|
||||
def wants_config_sync(anchor):
|
||||
try:
|
||||
with open(os.path.join(anchor, "deployment.json")) as f:
|
||||
dep = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return isinstance(dep, dict) and isinstance(dep.get("config_sync"), dict) and bool(dep["config_sync"])
|
||||
|
||||
|
||||
def set_config_sync_line(text, pkg, want):
|
||||
"""A Lepton launcher that brings the game's saved settings in step before Lepton starts (want), or without it."""
|
||||
text = "".join(line for line in text.splitlines(True) if " _config_sync " not in line)
|
||||
if not want or "\nsetsid " not in text:
|
||||
return text
|
||||
line = CONFIG_SYNC_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg))
|
||||
return text.replace("\nsetsid ", f"\n{line}\nsetsid ", 1)
|
||||
|
||||
|
||||
def config_sync_worker(pkg):
|
||||
"""launch.sh, before Lepton starts (the game isn't running: the launcher holds its launch lock)."""
|
||||
dep = deployment(check_pkg(pkg))
|
||||
print(json.dumps({"time": time.time(), "result": sync_game_config(pkg, dep, check_running=False) if dep else None}))
|
||||
|
||||
|
||||
def cmd_config_sync(args):
|
||||
"""Bring a game's saved settings in step with the eye buffers now (device.config_sync), unless it runs."""
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg)
|
||||
if not dep:
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
return sync_game_config(pkg, dep)
|
||||
|
||||
|
||||
def cmd_video_codec_status(args):
|
||||
"""The shared codec installed on this Frame ({digest, revision}, {} if none or damaged) and whether FramePort's
|
||||
setting switched it off for every game ("disabled")."""
|
||||
@@ -3291,19 +3753,25 @@ def cmd_finalize(args):
|
||||
gamepad = GAMEPAD_PATCH in (recipe.get("patches") or []) # Steam Input's virtual gamepad (PODMAN_WRAPPER)
|
||||
if gamepad:
|
||||
ensure_podman_wrapper()
|
||||
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad)
|
||||
config_sync = args.get("config_sync") if isinstance(args.get("config_sync"), dict) else {}
|
||||
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad, bool(config_sync))
|
||||
art_in = os.path.join(base, "incoming-artwork")
|
||||
if os.path.isdir(art_in):
|
||||
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
|
||||
shutil.move(art_in, os.path.join(anchor, "artwork"))
|
||||
previous = deployment(pkg) or {}
|
||||
dep = {"package": pkg, "appid": int(appid), "base": base, "title": title, "tags": args.get("tags") or [],
|
||||
"apk": args.get("apk_name", "game.apk"),
|
||||
"sha256": args.get("apk_sha256"), "recipe": args.get("recipe"), "installed_by": "frameport",
|
||||
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "agent_version": AGENT_VERSION,
|
||||
"time": time.time()}
|
||||
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "config_sync": config_sync,
|
||||
"agent_version": AGENT_VERSION, "time": time.time()}
|
||||
if previous.get("eye_base"): # the runtime's eye size learned from an earlier start (device.config_sync)
|
||||
dep["eye_base"] = previous["eye_base"]
|
||||
with open(os.path.join(anchor, "deployment.json"), "w") as f:
|
||||
json.dump(dep, f, indent=2)
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models}
|
||||
synced = sync_game_config(pkg, dep)
|
||||
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models,
|
||||
"config_sync": synced}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- Steam Frame controller models (XR_FB_render_model)
|
||||
@@ -4561,7 +5029,43 @@ def cmd_set_settings(args):
|
||||
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
|
||||
f.write(text)
|
||||
models = install_controller_models(files_dir, current.get("controller_models", "0") not in ("0", "0.0"))
|
||||
return {"settings": current, "controller_models": models}
|
||||
return {"settings": current, "controller_models": models, "config_sync": sync_game_config(pkg, dep)}
|
||||
|
||||
|
||||
TITLE_MAX = 128
|
||||
|
||||
|
||||
def clean_title(title):
|
||||
"""A game's name for its Steam shortcut: one line, no control characters, at most TITLE_MAX characters."""
|
||||
text = " ".join("".join(c if c.isprintable() else " " for c in str(title or "")).split())
|
||||
if not text:
|
||||
raise AgentError("the new name is empty")
|
||||
return text[:TITLE_MAX]
|
||||
|
||||
|
||||
def cmd_rename(args):
|
||||
"""Rename an installed game (agent v77): deployment.json's title is what its Steam shortcut shows. The shortcut
|
||||
keeps its appid (upsert_shortcut matches by Exe, deployment.json keeps the appid), so Play, its grid art, launch
|
||||
tests and the game's container stay the same. shortcuts (default on) rewrites the library entry like an install
|
||||
does (Steam restarts once, after a running game or Desktop Mode). A devkit entry is named by its id: unchanged."""
|
||||
pkg = check_pkg(args["package"])
|
||||
title = clean_title(args.get("title"))
|
||||
dep = deployment(pkg)
|
||||
if not dep or not dep.get("appid"):
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
old = dep.get("title") or pkg
|
||||
out = {"package": pkg, "title": title, "old": old, "appid": dep["appid"], "renamed": old != title}
|
||||
if out["renamed"]:
|
||||
dep["title"] = title
|
||||
write_deployment(pkg, dep)
|
||||
if (dep.get("overlay") or {}).get("registered"): # its SteamVR manifest shows the name too
|
||||
try:
|
||||
register_overlay(pkg, bool(dep["overlay"].get("autostart")))
|
||||
except Exception as exc: # noqa: BLE001 - the shortcut matters more
|
||||
out["overlay_error"] = str(exc)
|
||||
out["shortcuts"] = cmd_shortcuts({"packages": [pkg], "restart": True}) \
|
||||
if args.get("shortcuts", True) and out["renamed"] else {"started": False}
|
||||
return out
|
||||
|
||||
|
||||
def cmd_uninstall(args):
|
||||
@@ -4607,6 +5111,8 @@ def cmd_uninstall(args):
|
||||
for name in ("deployment.json", "launch.sh", "artwork", "launch.log", "launch-test.log", "session.log"):
|
||||
p = os.path.join(anchor, name)
|
||||
remove_tree(p)
|
||||
if is_overlay(dep):
|
||||
overlay_service_quiet() # the last autostart overlay app gone: the service goes too
|
||||
return {"removed": True, "kept_saves": keep_data, "shortcut_removed": removed_sc}
|
||||
|
||||
|
||||
@@ -5560,6 +6066,14 @@ def purge_worker(payload):
|
||||
result["removed"].append(f"SteamVR overlay: {d.get('title')}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{d.get('title')}: {exc}")
|
||||
try: # the overlay autostart service runs this agent: it goes before AGENT_HOME does
|
||||
if os.path.exists(VR_OVERLAY_UNIT_PATH):
|
||||
user_systemctl("disable", "--now", VR_OVERLAY_UNIT + ".service")
|
||||
os.remove(VR_OVERLAY_UNIT_PATH)
|
||||
user_systemctl("daemon-reload")
|
||||
result["removed"].append(VR_OVERLAY_UNIT_PATH)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"overlay autostart service: {exc}")
|
||||
try:
|
||||
service = stop_steam()
|
||||
except AgentError as exc:
|
||||
@@ -6794,6 +7308,12 @@ COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
|
||||
def main():
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_keyboard":
|
||||
return keyboard_session(sys.stdin, sys.stdout)
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_watch":
|
||||
vr_overlay_watch()
|
||||
return
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_vr_overlay_round":
|
||||
print(json.dumps(overlay_autostart_round()), flush=True)
|
||||
return
|
||||
if len(sys.argv) >= 2 and sys.argv[1] == "_monitor":
|
||||
return monitor_session(sys.stdin, sys.stdout)
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_shortcuts_worker":
|
||||
@@ -6814,6 +7334,9 @@ def main():
|
||||
if len(sys.argv) >= 4 and sys.argv[1] == "_dashboard_worker":
|
||||
dashboard_worker(sys.argv[2], sys.argv[3])
|
||||
return 0
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_config_sync":
|
||||
config_sync_worker(sys.argv[2])
|
||||
return 0
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_tools_worker":
|
||||
tools_worker(sys.argv[2])
|
||||
return 0
|
||||
|
||||
@@ -6,11 +6,11 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
|
||||
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
fc559dbe0b87eecc3641256837293260cefb7c2ce7b21d808d10edd2a2dba9fc ./arm64-v8a/libglshim.so
|
||||
1655921f4c91f2f336edf0aac66d8df1b2b649a272aafe365c1f2fa7867129de ./arm64-v8a/libopenxr_loader_generic.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
449e13bbe58de6718d4e530177924e09552a95d748a082739899144e2cb3ccf3 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
aa4dc0020c77e12d41ef6ce80b23ad90c9cb7e882338eddf2261efe8645cf38d ./arm64-v8a/libvrapi.so
|
||||
7943c6825e1c2841e54627f96acbd0754a3c06bb608c47f7aa15007aec2191a5 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1d83eb94ec1df44f29a5f525dce4d5b608dbbb13a9c7a5efeb80a4b1dc54a721 ./arm64-v8a/libvrapi.so
|
||||
47f736dfeffce6aa56be2fc049c2d11b87b0d1f7389db7ee1737fcded9457811 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
package: com.Armature.VR4
|
||||
title: VR4
|
||||
status: works
|
||||
notes: Installs the no-ForceQuit build and fixes a campaign shader that hung the GPU after the opening cutscene.
|
||||
details: Entering the campaign gave a black screen, slowing audio and a crash (Mercenaries loaded). One fragment shader
|
||||
reads a loop counter before setting it, which hangs the GPU; the Vulkan shim zeroes it (vk_shader_fix). The
|
||||
notes: Installs the no-ForceQuit build and fixes two campaign shaders that hung the GPU at cutscenes.
|
||||
details: Entering the campaign gave a black screen, slowing audio and a crash (Mercenaries loaded). Two permutations of one fragment shader
|
||||
read a loop counter before setting it, which hangs the GPU; the Vulkan shim zeroes it (vk_shader_fix). The
|
||||
no-ForceQuit build is kept. Diagnosed and confirmed on a Frame by the reporter of GitHub issue 10.
|
||||
tested_version: '2.3'
|
||||
engine: Unreal
|
||||
@@ -14,8 +14,8 @@ use_alt: true
|
||||
adapter:
|
||||
# one campaign shader reads an uninitialized loop counter and hangs the GPU (black screen, slowing audio, crash);
|
||||
# the Vulkan shim inserts two OpStores that zero it and its accumulator (from the reporter's capture, GitHub
|
||||
# issue 10)
|
||||
vk_shader_fix: 6488:5dcd842db8d21e3fdd13ec916d2e34dd0cfdf12a92fffaf8a27115072ef55c59:2624:0x0003003e,138,18,0x0003003e,150,42
|
||||
# issue 10); a second permutation of the same shader (6604 bytes) hung the GPU at a later cutscene (GitHub #140)
|
||||
vk_shader_fix: 6488:5dcd842db8d21e3fdd13ec916d2e34dd0cfdf12a92fffaf8a27115072ef55c59:2624:0x0003003e,138,18,0x0003003e,150,42;6604:a0e6edd8a8e969acbefe1a193f0bc535d8c0c18d004def4c361840a9f2601f13:2956:0x0003003e,122,18,0x0003003e,135,42
|
||||
verified:
|
||||
date: '2026-10-02'
|
||||
known_good_sha256: 40ff74e37264b28ae8826923b69d0731d9620094c76462be34159643d6ae91b3
|
||||
@@ -24,5 +24,5 @@ verified:
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 26
|
||||
issue: 1
|
||||
updated: '2026-10-04'
|
||||
updated: '2026-10-11'
|
||||
source_hint: Resident Evil 4
|
||||
@@ -0,0 +1,18 @@
|
||||
package: com.square_enix.android_meta.trianglexr
|
||||
title: TRIANGLE STRATEGY
|
||||
status: works
|
||||
notes: Works in XR, with and without Arcturus Vision color (switched while playing).
|
||||
tested_version: '1.0'
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 86e8297adeb4056eecfbc175438ef25387df67dd0a15ae79afbe6f2e67319a0c
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.5
|
||||
issue: 171
|
||||
source_hint: com.square_enix.android_meta.trianglexr
|
||||
@@ -0,0 +1,17 @@
|
||||
package: org.timecrisis2.arcadevr
|
||||
title: Time Crisis II VR (Test)
|
||||
status: works
|
||||
tested_version: 0.2.0
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
verified:
|
||||
date: '2026-10-10'
|
||||
known_good_sha256: 7829a4cb84298a03ebe539b893916a962aecc4119f189f9fa9ceeab24c747bc5
|
||||
app: 0.12.0
|
||||
overport_cli: 1.2.3
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 59
|
||||
issue: 173
|
||||
source_hint: TimeCrisis2VR-v0.2.0-quest3
|
||||
@@ -1,18 +1,41 @@
|
||||
package: ru.targem.blazerush
|
||||
title: BlazeRush
|
||||
status: unsupported
|
||||
notes: Starts and reaches the menu room, but the room shows no controllers and ignores all input (it all reaches
|
||||
the game); no fix yet.
|
||||
status: works
|
||||
notes: From Klownicle's guide (GitHub issue 57); FramePort's version verified in a Steam Frame headset (2026-10-11).
|
||||
Menus and racing controls, car bodies and controllers, particle effects, the full field of view and a sharp,
|
||||
correctly placed picture.
|
||||
details: 'Seven fixes from the guide. frame.blazerush (this exact build only) skips the menus'' wait for a Meta avatar,
|
||||
which never comes on the Frame, so the game creates its own controllers, and gives its shader cache a new folder
|
||||
(cache/br_shader_v1) so programs compiled before the shader fixes aren''t reused. The GL shim feeds integer vertex
|
||||
inputs (bone indices) as integers (gl_int_attribs: car bodies and controllers were invisible) and computes the
|
||||
particle vertex shaders in highp (gl_highp_markers: streaks and flashes). The game reads the field of view once at
|
||||
start, before tracking: the VrApi bridge answers with the FOV of the previous session (FramePort''s launch test
|
||||
after the install is one), not 90x90 degrees. scale 1.7 keeps the wider view sharp (about 2.9x the pixels).
|
||||
device.config_sync keeps the resolution saved in user_config.xml at the eye-buffer size, else the picture is
|
||||
displaced with black borders after a scale change.'
|
||||
tested_version: 1.0.349
|
||||
engine: Other
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.vrapi_bridge
|
||||
- frame.avatar_stub
|
||||
- frame.gl_shim
|
||||
- frame.blazerush
|
||||
adapter:
|
||||
scale: 1.7
|
||||
gl_int_attribs: 1
|
||||
gl_highp_markers: in uvec2 inBoneIndices;|uniform InstanceData|void main()
|
||||
gl_hide_multiview: 0
|
||||
gl_hide_msrtt: 0
|
||||
config_sync:
|
||||
user_config.xml:
|
||||
r_3dwidth: w
|
||||
r_3dheight: h
|
||||
r_width: h*16/9
|
||||
r_height: h
|
||||
verified:
|
||||
date: '2026-10-06'
|
||||
known_good_sha256: 7cbd5d4252788ccd3c95b5c1841dcc798ea029d687938c024e33bd698ba17671
|
||||
app: 0.12.0
|
||||
updated: '2026-10-06'
|
||||
min_app: 0.12.0
|
||||
date: '2026-10-11'
|
||||
issue: 57
|
||||
updated: '2026-10-11'
|
||||
min_app: 1.0.0
|
||||
source_hint: BlazeRush
|
||||
@@ -154,6 +154,11 @@ signatures:
|
||||
severity: fatal
|
||||
diagnosis: The app crashed at start because the Frame's Android has no clipboard. SDL apps (SDL2 / LÖVE) need Android's clipboard service, which Lepton lacks.
|
||||
suggest: [frame.sdl_clipboard]
|
||||
- id: godot-no-clipboard
|
||||
pattern: 'non-null type android\.content\.ClipboardManager(?:.*\n){1,4}?.*at org\.godotengine\.godot\.Godot\b'
|
||||
severity: fatal
|
||||
diagnosis: The app crashed at start because the Frame's Android has no clipboard. Godot 4 apps (Godot 4.2 to 4.4) expect Android's clipboard service, which Lepton lacks.
|
||||
suggest: [frame.godot_clipboard]
|
||||
- id: controller-profile-rejected
|
||||
pattern: 'PATH_UNSUPPORTED.*xrSuggestInteractionProfileBindings|xrSuggestInteractionProfileBindings.*(-48|PATH_UNSUPPORTED)'
|
||||
severity: info
|
||||
@@ -193,6 +198,20 @@ signatures:
|
||||
diagnosis: Meta's avatar library stopped the game because it needs Meta's Horizon app. A do-nothing stand-in lets the game run without Meta avatars (for example BlazeRush).
|
||||
suggest: [frame.avatar_stub]
|
||||
supersedes: [native-crash, java-crash]
|
||||
- id: vrapi-fov-before-tracking
|
||||
pattern: 'OVRPortVrApi: FOV properties: [0-9.]+ x [0-9.]+ degrees \(default, nothing known before tracking\)'
|
||||
severity: info
|
||||
diagnosis: The game read the field of view right after starting VrApi, before the Frame's runtime could tell it, and
|
||||
got 90x90 degrees (a narrow, square view if it keeps that value, for example BlazeRush). The VrApi bridge saves the
|
||||
headset's real FOV for the next start (framebridge-vrapi-fov.txt in the game's files); start the game once more.
|
||||
suggest: []
|
||||
- id: gl-integer-attribs
|
||||
pattern: 'GLShim: int attribs: program \d+ location \d+: floats -> glVertexAttribIPointer'
|
||||
severity: info
|
||||
diagnosis: The game feeds integer shader inputs (for example bone indices) through glVertexAttribPointer; the GL shim
|
||||
sets them up as integers (gl_int_attribs), else the Frame's GL driver converts them to floats and skinned models
|
||||
vanish (for example BlazeRush's cars and controllers).
|
||||
suggest: []
|
||||
- id: vrapi-symbol-missing
|
||||
pattern: 'cannot locate symbol "vrapi_\w+"'
|
||||
severity: fatal
|
||||
@@ -288,6 +307,19 @@ signatures:
|
||||
diagnosis: The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR). The patch makes Vivox's audio-route check return early (voice chat keeps the default route).
|
||||
suggest: [frame.vivox_audio_route]
|
||||
supersedes: [java-crash]
|
||||
- id: unreal-obb-check-stuck
|
||||
pattern: 'Displayed \S+/\S*DownloaderActivity\b'
|
||||
# the downloader returned (the engine's main init resumed, OpenXR started) or the game crashed (its own finding)
|
||||
unless: 'xrCreateInstance|xrCreateSession|FrameBridge: pacing:|Resuming main init|Fatal signal \d+|FATAL EXCEPTION'
|
||||
severity: fatal
|
||||
diagnosis: >-
|
||||
The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never
|
||||
starts VR (for example Contractors, GitHub #159). Unreal checks that the data file (.obb) has the name and size
|
||||
this APK expects, and with bVerifyOBBOnStartUp it also reads the whole file to check it; a failed check waits
|
||||
on an error screen. "Unreal - start without the OBB check" skips the content check. If the game still stops
|
||||
here, the OBB doesn't match this APK version - copy the APK and the OBB from the same install, add the game
|
||||
again and reinstall.
|
||||
suggest: [frame.unreal_skip_obb_check]
|
||||
- id: native-crash
|
||||
pattern: 'Fatal signal (\d+) \(SIG[A-Z]+\)'
|
||||
severity: fatal
|
||||
|
||||
+37
@@ -25,3 +25,40 @@ Games/ ← scan this folder (Add games → Scan
|
||||
|
||||
Not sure of the package name? It's in the OBB file names (`main.<version>.<package name>.obb`), and the game's page
|
||||
shows it under **Details**.
|
||||
|
||||
## Can I scan SideQuest backups or AXRB downloads directly?
|
||||
|
||||
Yes. Scan the folder as it is; FramePort finds the APK and its OBB files in both layouts:
|
||||
|
||||
```tree
|
||||
SideQuest Backups/ ← scan this folder
|
||||
└── com.Armature.VR4/ ← one folder per game (the package name)
|
||||
└── 2026-10-07T02-10-19-171Z/ ← one folder per backup
|
||||
├── apk/com.Armature.VR4.apk ← the game
|
||||
├── obb/ ← its OBB files, sent to the Frame
|
||||
│ ├── main.203.com.Armature.VR4.obb
|
||||
│ ├── patch.203.com.Armature.VR4.obb
|
||||
│ └── VR4-Android-Shipping-arm64.apk ← ignored (not a second game)
|
||||
├── data/ ← the app's own files on the Quest: not needed
|
||||
├── icon.png
|
||||
└── manifest.json
|
||||
|
||||
AXRB/ ← AXRB's download folder (Downloads/AXRB): scan this folder
|
||||
├── 1234567890/ ← AXRB's numbers for the game and the build
|
||||
│ └── 987654321/
|
||||
│ ├── base.apk ← the game
|
||||
│ ├── main.203.com.Armature.VR4.obb ← sent to the Frame, with any other files here (DLC)
|
||||
│ └── patch.203.com.Armature.VR4.obb
|
||||
└── patched/ ← AXRB's own PC builds: ignored
|
||||
```
|
||||
|
||||
- **Several backups of one game:** FramePort uses the newest one. To use another, scan just that backup's folder.
|
||||
- **AXRB's patched builds** (`patched/`, `*-axrb.apk`) are converted for AXRB's PC runtime and lack the game's data, so
|
||||
FramePort skips them and, on the next scan, replaces a game that was added from one with the original download.
|
||||
- **AXRB export ZIPs:** unzip first. A game's ZIP holds `base.apk` and `Android/obb/<package>/`, which FramePort finds
|
||||
as is.
|
||||
- **The game page says the data file (.obb) is missing** although you have one: the backup didn't include it (copy
|
||||
the game's `Android/obb/<package>` folder from the Quest with SideQuest), or it sits in a folder FramePort doesn't
|
||||
look in. Then use the layout in the previous answer.
|
||||
- **The game starts and stops at once (Steam shows Resume):** add the game again from its original download or
|
||||
backup, click **Update on Frame**, then **Report a problem…** on its page if it still stops.
|
||||
+19
-2
@@ -206,8 +206,9 @@ over Quest games.
|
||||
- **Registration** (what SteamVR honours on the Frame): `IVRApplications::AddApplicationManifest(path, false)` from
|
||||
a utility client, live; the path is kept in `~/.config/openvr/config/appconfig.json` `manifest_paths`.
|
||||
`SetApplicationAutoLaunch` is accepted (GetApplicationAutoLaunch → true; vrserver has
|
||||
`CAppInfoManager::StartAutolaunchOverlays`) but wasn't written to `steamvr.vrsettings` right away; whether it
|
||||
starts the app at the next SteamVR start is **not yet verified**. The linuxarm64 vrserver only reads
|
||||
`CAppInfoManager::StartAutolaunchOverlays`) but on the dev Frame it never reached `steamvr.vrsettings` (no
|
||||
autolaunch entry for `temporalreality.overlay` there, appconfig.json holds only manifest_paths) and the owner's
|
||||
watch didn't start by itself, so FramePort doesn't rely on it (see "Autostart" below). The linuxarm64 vrserver only reads
|
||||
**`binary_path_linux_arm`**: a manifest with `binary_path_linux` alone is skipped ("must specify binary_path for
|
||||
launch_type binary. Skipping"; Steam's own steamapps.vrmanifest entries are skipped the same way), so an app's
|
||||
own Linux manifest/`--install` that only writes binary_path_linux can't be launched by SteamVR on the Frame.
|
||||
@@ -219,6 +220,22 @@ over Quest games.
|
||||
absolute paths) and registers it; `unregister_vr_overlay`, uninstall and purge remove it; `ensure_host_fixes`
|
||||
registers ones SteamVR missed (it wasn't running). Overlay apps skip launch tests, the Linux launcher's
|
||||
Steam-parent watchdog (`FRAMEPORT_OVERLAY=1`) and don't count as a running game.
|
||||
- **Autostart (agent v76)**: the deployment's `overlay.autostart` is the source of truth (SetApplicationAutoLaunch
|
||||
is still set for SteamVR builds that honour it). While at least one installed overlay app has it on,
|
||||
`ensure_host_fixes` keeps the user service `frameport-vr-overlays.service` (`~/.config/systemd/user`, enabled for
|
||||
default.target, Restart=always) = `frameport_agent.py _vr_overlay_watch`: every 5 s it checks the vrserver it knows
|
||||
(`/proc/<pid>/stat`: name + start time; a full /proc scan only when that one is gone); a new vrserver (boot,
|
||||
SteamVR restart, or the first look after the service starts) gets one round in a child process
|
||||
(`_vr_overlay_round`, timeout): wait ≤180 s until IVRApplications answers, 15 s for SteamVR's own auto-launch,
|
||||
then `launch_vr_overlay` for each autostart app that isn't running (a process with its install folder/anchor in
|
||||
the command line, or GetApplicationProcessId ≠ 0: never a second copy). The handled vrserver is kept in
|
||||
`~/.cache/frameport-vr-overlays.json`, so an agent update (the watcher exits when its file changes, systemd starts
|
||||
the new one) doesn't restart an app the user closed. Log: `~/.local/share/frameport/vr-overlays.log` (128 KB, one
|
||||
`.1`). Removed when no overlay app has autostart (game page switch, uninstall), by purge, and by the kill switch
|
||||
`~/.local/share/frameport/vr-overlays.disabled` (or `FRAMEPORT_NO_OVERLAY_AUTOSTART=1` in the agent's environment).
|
||||
Cost: one idle python3 (~30-40 MB RSS, no CPU between polls). Dev Frame 2026-10-10 (vrserver up since boot,
|
||||
Temporal Reality already started from its Steam shortcut): `new SteamVR (vrserver 2297)` →
|
||||
`linux.temporalreality: running` (not started again). Start after a real SteamVR start/boot: not yet seen.
|
||||
|
||||
## Video of the headset view (surveyed 2026-10-05; used by the Live view tab)
|
||||
- `steamvr-v4l2cam.service` (user unit, part of gamescope-session.target, `Restart=always`) runs SteamVR's
|
||||
|
||||
+7
-3
@@ -104,6 +104,9 @@ refresh rate, controllers, menus, 360° video and mixed reality. Changes are use
|
||||
|
||||

|
||||
|
||||
**Rename…** (in the game's menu) changes the name in the Library and in Steam; FramePort keeps it from then on. An
|
||||
installed game keeps its Steam entry, artwork and saves (Steam restarts once).
|
||||
|
||||
### microSD cards and other drives
|
||||
|
||||
- The **Steam Frame** page's **Storage** section sets where new games go (**Install new games to**).
|
||||
@@ -192,9 +195,10 @@ the Steam library.
|
||||
page (**Desktop Mode**).
|
||||
- **SteamVR overlay apps** (a wrist watch, a performance display: programs that draw over games instead of being
|
||||
one) are registered with the Frame's SteamVR, which shows them over whatever you play, Quest and PC VR games
|
||||
alike. FramePort recognises them by the `.vrmanifest` file they ship. By default SteamVR starts them by itself
|
||||
(**SteamVR overlay → Start with SteamVR** on the game page); otherwise start the app from your library before the
|
||||
game. Windows overlay apps get the patch **SteamVR overlay app (Frame)**.
|
||||
alike. FramePort recognises them by the `.vrmanifest` file they ship. By default they start by themselves whenever
|
||||
SteamVR starts (**SteamVR overlay → Start with SteamVR** on the game page; a small FramePort service on the Frame
|
||||
does this, `frameport-vr-overlays`); otherwise start the app from your library before the game. Windows overlay
|
||||
apps get the patch **SteamVR overlay app (Frame)**.
|
||||
|
||||
## Files on the Frame
|
||||
|
||||
|
||||
@@ -90,6 +90,18 @@ not clickable. Use the `https://frameport.app/install?…` link:
|
||||
|
||||
Use the animated or still SVG: GitHub shows SVGs as images, so the hover version doesn't react there.
|
||||
|
||||
## Testing your setup
|
||||
|
||||
The example button on [frameport.app](https://frameport.app/#button) uses a demo link:
|
||||
|
||||
```
|
||||
https://frameport.app/install/?manifest=https%3A%2F%2Fframeport.app%2Fdemo%2Fcool-game.json
|
||||
```
|
||||
|
||||
Click it to check that FramePort receives install links on your PC. FramePort recognises this one address and shows
|
||||
a demo install question for a placeholder game, "Cool Game"; nothing is downloaded, added to the library or
|
||||
installed (`frameport open-link` says it's the demo link and stops). Real buttons install real games.
|
||||
|
||||
## If a visitor doesn't have FramePort
|
||||
|
||||
The `https://frameport.app/install?…` link handles this: its page offers the download and a second try. A bare
|
||||
|
||||
@@ -22,12 +22,14 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| `cannot locate symbol "ovrPeerConnectionState_ToString"` (or another `ovr<Enum>_ToString`) at start (BlazeRush, GitHub #57) | OVRPort's platform loader lacks several Platform SDK enum helpers | `frame.ovrstubs` now stubs them too; `*_ToString` stubs return "" (not NULL) |
|
||||
| Exits right after the OpenXR instance is created: `Failed to create XR session: -50` (`XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING`, Lambda1VR) | the game skips `xrGet*GraphicsRequirementsKHR`; Meta's runtime tolerates it, SteamVR's doesn't. Lambda1VR's TBXR doesn't even enable `XR_KHR_opengl_es_enable` (only `XR_EXT_local_floor`) | FrameBridge adds `XR_KHR_opengl_es_enable` when an app enables no graphics API extension, asks for the requirements on the game's behalf and retries xrCreateSession once (logs `added XR_KHR_opengl_es_enable …`, `the app skipped …; asked for it`; every launch logs the final extension list `xrCreateInstance with N extension(s)`); triage `graphics-requirements-missing`. Verified headless: Lambda1VR runs at 72 fps |
|
||||
| Exits at start: `OVRAvatar-Loader: DisplayErrorAndExit: Failed to launch SystemActivities` after `ovrAvatar_Initialize: Failed to load AvatarSDK driver` (BlazeRush, GitHub #57) | Meta's avatar loader needs Horizon; it then tries Meta's error screen and aborts | `frame.avatar_stub` (same exports, do nothing); triage `avatar-driver-missing`. Games that ship the library but don't start avatars at launch (Lucky's Tale, BattleSisters, Arcsmith) don't need it |
|
||||
| BlazeRush (GitHub #57, Klownicle's guide): the first menu takes A, the next ignores all input; car bodies and controllers invisible; coloured streaks/flashes from particles; narrow square view; blurry, or displaced with black borders after a scale change | the menus wait for a Meta avatar the stub never delivers; bone indices fed with glVertexAttribPointer (Mesa converts them to floats); mediump particle maths; FOV read once before any session (bridge said 90x90); old program binaries in its shader cache; user_config.xml keeps the old resolution | catalog recipe: `frame.blazerush` (avatar wait NOP + avatar renderer RET + cache folder `cache/br_shader_v1`, exact build only), GL shim `gl_int_attribs=1` + `gl_highp_markers=in uvec2 inBoneIndices;\|uniform InstanceData\|void main()` (`gl_hide_multiview=0`, `gl_hide_msrtt=0` keep its GL as tested), the VrApi bridge's saved FOV (`framebridge-vrapi-fov.txt`; triage `vrapi-fov-before-tracking`), `scale=1.7`, `device.config_sync` (agent writes r_3dwidth/r_3dheight/r_width/r_height from the runtime's eye size × scale before each start); triage `gl-integer-attribs` |
|
||||
| Frame freezes or the game is killed (OOM) on the first controller vibration (for example Lucky's Tale's save slots, BattleSisters, Sniper Elite VR's tutorial grab: memory grows ~1 GB/s until the kill) | OVRPort runtimes up to 3.4.3-23204ea read the haptic envelope's duration (ns) as seconds and allocate gigabytes of samples (GitHub #9, ovrport/app#73); also reached from Unity's legacy `ovrp_SetControllerHaptics`; perf page faults all in `libopenxr_loader.so` `xrApplyHapticFeedback` | `adapter.haptic_fix` (envelope → one plain vibration in `native/xrshim`; PCM vibrations (`XrHapticPcmVibrationFB`) are converted the same way). Fixed in runtime 3.4.3-aa54c3f: builds made with it leave the workaround out by themselves (log "not needed: adapter.haptic_fix", `frameport show` "last build left out"); rebuild an older build to switch |
|
||||
| Controller vibrations much stronger than on a Quest (The Boys VR, Jurassic World, BONELAB, …) | OVRPlugin games send amplitude envelopes; `haptic_fix` turned each into one vibration at the envelope's *peak* for its whole duration (a short fading pulse became a long full-strength buzz) | `haptic_fix` now uses the envelope's RMS; Game settings → "Vibration strength" (`haptic_scale`, 0-1) scales every vibration in FrameBridge; launch.log shows the first requests (`haptic: …`). OVRPlugin stops a vibration with amplitude 0 (2 s duration): FrameBridge turns that into xrStopHapticFeedback (without it the last buzz ran its full 2 s) |
|
||||
| Play or a launch test fails at once: `Game files missing at <path> (storage not mounted?)` in launch.log, or "… files are on a drive that isn't inserted" (GitHub #90) | the game was installed or moved to a microSD card (`<mount>/FramePort/<pkg>`) that isn't inserted or mounted now | insert the card; or move the game back (game menu → Move to…, `frameport frame move <pkg> --to internal`) once the card is back. `frameport frame drives` shows what the agent sees (exFAT/NTFS cards are refused: format in SteamOS) |
|
||||
| A Linux app started from Desktop Mode's menu closes after ~2 s, or opens on the wrong display (GitHub #84) | its launcher predates agent v63: the "Steam parent gone" watchdog ended it when Plasma's launcher exited, and it took gamescope's DISPLAY from Steam | connect FramePort once (agent v63's `upgrade_launchers` adds the `FRAMEPORT_DESKTOP` checks; the menu entry runs `env FRAMEPORT_DESKTOP=1 <anchor>/launch.sh`). Game Mode controller problems in such apps are Steam Input's (a separate issue) |
|
||||
| A Linux app (AppImage) shows no/FramePort's placeholder icon in Desktop Mode or Steam (GitHub #99) | before agent v64 only FramePort's art set's icon was used, and a lone AppImage has no store art | connect FramePort once (agent v64 copies the app's `.DirIcon` / `.desktop` `Icon=` to `<anchor>/artwork/app-icon.*` and refreshes the menu entry); reinstall to also get it into the library + Steam shortcut. Still the placeholder: check `<base>/app/squashfs-root/.DirIcon` and the `.desktop` file's `Icon=` (an icon outside the app folder or only an XPM isn't used); a chosen icon (`artwork/.icon-source` = `custom`) always wins |
|
||||
| An overlay app (fpsVR, a wrist watch) installed on the Frame never appears, or SteamVR doesn't start it | not registered with the Frame's SteamVR, or registered with a manifest SteamVR skips: on the Frame vrserver reads only `binary_path_linux_arm` (vrserver.txt: "must specify binary_path for launch_type binary. Skipping") | `pcvr.vr_overlay` (Windows) / a Linux app with a bundled `.vrmanifest` (`is_dashboard_overlay`): agent v75 `register_vr_overlay` (check `vrcmd --overlays`, `~/.config/openvr/config/appconfig.json` manifest_paths); the app's own `--install` writes binary_path_linux only. fpsVR under Proton never loads OpenVR (quits after 60 s): unsupported for now |
|
||||
| An overlay app with **Start with SteamVR** on doesn't start by itself after the Frame or SteamVR starts (it works when started by hand) | SteamVR's own auto-launch flag (SetApplicationAutoLaunch) didn't persist on the Frame (no autolaunch entry in `~/.config/openvr/config/steamvr.vrsettings`) | agent v76: FramePort's service `frameport-vr-overlays.service` starts it at every new vrserver. Check `systemctl --user status frameport-vr-overlays`, `~/.local/share/frameport/vr-overlays.log` (`new SteamVR (vrserver N)` → `<pkg>: started` / `running` / `couldn't start it: …`), the deployment's `overlay.autostart`, and that `~/.local/share/frameport/vr-overlays.disabled` doesn't exist; `frameport frame info` (ensure_host_fixes) installs a missing service |
|
||||
| Every game suddenly fails to start: `crun: create keyring …: Disk quota exceeded`, `is not a running context` | rootless podman leaked a kernel keyring per launch; 200-key quota exhausted | `keyring = false` in `~/.config/containers/containers.conf` (FramePort agent does it), then reboot the Frame once |
|
||||
| A Unity app's text field shows a caret for a moment and loses focus; no keyboard appears (for example Stremio VR login) | Unity's TMP_InputField/InputField wait for Android's on-screen keyboard and close themselves without one (Lepton has none); headless VR apps also have no focused Android window, so no key press reaches them | `frame.unity_text_input` (Cpp2IL finds `TouchScreenKeyboardShouldBeUsed`/`isKeyboardUsingEvents`, rewritten to false/true) + `device.text_input_window` (lepton-show-flatscreen: Steam's keyboard and Type on Frame work) |
|
||||
| Play gives Steam's "Game configuration unavailable"; Steam's console_log: `GameAction [AppID <id>] … RequestingLicense → UpdatingAppInfo → LaunchApp failed with AppError_9` | this Frame's Steam never loaded FramePort's shortcuts.vdf entry (it treats the id as a store app); cause unknown (GitHub #21/#30) | automatic since agent v43: Play registers the game through Steam's devkit interface ("Devkit Game: …", `devkit_register`) and starts that; uninstall removes it. Agent v58: a devkit entry Steam forgot after a restart is added again live on Play (no NOT_IN_LIBRARY → no Steam restart), and installs/art updates of devkit games only copy art (no Steam restart, GitHub #41) |
|
||||
@@ -50,8 +52,10 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (for example SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
|
||||
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
|
||||
| A backup's game was added without its data (library `data_dir` null, `data_bytes` 0) although the folder has .obb files | The OBBs sit in a layout the scan didn't know: `<game>/obb/<package>/`, `Android/obb/<package>/`, or SideQuest-style `<game>/apk/x.apk` + `<game>/obb/<package>/` | `sources/quest_dump.find_data_dir` finds a `<package>` folder with .obb files ≤3 levels below the APK's folder or ≤2 below its parent (skipping neighboring folders with APKs: other games); an `apk`/`apks` folder counts as part of its game folder. Add the folder again (Analyze again doesn't look for data) |
|
||||
| A game from an AXRB download starts and stops at once (Steam shows Resume), no OBB on the Frame; or a SideQuest backup adds a second game named after an APK in its `obb/` folder (reddit 2026-10-11) | AXRB keeps its own PC builds in `AXRB/patched/<package>/*-axrb.apk` (OVRPort-converted for its runtime, no OBBs); the scan added them over the original `AXRB/<app id>/<binary id>/base.apk`. SideQuest's VR4 backup has Unreal's `VR4-Android-Shipping-arm64.apk` next to the OBBs | `quest_dump._axrb_patched` skips AXRB's patched copies; a rescan replaces entries made from them; AXRB downloads send every non-APK file next to `base.apk` (DLC, assets) and are named after the package; APKs inside another game's data folder are ignored; SideQuest backups are named after the package folder, not the backup time. docs/FAQ.md has both layouts |
|
||||
| Game hangs at start / never loads although its .obb files are right there: next to the APK, in an `obb` folder beside an `apk` folder inside a `<package>` folder, or a SideQuest backup (`<timestamp>_<versionCode>.apk`) (GitHub #85, #91) | No folder named after the package, so the folder-layout checks found nothing and no OBB was uploaded | `quest_dump.find_data` then looks for the files by name, `(main\|patch).<versionCode>.<package>.obb` (any case), in the APK's folder, its parent and up to 3 levels below each (folders with their own APKs are other games); prefers the folder and files of the APK's own versionCode, else the newest older, else any version. When that folder also holds other things (the APK itself, other games' OBBs), the library entry gets `data_files` and only those files are uploaded, counted and deleted with the game. Add the folder again |
|
||||
| Unreal game: `JNI_OnLoad` (OVRPlugin), then silence: no OpenXR instance, no crash, no frames (GitHub #85 TRIANGLE STRATEGY; launch-test finding `missing-obb`) | Installed without its OBB: the manifest says `com.epicgames.ue4.GameActivity.bHasOBBFiles` = true (UE5: `com.epicgames.unreal.…`) but the library has no data folder (`data_dir` null, `data_bytes` 0) | Put the .obb files in a `<package>` (or `obb/`) folder next to the APK, add the folder again, reinstall. Analysis `extra.expects_obb`; the game page, the install question and `frameport scan`/`install` warn; launch tests that sent no frames get the `missing-obb` finding from the library (the game logs nothing). Unity split-binary builds count too (`extra.unity_split`, `analysis/unity_split.py`): an XR-plugin build (Oculus XR Plugin / Unity OpenXR library) without `assets/bin/Data/UnitySubsystems/` in the APK, or BuildSettings listing more scenes than the APK has `levelN` files (globalgamemanagers loose or read from the head of data.unity3d); exact on the 48 Unity APKs of the dumps (13 split, 17 full games; OBBs of full builds are asset bundles or sound banks) |
|
||||
| Unreal game: launch.log ends with `ActivityTaskManager: START u0 {… cmp=<pkg>/.DownloaderActivity}` and `Displayed <pkg>/.DownloaderActivity`, then nothing: no OpenXR instance, no crash (GitHub #159 Contractors; triage `unreal-obb-check-stuck`, also when the OBB is on the Frame) | Epic's DownloaderActivity (started by GameActivity.onResume before the engine's main init) checks every OBB's name + size compiled into the APK (OBBData); with manifest meta-data `com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp=true` it then reads the whole OBB and compares each entry's CRC32 (cached in `files/cacheFile.txt` by the OBB's mtime). Its progress and error screens are invisible in Lepton (headless VR); a CRC mismatch waits for a button forever. A missing / wrong-size OBB starts Google Play's downloader (or a "No Google Play Store Key" dialog) and also waits | `frame.unreal_skip_obb_check` (suggested by analysis `unreal_verify_obb`): sets that meta-data to false (one boolean in the manifest, `axml.set_meta_data_bool`), which skips only the CRC pass. Still stuck: the OBB doesn't match the APK version (name `main.<versionCode>.<pkg>.obb` and its size) - copy APK + OBB from the same install |
|
||||
| `INSTALL_FAILED_NO_MATCHING_ABIS` | 32-bit-only APK; Frame has no AArch32 | None. PC/Rift version via Revive |
|
||||
| Crash at start: `NoClassDefFoundError: … Landroid/window/OnBackInvokedCallback;` or `NoSuchMethodError: No static method storeStoreFence()V in class Ljava/lang/invoke/VarHandle;` (GitHub #71/#72, triage `android-too-new`) | The APK needs Android 13+ (minSdk 33 or more; those were 34) and calls its classes at start; Lepton's Android is 11 (API 30). OVRPort lowers minSdk to 29, so it installs anyway. minSdk ≤ 32 (Quest's Android 12L) is fine: 16 working catalog games declare 32 | None (analysis `extra.min_sdk` > 32 → a warning on the game page/CLI and in the recipe notes; never unsupported from the manifest alone, the launch test's triage decides). Waits for Valve to update Lepton's Android |
|
||||
| Nothing opens; `TWALauncherActivity: Using URL from Manifest (https://…)`, `Creating TwaLauncher for com.oculus.browser`, `NameNotFoundException: com.oculus.browser` (GitHub #86 Mahjong Table VR, triage `web-wrapper`) | The APK is a Trusted Web Activity (Bubblewrap / Meta's PWA packaging): a website that opens in Meta's browser, which Lepton doesn't have | None: open the URL in a browser. Analysis `extra.web_wrapper` (manifest meta-data `android.support.customtabs.trusted.DEFAULT_URL`, resolved through resources.arsc, or androidbrowserhelper's LauncherActivity) → a warning with the URL (not unsupported: the manifest alone never decides) |
|
||||
@@ -98,6 +102,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Crash right after the game gets focus, in `vrclient.so … UpdateActionStateInternal` / `sxr_xrSyncActions` (for example Myst) | a race in the Frame runtime's input code on the first sync after focus (not every launch) | adapter `sync_guard` (input syncs one at a time with event polling, paused 250 ms after FOCUSED) |
|
||||
| View jumps sideways / game pauses or freezes for a moment every few seconds (for example Blade & Sorcery, BattleGlide) | the Frame drops focus for ~0.5 s (FOCUSED→VISIBLE→SYNCHRONIZED); games pause or re-align the player (`OnVRPresence … Teleport`) | adapter `focus_hold` |
|
||||
| SDL / LÖVE app crashes at start: `NullPointerException … ClipboardManager.addPrimaryClipChangedListener` in `SDLClipboardHandler.<init>` (for example Dramatic Shape) | Lepton's Android has no clipboard service (`service check clipboard`: not found) | `frame.sdl_clipboard` (that call → nops in classes.dex, in place, `apk/dex.py`); reproduced and verified with LÖVE for Android 11.5 |
|
||||
| Godot 4 app crashes at start: `java.lang.NullPointerException: null cannot be cast to non-null type android.content.ClipboardManager` at `org.godotengine.godot.Godot.<init>` (Godot 4.3/4.4; 4.2 when the clipboard is first used, in `Godot$mClipboard$2.invoke`) (for example EndoparasiticVR, GitHub #134); triage `godot-no-clipboard` | Lepton's Android has no clipboard service and Godot's Kotlin code casts it with a non-null `as ClipboardManager` (`Intrinsics.checkNotNull` before the check-cast). Godot 4.1 (Java) and 4.5+ (`as?`) don't throw | `frame.godot_clipboard` (suggested by analysis `godot_clipboard`): that checkNotNull call → nops in classes.dex (check-cast accepts null), and Godot's getClipboard/hasClipboard/setClipboard return at once ("" / false / nothing; `Dex.return_early`), in place; matched exactly, other dex files untouched. Checked against the Maven godot-lib 4.2.2/4.3.0/4.4.1 and the 4.3/4.4.1 release export templates (D8 re-reads the result); not yet run on the device |
|
||||
| Game crashes at start: `java.lang.NoSuchMethodError: No virtual method getAvailableCommunicationDevices()… in class Landroid/media/AudioManager` at `com.vivox.sdk.AudioChangeListener.checkAudioRouteAndApplyChanges` (for example Green Hell VR, GitHub #101); triage `vivox-api31` | Newer Vivox (voice chat) builds call Android 12 audio-routing methods without a version check; Lepton is Android 11. Older Vivox builds (Eleven Table Tennis, BattleSisters) don't have that code | `frame.vivox_audio_route` (suggested by analysis `vivox_api31`): every AudioChangeListener method that calls one of them returns at once (`Dex.return_early`: return-void / `const/4 v0, 0; return v0`, in place); voice chat keeps the default audio route |
|
||||
| Controllers do nothing (for example stuck on a setup screen); log: `xrSuggestInteractionProfileBindings … XR_ERROR_PATH_UNSUPPORTED` | the game only suggests bindings for Meta's newer profiles (Touch Plus / Touch Pro); the Frame's Android runtime knows `oculus/touch_controller`, not those | adapter `profile_remap` (default on): the bindings are suggested again for Touch (log: `controller profile … -> oculus/touch_controller`) |
|
||||
| Some buttons do nothing or the wrong thing (for example a Quest game's X/Y on the Frame's left controller) | the game binds only Quest Touch and SteamVR maps Touch onto the Frame controllers with its own remap (Touch left X/Y land on the d-pad, right X and Y repeat B), or the runtime rejected a profile or an input call | turn on adapter `input_diag` (diagnostics, off by default; `frameport settings <pkg> input_diag=1`) and look for `input_diag:` lines: `bindings: <profile> accepted` or `unsupported: interaction profile <profile> -> <result>` with the rejected paths, `bindings: /user/hand/<hand> uses <profile>`, `unsupported: function <name>`, `unsupported: xrCreateAction -> <result> (<action>)` |
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Brand assets
|
||||
|
||||
- `frameport-icon-1024.png`: square app icon, 1024 × 1024, background to the edges (for project pages, store listings,
|
||||
profile pictures).
|
||||
- `frameport-icon-square.svg`: the same as a vector (FramePort's logo, `src/frameport/ui/icons/logo.svg`, with square
|
||||
corners).
|
||||
|
||||
For the "Install with FramePort" button, see [`docs/badges/`](../badges/) and [INSTALL_BUTTON.md](../INSTALL_BUTTON.md).
|
||||
- `frameport-social-preview.png`: GitHub's social preview (1280 × 640): repository Settings → General → Social
|
||||
preview. Built from the logo and the docs' Library screenshot (`docs/images/library.png`).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 64 64"><defs><linearGradient id="fp0" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#3AA8FF" stop-opacity=".18"/><stop offset=".5" stop-color="#3AA8FF" stop-opacity="0"/><stop offset=".5" stop-color="#FF8A1F" stop-opacity="0"/><stop offset="1" stop-color="#FF8A1F" stop-opacity=".18"/></linearGradient><linearGradient id="fp1" gradientUnits="userSpaceOnUse" x1="26" y1="0" x2="38" y2="0"><stop offset=".3" stop-color="#3AA8FF"/><stop offset=".7" stop-color="#FF8A1F"/></linearGradient><clipPath id="fp2"><polygon points="-30,-30 49.14502116644019,-30 8.854978833559809,94 -30,94"/></clipPath><clipPath id="fp3"><polygon points="55.14502116644019,-30 94,-30 94,94 14.854978833559809,94"/></clipPath></defs><rect width="64" height="64" rx="0" fill="#101218"/><rect width="64" height="64" rx="0" fill="url(#fp0)"/><g clip-path="url(#fp2)"><g transform="translate(0 0)"><path d="M34 15H9a3 3 0 0 0-3 3v20a3 3 0 0 0 3 3h25z" fill="#101218" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><rect x="10.5" y="19.5" width="24" height="17" rx="1" fill="#3AA8FF" fill-opacity="0.35"/><path d="M18 41v6M11 47h14" stroke="#ECEDF3" stroke-width="3" stroke-linecap="round"/></g></g><g clip-path="url(#fp3)"><path d="M56.5 31.5H58.5" stroke="#ECEDF3" stroke-width="3.4" stroke-linecap="round"/><path d="M22 21h27a8 8 0 0 1 8 8v5a8 8 0 0 1-8 8H48.65a3 3 0 0 1-2.6-1.5l-1-1.7a.9.9 0 0 0-1.6 0l-1 1.7a3 3 0 0 1-2.6 1.5H22z" fill="#101218" stroke="#ECEDF3" stroke-width="3" stroke-linejoin="round"/><ellipse cx="39.5" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/><ellipse cx="49" cy="30.5" rx="4.2" ry="3.8" fill="#FF8A1F"/></g><g transform="rotate(18 32 32)"><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="#08090C" stroke="#08090C" stroke-width="5.8"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="none" stroke="url(#fp1)" stroke-opacity=".25" stroke-width="6.46"/><ellipse cx="32" cy="32" rx="4.5" ry="22" fill="url(#fp1)" fill-opacity="0.14" stroke="url(#fp1)" stroke-width="3.4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 424 KiB |
@@ -5,7 +5,7 @@
|
||||
# docs/SHOWCASE.md ("Videos").
|
||||
|
||||
output: docs/media/frameport-install.mp4
|
||||
budget_mb: 12
|
||||
budget_mb: 20
|
||||
poster_at: 9
|
||||
start:
|
||||
profile: fresh # FramePort's first start
|
||||
@@ -24,7 +24,7 @@ title:
|
||||
end:
|
||||
heading: You're set
|
||||
line: Your game is in the Frame's Steam library. More help at frameport.app/docs/install/
|
||||
footer: github.com/spoopyghosty0/frameport
|
||||
footer: frameport.app
|
||||
seconds: 4.0
|
||||
|
||||
scenes:
|
||||
@@ -34,8 +34,8 @@ scenes:
|
||||
eyebrow: "Step 1 · On your PC"
|
||||
heading: Download FramePort
|
||||
steps:
|
||||
- "Open **github.com/spoopyghosty0/frameport** → **Releases** → the latest one"
|
||||
- "Download the file for your PC: **FramePort-windows-x64.zip**, **FramePort-macos-arm64.zip** or **FramePort-linux-x64.tar.gz**"
|
||||
- "Open **frameport.app/download**"
|
||||
- "Download the file for your PC (the page picks it): **FramePort-windows-x64.zip**, **FramePort-macos-arm64.zip** or **FramePort-linux-x64.tar.gz**"
|
||||
- "Unpack it anywhere. No installer or admin rights needed"
|
||||
|
||||
- name: first-start
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
output: docs/media/frameport-tour.mp4
|
||||
teaser: docs/images/tour-teaser.webp
|
||||
budget_mb: 12
|
||||
budget_mb: 20
|
||||
start:
|
||||
profile: demo # the library in use: games installed on the pretend Frame, BONELAB running
|
||||
frame: connected
|
||||
|
||||
@@ -401,6 +401,11 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateViewConfigurationViews(XrInstance inst
|
||||
(PFN_xrEnumerateViewConfigurationViews)lookup(instance, "xrEnumerateViewConfigurationViews");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult result = fn(instance, system, type, capacity, count, views);
|
||||
static int runtime_logged;
|
||||
if (XR_SUCCEEDED(result) && views && count && *count && capacity && !runtime_logged++)
|
||||
// the runtime's own size, before scale (FramePort's agent reads it to keep games' saved resolutions in step)
|
||||
LOG("runtime recommended eye size %ux%u (max %ux%u, scale %.2f)", views[0].recommendedImageRectWidth,
|
||||
views[0].recommendedImageRectHeight, views[0].maxImageRectWidth, views[0].maxImageRectHeight, scale);
|
||||
if (XR_SUCCEEDED(result) && views && count && scale != 1.0f) {
|
||||
for (uint32_t i = 0; i < *count && i < capacity; ++i) {
|
||||
uint32_t w = (uint32_t)(views[i].recommendedImageRectWidth * scale + 0.5f);
|
||||
|
||||
@@ -251,6 +251,7 @@ def build_bridge(tc: Path):
|
||||
inc = openxr_include("bridge")
|
||||
run([tc / "bin/clang++", "--target=aarch64-linux-android29", f"--sysroot={tc / 'sysroot'}", "-std=c++17", "-O2", "-g",
|
||||
"-fPIC", f"-ffile-prefix-map={src}=native/vrapi-bridge", f"-ffile-prefix-map={CACHE}=native/.cache",
|
||||
f"-ffile-prefix-map={CACHE.resolve()}=native/.cache", # a symlinked cache (worktrees)
|
||||
"-fvisibility=hidden", "-fvisibility-inlines-hidden", "-Wall", "-Wextra",
|
||||
"-Wno-missing-field-initializers", "-Werror=return-type", "-DXR_NO_PROTOTYPES", "-DXR_USE_PLATFORM_ANDROID",
|
||||
"-DXR_USE_GRAPHICS_API_VULKAN", "-DXR_USE_GRAPHICS_API_OPENGL_ES", "-DXR_USE_TIMESPEC", f"-I{inc}", "-shared",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Pure helpers of the GL shim's per-game fixes (no GL calls; tests/test_blazerush.py builds them for the host).
|
||||
//
|
||||
// gl_highp_markers: vertex shaders whose source contains every '|'-separated marker get their calculation precision
|
||||
// raised: whole-word "mediump" becomes "highp" on every line that has none of the words in / out / uniform, so the
|
||||
// shader's interface (inputs, outputs to the fragment shader, uniforms and their blocks' declarations) keeps its
|
||||
// precision. BlazeRush's particle vertex shaders (markers "in uvec2 inBoneIndices;", "uniform InstanceData",
|
||||
// "void main()") computed with large encoded values at mediump and drew big coloured streaks and flashes on the
|
||||
// Frame's GPU (Klownicle's guide, GitHub #57).
|
||||
// gl_int_attribs: the attribute type helpers for the integer vertex-attribute fix in glshim.c.
|
||||
#pragma once
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
static inline int glfix_ident(char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
|
||||
}
|
||||
|
||||
// `word` as a whole word in s[0..n)
|
||||
static inline int glfix_has_word(const char *s, size_t n, const char *word) {
|
||||
size_t w = strlen(word);
|
||||
for (size_t i = 0; i + w <= n; ++i)
|
||||
if (!memcmp(s + i, word, w) && (i == 0 || !glfix_ident(s[i - 1])) && (i + w == n || !glfix_ident(s[i + w])))
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// every non-empty '|'-separated marker occurs in src (and there is at least one)
|
||||
static inline int glfix_markers_match(const char *src, const char *markers) {
|
||||
if (!src || !markers || !*markers) return 0;
|
||||
int any = 0;
|
||||
char piece[256];
|
||||
for (const char *p = markers; *p;) {
|
||||
const char *bar = strchr(p, '|');
|
||||
size_t n = bar ? (size_t)(bar - p) : strlen(p);
|
||||
if (n >= sizeof(piece)) return 0;
|
||||
if (n) {
|
||||
memcpy(piece, p, n);
|
||||
piece[n] = 0;
|
||||
if (!strstr(src, piece)) return 0;
|
||||
any = 1;
|
||||
}
|
||||
p += n + (bar ? 1 : 0);
|
||||
}
|
||||
return any;
|
||||
}
|
||||
|
||||
// A copy of src with the calculation precision raised (see above), or NULL when nothing changed. *count = number of
|
||||
// "mediump" replaced.
|
||||
static inline char *glfix_promote_highp(const char *src, int *count) {
|
||||
if (count) *count = 0;
|
||||
if (!src) return NULL;
|
||||
size_t len = strlen(src);
|
||||
char *out = malloc(len + 1);
|
||||
if (!out) return NULL;
|
||||
size_t o = 0;
|
||||
int n = 0;
|
||||
for (const char *line = src; *line;) {
|
||||
const char *end = strchr(line, '\n');
|
||||
size_t ll = end ? (size_t)(end - line + 1) : strlen(line);
|
||||
int interface = glfix_has_word(line, ll, "in") || glfix_has_word(line, ll, "out") ||
|
||||
glfix_has_word(line, ll, "uniform");
|
||||
for (size_t i = 0; i < ll;) {
|
||||
if (!interface && i + 7 <= ll && !memcmp(line + i, "mediump", 7) && (i == 0 || !glfix_ident(line[i - 1])) &&
|
||||
(i + 7 == ll || !glfix_ident(line[i + 7]))) {
|
||||
memcpy(out + o, "highp", 5);
|
||||
o += 5;
|
||||
i += 7;
|
||||
++n;
|
||||
} else {
|
||||
out[o++] = line[i++];
|
||||
}
|
||||
}
|
||||
line += ll;
|
||||
}
|
||||
out[o] = 0;
|
||||
if (!n) {
|
||||
free(out);
|
||||
return NULL;
|
||||
}
|
||||
if (count) *count = n;
|
||||
return out;
|
||||
}
|
||||
|
||||
// GL enums (numeric, so this header needs no GL headers)
|
||||
#define GLFIX_BYTE 0x1400
|
||||
#define GLFIX_UNSIGNED_BYTE 0x1401
|
||||
#define GLFIX_SHORT 0x1402
|
||||
#define GLFIX_UNSIGNED_SHORT 0x1403
|
||||
#define GLFIX_INT 0x1404
|
||||
#define GLFIX_UNSIGNED_INT 0x1405
|
||||
#define GLFIX_INT_VEC2 0x8B53
|
||||
#define GLFIX_INT_VEC3 0x8B54
|
||||
#define GLFIX_INT_VEC4 0x8B55
|
||||
#define GLFIX_UNSIGNED_INT_VEC2 0x8DC6
|
||||
#define GLFIX_UNSIGNED_INT_VEC3 0x8DC7
|
||||
#define GLFIX_UNSIGNED_INT_VEC4 0x8DC8
|
||||
#define GLFIX_FLOAT_MAT2 0x8B5A
|
||||
#define GLFIX_FLOAT_MAT3 0x8B5B
|
||||
#define GLFIX_FLOAT_MAT4 0x8B5C
|
||||
#define GLFIX_FLOAT_MAT2x3 0x8B65
|
||||
#define GLFIX_FLOAT_MAT2x4 0x8B66
|
||||
#define GLFIX_FLOAT_MAT3x2 0x8B67
|
||||
#define GLFIX_FLOAT_MAT3x4 0x8B68
|
||||
#define GLFIX_FLOAT_MAT4x2 0x8B69
|
||||
#define GLFIX_FLOAT_MAT4x3 0x8B6A
|
||||
|
||||
// an active attribute's GLSL type: 1 signed integer (int, ivecN), 2 unsigned (uint, uvecN), 0 floating point
|
||||
static inline int glfix_int_attrib_kind(unsigned type) {
|
||||
switch (type) {
|
||||
case GLFIX_INT: case GLFIX_INT_VEC2: case GLFIX_INT_VEC3: case GLFIX_INT_VEC4: return 1;
|
||||
case GLFIX_UNSIGNED_INT: case GLFIX_UNSIGNED_INT_VEC2: case GLFIX_UNSIGNED_INT_VEC3:
|
||||
case GLFIX_UNSIGNED_INT_VEC4: return 2;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// attribute locations an active attribute uses per array element (matrices take one per column)
|
||||
static inline int glfix_attrib_columns(unsigned type) {
|
||||
switch (type) {
|
||||
case GLFIX_FLOAT_MAT2: case GLFIX_FLOAT_MAT2x3: case GLFIX_FLOAT_MAT2x4: return 2;
|
||||
case GLFIX_FLOAT_MAT3: case GLFIX_FLOAT_MAT3x2: case GLFIX_FLOAT_MAT3x4: return 3;
|
||||
case GLFIX_FLOAT_MAT4: case GLFIX_FLOAT_MAT4x2: case GLFIX_FLOAT_MAT4x3: return 4;
|
||||
default: return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// vertex data glVertexAttribIPointer accepts (integer component types only)
|
||||
static inline int glfix_int_component_type(unsigned type) {
|
||||
return type == GLFIX_BYTE || type == GLFIX_UNSIGNED_BYTE || type == GLFIX_SHORT || type == GLFIX_UNSIGNED_SHORT ||
|
||||
type == GLFIX_INT || type == GLFIX_UNSIGNED_INT;
|
||||
}
|
||||
+376
-3
@@ -12,6 +12,12 @@
|
||||
#include <string.h>
|
||||
|
||||
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "GLShim", __VA_ARGS__)
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
#ifndef GLSHIM_GLES_LIB // (the host test points these at a stand-in GL library)
|
||||
#define GLSHIM_GLES_LIB "libGLESv3.so"
|
||||
#define GLSHIM_EGL_LIB "libEGL.so"
|
||||
#endif
|
||||
#include "glfix.h"
|
||||
|
||||
static const char *const hidden_multiview[] = {
|
||||
"GL_OVR_multiview", "GL_OVR_multiview2", "GL_OVR_multiview_multisampled_render_to_texture",
|
||||
@@ -37,14 +43,25 @@ static int hide_msrtt = 1;
|
||||
// with the MSRTT extensions switched Unreal's multiview off, so for Unreal it stays visible and its function draws
|
||||
// single-sampled (glFramebufferTextureMultiviewOVR), like the EXT stand-ins below.
|
||||
static int unreal;
|
||||
// Per-game fixes (off unless a recipe sets them; see glfix.h): integer vertex attributes fed as floats are rebound as
|
||||
// integers before each draw (gl_int_attribs), and vertex shaders containing every marker get highp calculations
|
||||
// (gl_highp_markers).
|
||||
static int int_attribs;
|
||||
static char highp_markers[512];
|
||||
static void forget_program(GLuint prog);
|
||||
|
||||
static void read_conf_file(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
char line[256];
|
||||
char line[1024];
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (!strncmp(line, "gl_hide_multiview=", 18)) hide_multiview = atoi(line + 18);
|
||||
if (!strncmp(line, "gl_hide_msrtt=", 14)) hide_msrtt = atoi(line + 14);
|
||||
if (!strncmp(line, "gl_int_attribs=", 15)) int_attribs = atoi(line + 15);
|
||||
if (!strncmp(line, "gl_highp_markers=", 17)) {
|
||||
snprintf(highp_markers, sizeof(highp_markers), "%s", line + 17);
|
||||
highp_markers[strcspn(highp_markers, "\r\n")] = 0;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
@@ -213,6 +230,7 @@ static void w_compile(GLuint sh) {
|
||||
}
|
||||
static void w_link(GLuint prog) {
|
||||
r_link(prog);
|
||||
forget_program(prog);
|
||||
GLint ok = 1;
|
||||
r_getProgramiv(prog, GL_LINK_STATUS, &ok);
|
||||
if (!ok && shader_logs < 40) {
|
||||
@@ -263,6 +281,231 @@ static void w_blit(GLint a, GLint b, GLint c, GLint d, GLint e, GLint f, GLint g
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-game fixes: the real functions (from libGLESv3 / libEGL, whatever the game imports or looks up)
|
||||
static struct {
|
||||
void (*ShaderSource)(GLuint, GLsizei, const GLchar *const *, const GLint *);
|
||||
void (*GetShaderiv)(GLuint, GLenum, GLint *);
|
||||
void (*LinkProgram)(GLuint);
|
||||
void (*ProgramBinary)(GLuint, GLenum, const void *, GLsizei);
|
||||
void (*DeleteProgram)(GLuint);
|
||||
void (*GetProgramiv)(GLuint, GLenum, GLint *);
|
||||
void (*GetActiveAttrib)(GLuint, GLuint, GLsizei, GLsizei *, GLint *, GLenum *, GLchar *);
|
||||
GLint (*GetAttribLocation)(GLuint, const GLchar *);
|
||||
void (*GetVertexAttribiv)(GLuint, GLenum, GLint *);
|
||||
void (*GetVertexAttribPointerv)(GLuint, GLenum, void **);
|
||||
void (*VertexAttribPointer)(GLuint, GLint, GLenum, GLboolean, GLsizei, const void *);
|
||||
void (*VertexAttribIPointer)(GLuint, GLint, GLenum, GLsizei, const void *);
|
||||
void (*VertexAttribI4i)(GLuint, GLint, GLint, GLint, GLint);
|
||||
void (*VertexAttribI4ui)(GLuint, GLuint, GLuint, GLuint, GLuint);
|
||||
void (*BindBuffer)(GLenum, GLuint);
|
||||
void (*DrawArrays)(GLenum, GLint, GLsizei);
|
||||
void (*DrawElements)(GLenum, GLsizei, GLenum, const void *);
|
||||
void (*DrawArraysInstanced)(GLenum, GLint, GLsizei, GLsizei);
|
||||
void (*DrawElementsInstanced)(GLenum, GLsizei, GLenum, const void *, GLsizei);
|
||||
void (*DrawRangeElements)(GLenum, GLuint, GLuint, GLsizei, GLenum, const void *);
|
||||
EGLContext (*GetCurrentContext)(void);
|
||||
} d;
|
||||
|
||||
// gl_highp_markers: a copy of a matching vertex shader's source with highp calculations, else NULL
|
||||
static int highp_logs;
|
||||
static char *precision_fix(GLuint sh, const char *src) {
|
||||
if (!highp_markers[0] || !glfix_markers_match(src, highp_markers)) return NULL;
|
||||
GLint type = 0;
|
||||
if (d.GetShaderiv) d.GetShaderiv(sh, GL_SHADER_TYPE, &type);
|
||||
if (type != GL_VERTEX_SHADER) return NULL;
|
||||
int n = 0;
|
||||
char *out = glfix_promote_highp(src, &n);
|
||||
if (out && highp_logs < 16) { ++highp_logs; LOG("highp: vertex shader %u: %d mediump -> highp", sh, n); }
|
||||
return out;
|
||||
}
|
||||
|
||||
static char *concat_sources(GLsizei count, const GLchar *const *strs, const GLint *lens) {
|
||||
size_t total = 0;
|
||||
for (GLsizei i = 0; i < count; ++i) total += lens && lens[i] >= 0 ? (size_t)lens[i] : strlen(strs[i]);
|
||||
char *src = malloc(total + 1);
|
||||
if (!src) return NULL;
|
||||
size_t off = 0;
|
||||
for (GLsizei i = 0; i < count; ++i) {
|
||||
size_t n = lens && lens[i] >= 0 ? (size_t)lens[i] : strlen(strs[i]);
|
||||
memcpy(src + off, strs[i], n);
|
||||
off += n;
|
||||
}
|
||||
src[off] = 0;
|
||||
return src;
|
||||
}
|
||||
|
||||
// gl_int_attribs: the shader declares an integer input (int/uint/ivecN/uvecN), the game set its array with
|
||||
// glVertexAttribPointer (floats; GL_VERTEX_ATTRIB_ARRAY_INTEGER 0). Qualcomm's Quest driver read the raw integers
|
||||
// anyway; Mesa (Zink) converts them to floats, so e.g. BlazeRush's bone indices were garbage and its car bodies and
|
||||
// controller models invisible (Klownicle's guide, GitHub #57). Before each draw, such an array is set again with
|
||||
// glVertexAttribIPointer (same buffer, size, component type, stride and offset; the game's GL_ARRAY_BUFFER binding is
|
||||
// restored), and set back to floats (with the game's normalization) when a program with a float input at that
|
||||
// location draws from the same vertex array state. A disabled integer input gets the integer default (0, 0, 0, 1).
|
||||
// The active-attribute reflection is cached per EGL context and program, and dropped when a program is linked,
|
||||
// loaded from a binary or deleted (program names are reused).
|
||||
#define MAXLOC 32
|
||||
#define NPROG 1024
|
||||
#define PROBE 16
|
||||
#define NCONV 256
|
||||
#define NCTX 16
|
||||
struct proginfo { EGLContext ctx; GLuint prog; uint32_t int_mask, unsigned_mask, float_mask; };
|
||||
struct convrec { EGLContext ctx; GLuint vao, buffer; int loc, used; GLint size, stride; GLenum type; const void *ptr;
|
||||
GLboolean normalized; };
|
||||
struct ctxstate { EGLContext ctx; uint32_t converted; };
|
||||
static struct proginfo progs[NPROG];
|
||||
static struct convrec convs[NCONV];
|
||||
static struct ctxstate ctxs[NCTX];
|
||||
static unsigned conv_next, ctx_next;
|
||||
static pthread_mutex_t attrib_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static int attrib_logs, reflect_logs, skip_logs;
|
||||
|
||||
static unsigned prog_slot(EGLContext ctx, GLuint prog) {
|
||||
uint64_t h = (uint64_t)(uintptr_t)ctx * 0x9E3779B97F4A7C15ull ^ (uint64_t)prog * 0x85EBCA6Bu;
|
||||
return (unsigned)((h ^ (h >> 29)) % NPROG);
|
||||
}
|
||||
|
||||
static void forget_program(GLuint prog) { // every context's entry: shared contexts see the same program name
|
||||
if (!prog || !int_attribs) return;
|
||||
pthread_mutex_lock(&attrib_lock);
|
||||
for (unsigned i = 0; i < NPROG; ++i)
|
||||
if (progs[i].prog == prog) progs[i] = (struct proginfo){0};
|
||||
pthread_mutex_unlock(&attrib_lock);
|
||||
}
|
||||
|
||||
static void reflect(struct proginfo *pi, GLuint prog) {
|
||||
GLint linked = 0, n = 0;
|
||||
d.GetProgramiv(prog, GL_LINK_STATUS, &linked);
|
||||
if (!linked) return;
|
||||
d.GetProgramiv(prog, GL_ACTIVE_ATTRIBUTES, &n);
|
||||
for (GLint i = 0; i < n; ++i) {
|
||||
char name[128] = {0};
|
||||
GLsizei len = 0;
|
||||
GLint size = 0;
|
||||
GLenum type = 0;
|
||||
d.GetActiveAttrib(prog, (GLuint)i, sizeof(name), &len, &size, &type, name);
|
||||
GLint loc = name[0] ? d.GetAttribLocation(prog, name) : -1;
|
||||
if (loc < 0) continue; // built-ins (gl_VertexID ...)
|
||||
int kind = glfix_int_attrib_kind(type), cols = glfix_attrib_columns(type);
|
||||
for (int k = 0; k < (size > 0 ? size : 1) * cols && loc + k < MAXLOC; ++k) {
|
||||
uint32_t bit = 1u << (loc + k);
|
||||
if (kind) pi->int_mask |= bit; else pi->float_mask |= bit;
|
||||
if (kind == 2) pi->unsigned_mask |= bit;
|
||||
}
|
||||
if (kind && reflect_logs < 16) {
|
||||
++reflect_logs;
|
||||
LOG("int attribs: program %u has integer input %s (%s) at location %d", prog, name,
|
||||
kind == 2 ? "unsigned" : "signed", loc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static struct proginfo *program_info(EGLContext ctx, GLuint prog) {
|
||||
unsigned h = prog_slot(ctx, prog), free_slot = NPROG;
|
||||
for (unsigned k = 0; k < PROBE; ++k) {
|
||||
struct proginfo *e = &progs[(h + k) % NPROG];
|
||||
if (e->prog == prog && e->ctx == ctx) return e;
|
||||
if (!e->prog && free_slot == NPROG) free_slot = (h + k) % NPROG;
|
||||
}
|
||||
struct proginfo *e = &progs[free_slot < NPROG ? free_slot : h];
|
||||
*e = (struct proginfo){.ctx = ctx, .prog = prog};
|
||||
reflect(e, prog);
|
||||
return e;
|
||||
}
|
||||
|
||||
static struct ctxstate *ctx_state(EGLContext ctx) {
|
||||
for (unsigned i = 0; i < NCTX; ++i)
|
||||
if (ctxs[i].ctx == ctx) return &ctxs[i];
|
||||
struct ctxstate *c = &ctxs[ctx_next++ % NCTX];
|
||||
*c = (struct ctxstate){.ctx = ctx};
|
||||
return c;
|
||||
}
|
||||
|
||||
static struct convrec *find_conv(EGLContext ctx, GLuint vao, int loc) {
|
||||
for (unsigned i = 0; i < NCONV; ++i)
|
||||
if (convs[i].used && convs[i].ctx == ctx && convs[i].vao == vao && convs[i].loc == loc) return &convs[i];
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void rebind(GLuint loc, GLuint buffer, GLint size, GLenum type, GLboolean normalized, GLsizei stride,
|
||||
const void *ptr, int integer) {
|
||||
GLint previous = 0;
|
||||
real_glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &previous);
|
||||
d.BindBuffer(GL_ARRAY_BUFFER, buffer);
|
||||
if (integer) d.VertexAttribIPointer(loc, size, type, stride, ptr);
|
||||
else d.VertexAttribPointer(loc, size, type, normalized, stride, ptr);
|
||||
d.BindBuffer(GL_ARRAY_BUFFER, (GLuint)previous);
|
||||
}
|
||||
|
||||
static void fix_attribs(void) {
|
||||
if (!int_attribs || !real_glGetIntegerv || !d.GetVertexAttribiv || !d.GetVertexAttribPointerv || !d.BindBuffer ||
|
||||
!d.VertexAttribIPointer || !d.VertexAttribPointer || !d.GetProgramiv || !d.GetActiveAttrib ||
|
||||
!d.GetAttribLocation)
|
||||
return;
|
||||
GLint prog = 0;
|
||||
real_glGetIntegerv(GL_CURRENT_PROGRAM, &prog);
|
||||
if (prog <= 0) return;
|
||||
EGLContext ctx = d.GetCurrentContext ? d.GetCurrentContext() : NULL;
|
||||
pthread_mutex_lock(&attrib_lock);
|
||||
struct proginfo *pi = program_info(ctx, (GLuint)prog);
|
||||
struct ctxstate *cs = ctx_state(ctx);
|
||||
uint32_t check = pi->int_mask | (pi->float_mask & cs->converted);
|
||||
if (!check) { pthread_mutex_unlock(&attrib_lock); return; }
|
||||
GLint vao = 0;
|
||||
real_glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao);
|
||||
for (int loc = 0; loc < MAXLOC; ++loc) {
|
||||
uint32_t bit = 1u << loc;
|
||||
if (!(check & bit)) continue;
|
||||
int want_int = (pi->int_mask & bit) != 0;
|
||||
GLint enabled = 0, integer = 0;
|
||||
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_ENABLED, &enabled);
|
||||
if (!enabled) {
|
||||
if (want_int && (pi->unsigned_mask & bit) && d.VertexAttribI4ui) d.VertexAttribI4ui((GLuint)loc, 0, 0, 0, 1);
|
||||
else if (want_int && d.VertexAttribI4i) d.VertexAttribI4i((GLuint)loc, 0, 0, 0, 1);
|
||||
continue;
|
||||
}
|
||||
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_INTEGER, &integer);
|
||||
if (want_int == (integer != 0)) continue;
|
||||
GLint buffer = 0, size = 0, type = 0, stride = 0, normalized = 0;
|
||||
void *ptr = NULL;
|
||||
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING, &buffer);
|
||||
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_SIZE, &size);
|
||||
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_TYPE, &type);
|
||||
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_STRIDE, &stride);
|
||||
d.GetVertexAttribiv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_NORMALIZED, &normalized);
|
||||
d.GetVertexAttribPointerv((GLuint)loc, GL_VERTEX_ATTRIB_ARRAY_POINTER, &ptr);
|
||||
if (vao && !buffer) continue; // client-side arrays only exist on the default vertex array
|
||||
if (want_int) {
|
||||
if (!glfix_int_component_type((unsigned)type)) {
|
||||
if (skip_logs < 8) {
|
||||
++skip_logs;
|
||||
LOG("int attribs: program %d location %d has data of type 0x%x, left as it is", prog, loc, type);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
rebind((GLuint)loc, (GLuint)buffer, size, (GLenum)type, (GLboolean)normalized, stride, ptr, 1);
|
||||
struct convrec *r = find_conv(ctx, (GLuint)vao, loc);
|
||||
if (!r) r = &convs[conv_next++ % NCONV];
|
||||
*r = (struct convrec){ctx, (GLuint)vao, (GLuint)buffer, loc, 1, size, stride, (GLenum)type, ptr,
|
||||
(GLboolean)normalized};
|
||||
cs->converted |= bit;
|
||||
if (attrib_logs < 24) {
|
||||
++attrib_logs;
|
||||
LOG("int attribs: program %d location %d: floats -> glVertexAttribIPointer (vao %d buffer %d size %d "
|
||||
"type 0x%x stride %d offset %p)", prog, loc, vao, buffer, size, type, stride, ptr);
|
||||
}
|
||||
} else {
|
||||
struct convrec *r = find_conv(ctx, (GLuint)vao, loc);
|
||||
if (!r || r->buffer != (GLuint)buffer || r->size != size || r->type != (GLenum)type || r->stride != stride ||
|
||||
r->ptr != ptr)
|
||||
continue; // the game set it up as integers itself
|
||||
rebind((GLuint)loc, (GLuint)buffer, size, (GLenum)type, r->normalized, stride, ptr, 0);
|
||||
r->used = 0;
|
||||
if (attrib_logs < 24) { ++attrib_logs; LOG("int attribs: program %d location %d: back to floats", prog, loc); }
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&attrib_lock);
|
||||
}
|
||||
|
||||
// Mesa's GLSL preprocessor rejects "#extension" after "#pragma" lines ("not allowed in the middle of a shader");
|
||||
// Quest drivers accept it. Neutralise #pragma lines (optimize/debug hints only) as sources are uploaded.
|
||||
typedef void (*PFN_SS)(GLuint, GLsizei, const GLchar *const *, const GLint *);
|
||||
@@ -323,6 +566,8 @@ static void w_shaderSource(GLuint sh, GLsizei count, const GLchar *const *strs,
|
||||
}
|
||||
}
|
||||
}
|
||||
char *highp = precision_fix(sh, src);
|
||||
if (highp) { free(src); src = highp; }
|
||||
const GLchar *one = src;
|
||||
r_shaderSourceSet(sh, 1, &one, NULL);
|
||||
free(src);
|
||||
@@ -334,12 +579,24 @@ __attribute__((constructor)) static void shim_ctor(void) {
|
||||
|
||||
static void init(void) {
|
||||
read_conf();
|
||||
gles = dlopen("libGLESv3.so", RTLD_NOW | RTLD_LOCAL);
|
||||
void *egl = dlopen("libEGL.so", RTLD_NOW | RTLD_LOCAL);
|
||||
gles = dlopen(GLSHIM_GLES_LIB, RTLD_NOW | RTLD_LOCAL);
|
||||
void *egl = dlopen(GLSHIM_EGL_LIB, RTLD_NOW | RTLD_LOCAL);
|
||||
real_glGetString = gles ? dlsym(gles, "glGetString") : NULL;
|
||||
real_glGetStringi = gles ? dlsym(gles, "glGetStringi") : NULL;
|
||||
real_glGetIntegerv = gles ? dlsym(gles, "glGetIntegerv") : NULL;
|
||||
real_eglGetProcAddress = egl ? dlsym(egl, "eglGetProcAddress") : NULL;
|
||||
#define REAL(field, sym) d.field = gles ? dlsym(gles, sym) : NULL;
|
||||
REAL(ShaderSource, "glShaderSource") REAL(GetShaderiv, "glGetShaderiv") REAL(LinkProgram, "glLinkProgram")
|
||||
REAL(ProgramBinary, "glProgramBinary") REAL(DeleteProgram, "glDeleteProgram") REAL(GetProgramiv, "glGetProgramiv")
|
||||
REAL(GetActiveAttrib, "glGetActiveAttrib") REAL(GetAttribLocation, "glGetAttribLocation")
|
||||
REAL(GetVertexAttribiv, "glGetVertexAttribiv") REAL(GetVertexAttribPointerv, "glGetVertexAttribPointerv")
|
||||
REAL(VertexAttribPointer, "glVertexAttribPointer") REAL(VertexAttribIPointer, "glVertexAttribIPointer")
|
||||
REAL(VertexAttribI4i, "glVertexAttribI4i") REAL(VertexAttribI4ui, "glVertexAttribI4ui")
|
||||
REAL(BindBuffer, "glBindBuffer") REAL(DrawArrays, "glDrawArrays") REAL(DrawElements, "glDrawElements")
|
||||
REAL(DrawArraysInstanced, "glDrawArraysInstanced") REAL(DrawElementsInstanced, "glDrawElementsInstanced")
|
||||
REAL(DrawRangeElements, "glDrawRangeElements")
|
||||
#undef REAL
|
||||
d.GetCurrentContext = egl ? dlsym(egl, "eglGetCurrentContext") : NULL;
|
||||
static const char *const engines[] = {"libunity.so", "libUE4.so", "libUnreal.so"};
|
||||
int engine_found = 0;
|
||||
for (unsigned i = 0; i < sizeof engines / sizeof *engines && !engine_found; i++) {
|
||||
@@ -355,6 +612,9 @@ static void init(void) {
|
||||
unreal = unreal && hide_msrtt && !hide_multiview; // only matters while MSRTT is hidden and multiview kept
|
||||
LOG("GL shim active: hide_multiview=%d hide_msrtt=%d%s", hide_multiview, hide_msrtt,
|
||||
unreal ? " (Unreal: multiview MSRTT kept, drawn single-sampled)" : "");
|
||||
if (int_attribs || highp_markers[0])
|
||||
LOG("GL shim per-game fixes: gl_int_attribs=%d gl_highp_markers=%s", int_attribs,
|
||||
highp_markers[0] ? highp_markers : "(none)");
|
||||
}
|
||||
|
||||
static int is_hidden(const char *name) {
|
||||
@@ -440,6 +700,100 @@ static void plain_ftmsmv(GLenum target, GLenum attachment, GLuint texture, GLint
|
||||
if (r_plain_ftmv) r_plain_ftmv(target, attachment, texture, level, base, views);
|
||||
}
|
||||
|
||||
// gl_int_attribs: draws looked up through eglGetProcAddress (wrapped only when the setting is on)
|
||||
typedef void (*PFN_PB)(GLuint, GLenum, const void *, GLsizei);
|
||||
typedef void (*PFN_DEBV)(GLenum, GLsizei, GLenum, const void *, GLint);
|
||||
typedef void (*PFN_DREBV)(GLenum, GLuint, GLuint, GLsizei, GLenum, const void *, GLint);
|
||||
typedef void (*PFN_DEIBV)(GLenum, GLsizei, GLenum, const void *, GLsizei, GLint);
|
||||
static PFN_PB e_pb, e_pboes;
|
||||
static PFN_V_U e_dp;
|
||||
static PFN_DA e_da;
|
||||
static PFN_DE e_de;
|
||||
static PFN_DAI e_dai;
|
||||
static PFN_DEI e_dei;
|
||||
static PFN_DRE e_dre;
|
||||
static PFN_DEBV e_debv0, e_debv1, e_debv2;
|
||||
static PFN_DREBV e_drebv0, e_drebv1, e_drebv2;
|
||||
static PFN_DEIBV e_deibv0, e_deibv1, e_deibv2;
|
||||
static void x_pb(GLuint p, GLenum f, const void *b, GLsizei n) { e_pb(p, f, b, n); forget_program(p); }
|
||||
static void x_pboes(GLuint p, GLenum f, const void *b, GLsizei n) { e_pboes(p, f, b, n); forget_program(p); }
|
||||
static void x_dp(GLuint p) { e_dp(p); forget_program(p); }
|
||||
static void x_da(GLenum m, GLint f, GLsizei c) { fix_attribs(); e_da(m, f, c); }
|
||||
static void x_de(GLenum m, GLsizei c, GLenum t, const void *i) { fix_attribs(); e_de(m, c, t, i); }
|
||||
static void x_dai(GLenum m, GLint f, GLsizei c, GLsizei n) { fix_attribs(); e_dai(m, f, c, n); }
|
||||
static void x_dei(GLenum m, GLsizei c, GLenum t, const void *i, GLsizei n) { fix_attribs(); e_dei(m, c, t, i, n); }
|
||||
static void x_dre(GLenum m, GLuint s, GLuint e, GLsizei c, GLenum t, const void *i) {
|
||||
fix_attribs();
|
||||
e_dre(m, s, e, c, t, i);
|
||||
}
|
||||
#define BV(n) \
|
||||
static void x_debv##n(GLenum m, GLsizei c, GLenum t, const void *i, GLint b) { fix_attribs(); e_debv##n(m, c, t, i, b); } \
|
||||
static void x_drebv##n(GLenum m, GLuint s, GLuint e, GLsizei c, GLenum t, const void *i, GLint b) { \
|
||||
fix_attribs(); e_drebv##n(m, s, e, c, t, i, b); } \
|
||||
static void x_deibv##n(GLenum m, GLsizei c, GLenum t, const void *i, GLsizei k, GLint b) { \
|
||||
fix_attribs(); e_deibv##n(m, c, t, i, k, b); }
|
||||
BV(0) BV(1) BV(2)
|
||||
#undef BV
|
||||
|
||||
// Direct imports of a game library that loads the shim first (DT_NEEDED): these come before libGLESv3's. Plain
|
||||
// pass-through unless a per-game fix is on.
|
||||
EXPORT void glShaderSource(GLuint sh, GLsizei count, const GLchar *const *strs, const GLint *lens) {
|
||||
pthread_once(&once, init);
|
||||
if (!d.ShaderSource) return;
|
||||
if (highp_markers[0] && count > 0 && strs) {
|
||||
char *src = concat_sources(count, strs, lens);
|
||||
char *highp = src ? precision_fix(sh, src) : NULL;
|
||||
free(src);
|
||||
if (highp) {
|
||||
const GLchar *one = highp;
|
||||
d.ShaderSource(sh, 1, &one, NULL);
|
||||
free(highp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
d.ShaderSource(sh, count, strs, lens);
|
||||
}
|
||||
EXPORT void glLinkProgram(GLuint p) {
|
||||
pthread_once(&once, init);
|
||||
if (d.LinkProgram) d.LinkProgram(p);
|
||||
forget_program(p);
|
||||
}
|
||||
EXPORT void glProgramBinary(GLuint p, GLenum f, const void *b, GLsizei n) {
|
||||
pthread_once(&once, init);
|
||||
if (d.ProgramBinary) d.ProgramBinary(p, f, b, n);
|
||||
forget_program(p);
|
||||
}
|
||||
EXPORT void glDeleteProgram(GLuint p) {
|
||||
pthread_once(&once, init);
|
||||
if (d.DeleteProgram) d.DeleteProgram(p);
|
||||
forget_program(p);
|
||||
}
|
||||
EXPORT void glDrawArrays(GLenum m, GLint f, GLsizei c) {
|
||||
pthread_once(&once, init);
|
||||
fix_attribs();
|
||||
if (d.DrawArrays) d.DrawArrays(m, f, c);
|
||||
}
|
||||
EXPORT void glDrawElements(GLenum m, GLsizei c, GLenum t, const void *i) {
|
||||
pthread_once(&once, init);
|
||||
fix_attribs();
|
||||
if (d.DrawElements) d.DrawElements(m, c, t, i);
|
||||
}
|
||||
EXPORT void glDrawArraysInstanced(GLenum m, GLint f, GLsizei c, GLsizei n) {
|
||||
pthread_once(&once, init);
|
||||
fix_attribs();
|
||||
if (d.DrawArraysInstanced) d.DrawArraysInstanced(m, f, c, n);
|
||||
}
|
||||
EXPORT void glDrawElementsInstanced(GLenum m, GLsizei c, GLenum t, const void *i, GLsizei n) {
|
||||
pthread_once(&once, init);
|
||||
fix_attribs();
|
||||
if (d.DrawElementsInstanced) d.DrawElementsInstanced(m, c, t, i, n);
|
||||
}
|
||||
EXPORT void glDrawRangeElements(GLenum m, GLuint s, GLuint e, GLsizei c, GLenum t, const void *i) {
|
||||
pthread_once(&once, init);
|
||||
fix_attribs();
|
||||
if (d.DrawRangeElements) d.DrawRangeElements(m, s, e, c, t, i);
|
||||
}
|
||||
|
||||
__attribute__((visibility("default"))) __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char *name) {
|
||||
pthread_once(&once, init);
|
||||
if (name && !strcmp(name, "glGetStringi")) return (__eglMustCastToProperFunctionPointerType)glGetStringi;
|
||||
@@ -477,6 +831,25 @@ __attribute__((visibility("default"))) __eglMustCastToProperFunctionPointerType
|
||||
WRAP("glShaderSource", r_shaderSourceSet, w_shaderSource)
|
||||
WRAP("glCompileShader", r_compile, w_compile)
|
||||
WRAP("glLinkProgram", r_link, w_link)
|
||||
if (int_attribs) {
|
||||
WRAP("glProgramBinary", e_pb, x_pb)
|
||||
WRAP("glProgramBinaryOES", e_pboes, x_pboes)
|
||||
WRAP("glDeleteProgram", e_dp, x_dp)
|
||||
WRAP("glDrawArrays", e_da, x_da)
|
||||
WRAP("glDrawElements", e_de, x_de)
|
||||
WRAP("glDrawArraysInstanced", e_dai, x_dai)
|
||||
WRAP("glDrawElementsInstanced", e_dei, x_dei)
|
||||
WRAP("glDrawRangeElements", e_dre, x_dre)
|
||||
WRAP("glDrawElementsBaseVertex", e_debv0, x_debv0)
|
||||
WRAP("glDrawElementsBaseVertexEXT", e_debv1, x_debv1)
|
||||
WRAP("glDrawElementsBaseVertexOES", e_debv2, x_debv2)
|
||||
WRAP("glDrawRangeElementsBaseVertex", e_drebv0, x_drebv0)
|
||||
WRAP("glDrawRangeElementsBaseVertexEXT", e_drebv1, x_drebv1)
|
||||
WRAP("glDrawRangeElementsBaseVertexOES", e_drebv2, x_drebv2)
|
||||
WRAP("glDrawElementsInstancedBaseVertex", e_deibv0, x_deibv0)
|
||||
WRAP("glDrawElementsInstancedBaseVertexEXT", e_deibv1, x_deibv1)
|
||||
WRAP("glDrawElementsInstancedBaseVertexOES", e_deibv2, x_deibv2)
|
||||
}
|
||||
#ifdef GLSHIM_TRACE
|
||||
WRAP("glBindFramebuffer", r_bindFb, w_bindFb)
|
||||
WRAP("glFramebufferTexture2D", r_ft2d, w_ft2d)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// FramePort: the VrApi FOV properties (7/8) before tracking starts.
|
||||
//
|
||||
// VrApi games may read the FOV right after vrapi_Initialize and cache it (BlazeRush builds its projection and its
|
||||
// TexCoordsFromTanAngles matrix from it once). OpenXR has no FOV before a session exists unless the runtime offers
|
||||
// XR_EPIC_view_configuration_fov, which the Steam Frame's doesn't, so the bridge answered 90 x 90 degrees and those
|
||||
// games kept a narrow, square view. The bridge now saves the live FOV of each session (it is a property of the
|
||||
// headset, not of the game's state) in the app's external files folder and answers with it the next time the
|
||||
// properties are asked for before a session has located its views. The headset's FOV is never hardcoded: the first
|
||||
// session on a Frame still starts at 90 degrees (FramePort's launch test after an install is that session).
|
||||
// Plain functions, no Android or OpenXR types: tests/test_blazerush.py builds them for the host.
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
namespace ovp {
|
||||
namespace fovcache {
|
||||
constexpr const char* FileName = "framebridge-vrapi-fov.txt";
|
||||
// symmetric coverage angles a VR headset can have (the Frame reports about 118 x 120)
|
||||
inline bool plausible(float horizontal, float vertical) {
|
||||
return std::isfinite(horizontal) && std::isfinite(vertical) && horizontal >= 60.0f && horizontal <= 170.0f &&
|
||||
vertical >= 60.0f && vertical <= 170.0f;
|
||||
}
|
||||
// "fov_h=118.14\nfov_v=119.55\n" (other lines ignored); false unless both are there and plausible
|
||||
inline bool parse(const char* text, float out[2]) {
|
||||
float h = NAN, v = NAN;
|
||||
for (const char* line = text; line && *line; line = std::strchr(line, '\n'), line = line ? line + 1 : nullptr) {
|
||||
float value = 0;
|
||||
if (std::sscanf(line, "fov_h=%f", &value) == 1) h = value;
|
||||
else if (std::sscanf(line, "fov_v=%f", &value) == 1) v = value;
|
||||
}
|
||||
if (!plausible(h, v)) return false;
|
||||
out[0] = h;
|
||||
out[1] = v;
|
||||
return true;
|
||||
}
|
||||
inline int format(char* buffer, size_t size, const float fov[2]) {
|
||||
return std::snprintf(buffer, size, "fov_h=%.3f\nfov_v=%.3f\n", fov[0], fov[1]);
|
||||
}
|
||||
// worth rewriting the saved value: none saved yet, or a change of more than 0.01 degrees on an axis
|
||||
inline bool changed(bool haveSaved, const float saved[2], const float now[2]) {
|
||||
return !haveSaved || std::fabs(saved[0] - now[0]) > 0.01f || std::fabs(saved[1] - now[1]) > 0.01f;
|
||||
}
|
||||
// the external files folder's file for the package in /proc/self/cmdline ("pkg" or "pkg:process"); false if unknown
|
||||
inline bool path(const char* cmdline, char* buffer, size_t size) {
|
||||
char package[256] = {0};
|
||||
std::strncpy(package, cmdline ? cmdline : "", sizeof(package) - 1);
|
||||
if (char* colon = std::strchr(package, ':')) *colon = 0;
|
||||
if (!*package || std::strchr(package, '/') || std::strstr(package, "..")) return false;
|
||||
const int n = std::snprintf(buffer, size, "/sdcard/Android/data/%s/files/%s", package, FileName);
|
||||
return n > 0 && static_cast<size_t>(n) < size;
|
||||
}
|
||||
} // namespace fovcache
|
||||
} // namespace ovp
|
||||
@@ -1,8 +1,10 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#include "runtime.h"
|
||||
#include "fov_cache.h"
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <dlfcn.h>
|
||||
#include <thread>
|
||||
@@ -45,6 +47,49 @@ static bool load(const char* name, PFN_xrVoidFunction* target) {
|
||||
auto& s = runtime();
|
||||
return xrOk(s.xr.xrGetInstanceProcAddr(s.instance, name, target), name) && *target;
|
||||
}
|
||||
// FramePort: the live FOV of earlier sessions for games that read it before any session (see fov_cache.h)
|
||||
static bool savedFovPath(char* buffer, size_t size) {
|
||||
char cmdline[256] = {0};
|
||||
if (FILE* f = std::fopen("/proc/self/cmdline", "r")) {
|
||||
cmdline[std::fread(cmdline, 1, sizeof(cmdline) - 1, f)] = 0;
|
||||
std::fclose(f);
|
||||
}
|
||||
return fovcache::path(cmdline, buffer, size);
|
||||
}
|
||||
static void loadSavedFov() {
|
||||
auto& s = runtime();
|
||||
char path[512], text[256] = {0};
|
||||
if (!savedFovPath(path, sizeof(path))) return;
|
||||
FILE* f = std::fopen(path, "r");
|
||||
if (!f) return;
|
||||
text[std::fread(text, 1, sizeof(text) - 1, f)] = 0;
|
||||
std::fclose(f);
|
||||
float fov[2];
|
||||
if (!fovcache::parse(text, fov)) return;
|
||||
s.savedFovDegrees = {fov[0], fov[1]};
|
||||
s.savedFovValid = true;
|
||||
OVP_LOG("FOV of an earlier session: %.2f x %.2f degrees", fov[0], fov[1]);
|
||||
}
|
||||
static void saveLiveFov() {
|
||||
auto& s = runtime();
|
||||
const float now[2] = {s.locatedFovDegrees[0], s.locatedFovDegrees[1]};
|
||||
const float saved[2] = {s.savedFovDegrees[0], s.savedFovDegrees[1]};
|
||||
if (!s.locatedFovValid || !fovcache::plausible(now[0], now[1]) || !fovcache::changed(s.savedFovValid, saved, now))
|
||||
return;
|
||||
s.savedFovDegrees = {now[0], now[1]}; // also when it can't be written: tried once per change, not every frame
|
||||
s.savedFovValid = true;
|
||||
char path[512], tmp[540], text[96];
|
||||
if (!savedFovPath(path, sizeof(path))) return;
|
||||
std::snprintf(tmp, sizeof(tmp), "%s.tmp", path);
|
||||
FILE* f = std::fopen(tmp, "w");
|
||||
if (!f) return;
|
||||
const int n = fovcache::format(text, sizeof(text), now);
|
||||
const bool ok = n > 0 && std::fwrite(text, 1, static_cast<size_t>(n), f) == static_cast<size_t>(n);
|
||||
if (std::fclose(f) == 0 && ok && std::rename(tmp, path) == 0)
|
||||
OVP_LOG("live FOV %.2f x %.2f degrees saved for the next start", now[0], now[1]);
|
||||
else
|
||||
std::remove(tmp);
|
||||
}
|
||||
static bool referenceSpace(XrReferenceSpaceType type, const XrPosef& pose, XrSpace& space) {
|
||||
auto& s = runtime();
|
||||
XrReferenceSpaceCreateInfo info{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
|
||||
@@ -94,6 +139,9 @@ static void shutdown() {
|
||||
s.locatedFovValid = false;
|
||||
s.recommendedFovDegrees = {};
|
||||
s.locatedFovDegrees = {};
|
||||
s.savedFovValid = false;
|
||||
s.savedFovDegrees = {};
|
||||
s.loggedFovSource = -1;
|
||||
s.recenterCount = 0;
|
||||
s.loggedDisplayRefreshRateSource = DisplayRefreshRateSource::Unqueried;
|
||||
s.loggedDisplayRefreshRate = 0;
|
||||
@@ -284,6 +332,23 @@ bool beginFrame(int64_t index) {
|
||||
// Steam Frame reports poses as VALID without the TRACKED bits here, so only validity is required.
|
||||
constexpr XrSpaceLocationFlags tracked = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_POSITION_VALID_BIT;
|
||||
const bool visible = s.sessionState == XR_SESSION_STATE_VISIBLE || s.sessionState == XR_SESSION_STATE_FOCUSED;
|
||||
if (!s.locatedFovValid) {
|
||||
// The views' FOV doesn't depend on tracking: take it as soon as the session runs (also headless, without
|
||||
// anyone wearing the headset), so it is saved for games that ask before their next session.
|
||||
XrViewLocateInfo fovInfo{XR_TYPE_VIEW_LOCATE_INFO};
|
||||
fovInfo.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
|
||||
fovInfo.displayTime = s.frame.predictedDisplayTime;
|
||||
fovInfo.space = s.viewSpace;
|
||||
XrViewState fovState{XR_TYPE_VIEW_STATE};
|
||||
uint32_t fovCount = 0;
|
||||
if (XR_SUCCEEDED(s.xr.xrLocateViews(s.session, &fovInfo, &fovState, 2, &fovCount, s.views.data())) &&
|
||||
fovCount == 2 && symmetricFovDegrees(s.views[0].fov, s.views[1].fov, s.locatedFovDegrees)) {
|
||||
s.locatedFovValid = true;
|
||||
OVP_LOG("FOV located before tracking: %.2f x %.2f degrees (view state 0x%llx)", s.locatedFovDegrees[0],
|
||||
s.locatedFovDegrees[1], (unsigned long long)fovState.viewStateFlags);
|
||||
saveLiveFov();
|
||||
}
|
||||
}
|
||||
const bool located = visible &&
|
||||
xrOk(s.xr.xrLocateSpace(s.viewSpace, s.localSpace, s.frame.predictedDisplayTime, &head), "initial eye-level origin");
|
||||
static int logged;
|
||||
@@ -325,6 +390,7 @@ bool beginFrame(int64_t index) {
|
||||
}
|
||||
s.locatedFovValid = symmetricFovDegrees(
|
||||
s.views[0].fov, s.views[1].fov, s.locatedFovDegrees);
|
||||
saveLiveFov();
|
||||
input::sync();
|
||||
return true;
|
||||
}
|
||||
@@ -519,6 +585,7 @@ VRAPI int vrapi_Initialize(const ovrInitParms* parms) {
|
||||
symmetricFovDegrees(s.viewConfigFov[0].recommendedFov,
|
||||
s.viewConfigFov[1].recommendedFov,
|
||||
s.recommendedFovDegrees);
|
||||
if (!s.recommendedFovValid) loadSavedFov();
|
||||
s.colorSpaceExtension = has(XR_FB_COLOR_SPACE_EXTENSION_NAME);
|
||||
s.performanceExtension = has(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
|
||||
s.threadExtension = has(XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME);
|
||||
@@ -680,10 +747,20 @@ VRAPI float vrapi_GetSystemPropertyFloat(const ovrJava*, int property) {
|
||||
if (property == 4) return queryDisplayRefreshRate();
|
||||
if (property == 7 || property == 8) {
|
||||
const size_t axis = static_cast<size_t>(property - 7);
|
||||
// Live optical FOV supersedes a provisional startup recommendation.
|
||||
if (s.locatedFovValid) return s.locatedFovDegrees[axis];
|
||||
if (s.recommendedFovValid) return s.recommendedFovDegrees[axis];
|
||||
return 90.0f;
|
||||
// Live optical FOV supersedes a provisional startup recommendation; before any session (no runtime
|
||||
// recommendation): the live FOV saved by an earlier session, else 90 degrees.
|
||||
static const char* const sources[] = {"live", "runtime recommendation", "saved by an earlier session",
|
||||
"default, nothing known before tracking"};
|
||||
int source = 3;
|
||||
std::array<float, 2> fov{90.0f, 90.0f};
|
||||
if (s.locatedFovValid) { source = 0; fov = s.locatedFovDegrees; }
|
||||
else if (s.recommendedFovValid) { source = 1; fov = s.recommendedFovDegrees; }
|
||||
else if (s.savedFovValid) { source = 2; fov = s.savedFovDegrees; }
|
||||
if (source != s.loggedFovSource) {
|
||||
s.loggedFovSource = source;
|
||||
OVP_LOG("FOV properties: %.2f x %.2f degrees (%s)", fov[0], fov[1], sources[source]);
|
||||
}
|
||||
return fov[axis];
|
||||
}
|
||||
OVP_LOG("Unsupported float system property %d", property);
|
||||
return 0;
|
||||
|
||||
@@ -60,6 +60,12 @@ struct Runtime {
|
||||
std::array<XrViewConfigurationViewFovEPIC, 2> viewConfigFov{};
|
||||
std::array<float, 2> recommendedFovDegrees{};
|
||||
std::array<float, 2> locatedFovDegrees{};
|
||||
// FramePort: the last live FOV of an earlier session (framebridge-vrapi-fov.txt in the app's external files).
|
||||
// Games that read VrApi's FOV properties right after vrapi_Initialize (before any session, e.g. BlazeRush) and
|
||||
// cache them get this instead of a 90 degree guess when the runtime has no XR_EPIC_view_configuration_fov.
|
||||
std::array<float, 2> savedFovDegrees{};
|
||||
bool savedFovValid = false;
|
||||
int loggedFovSource = -1;
|
||||
std::array<XrView, 2> views{{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}}};
|
||||
ovrSystemCreateInfoVulkan vk{};
|
||||
// GLES apps (no vrapi_CreateSystemVulkan): EGL objects from ovrModeParms.
|
||||
|
||||
@@ -25,7 +25,7 @@ LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "fr
|
||||
"device"}
|
||||
# FramePort's own patch ids must exist (OVRPort's are listed by its CLI at runtime, so not checked here)
|
||||
OWN_PATCH_FIELDS = ("frame", "frame_remove", "pcvr", "pcvr_remove", "device")
|
||||
DICT_FIELDS = {"adapter", "device_files", "lepton_env", "proton_env", "verified"}
|
||||
DICT_FIELDS = {"adapter", "device_files", "config_sync", "lepton_env", "proton_env", "verified"}
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,80}$")
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ def validate(d: dict) -> CatalogEntry:
|
||||
for k in DICT_FIELDS & set(d):
|
||||
if not isinstance(d[k], dict):
|
||||
raise Invalid(f"{k} must be a mapping")
|
||||
for k in ("device_files",):
|
||||
for k in ("device_files", "config_sync"):
|
||||
for path in (d.get(k) or {}):
|
||||
if not isinstance(path, str) or path.startswith("/") or ".." in Path(path).parts:
|
||||
raise Invalid(f"{k}: bad path {path!r}")
|
||||
|
||||
@@ -490,6 +490,13 @@ class FakePairingServer:
|
||||
self.asks: list = [] # the setup URL: the `frame_asks` step hook plays a Frame asking (Allow / Deny)
|
||||
self.running = False
|
||||
|
||||
def open_asks(self) -> list:
|
||||
"""Like PairingServer.open_asks (requests never expire on film)."""
|
||||
return [a for a in self.asks if a.state == "open"]
|
||||
|
||||
def prune(self, now: float | None = None) -> list:
|
||||
return []
|
||||
|
||||
def decide(self, ask_id: str, allow: bool) -> None:
|
||||
for a in self.asks:
|
||||
if a.id == ask_id and a.state == "open":
|
||||
|
||||
@@ -139,21 +139,21 @@ def join(clips: list[Path], durations: list[float], out: Path, transition: float
|
||||
|
||||
|
||||
def encode_within(clips: list[Path], durations: list[float], out: Path, budget_mb: float, transition: float = 0.5,
|
||||
crf: int = 22, max_crf: int = 32) -> int:
|
||||
crf: int = 17, max_crf: int = 30) -> int:
|
||||
"""The final MP4 at the best quality (lowest CRF) that fits `budget_mb`. Returns the CRF used."""
|
||||
while True:
|
||||
join(clips, durations, out, transition, crf)
|
||||
if out.stat().st_size <= budget_mb * 1e6 or crf >= max_crf:
|
||||
return crf
|
||||
crf += 2
|
||||
crf += 1
|
||||
|
||||
|
||||
def poster(video: Path, at: float, out: Path) -> None:
|
||||
run(["-ss", f"{at:.2f}", "-i", video, "-frames:v", "1", "-q:v", "2", out])
|
||||
|
||||
|
||||
def teaser_webp(clips: list[Path], durations: list[float], out: Path, width: int = 960, fps: int = 15,
|
||||
budget_mb: float = 4.0, transition: float = 0.35) -> int:
|
||||
def teaser_webp(clips: list[Path], durations: list[float], out: Path, width: int = 1280, fps: int = 20,
|
||||
budget_mb: float = 8.0, transition: float = 0.35) -> int:
|
||||
"""A looping animated WebP of the highlight clips (README: plays inline where an MP4 wouldn't). Lowers the
|
||||
quality until it fits `budget_mb`. Returns the quality used."""
|
||||
graph, label = xfade_graph(durations, transition)
|
||||
@@ -161,13 +161,15 @@ def teaser_webp(clips: list[Path], durations: list[float], out: Path, width: int
|
||||
args = []
|
||||
for c in clips:
|
||||
args += ["-i", c]
|
||||
quality = 72
|
||||
# UI footage: the "drawing" preset keeps text and edges crisp; start near-lossless (quality 72 at 960 px showed
|
||||
# blocky text on GitHub) and only step down when the file would be too big for a README
|
||||
quality = 95
|
||||
while True:
|
||||
run([*args, "-filter_complex", graph, "-map", label, "-an", "-c:v", "libwebp_anim", "-lossless", "0",
|
||||
"-quality", quality, "-compression_level", "6", "-loop", "0", out])
|
||||
if out.stat().st_size <= budget_mb * 1e6 or quality <= 30:
|
||||
"-preset", "drawing", "-quality", quality, "-compression_level", "6", "-loop", "0", out])
|
||||
if out.stat().st_size <= budget_mb * 1e6 or quality <= 50:
|
||||
return quality
|
||||
quality -= 8
|
||||
quality -= 5
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ cards and captions
|
||||
|
||||
@@ -39,7 +39,7 @@ START_KEYS = {"profile", "frame", "stream", "route"}
|
||||
SCENE_KEYS = {"name", "caption", "teaser", "hold", "steps", "card", "seconds"}
|
||||
CARD_KEYS = {"eyebrow", "heading", "line", "steps", "code", "note"}
|
||||
VIEWPORT = (1440, 810) # the layout size; filmed at 4/3 device pixels = 1920x1080
|
||||
TEASER_MB = 4.0
|
||||
TEASER_MB = 8.0
|
||||
|
||||
|
||||
def video_names() -> list[str]:
|
||||
|
||||
@@ -281,6 +281,8 @@ def main() -> int:
|
||||
a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
if args.links:
|
||||
from frameport import deeplink
|
||||
from frameport.ui import components as C
|
||||
from frameport.ui import easter
|
||||
from frameport.ui.views import link_dialog
|
||||
|
||||
fake = deeplink.Manifest("Example Game", [
|
||||
@@ -305,6 +307,11 @@ def main() -> int:
|
||||
steps = [("library", lambda a: a.navigate(0)),
|
||||
("links-paste", lambda a: a.pick_link()),
|
||||
("links-confirm", lambda a: (a.page.pop_dialog(), link_dialog._confirm(a, fake, sizes, False, icon))),
|
||||
# the homepage's example button: the demo question (no network), then its Install's easter egg
|
||||
("links-demo", lambda a: (a.page.pop_dialog(), a.open_install_link(
|
||||
deeplink.make_link(deeplink.DEMO_MANIFEST), from_web=True))),
|
||||
("links-demo-install", lambda a: (a.page.pop_dialog(), easter.demo_install(
|
||||
a, C._asset_src(link_dialog.DEMO_COVER)), time.sleep(0.9))),
|
||||
("links-settings", lambda a: (a.page.pop_dialog(), a.go("settings")))]
|
||||
if game:
|
||||
steps.append(("links-display-mode", lambda a: a.open_game(game, advanced=True)))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema": "framedrop.install/v1",
|
||||
"name": "Cool Game",
|
||||
"frameport": {
|
||||
"description": "A placeholder game for the example \"Install with FramePort\" button. FramePort recognises this link and shows a demo: nothing is downloaded or installed.",
|
||||
"icon": "https://frameport.app/demo/cool-game.svg"
|
||||
},
|
||||
"files": [
|
||||
{ "url": "https://frameport.app/demo/cool-game-arm64.apk" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="96" viewBox="0 0 72 96"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2D7FF9"/><stop offset=".55" stop-color="#7A4BD8"/><stop offset="1" stop-color="#FF8A3D"/></linearGradient><radialGradient id="sun" cx=".4" cy=".35" r=".75"><stop offset="0" stop-color="#FFE48A"/><stop offset="1" stop-color="#FFB42E"/></radialGradient></defs><rect width="72" height="96" fill="url(#bg)"/><path d="M0 74 18 60 30 68 46 54 72 72V96H0z" fill="#101218" fill-opacity=".35"/><circle cx="36" cy="40" r="20" fill="url(#sun)" stroke="#101218" stroke-width="2.4"/><path d="M17.5 35.5h37M20 35.5h13l-1.4 6.2a3.4 3.4 0 0 1-3.3 2.6h-4.6A3.4 3.4 0 0 1 20.4 42zM39 35.5h13l-.4 6.5a3.4 3.4 0 0 1-3.3 2.3h-4.6a3.4 3.4 0 0 1-3.3-2.6z" fill="#101218" stroke="#101218" stroke-width="2" stroke-linejoin="round"/><path d="M23.5 37.5h4" stroke="#FFFFFF" stroke-opacity=".7" stroke-width="1.4" stroke-linecap="round"/><path d="M29 50.5c4 3.2 10 3.2 14 0" fill="none" stroke="#101218" stroke-width="2.4" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -26,8 +26,8 @@ const snippet = `<a href="${pageUrl}?manifest=https%3A%2F%2Fexample.com%2Fmy-gam
|
||||
<h3>An "Install with FramePort" button</h3>
|
||||
<p class="muted">Put it on your download page. A click opens FramePort, which installs your game on the
|
||||
visitor's Frame.</p>
|
||||
<div class="badge-row"><InstallBadge id="makers" href={url('install/?url=https%3A%2F%2Fexample.com%2Fgames%2Fcool-game-arm64.apk')}
|
||||
label="Try it: what a visitor without FramePort sees" /><span class="try">← try it</span></div>
|
||||
<div class="badge-row"><InstallBadge id="makers" href={url('install/?manifest=https%3A%2F%2Fframeport.app%2Fdemo%2Fcool-game.json')}
|
||||
label="Try it: a demo install link (nothing is downloaded)" /><span class="try">← try it</span></div>
|
||||
<a class="small" href={url('docs/install-button/')}>How to add the button</a>
|
||||
</div>
|
||||
<div class="snippet">
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
// How it works for PC VR games (Windows VR games): on the Frame through Proton, on the PC through SteamVR (Revive for
|
||||
// Oculus Rift games), and two paths that only exist on experimental branches. Closed by default, like Compare's table.
|
||||
import Glyph from './Glyph.astro';
|
||||
import { REPO, url } from '../data/site';
|
||||
|
||||
type Step = { glyph: Parameters<typeof Glyph>[0]['name']; side: 'pc' | 'frame'; name: string; text: string };
|
||||
const onFrame: Step[] = [
|
||||
{ glyph: 'pc', side: 'pc', name: 'scan', text: 'One folder per game. FramePort finds the program that starts it and asks when there are several.' },
|
||||
{ glyph: 'container', side: 'pc', name: 'analyze', text: 'Reads which VR interface the game uses (OpenXR, OpenVR or Oculus) from its files.' },
|
||||
{ glyph: 'sync', side: 'frame', name: 'upload', text: 'The game goes to the Frame unchanged. Proton, Valve’s layer for Windows games, is installed for you.' },
|
||||
{ glyph: 'patch', side: 'frame', name: 'patch', text: 'A small OpenXR layer lets the game talk to the Frame’s SteamVR. Unreal games start without their crash reporter.' },
|
||||
{ glyph: 'frame', side: 'frame', name: 'library', text: 'The game shows up in your Steam library and starts in the headset like any other.' },
|
||||
];
|
||||
const onPc: Step[] = [
|
||||
{ glyph: 'pc', side: 'pc', name: 'install', text: '“Install on this PC” adds the game to Steam on your PC, with its artwork.' },
|
||||
{ glyph: 'port', side: 'pc', name: 'revive', text: 'Oculus Rift games get Revive automatically: your installed Revive if you have one, else a private copy unpacked from Revive’s own installer. Your install is never replaced.' },
|
||||
{ glyph: 'live', side: 'pc', name: 'play', text: 'Play starts SteamVR. After a session, FramePort picks a refresh rate and motion smoothing that the game keeps up with.' },
|
||||
{ glyph: 'frame', side: 'frame', name: 'stream', text: 'Steam Link streams the game from your PC to the Frame.' },
|
||||
];
|
||||
---
|
||||
<details class="pcvr reveal">
|
||||
<summary><span class="show">How PC VR games work</span><span class="hide">Hide PC VR</span><i aria-hidden="true">▾</i></summary>
|
||||
<p class="lead muted">PC VR games are Windows VR games. FramePort runs them on the Frame itself or on your PC.</p>
|
||||
|
||||
<div class="panels">
|
||||
<details class="panel">
|
||||
<summary><Glyph name="frame" size={18} /><span>On the Frame</span><i aria-hidden="true">▾</i></summary>
|
||||
<ol class="steps">
|
||||
{onFrame.map((s) => (
|
||||
<li class={s.side}><span class="g"><Glyph name={s.glyph} size={18} /></span><code class="n">{s.name}</code><p>{s.text}</p></li>
|
||||
))}
|
||||
</ol>
|
||||
<p class="note muted">Games that use OpenXR or OpenVR run directly. Oculus-only games run on your PC; running them on the Frame
|
||||
is the experimental path below. <a href={url('docs/install/#pc-vr-games')}>PC VR in the docs</a></p>
|
||||
</details>
|
||||
|
||||
<details class="panel">
|
||||
<summary><Glyph name="pc" size={18} /><span>On your PC</span><i aria-hidden="true">▾</i></summary>
|
||||
<ol class="steps">
|
||||
{onPc.map((s) => (
|
||||
<li class={s.side}><span class="g"><Glyph name={s.glyph} size={18} /></span><code class="n">{s.name}</code><p>{s.text}</p></li>
|
||||
))}
|
||||
</ol>
|
||||
<p class="note muted">Needs Windows with Steam and SteamVR. Games that check their license through Meta’s app need that
|
||||
app on the PC too. <a href={url('docs/install/#pc-vr-games')}>PC VR in the docs</a></p>
|
||||
</details>
|
||||
|
||||
<details class="panel">
|
||||
<summary><Glyph name="pc" size={18} /><span>Quest games in PC VR</span>
|
||||
<span class="tag" aria-label="Experimental, not in the app yet">Experimental · not in the app yet</span><i aria-hidden="true">▾</i></summary>
|
||||
<p>Quest games run on your PC through <b>AXRB</b> (Android XR Bridge) and SteamVR, then stream to the Frame like any PC
|
||||
VR game. FramePort downloads AXRB the first time you use it, like Revive. Some games already start; most don’t
|
||||
show a usable picture yet.</p>
|
||||
<p class="note muted">On an experimental branch, not in a release yet. <a href={`${REPO}/pull/136`}>Follow its progress</a></p>
|
||||
</details>
|
||||
|
||||
<details class="panel">
|
||||
<summary><Glyph name="frame" size={18} /><span>Rift games on the Frame alone</span>
|
||||
<span class="tag" aria-label="Experimental, not in the app yet">Experimental · not in the app yet</span><i aria-hidden="true">▾</i></summary>
|
||||
<p>Oculus Rift games run on the Frame itself, without a PC: Windows x86 emulation (FEX and Wine) runs the game,
|
||||
Revive and Meta’s own PC runtime, and you sign in with your Meta account. The first Oculus game has run in the
|
||||
headset; it isn’t smooth enough to ship yet.</p>
|
||||
<p class="note muted">On an experimental branch, not in a release yet. <a href={`${REPO}/tree/rift-on-frame`}>Follow its progress</a></p>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.pcvr { margin-top: 28px; }
|
||||
.pcvr > summary { list-style: none; display: inline-flex; align-items: center; gap: 10px; padding: 10px 16px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border); cursor: pointer; font-weight: 600; }
|
||||
.pcvr summary::-webkit-details-marker { display: none; }
|
||||
.pcvr summary:hover { background: rgba(58, 168, 255, .08); }
|
||||
.pcvr summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.pcvr summary i { font-style: normal; transition: transform 300ms var(--ease-out); }
|
||||
.pcvr[open] > summary i, .panel[open] > summary i { transform: rotate(180deg); }
|
||||
.pcvr > summary .hide, .pcvr[open] > summary .show { display: none; }
|
||||
.pcvr[open] > summary .hide { display: inline; }
|
||||
.lead { margin: 16px 0 12px; }
|
||||
|
||||
.panels { display: grid; gap: 10px; }
|
||||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
|
||||
.panel > summary { list-style: none; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 14px 18px;
|
||||
cursor: pointer; font: 600 15px/1.3 var(--font-sans); }
|
||||
.panel > summary .glyph { color: var(--accent); }
|
||||
.panel > summary i { margin-left: auto; color: var(--text-2); }
|
||||
.panel > :not(summary) { margin-inline: 18px; }
|
||||
.panel > p { color: var(--text-2); line-height: 1.6; }
|
||||
.panel > :last-child { margin-bottom: 16px; }
|
||||
|
||||
.tag { font: 600 11px/1 var(--font-mono); letter-spacing: .04em; padding: 4px 8px; border-radius: 999px;
|
||||
color: var(--accent); background: rgba(255, 138, 31, .12); border: 1px solid rgba(255, 138, 31, .35); }
|
||||
|
||||
.steps { list-style: none; padding: 0; margin-top: 0; }
|
||||
.steps li { display: grid; grid-template-columns: 32px 72px minmax(0, 1fr); gap: 4px 12px; align-items: baseline; padding: 10px 0; }
|
||||
.steps li + li { border-top: 1px solid rgba(42, 45, 55, 0.6); }
|
||||
.steps .g { align-self: center; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; }
|
||||
.steps .pc .g { color: var(--secondary); background: rgba(58, 168, 255, 0.12); }
|
||||
.steps .frame .g { color: var(--accent); background: rgba(255, 138, 31, 0.12); }
|
||||
.steps .n { font-size: 13px; font-weight: 600; }
|
||||
.steps .pc .n { color: var(--secondary); }
|
||||
.steps .frame .n { color: var(--accent); }
|
||||
.steps p { margin: 0; color: var(--text-2); font-size: 15px; line-height: 1.55; }
|
||||
.note { font-size: 14px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.steps li { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.steps .g { grid-row: span 2; align-self: start; }
|
||||
.steps p { grid-column: 2; }
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
// How it works, as the install log of one real catalog game: the stages on the PC in blue, the ones on the Frame in
|
||||
// orange, the portal between them.
|
||||
import Glyph from './Glyph.astro';
|
||||
import PcvrHow from './PcvrHow.astro';
|
||||
import { patchLabel, stageDetails, type LogGame } from '../scripts/logdetails';
|
||||
|
||||
interface Props { example: LogGame; others: LogGame[] }
|
||||
@@ -84,6 +85,7 @@ const frame: Stage[] = [
|
||||
<span class="foot-t"><span data-lg="foot">{example.title}</span>'s recipe from the catalog. Untested games get suggested patches.</span>
|
||||
<button type="button" class="btn btn-secondary btn-small again" data-again>↻ Try another game</button>
|
||||
</p>
|
||||
<PcvrHow />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -21,10 +21,12 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
|
||||
<article class="item" data-item hidden>
|
||||
<div class="item-icon" data-icon aria-hidden="true"><Glyph name="frame" size={30} /></div>
|
||||
<div class="item-text">
|
||||
<p class="item-kicker">This link installs</p>
|
||||
<p class="item-kicker">This link installs <span class="demo-tag" data-demo hidden>Demo link</span></p>
|
||||
<h1 data-name>…</h1>
|
||||
<p class="item-desc" data-desc hidden></p>
|
||||
<p class="item-from"><span data-files></span> <span class="from">from <b data-host></b></span></p>
|
||||
<p class="demo-note" data-demo hidden>This is the example button from frameport.app: use it to check that
|
||||
FramePort receives install links. Real buttons install real games.</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -107,6 +109,10 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
|
||||
$('[data-files]').textContent = parsed.kind === 'url' ? '' : 'A manifest';
|
||||
$('[data-item]').hidden = false;
|
||||
document.title = `Install ${parsed.kind === 'url' ? fileName : 'with FramePort'}`;
|
||||
if (parsed.demo) {
|
||||
page.querySelectorAll<HTMLElement>('[data-demo]').forEach((el) => { el.hidden = false; });
|
||||
$('[data-name]').textContent = 'Cool Game';
|
||||
}
|
||||
|
||||
// the manifest's own name and description, when its host allows reading it from a page (CORS)
|
||||
if (parsed.kind === 'manifest') {
|
||||
@@ -173,6 +179,9 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
|
||||
.item-desc { color: var(--text-2); white-space: pre-line; }
|
||||
.item-from { font: 400 13px/1.5 var(--font-mono); color: var(--text-2); overflow-wrap: anywhere; }
|
||||
.item-from b { color: var(--text); font-weight: 500; }
|
||||
.demo-tag { margin-left: 8px; padding: 3px 8px; border-radius: 20px; letter-spacing: .08em; color: var(--secondary);
|
||||
background: var(--secondary-soft); }
|
||||
.demo-note { font-size: 14px; color: var(--text-2); }
|
||||
|
||||
[data-when] { display: none; }
|
||||
.page[data-state="opening"] [data-when~="opening"],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// node --test --experimental-strip-types src/scripts/ (npm test). Cases from tests/test_deeplink.py.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { checkUrl, parseInstallParams, readManifest, toFrameportLink } from './install-link.ts';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { DEMO_MANIFEST, checkUrl, isDemo, parseInstallParams, readManifest, toFrameportLink } from './install-link.ts';
|
||||
|
||||
const MANIFEST = 'https://cdn.example.com/game.framedrop.json';
|
||||
|
||||
@@ -55,3 +56,23 @@ test('manifest preview', () => {
|
||||
assert.equal(readManifest({ schema: 'framedrop.install/v2', name: 'x', files: [{ url: 'https://a.com/x.apk' }] }), null);
|
||||
assert.equal(readManifest({ schema: 'framedrop.install/v1', files: [{ url: 'https://a.com/x.apk' }] }), null);
|
||||
});
|
||||
|
||||
test('the homepage demo link is recognised (and only it)', () => {
|
||||
const p = parseInstallParams(`?manifest=${encodeURIComponent(DEMO_MANIFEST)}`);
|
||||
assert.deepEqual([p.ok, p.kind, p.demo], [true, 'manifest', true]);
|
||||
assert.equal(p.link, `frameport://install?manifest=${encodeURIComponent(DEMO_MANIFEST)}`);
|
||||
assert.equal(parseInstallParams(`?manifest=${encodeURIComponent(MANIFEST)}`).demo, false);
|
||||
assert.equal(isDemo('https://www.frameport.app/demo/cool-game.json'), true);
|
||||
for (const u of ['https://frameport.app/demo/cool-game.json?x=1', 'https://frameport.app.evil.com/demo/cool-game.json',
|
||||
'http://frameport.app/demo/cool-game.json', 'https://frameport.app:8443/demo/cool-game.json']) {
|
||||
assert.equal(isDemo(u), false, u);
|
||||
}
|
||||
});
|
||||
|
||||
test('the published demo manifest previews as Cool Game', () => {
|
||||
const data = JSON.parse(readFileSync(new URL('../../public/demo/cool-game.json', import.meta.url), 'utf8'));
|
||||
const m = readManifest(data);
|
||||
assert.equal(m?.name, 'Cool Game');
|
||||
assert.equal(m?.icon, 'https://frameport.app/demo/cool-game.svg');
|
||||
assert.deepEqual(m?.files, ['cool-game-arm64.apk']);
|
||||
});
|
||||
@@ -3,7 +3,20 @@
|
||||
// lookups here: the app checks where a name resolves right before it downloads.
|
||||
|
||||
export type Kind = 'manifest' | 'url';
|
||||
export type Parsed = { ok: true; kind: Kind; target: string; link: string } | { ok: false; error: string };
|
||||
export type Parsed = { ok: true; kind: Kind; target: string; link: string; demo: boolean } | { ok: false; error: string };
|
||||
|
||||
/** The homepage's example button installs this placeholder manifest (public/demo/cool-game.json); the app knows it
|
||||
* (deeplink.DEMO_MANIFEST) and only shows a demo, nothing is downloaded. */
|
||||
export const DEMO_MANIFEST = 'https://frameport.app/demo/cool-game.json';
|
||||
|
||||
/** Whether a manifest address is the demo one (deeplink.is_demo: https, frameport.app, that path, nothing else). */
|
||||
export function isDemo(text: string): boolean {
|
||||
let url: URL;
|
||||
try { url = new URL(text.trim()); } catch { return false; }
|
||||
return url.protocol === 'https:' && (url.hostname === 'frameport.app' || url.hostname === 'www.frameport.app')
|
||||
&& !url.port && !url.username && !url.password && url.pathname === new URL(DEMO_MANIFEST).pathname
|
||||
&& !url.search && !url.hash;
|
||||
}
|
||||
|
||||
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
||||
const APP_FILES = ['.apk', '.exe', '.zip', '.tar.gz', '.tgz', '.tar.xz', '.txz', '.tar.bz2', '.tar', '.appimage'];
|
||||
@@ -66,7 +79,8 @@ export function parseInstallParams(search: string): Parsed {
|
||||
const file = q.get('url')?.trim();
|
||||
if (manifest) {
|
||||
const error = checkUrl(manifest, 'manifest address');
|
||||
return error ? { ok: false, error } : { ok: true, kind: 'manifest', target: manifest, link: toFrameportLink('manifest', manifest) };
|
||||
if (error) return { ok: false, error };
|
||||
return { ok: true, kind: 'manifest', target: manifest, link: toFrameportLink('manifest', manifest), demo: isDemo(manifest) };
|
||||
}
|
||||
if (file) {
|
||||
const error = checkUrl(file, 'file address');
|
||||
@@ -75,7 +89,7 @@ export function parseInstallParams(search: string): Parsed {
|
||||
if (!APP_FILES.some((ext) => name.endsWith(ext))) {
|
||||
return { ok: false, error: 'The file address must point to an APK, a Windows program (.exe) or a Linux app (zip, tar or AppImage).' };
|
||||
}
|
||||
return { ok: true, kind: 'url', target: file, link: toFrameportLink('url', file) };
|
||||
return { ok: true, kind: 'url', target: file, link: toFrameportLink('url', file), demo: false };
|
||||
}
|
||||
return { ok: false, error: "The link doesn't say what to install (no manifest= or url=)." };
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "0.12.0"
|
||||
__version__ = "1.0.0"
|
||||
@@ -30,7 +30,9 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# 8: media_codec (plays video through Android's decoders: MediaCodec/ExoPlayer/Media3 or VLC: frame.hw_video_decode)
|
||||
# 9: gamepad (the manifest declares gamepad support: android.hardware.gamepad / Android TV's LEANBACK_LAUNCHER:
|
||||
# device.steam_gamepad) (2026-10)
|
||||
ANALYSIS_VERSION = 9
|
||||
# 10: godot_clipboard (Godot 4.2-4.4's non-null ClipboardManager cast: frame.godot_clipboard) (2026-10)
|
||||
# 11: unreal_verify_obb (Unreal's bVerifyOBBOnStartUp: frame.unreal_skip_obb_check, GitHub #159) (2026-10)
|
||||
ANALYSIS_VERSION = 11
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -62,6 +64,15 @@ def expects_obb(meta: dict) -> bool:
|
||||
return any(k.endswith(UE_OBB_KEY) and v in (True, "true", "True") for k, v in meta.items())
|
||||
|
||||
|
||||
UE_VERIFY_OBB_KEY = ".GameActivity.bVerifyOBBOnStartUp"
|
||||
|
||||
|
||||
def unreal_verify_obb(meta: dict) -> bool:
|
||||
"""Unreal's DownloaderActivity checks every OBB entry's CRC at the first start (a full read; on a mismatch it waits
|
||||
on an error screen). Only a real boolean counts: GameActivity reads it with Bundle.getBoolean."""
|
||||
return any(k.endswith(UE_VERIFY_OBB_KEY) and v is True for k, v in meta.items())
|
||||
|
||||
|
||||
def _read_manifest_info(path: Path):
|
||||
"""(package, versionName, label, main activity, pyaxmlparser APK)."""
|
||||
from pyaxmlparser import APK
|
||||
@@ -152,6 +163,9 @@ def vr_kind(libs: set[str], manifest_strings: list[str]) -> str:
|
||||
return "none"
|
||||
|
||||
|
||||
# Kotlin's message for a failed non-null cast to ClipboardManager (Godot 4.2-4.4; 4.1 is Java, 4.5+ uses `as?`)
|
||||
GODOT_CLIPBOARD_CAST = b"null cannot be cast to non-null type android.content.ClipboardManager"
|
||||
|
||||
# dex type names of Android's video decoding APIs (frame.hw_video_decode)
|
||||
MEDIA_CODEC_MARKERS = (b"Landroid/media/MediaCodec;", b"Landroidx/media3/exoplayer", b"Lcom/google/android/exoplayer2/")
|
||||
|
||||
@@ -171,6 +185,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
# SDL's Java side (SDL2 / LÖVE apps): crashes in Lepton without a clipboard service (frame.sdl_clipboard)
|
||||
dexes = [z.read(n) for n in names if n.startswith("classes") and n.endswith(".dex")]
|
||||
sdl_java = any(b"Lorg/libsdl/app/SDLClipboardHandler;" in d for d in dexes)
|
||||
# Godot 4.2-4.4's Kotlin `as ClipboardManager` (non-null cast): the same crash (frame.godot_clipboard)
|
||||
godot_clipboard = any(b"Lorg/godotengine/godot/Godot;" in d and GODOT_CLIPBOARD_CAST in d for d in dexes)
|
||||
# Vivox voice chat calling Android 12 audio-routing methods: crashes in Lepton (Android 11)
|
||||
vivox_api31 = any(b"Lcom/vivox/sdk/AudioChangeListener;" in d and b"CommunicationDevice" in d for d in dexes)
|
||||
# video through Android's decoders (frame.hw_video_decode): Java MediaCodec, ExoPlayer/Media3, or libVLC
|
||||
@@ -294,6 +310,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
# Meta's OVRManager raises MSAA at runtime (frame.unity_runtime_msaa_off); Oculus XR Plugin (multiview)
|
||||
"ovr_runtime_msaa": bool(il2cpp_meta) and b"\0useRecommendedMSAALevel\0" in il2cpp_meta,
|
||||
"sdl_java": sdl_java,
|
||||
"godot_clipboard": godot_clipboard,
|
||||
"vivox_api31": vivox_api31,
|
||||
"media_codec": media_codec,
|
||||
# declares gamepad support (SDL's manifest template has both): device.steam_gamepad
|
||||
@@ -312,6 +329,8 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
# scene in one: without it the game hangs at start (GitHub #85, #92)
|
||||
"expects_obb": expects_obb(meta) or unity_split,
|
||||
"unity_split": unity_split,
|
||||
# Unreal CRC-checks the OBB before the game starts (frame.unreal_skip_obb_check, GitHub #159)
|
||||
"unreal_verify_obb": unreal_verify_obb(meta),
|
||||
# Unreal's Oculus module needs every OVRPlugin function it looks up (frame.unreal_ovrp_entrypoints)
|
||||
"unreal_ovrp_lookups": (ovrp_lookups(lib_bytes.get(engine_lib.rsplit("/", 1)[1], b""))
|
||||
if engine_lib and "libOVRPlugin.so" in libset else []),
|
||||
|
||||
@@ -260,6 +260,24 @@ def meta_data(manifest: bytes) -> dict[str, str | int | bool | None]:
|
||||
return out
|
||||
|
||||
|
||||
def set_meta_data_bool(manifest: bytes, name_suffix: str, value: bool) -> bytes | None:
|
||||
"""Set the boolean android:value of every <meta-data> whose android:name ends with `name_suffix` (one 4-byte
|
||||
value per element, nothing else moves). None when no such boolean entry differs from `value`. A string value
|
||||
("true") is left alone: Android's Bundle.getBoolean reads only real booleans."""
|
||||
x = Axml(manifest)
|
||||
names = x.strings()
|
||||
hits = 0
|
||||
for el in x.elements():
|
||||
if el.name != "meta-data" or not (x.attr_str(el, "name") or "").endswith(name_suffix):
|
||||
continue
|
||||
for a in el.attrs:
|
||||
if (a.name < len(names) and names[a.name] == "value" and a.dtype == TYPE_INT_BOOLEAN
|
||||
and (a.value != 0) != value):
|
||||
struct.pack_into("<I", x.data, a.offset + 16, 0xFFFFFFFF if value else 0)
|
||||
hits += 1
|
||||
return x.bytes() if hits else None
|
||||
|
||||
|
||||
def categories(manifest: bytes) -> set[str]:
|
||||
x = Axml(manifest)
|
||||
return {v for el in x.elements() if el.name == "category" for v in [x.attr_str(el, "name")] if v}
|
||||
|
||||
@@ -11,6 +11,7 @@ INVOKE_VIRTUAL, INVOKE_VIRTUAL_RANGE = 0x6E, 0x74 # 35c / 3rc: 3 code units, me
|
||||
INVOKES = frozenset(range(0x6E, 0x73)) | frozenset(range(0x74, 0x79)) # invoke-virtual..interface(/range)
|
||||
RETURN_VOID = 0x0E
|
||||
CONST4_V0_0, RETURN_V0 = 0x0012, 0x000F # const/4 v0, #0 ; return v0
|
||||
CONST_STRING_V0, RETURN_OBJECT_V0 = 0x001A, 0x0011 # const-string v0, string@BBBB ; return-object v0
|
||||
|
||||
|
||||
def _widths() -> list[int]:
|
||||
@@ -78,6 +79,11 @@ class Dex:
|
||||
"""Offsets of the code items of the methods `name` in class `cls` (direct and virtual)."""
|
||||
return [code for _idx, code in self.class_methods(cls) if self.method(_idx)[1] == name]
|
||||
|
||||
def class_names(self) -> list[str]:
|
||||
"""Type names of the classes defined in this dex."""
|
||||
return [self.type_name(struct.unpack_from("<I", self.data, self.classes_off + 32 * c)[0])
|
||||
for c in range(self.classes_n)]
|
||||
|
||||
def class_methods(self, cls: str) -> list[tuple[int, int]]:
|
||||
"""(method index, code item offset) of every method of class `cls` that has code."""
|
||||
out = []
|
||||
@@ -147,16 +153,18 @@ class Dex:
|
||||
return self.return_early(code_off, "V")
|
||||
|
||||
def return_early(self, code_off: int, ret: str) -> bool:
|
||||
"""Make a method return at once: its first instruction(s) become `return-void` (ret "V") or `const/4 v0, 0;
|
||||
return v0` (ret Z/B/S/C/I: false / 0), padded with nops to the next instruction boundary. The rest of the code
|
||||
stays as it was (unreachable). False if the method already starts that way, the return type isn't supported
|
||||
(wide, objects) or its first instructions can't be decoded."""
|
||||
"""Make a method return at once: its first instruction(s) become `return-void` (ret "V"), `const/4 v0, 0;
|
||||
return v0` (ret Z/B/S/C/I: false / 0) or `const-string v0, ""; return-object v0` (ret Ljava/lang/String;: the
|
||||
empty string, only when the dex has it), padded with nops to the next instruction boundary. The rest of the
|
||||
code stays as it was (unreachable). False if the method already starts that way, the return type isn't
|
||||
supported (wide, other objects) or its first instructions can't be decoded."""
|
||||
regs = struct.unpack_from("<H", self.data, code_off)[0] # registers_size: v0 must exist for a value
|
||||
if ret == "V":
|
||||
new = [RETURN_VOID]
|
||||
elif ret in ("Z", "B", "S", "C", "I"):
|
||||
if not struct.unpack_from("<H", self.data, code_off)[0]: # registers_size: v0 must exist
|
||||
return False
|
||||
elif ret in ("Z", "B", "S", "C", "I") and regs:
|
||||
new = [CONST4_V0_0, RETURN_V0]
|
||||
elif ret == "Ljava/lang/String;" and regs and self.strings_n and self.string(0) == "":
|
||||
new = [CONST_STRING_V0, 0, RETURN_OBJECT_V0] # string ids are sorted: "" is always the first
|
||||
else:
|
||||
return False
|
||||
start = code_off + 16
|
||||
|
||||
+46
-1
@@ -5,6 +5,7 @@ Errors are one line on stderr; FRAMEPORT_DEBUG=1 shows the traceback.
|
||||
frameport tools install # portable Java + OVRPort + apksigner
|
||||
frameport scan "<folder with game dumps>" # analyze + suggest recipes
|
||||
frameport list | show <pkg> | patches
|
||||
frameport rename <pkg> "<new name>" # the name in the library and in Steam (--reset: automatic)
|
||||
frameport recipe <pkg> --enable frame.nodebug --set scale=1.2
|
||||
frameport build <pkg>|--all
|
||||
frameport frame discover | pair | info --frame steamos@frame.local
|
||||
@@ -282,7 +283,12 @@ def open_link(link: str = typer.Argument(..., help="an \"Install with FrameDrop\
|
||||
typer.echo("handed to the FramePort window")
|
||||
return
|
||||
try:
|
||||
m = deeplink.fetch_manifest(deeplink.parse(link))
|
||||
req = deeplink.parse(link)
|
||||
if req.demo: # the homepage's example button: nothing to fetch or install
|
||||
typer.echo("This is FramePort's demo link (the example button on frameport.app): FramePort receives it; "
|
||||
"nothing is downloaded or installed.")
|
||||
return
|
||||
m = deeplink.fetch_manifest(req)
|
||||
except deeplink.LinkError as exc:
|
||||
raise typer.BadParameter(str(exc)) from None
|
||||
typer.echo(f"{m.name} (from {m.host or m.source})")
|
||||
@@ -353,6 +359,45 @@ def show(package: str, as_json: bool = typer.Option(False, "--json", help=JSON_H
|
||||
typer.echo(f" alternate build adds: {', '.join(r['alt_patches'])} (installed: {which})")
|
||||
|
||||
|
||||
@app.command()
|
||||
def rename(package: str = typer.Argument(..., help="the game (package or part of its title)"),
|
||||
title: Optional[str] = typer.Argument(None, help="the new name"),
|
||||
reset: bool = typer.Option(False, "--reset", help="back to FramePort's own name (store or APK name)"),
|
||||
sync: bool = typer.Option(True, "--sync/--no-sync",
|
||||
help="also rename it in Steam where it's installed (Steam restarts once)"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Rename a game in the library and in Steam. The name is kept from then on (scans and store lookups don't
|
||||
change it); its Steam entry keeps its id, so Play, artwork and saves stay."""
|
||||
[pkg] = _pkgs(package, False)
|
||||
if reset == bool(title):
|
||||
raise typer.BadParameter("give a new name or --reset")
|
||||
if not reset and not pipeline.clean_title(title):
|
||||
raise typer.BadParameter("the new name is empty")
|
||||
before = library.game(pkg).get("title")
|
||||
g = pipeline.rename_game(pkg, None if reset else title)
|
||||
typer.echo(f"{pkg}: {before} -> {g['title']}" + ("" if g.get("title_locked") else " (automatic name)"))
|
||||
if g["title"] == before or not sync:
|
||||
if g.get("steam_name_stale"):
|
||||
typer.echo("The Frame's Steam library keeps the old name until `frameport rename` runs with --sync or "
|
||||
"the game is installed again.")
|
||||
return
|
||||
from .targets.base import PC_LABEL
|
||||
|
||||
rep = printing_reporter(verbose=False)
|
||||
installs = g.get("installs") or {}
|
||||
if any(w != PC_LABEL for w in installs):
|
||||
try:
|
||||
pipeline.sync_title(pkg, _target(frame), rep)
|
||||
except Exception as exc: # noqa: BLE001 - the library name is saved either way
|
||||
typer.echo(f"Steam on the Frame wasn't renamed ({exc}); run this again when the Frame is reachable.",
|
||||
err=True)
|
||||
if g.get("kind") == "rift":
|
||||
from .targets.pc_revive import local_installs
|
||||
|
||||
if pkg in local_installs():
|
||||
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
|
||||
|
||||
|
||||
@app.command()
|
||||
def patches():
|
||||
"""List every available patch."""
|
||||
|
||||
@@ -32,9 +32,9 @@ def twins(games: list[dict]) -> set[str]:
|
||||
|
||||
def display_title(game: dict, twin_set: set[str] | None = None) -> str:
|
||||
"""The title shown everywhere (cards, pages, jobs, Steam shortcut): "<Title> (Quest)" / "(Rift)" while both
|
||||
versions of a game are in the library."""
|
||||
versions of a game are in the library. A name the user chose (Rename…, title_locked) is shown as it is."""
|
||||
title = game.get("title") or game["package"]
|
||||
if twin_set is not None and game["package"] in twin_set:
|
||||
if twin_set is not None and game["package"] in twin_set and not game.get("title_locked"):
|
||||
return f"{title} ({'Rift' if game.get('kind') == 'rift' else 'Quest'})"
|
||||
return title
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ system's handler, its own `frameport://`, FramePort's `https://frameport.app/ins
|
||||
pasted into "Install from link…"), follows the same
|
||||
rules (https only, http only on this PC, no credentials, no LAN addresses, a URL that ends in a file name) and
|
||||
always asks before it downloads anything a web page sent. `urlhandler.py` registers the schemes.
|
||||
|
||||
The homepage's example button points at one fixed demo manifest (DEMO_MANIFEST, also published on the site): every
|
||||
form of that link becomes a request flagged `demo`, which never touches the network (fetch_manifest answers with
|
||||
demo_manifest(), download refuses it) and the GUI answers with a placeholder dialog (ui/views/link_dialog.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -32,6 +36,8 @@ MAX_DESCRIPTION = 2000
|
||||
MAX_ICON = 2 << 20
|
||||
LOOPBACK = ("localhost", "127.0.0.1", "[::1]", "::1")
|
||||
APK, LINUX, EXE, OBB = "apk", "linux", "exe", "obb"
|
||||
DEMO_MANIFEST = "https://frameport.app/demo/cool-game.json" # the homepage's example button
|
||||
DEMO_NAME = "Cool Game"
|
||||
LINUX_EXTS = (".zip", ".tar.gz", ".tgz", ".tar.xz", ".txz", ".tar.bz2", ".tar", ".appimage")
|
||||
|
||||
|
||||
@@ -44,6 +50,7 @@ class InstallRequest:
|
||||
manifest_url: str | None = None
|
||||
file_url: str | None = None
|
||||
local_manifest: str | None = None # a .framedrop.json dropped on the window
|
||||
demo: bool = False # the homepage's example button (DEMO_MANIFEST): nothing is fetched or installed
|
||||
|
||||
@property
|
||||
def source(self) -> str:
|
||||
@@ -73,6 +80,7 @@ class Manifest:
|
||||
# FramePort-only extension (FrameDrop ignores it): "frameport": {"description": "…", "icon": "https://….png"}
|
||||
description: str = ""
|
||||
icon: str | None = None
|
||||
demo: bool = False # demo_manifest(): never downloaded
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
@@ -145,6 +153,31 @@ def check_url(url: str, what: str = "link", resolve: bool = False) -> str:
|
||||
return url.strip()
|
||||
|
||||
|
||||
def is_demo(url: str | None) -> bool:
|
||||
"""Whether a manifest address is the homepage's demo manifest (https, frameport.app, that path, nothing else)."""
|
||||
try:
|
||||
parts = urlsplit((url or "").strip())
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
return False
|
||||
host = (parts.hostname or "").lower()
|
||||
return parts.scheme.lower() == "https" and host in ("frameport.app", "www.frameport.app") and port is None \
|
||||
and not parts.username and not parts.password and parts.path == urlsplit(DEMO_MANIFEST).path \
|
||||
and not parts.query and not parts.fragment
|
||||
|
||||
|
||||
def demo_request() -> InstallRequest:
|
||||
return InstallRequest(manifest_url=DEMO_MANIFEST, demo=True)
|
||||
|
||||
|
||||
def demo_manifest() -> Manifest:
|
||||
"""What the demo link "installs": built here, never fetched (site/public/demo/cool-game.json says the same)."""
|
||||
return Manifest(DEMO_NAME, [ManifestFile("https://frameport.app/demo/cool-game-arm64.apk")], DEMO_MANIFEST,
|
||||
description="A placeholder game for the example \"Install with FramePort\" button. FramePort "
|
||||
"recognises this link and shows a demo: nothing is downloaded or installed.",
|
||||
demo=True)
|
||||
|
||||
|
||||
def parse(text: str) -> InstallRequest:
|
||||
"""framedrop://install?…, frameport://install?…, the https://framedropvr.com/install?… button link, a manifest
|
||||
URL (…json) or a direct file URL."""
|
||||
@@ -156,12 +189,16 @@ def parse(text: str) -> InstallRequest:
|
||||
except ValueError:
|
||||
raise LinkError("That isn't a link FramePort understands.") from None
|
||||
scheme = parts.scheme.lower()
|
||||
if is_demo(text): # the demo manifest pasted on its own
|
||||
return demo_request()
|
||||
if scheme in SCHEMES or (scheme == "https" and (parts.hostname or "").lower() in WEB_HOSTS):
|
||||
action = (parts.netloc or parts.path.strip("/")) if scheme in SCHEMES else parts.path.strip("/")
|
||||
if action.lower().split("/")[0] != "install":
|
||||
raise LinkError("FramePort only understands install links (…/install?manifest=… or ?url=…).")
|
||||
query = parse_qs(parts.query)
|
||||
manifest, file = (query.get("manifest") or [None])[0], (query.get("url") or [None])[0]
|
||||
if manifest and is_demo(manifest):
|
||||
return demo_request()
|
||||
if manifest:
|
||||
return InstallRequest(manifest_url=check_url(manifest, "manifest address"))
|
||||
if file:
|
||||
@@ -240,7 +277,10 @@ def manifest_from_data(data, source: str = "") -> Manifest:
|
||||
|
||||
|
||||
def fetch_manifest(req: InstallRequest) -> Manifest:
|
||||
"""The manifest a request names (a direct file link becomes a one-file manifest titled after the file)."""
|
||||
"""The manifest a request names (a direct file link becomes a one-file manifest titled after the file; the demo
|
||||
link's is built in, without a network request)."""
|
||||
if req.demo or is_demo(req.manifest_url):
|
||||
return demo_manifest()
|
||||
if req.file_url:
|
||||
name = filename_of(req.file_url)
|
||||
m = Manifest(title_from_filename(name), [ManifestFile(req.file_url)], req.file_url, direct=True)
|
||||
@@ -366,6 +406,9 @@ def download(m: Manifest, reporter: Reporter | None = None) -> Path:
|
||||
`obb/` folder next to it, where the APK scan finds them), the Linux build or the Windows program."""
|
||||
import shutil
|
||||
|
||||
if m.demo or is_demo(m.source):
|
||||
raise LinkError("This is FramePort's demo link: there's nothing to download.")
|
||||
|
||||
folder = staging_dir(m)
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
folder.mkdir(parents=True)
|
||||
|
||||
@@ -113,13 +113,26 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
|
||||
apk_name=plan.apk.name, settings=ctx.adapter_settings,
|
||||
files={k: v.decode() if isinstance(v, bytes) else v for k, v in ctx.files.items()}, env=ctx.env,
|
||||
obb_manifest=manifest or None, flatscreen=show_window(ctx, _flatscreen(plan.package)),
|
||||
config_sync=ctx.config_sync or None,
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt},
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
log_controller_models(result.get("controller_models"), reporter)
|
||||
log_config_sync(result.get("config_sync"), reporter)
|
||||
return result
|
||||
|
||||
|
||||
def log_config_sync(report: dict | None, reporter: Reporter) -> None:
|
||||
"""What the agent did to the game's saved settings (device.config_sync)."""
|
||||
if not report:
|
||||
return
|
||||
if report.get("changed"):
|
||||
reporter.log("game settings kept in step with the eye buffers: " + ", ".join(
|
||||
f"{k} {old} -> {new}" for k, (old, new) in sorted(report["changed"].items())))
|
||||
elif report.get("skipped"):
|
||||
reporter.log(f"game settings not changed: {report['skipped']}")
|
||||
|
||||
|
||||
def log_controller_models(models: dict | None, reporter: Reporter) -> None:
|
||||
"""Outcome of the agent's Steam Frame controller model conversion (adapter setting controller_models)."""
|
||||
if not models:
|
||||
@@ -490,9 +503,11 @@ def apply_app_icon(frame: Frame, package: str, anchor: str | None, result: dict,
|
||||
reporter.check("App icon", True, "the app's own icon is used for the library")
|
||||
|
||||
|
||||
def update_steam_art(frame: Frame, package: str, reporter: Reporter) -> dict:
|
||||
def update_steam_art(frame: Frame, package: str, reporter: Reporter, title: str | None = None) -> dict:
|
||||
"""Replace an installed game's Steam library art with the current artwork (after the user picked new art):
|
||||
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once)."""
|
||||
the art set goes to the game's anchor, then the shortcut is rewritten (Steam restarts once). `title`: the game's
|
||||
name in the library; a different name on the Frame is renamed first (agent v77 `rename`: the shortcut keeps its
|
||||
appid, so Play, grid art and saves stay). Placeholder art shows the name, so it's sent again too."""
|
||||
from ..artwork.steam import steam_set_for
|
||||
|
||||
dep = next((d for d in frame.agent("list_installed")["games"] if d.get("package") == package), None)
|
||||
@@ -500,8 +515,16 @@ def update_steam_art(frame: Frame, package: str, reporter: Reporter) -> dict:
|
||||
raise RuntimeError(f"{package} isn't installed on the Frame")
|
||||
if not dep.get("anchor"):
|
||||
raise RuntimeError("the Frame's FramePort agent is too old; reinstall the game to update its art")
|
||||
renamed = False
|
||||
if title and dep.get("title") != title:
|
||||
reporter.stage("Name in the Steam library")
|
||||
res = frame.agent("rename", package=package, title=title, shortcuts=False)
|
||||
renamed = bool(res.get("renamed"))
|
||||
reporter.check("Name", True, f"{dep.get('title')} -> {res.get('title')} (shortcut {res.get('appid')} kept)")
|
||||
art = steam_set_for(package)
|
||||
if not art:
|
||||
if renamed:
|
||||
return add_to_steam(frame, [package], reporter)
|
||||
raise RuntimeError("no artwork to send")
|
||||
reporter.stage("Artwork for the Steam library")
|
||||
remote = posixpath.join(dep["anchor"], "artwork")
|
||||
|
||||
@@ -162,7 +162,6 @@
|
||||
"Appearance": "",
|
||||
"AppImage": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped). Without it such apps get no controller input.": "",
|
||||
"Artwork": "",
|
||||
"Artwork for {get}": "",
|
||||
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
|
||||
"Artwork updated for {get}": "",
|
||||
@@ -186,6 +185,8 @@
|
||||
"Before a swapchain image is handed to the Frame, wait until the game's GPU work on it has finished, for games that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s (compare both in one session).": "",
|
||||
"Before each Play on this PC, FramePort reads SteamVR's record of the game's last session (frames dropped; frame times from fpsVR if installed). If it dropped frames, it lowers the game's refresh rate to one whose frame budget fits (for example 96 → 90 Hz) and turns motion smoothing on, through SteamVR's per-application settings; each later session can step down again. Set a refresh rate or smoothing mode here to choose yourself. Ignored on the Frame.": "",
|
||||
"Best guess": "",
|
||||
"BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which never comes on the Frame, before they create the game's own controllers: the second menu ignored all input. Two instructions in libtargemapp.so skip that wait and the avatar mesh drawing; the game's shader cache gets a new folder name (cache/br_shader_v1), so programs compiled before the GL shim's shader fixes aren't reused. Only this exact build is changed (code fingerprint and every instruction checked); anything else is left as it is.": "",
|
||||
"BlazeRush: controllers without Meta avatars, own shader cache": "",
|
||||
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
|
||||
"Browse each installed game's files and download your saves.": "",
|
||||
"Browse this game's files.": "",
|
||||
@@ -250,6 +251,7 @@
|
||||
"Collect logs": "",
|
||||
"Collect logs: {title}": "",
|
||||
"Command-line arguments for the game program. Games that support several VR runtimes pick one with an argument, for example Unreal's -hmd=OpenXR or -hmd=SteamVR, Unity's -vrmode OpenVR, or a game's own switch (-Runtime=OpenXRHMD). FramePort fills them in from the game files (the repack's VD.bat with the Oculus module swapped for OpenXR, else the engine's usual switch).": "",
|
||||
"Computes some of the game's effects more precisely, so they don't draw streaks or flashes (for example BlazeRush's particles). Comes from a game's recipe.": "",
|
||||
"Connect": "",
|
||||
"Connect Frames in Developer Mode automatically": "",
|
||||
"Connect the Frame's USB-C port to this PC with a data cable (not a charge-only one).": "",
|
||||
@@ -276,6 +278,7 @@
|
||||
"Controllers": "",
|
||||
"Controllers as a gamepad (Steam Input)": "",
|
||||
"Converts Quest games to OpenXR": "",
|
||||
"Cool Game went through the portal. It was only a demo: nothing was downloaded or installed.": "",
|
||||
"Copied": "",
|
||||
"Copied. Save it as a .json file, change the colors and install it.": "",
|
||||
"Copies the game and its saves to the other drive, checks the copy, then removes the old one. The game must be closed. A game on a microSD card only starts while the card is in.": "",
|
||||
@@ -340,6 +343,8 @@
|
||||
],
|
||||
"Deletes copies kept from earlier installs and unfinished uploads. Games and saves stay.": "",
|
||||
"Delete…": "",
|
||||
"Demo": "",
|
||||
"demo: nothing is downloaded": "",
|
||||
"Deny": "",
|
||||
"Deselect": "",
|
||||
"Desktop": "",
|
||||
@@ -551,6 +556,7 @@
|
||||
"FramePort {version} · {kind}": "",
|
||||
"FramePort's layer between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.": "",
|
||||
"FramePort's original violet": "",
|
||||
"FramePort's own name for it": "",
|
||||
"FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the same key, or the game must be reinstalled and loses its saves.": "",
|
||||
"Frames in Developer Mode show up here.": "",
|
||||
"frames per second": "",
|
||||
@@ -593,13 +599,18 @@
|
||||
"GiB": "",
|
||||
"GitHub issue opened": "",
|
||||
"Gives older Quest games a newer version of Meta's VR plugin that can be converted.": "",
|
||||
"GL shim (frame.gl_shim): shader inputs declared as integers (int/uint, ivecN/uvecN) whose vertex data the game set up with glVertexAttribPointer (as floats) are set up again with glVertexAttribIPointer before each draw. Qualcomm's Quest driver read the raw integers anyway; the Frame's GL driver converts them to floats, so skinned models using bone indices vanish (for example BlazeRush's car bodies and controllers; Klownicle's guide, GitHub #57). Comes from a game's recipe.": "",
|
||||
"GL shim (frame.gl_shim): vertex shaders whose source contains every '|'-separated text compute in highp: whole-word mediump becomes highp on every line without the words in, out or uniform (the shader's interface keeps its precision). For effects that draw streaks or flashes from mediump rounding on the Frame (for example BlazeRush's particles: in uvec2 inBoneIndices;|uniform InstanceData|void main()). Pair it with a new shader cache name if the game caches compiled programs. Comes from a game's recipe.": "",
|
||||
"GL shim (Mesa GLSL compatibility)": "",
|
||||
"GL shim for Unity (no multisampled render-to-texture)": "",
|
||||
"GL shim for Unreal (no multisampled render-to-texture)": "",
|
||||
"GL shim only: hide GL_EXT_multisampled_render_to_texture(2) (Zink crashes rendering Unity's runtime MSAA eye buffer through it, for example The Room VR).": "",
|
||||
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders. For GLES games whose multiview shaders fail on single-view render targets (for example Path of the Warrior).": "",
|
||||
"GL shim: high-precision vertex shaders": "",
|
||||
"GL shim: integer vertex inputs": "",
|
||||
"GL wrapper: OpenGL ES depth formats (Minecraft)": "",
|
||||
"Go to my library": "",
|
||||
"Godot apps: start without a clipboard service": "",
|
||||
"GPU": "",
|
||||
"Graphics": "",
|
||||
"Group": "",
|
||||
@@ -728,12 +739,14 @@
|
||||
"Keep game saves on the Frame": "",
|
||||
"Keep menus in place": "",
|
||||
"Keep pictures in bounds": "",
|
||||
"Keep the game's saved resolution in step": "",
|
||||
"Keep the signing keys: without them, updating a game loses its saves.": "",
|
||||
"Keeps a smoothing setting off that the game turns on itself and that can freeze the Frame.": "",
|
||||
"Keeps every submitted image rect inside its swapchain. Unity can size the eye area a few pixels past the image on some Frames; SteamVR then rejects every frame (xrEndFrame -25, XR_ERROR_SWAPCHAIN_RECT_INVALID) and the game stops drawing (for example PowerWash Simulator stuck at \"Waiting\").": "",
|
||||
"Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts Ultimate). Leave it on.": "",
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a sharpness change).": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
"KiB": "",
|
||||
@@ -761,12 +774,14 @@
|
||||
"Lepton only launches an activity with category LAUNCHER (Quest apps often use INFO). Symptom when missing: launch.log says 'APP_ACTIVITY is empty' and nothing starts.": "",
|
||||
"Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as \"Gamescope\" (the Frame's compositor). Pairs with \"Make Unity text fields work without a system keyboard\".": "",
|
||||
"Lepton's Android has no clipboard service, and SDL's Java code (SDL2 and LÖVE games, for example Dramatic Shape) registers a clipboard listener at start without checking, so the app crashes with a NullPointerException in SDLClipboardHandler. That call is skipped (an in-place edit of classes.dex).": "",
|
||||
"Lepton's Android has no clipboard service, and the Android code of Godot 4.2-4.4 (for example EndoparasiticVR) casts the missing service to a ClipboardManager without checking, so the app crashes at start with a NullPointerException in Godot.<init>. That check is skipped and Godot's clipboard functions do nothing instead (an in-place edit of classes.dex).": "",
|
||||
"Lepton's Android only gets a keyboard, a pointer and touch from the Frame, so a 2D game never sees a controller (the controllers only move the pointer; SDL games show their touch controls). This passes Steam Input's virtual gamepad (\"Microsoft X-Box 360 pad\", made by Steam for the Frame's controllers and paired Bluetooth pads) into this app's container with an Xbox 360 key layout, and tells SDL games to use it (SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD). Steam Input must be on for the game's shortcut; the controllers must be on when the game starts (a pad Steam makes later isn't seen until the next start). The APK isn't changed. Off for one start: FRAMEPORT_NO_GAMEPAD=1 in the game's launch options.": "",
|
||||
"Lets games built on Meta's oldest VR interface run on the Frame.": "",
|
||||
"Lets games made for Oculus PCs run on SteamVR.": "",
|
||||
"Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta avatars.": "",
|
||||
"Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 audio call).": "",
|
||||
"Lets games written for newer OpenXR versions run on the Frame.": "",
|
||||
"Lets Godot games start on the Frame (they'd crash looking for a clipboard).": "",
|
||||
"Lets mixed-reality games start even though they insist on a camera view of your room.": "",
|
||||
"Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D screen).": "",
|
||||
"Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR).": "",
|
||||
@@ -824,6 +839,7 @@
|
||||
"Looking up the store description and screenshots…": "",
|
||||
"Low": "",
|
||||
"Make Unity text fields work": "",
|
||||
"Makes BlazeRush's menus and controllers work without Meta's avatars, and lets its shader fixes take effect.": "",
|
||||
"Makes grip, trigger and touch controls reach the game's hands (it picked the empty Gear VR controls on the Frame, for example Vader Immortal).": "",
|
||||
"Makes Meta's 3D audio library work outside Quest headsets.": "",
|
||||
"Makes sure the game has an entry that can be started.": "",
|
||||
@@ -867,6 +883,7 @@
|
||||
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.": "",
|
||||
"Must be at least the video's own frame rate.": "",
|
||||
"Name": "",
|
||||
"Name and artwork": "",
|
||||
"Native video surfaces (Batman renderer)": "",
|
||||
"Needed by another patch": "",
|
||||
"Needs the Meta Horizon app for its license check, so it crashes on the Frame. Play it on this PC.": "",
|
||||
@@ -881,6 +898,7 @@
|
||||
"New name": "",
|
||||
"New recipe applied. Click Update on Frame to install it.": "",
|
||||
"Next": "",
|
||||
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
|
||||
"No": "",
|
||||
"No activity": "",
|
||||
"no answer from the Frame": "",
|
||||
@@ -983,6 +1001,7 @@
|
||||
"or": "",
|
||||
"Or paste text to type it in one go": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System) and connect it under step 2.": "",
|
||||
"Or use:": "",
|
||||
"Orange for actions, blue for the PC side: the logo's two portals": "",
|
||||
"Other": "",
|
||||
"other file": "",
|
||||
@@ -1158,7 +1177,11 @@
|
||||
"Removes {title} from Steam on this PC (Steam restarts once). The game folder stays.": "",
|
||||
"Removes {title}'s game files from the Frame. Its saves and own files stay unless you tick the box below. Its Steam entry goes away after Steam's next restart.": "",
|
||||
"Rename": "",
|
||||
"Rename in Steam: {title}": "",
|
||||
"Rename {name}": "",
|
||||
"Rename {title}": "",
|
||||
"Renamed to {title}": "",
|
||||
"Renamed to {title}. Steam restarts once to show it.": "",
|
||||
"Rename…": "",
|
||||
"Repairs OpenGL ES shaders that hang the graphics chip, from the game's recipe.": "",
|
||||
"Repairs shaders that hang the graphics chip at certain effects, from the game's recipe (for example Vader Immortal's lightspeed jump).": "",
|
||||
@@ -1287,6 +1310,7 @@
|
||||
"Shows 3D 360° pictures flat if they look doubled.": "",
|
||||
"Shows Batman: Arkham Shadow's cutscenes; set by that game's recipe.": "",
|
||||
"Shows each eye's picture to the other eye. For testing.": "",
|
||||
"Shows models whose shapes come from bone data the Frame's graphics driver misreads (for example BlazeRush's cars). Comes from a game's recipe.": "",
|
||||
"Shows the Frame's cameras where the game expects passthrough.": "",
|
||||
"Shows the headset's view with sound, whatever is running. It plays here where possible, else in mpv or your browser. Stop it when you're done: it costs the game some speed.": "",
|
||||
"Shows videos that games play on a flat panel (for example an intro video).": "",
|
||||
@@ -1311,6 +1335,7 @@
|
||||
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, for example Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (for example Metro Awakening).": "",
|
||||
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (for example xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; for example the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
|
||||
"Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
"Some games save their render resolution in a settings file and load it after creating the headset's eye buffers; after a resolution scale change the saved size no longer matches and the picture is displaced or stretched, with black borders (for example BlazeRush's user_config.xml). FramePort writes the values its recipe names into the game's file while the game isn't running (after an install and after a Game settings change), from the Frame's own eye-buffer size times the resolution scale: w and h = the eye buffer's width and height, for example h*16/9. Only existing entries change; the previous file is kept as <file>.frameport-backup. The size is learned from the game's last start, so a file the game wrote before is corrected from the next install or settings change on.": "",
|
||||
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the same shaders to draw into ordinary single-layer framebuffers, for example a HUD or PDA screen rendered to a texture. Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops every such draw, so those panels stay black (for example Doom3Quest's HUD and PDA). Loads OpenGL ES through a small interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the setting gl_mv_debug adds error checks and counters.": "",
|
||||
"Some OpenXR games still ship Meta's VrApi loader and call it a few times without starting VrApi (for example Jurassic World Aftermath); Meta's loader then stops the game (\"vrapi_SetPropertyInt was called before vrapi_Initialize()\"). Replaces it with a library that has the same functions, each doing nothing. Only for games whose VR runs through OVRPlugin on OpenXR.": "",
|
||||
"Some websites have \"Install with FramePort\" buttons (frameport.app links). FramePort opens these links, shows what they offer and installs it when you agree. On macOS, use Add games → Add from a link… instead.": "",
|
||||
@@ -1347,6 +1372,7 @@
|
||||
"Starts the game on the Frame — put the headset on.": "",
|
||||
"Starts the game with options, for example to choose SteamVR or OpenXR.": "",
|
||||
"Starts the game with the launcher that came with your copy.": "",
|
||||
"Starts Unreal games without re-checking their data file (they'd seem to hang on an invisible check screen).": "",
|
||||
"Starts with SteamVR": "",
|
||||
"Status": "",
|
||||
"Steadier hands (older Unity)": "",
|
||||
@@ -1453,6 +1479,7 @@
|
||||
"The Frame's Steam couldn't start {title} (error {code}). Please report it.": "",
|
||||
"The Frame's Steam didn't accept {title}, so it was added as \"Devkit Game: …\". Starting it now — put the headset on.": "",
|
||||
"The Frame's Steam library still shows the old artwork.": "",
|
||||
"The Frame's Steam library still shows the old name.": "",
|
||||
"the game failed its checks (see the list above)": "",
|
||||
"The game's audio streams along with the picture.": "",
|
||||
"The headset is asleep: put it on, then take the screenshot.": "",
|
||||
@@ -1477,6 +1504,7 @@
|
||||
"The recipe or your game files changed since the install. Update to install the new build; saves are kept.": "",
|
||||
"The running game's frames per second, as it plays.": "",
|
||||
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
|
||||
"The store's name": "",
|
||||
"The time the Frame spends collecting these numbers.": "",
|
||||
"The upload to the Frame was damaged. Try again, ideally over a cable or the Frame's hotspot.": "",
|
||||
"The VR runtime games talk to. On the Frame that's SteamVR.": "",
|
||||
@@ -1499,6 +1527,7 @@
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
"This game is marked \"Can't run\" on the Steam Frame:": "",
|
||||
"This is a website in an app: it opens in Meta's browser, which the Frame doesn't have. If nothing opens, visit the website in a browser.": "",
|
||||
"This is the example button from frameport.app. Real buttons install real games.": "",
|
||||
"this PC": "",
|
||||
"This PC": "",
|
||||
"This PC (for PC VR games)": "",
|
||||
@@ -1575,11 +1604,13 @@
|
||||
"Unpaired pointer-authentication checks": "",
|
||||
"Unreal 4's Oculus input sets the ThumbUp axis (games animate the hand's thumb with it) from Meta's near-touch sensing, which the Frame never reports: the thumb always points up. Uses the touches instead (left X/Y/stick/thumb rest, right A/B/stick/thumb rest). Changes three instructions in OculusInput::SendControllerEvents, only where the code matches exactly (UE4 Oculus games of one engine generation, for example Vader Immortal, Robo Recall). Found by Klownicle (GitHub #49).": "",
|
||||
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (for example NOPE Challenge).": "",
|
||||
"Unreal games can check their whole data file (.obb) before starting (bVerifyOBBOnStartUp). On the Frame that check's progress and error screens are invisible (Lepton runs VR games without an Android window), so the game seems to hang on its DownloaderActivity, for example Contractors. This turns the check off in the manifest; Unreal still checks that the OBB is there with the right size, so a missing or wrong-version OBB still stops the game.": "",
|
||||
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
|
||||
"Unreal's Oculus module looks up every OVRPlugin function it was built against and starts no VR when one is missing; OVRPort's OpenXR OVRPlugin lacks some old ones (for example ovrp_GetPTWNear, which Unreal 4.25 builds like Star Wars Pinball VR look up). The game then never starts an OpenXR session and crashes a few seconds in. Adds a generated library to libOVRPlugin.so with a stand-in for each missing function that reports failure (ovrpFailure), so Unreal's Oculus support starts. The functions OVRPort has are untouched.": "",
|
||||
"Unreal: don't wait for the Quest shader precompile": "",
|
||||
"Unreal: Quest controller bindings (RPOC key map)": "",
|
||||
"Unreal: stand-ins for OVRPlugin functions OVRPort lacks": "",
|
||||
"Unreal: start without the OBB check": "",
|
||||
"Unreal: thumb follows the capacitive touches": "",
|
||||
"Untested": "",
|
||||
"Update all": "",
|
||||
@@ -1590,6 +1621,7 @@
|
||||
"Update failed: {exc}": "",
|
||||
"Update FramePort to {version}": "",
|
||||
"Update game recipes from GitHub automatically": "",
|
||||
"Update name on Frame": "",
|
||||
"Update now": "",
|
||||
"Update on Frame": "",
|
||||
"Update on this PC": "",
|
||||
@@ -1855,6 +1887,7 @@
|
||||
"{title}: details from {value}": "",
|
||||
"{title}: done": "",
|
||||
"{title}: launch test {verdict} · furthest step: {value}": "",
|
||||
"{title}: name updated in Steam": "",
|
||||
"{title}: ready": "",
|
||||
"{title}: Steam artwork updated on the Frame": "",
|
||||
"{used} of {total}": "",
|
||||
|
||||
@@ -59,6 +59,8 @@ class InstallContext:
|
||||
adapter_settings: dict[str, Any]
|
||||
flatscreen: bool = False # show the app's Android window (Lepton's lepton-show-flatscreen) even for a VR app
|
||||
display: str = "" # device.display_mode: "vr" / "flat" = the user's choice, "" = automatic (2D apps get a window)
|
||||
# device.config_sync: path relative to files/ -> {setting: w | h | expression}, kept in step by the agent
|
||||
config_sync: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class Patch:
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""BlazeRush (ru.targem.blazerush 1.0.349): two instruction edits and one string in libtargemapp.so, from Klownicle's
|
||||
verified developer guide (GitHub #57).
|
||||
|
||||
1. Controllers: the menus wait for Meta's Avatar SDK to deliver the player's avatar before they create the game's own
|
||||
interactive controller entities. FramePort's avatar stub (frame.avatar_stub) keeps the game alive without Meta's
|
||||
avatar driver but never delivers an avatar, so the second menu ignored all input. The conditional branch that waits
|
||||
for avatar readiness becomes a NOP (0x4b2dbc), and the wrapper that renders the avatar meshes returns at once
|
||||
(0x4b2adc), so the entities that now exist never touch the missing avatar renderer. The generic avatar stub keeps
|
||||
reporting "no avatar": other games could dereference avatar assets that don't exist.
|
||||
2. Shader cache: the game keeps compiled program binaries under files/cache/shader_cache and uses them instead of the
|
||||
shader sources. "cache/shader_cache" becomes "cache/br_shader_v1" (same length), so programs compiled before the GL
|
||||
shim's precision fix (gl_highp_markers) aren't used; the old cache and the saves stay.
|
||||
|
||||
Only the exact build is edited: its code segment must hash to the known fingerprint (with the edit sites as shipped
|
||||
or already edited), every instruction word must be the expected original or replacement, and the string must sit at
|
||||
its known address, once. Anything else is refused and left unchanged (logged in the build notes). File offsets come
|
||||
from the PT_LOAD segments, so libraries rewritten by other patches (the GL shim adds a DT_NEEDED entry and a segment,
|
||||
nothing that exists moves) still match.
|
||||
|
||||
The guide's other fixes are elsewhere: integer vertex attributes and the particle shaders' precision in the GL shim
|
||||
(gl_int_attribs, gl_highp_markers), the startup FOV in the VrApi bridge (the FOV of the previous session), scale 1.7
|
||||
in the catalog recipe, and the saved resolution in user_config.xml through device.config_sync.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...analysis import elf
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
PACKAGE = "ru.targem.blazerush"
|
||||
LIB = "libtargemapp.so"
|
||||
NOP = 0xD503201F
|
||||
RET = 0xD65F03C0
|
||||
OLD_CACHE = b"cache/shader_cache\0"
|
||||
NEW_CACHE = b"cache/br_shader_v1\0"
|
||||
EM_AARCH64 = 183
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Build:
|
||||
version: str
|
||||
text_sha256: str # SHA-256 of the executable PT_LOAD segment with every edit site as shipped
|
||||
sites: tuple[tuple[int, int, int, str], ...] # (virtual address, original word, replacement, what)
|
||||
cache_va: int # virtual address of "cache/shader_cache"
|
||||
|
||||
|
||||
BUILDS = (
|
||||
# 1.0.349: libtargemapp.so SHA-256 89686ffc5105f5149adf9bf08d88eafdd4e8e99a3cd14d1ac9cc0d3317dfe568 (as shipped;
|
||||
# OVRPort and FramePort's other patches leave it unchanged)
|
||||
Build("1.0.349", "951bf1d5208ae36603f73990346d164a8523e7d1e60cc3e67d3571c7fc55d5f4",
|
||||
((0x4B2DBC, 0x36000AE0, NOP, "avatar readiness wait (tbz) -> nop"),
|
||||
(0x4B2ADC, 0xF0002368, RET, "avatar mesh renderer -> ret")),
|
||||
0x13AF70),
|
||||
)
|
||||
|
||||
|
||||
def _exec_segment(data: bytes, va: int) -> tuple[int, int, int] | None:
|
||||
"""(vaddr, file offset, size) of the executable PT_LOAD segment holding `va`."""
|
||||
for seg in elf._elf(data).iter_segments():
|
||||
start, size = seg["p_vaddr"], seg["p_filesz"]
|
||||
if seg["p_type"] == "PT_LOAD" and seg["p_flags"] & 1 and start <= va < start + size:
|
||||
return start, seg["p_offset"], size
|
||||
return None
|
||||
|
||||
|
||||
def find_sites(data: bytes) -> dict | None:
|
||||
"""The edits for a supported build: {"build", "edits": [(file offset, old word, new word, what)], "cache_offset",
|
||||
"done": every edit already made}; None for anything else (another build, a damaged or foreign library)."""
|
||||
if not elf.is_elf(data) or not elf.is_64bit(data) or data[5] != 1:
|
||||
return None
|
||||
if struct.unpack_from("<H", data, 18)[0] != EM_AARCH64:
|
||||
return None
|
||||
try:
|
||||
segments = elf.load_segments(data)
|
||||
except Exception: # noqa: BLE001 - not a readable ELF: refuse
|
||||
return None
|
||||
for build in BUILDS:
|
||||
text = _exec_segment(data, build.sites[0][0])
|
||||
if not text:
|
||||
continue
|
||||
t_va, t_off, t_size = text
|
||||
code = bytearray(data[t_off:t_off + t_size])
|
||||
edits, ok = [], True
|
||||
for va, old, new, what in build.sites:
|
||||
if not t_va <= va < t_va + t_size - 3:
|
||||
ok = False
|
||||
break
|
||||
at = va - t_va
|
||||
word = struct.unpack_from("<I", code, at)[0]
|
||||
if word not in (old, new):
|
||||
ok = False
|
||||
break
|
||||
struct.pack_into("<I", code, at, old) # fingerprint over the code as shipped
|
||||
edits.append((t_off + at, old, new, what, word == new))
|
||||
if not ok or hashlib.sha256(code).hexdigest() != build.text_sha256:
|
||||
continue
|
||||
off = elf.vaddr_to_offset(segments, build.cache_va)
|
||||
if off is None:
|
||||
continue
|
||||
here = data[off:off + len(OLD_CACHE)]
|
||||
old_n, new_n = data.count(OLD_CACHE), data.count(NEW_CACHE)
|
||||
if not ((here == OLD_CACHE and old_n == 1 and new_n == 0) or (here == NEW_CACHE and old_n == 0 and new_n == 1)):
|
||||
continue
|
||||
cache_done = here == NEW_CACHE
|
||||
return {"build": build.version, "edits": [e[:4] for e in edits], "cache_offset": off,
|
||||
"done": cache_done and all(e[4] for e in edits),
|
||||
"pending": [e[3] for e in edits if not e[4]] + ([] if cache_done else ["shader cache namespace"])}
|
||||
return None
|
||||
|
||||
|
||||
def patch_library(data: bytes) -> tuple[bytes, dict | None]:
|
||||
"""(edited library, sites) for a supported build; the data unchanged and None otherwise."""
|
||||
site = find_sites(data)
|
||||
if not site or site["done"]:
|
||||
return data, site
|
||||
buf = bytearray(data)
|
||||
for at, _old, new, _what in site["edits"]:
|
||||
struct.pack_into("<I", buf, at, new)
|
||||
buf[site["cache_offset"]:site["cache_offset"] + len(NEW_CACHE)] = NEW_CACHE
|
||||
return bytes(buf), site
|
||||
|
||||
|
||||
class BlazeRush(Patch):
|
||||
id = "frame.blazerush"
|
||||
title = "BlazeRush: controllers without Meta avatars, own shader cache"
|
||||
description = ("BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which "
|
||||
"never comes on the Frame, before they create the game's own controllers: the second menu ignored "
|
||||
"all input. Two instructions in libtargemapp.so skip that wait and the avatar mesh drawing; the "
|
||||
"game's shader cache gets a new folder name (cache/br_shader_v1), so programs compiled before the "
|
||||
"GL shim's shader fixes aren't reused. Only this exact build is changed (code fingerprint and "
|
||||
"every instruction checked); anything else is left as it is.")
|
||||
order = 59 # after frame.avatar_stub (58), before the GL shim (61): it doesn't matter, nothing here moves
|
||||
strict = True # a catalog recipe only uses it where applies() holds
|
||||
note = "BlazeRush: avatar wait skipped, avatar meshes not drawn, shader cache cache/br_shader_v1"
|
||||
reason = ("BlazeRush waits for a Meta avatar before it creates its controllers (the menus ignore input on the "
|
||||
"Frame) and reuses shaders compiled before the GL shim's fixes.")
|
||||
|
||||
def applies(self, a):
|
||||
return a.package == PACKAGE and "arm64-v8a" in a.abis and LIB in a.libs
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a) and a.version in {b.version for b in BUILDS}:
|
||||
return Suggestion(True, self.reason)
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a" or not ws.has(ws.lib(LIB)):
|
||||
return False
|
||||
data = ws.read_lib(LIB)
|
||||
new, site = patch_library(data)
|
||||
if not site:
|
||||
ctx.notes.append(f"{self.id}: {LIB} isn't BlazeRush 1.0.349's library as known, left unchanged")
|
||||
return False
|
||||
if new == data:
|
||||
return False
|
||||
ws.put(ws.lib(LIB), new)
|
||||
ctx.notes.append(f"{self.note} ({LIB}: " + ", ".join(site["pending"]) + ")")
|
||||
return True
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a" or not ws.has(ws.lib(LIB)):
|
||||
return []
|
||||
site = find_sites(ws.read_lib(LIB))
|
||||
return [("BlazeRush avatar wait skipped and own shader cache", bool(site) and site["done"],
|
||||
"" if site else "library not recognised (different build)")]
|
||||
|
||||
|
||||
register(BlazeRush)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Godot 4 apps (Godot 4.2-4.4 Android library, e.g. EndoparasiticVR, GitHub #134) crash at start in Lepton: its
|
||||
Android has no clipboard service, getSystemService(CLIPBOARD_SERVICE) returns null, and Godot's Kotlin code casts it
|
||||
with `as ClipboardManager` (a non-null cast: `Intrinsics.checkNotNull(x, "null cannot be cast to non-null type
|
||||
android.content.ClipboardManager")` before the check-cast) → NullPointerException in Godot.<init> (4.3/4.4) or the
|
||||
first time the clipboard is used (4.2's lazy property). Godot 4.1 (Java) and 4.5+ (`as?`) don't throw.
|
||||
|
||||
In place in classes*.dex: the null check in front of that one cast is turned into nops (check-cast accepts null, so the
|
||||
field simply holds null), and, because the property was non-null typed, Godot's clipboard methods use it without a
|
||||
null check: they return at once instead (hasClipboard false, getClipboard "", setClipboard nothing). Only exactly that
|
||||
bytecode is edited; a dex without it is left alone."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...apk.dex import Dex
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
PACKAGE = "Lorg/godotengine/"
|
||||
CLIPBOARD = "Landroid/content/ClipboardManager;"
|
||||
MESSAGE = "null cannot be cast to non-null type android.content.ClipboardManager"
|
||||
CHECK_NOT_NULL = ("Lkotlin/jvm/internal/Intrinsics;", "checkNotNull")
|
||||
NPE = "Ljava/lang/NullPointerException;"
|
||||
CHECK_CAST, NEW_INSTANCE, THROW, IF_EQZ, IF_NEZ, GOTO16 = 0x1F, 0x22, 0x27, 0x38, 0x39, 0x29
|
||||
INVOKE_DIRECT, INVOKE_STATIC, INVOKE_STATIC_RANGE = 0x70, 0x71, 0x77
|
||||
|
||||
|
||||
def _const_string(dex: Dex, u) -> tuple[int, str] | None:
|
||||
"""(register, text) of a const-string(/jumbo), else None."""
|
||||
op = u[0] & 0xFF
|
||||
if op == 0x1A:
|
||||
return u[0] >> 8, dex.string(u[1])
|
||||
if op == 0x1B:
|
||||
return u[0] >> 8, dex.string(u[1] | u[2] << 16)
|
||||
return None
|
||||
|
||||
|
||||
def _args(u) -> list[int]:
|
||||
"""Argument registers of an invoke (35c or 3rc)."""
|
||||
op = u[0] & 0xFF
|
||||
if op in (INVOKE_STATIC_RANGE, 0x76): # invoke-static/range, invoke-direct/range
|
||||
return list(range(u[2], u[2] + (u[0] >> 8)))
|
||||
regs = [u[2] & 0xF, (u[2] >> 4) & 0xF, (u[2] >> 8) & 0xF, u[2] >> 12, (u[0] >> 8) & 0xF]
|
||||
return regs[:u[0] >> 12]
|
||||
|
||||
|
||||
def _s16(unit: int) -> int:
|
||||
return unit - 0x10000 if unit & 0x8000 else unit
|
||||
|
||||
|
||||
def _is_message(dex: Dex, u, reg: int | None = None) -> bool:
|
||||
cs = _const_string(dex, u)
|
||||
return cs is not None and cs[1] == MESSAGE and (reg is None or cs[0] == reg)
|
||||
|
||||
|
||||
def _throw_block(dex: Dex, insns: list, i: int) -> bool:
|
||||
"""insns[i:i+4] = new-instance vX, NullPointerException; const-string vY, MESSAGE; invoke-direct {vX, vY},
|
||||
NullPointerException.<init>; throw vX (what kotlinc before Intrinsics.checkNotNull emitted)."""
|
||||
if i + 4 > len(insns):
|
||||
return False
|
||||
(_, new), (_, msg), (_, init), (_, thr) = insns[i:i + 4]
|
||||
if new[0] & 0xFF != NEW_INSTANCE or dex.type_name(new[1]) != NPE or not _is_message(dex, msg):
|
||||
return False
|
||||
x, y = new[0] >> 8, msg[0] >> 8
|
||||
return (init[0] & 0xFF == INVOKE_DIRECT and dex.method(init[1]) == (NPE, "<init>") and _args(init) == [x, y]
|
||||
and thr[0] & 0xFF == THROW and thr[0] >> 8 == x)
|
||||
|
||||
|
||||
def unchecked_casts(dex: Dex, code: int) -> list[tuple[int, list[int]]]:
|
||||
"""(code unit position, replacement units) that let `x as ClipboardManager` pass a null x in one method."""
|
||||
insns = list(dex._insns(code))
|
||||
pos_of = {pos: k for k, (pos, _u) in enumerate(insns)}
|
||||
edits = []
|
||||
for k, (pos, u) in enumerate(insns):
|
||||
if u[0] & 0xFF != CHECK_CAST or dex.type_name(u[1]) != CLIPBOARD:
|
||||
continue
|
||||
reg = u[0] >> 8
|
||||
# Kotlin >= 1.4: const-string vB, MESSAGE; invoke-static {vA, vB}, Intrinsics.checkNotNull; check-cast vA
|
||||
if k >= 2:
|
||||
(_, msg), (ipos, inv) = insns[k - 2], insns[k - 1]
|
||||
if (inv[0] & 0xFF in (INVOKE_STATIC, INVOKE_STATIC_RANGE) and dex.method(inv[1]) == CHECK_NOT_NULL
|
||||
and _args(inv) == [reg, msg[0] >> 8] and _is_message(dex, msg)):
|
||||
edits.append((ipos, [0, 0, 0]))
|
||||
continue
|
||||
# older kotlinc: if-nez vA, :cast; <throw block>; :cast check-cast vA → the branch is always taken
|
||||
if k >= 5:
|
||||
bpos, br = insns[k - 5]
|
||||
if (br[0] & 0xFF == IF_NEZ and br[0] >> 8 == reg and bpos + _s16(br[1]) == pos
|
||||
and _throw_block(dex, insns, k - 4)):
|
||||
edits.append((bpos, [GOTO16, br[1]]))
|
||||
continue
|
||||
# the same with the throw block moved away: if-eqz vA, :throw; check-cast vA → never branches
|
||||
if k >= 1:
|
||||
bpos, br = insns[k - 1]
|
||||
if (br[0] & 0xFF == IF_EQZ and br[0] >> 8 == reg and bpos + _s16(br[1]) in pos_of
|
||||
and _throw_block(dex, insns, pos_of[bpos + _s16(br[1])])):
|
||||
edits.append((bpos, [0, 0]))
|
||||
return edits
|
||||
|
||||
|
||||
def patch_dex(dex: Dex) -> list[str]:
|
||||
"""What was changed (method names); [] if this dex doesn't have Godot's throwing clipboard cast."""
|
||||
if MESSAGE.encode() not in dex.data:
|
||||
return []
|
||||
classes = [c for c in dex.class_names() if c.startswith(PACKAGE)]
|
||||
done = []
|
||||
for cls in classes:
|
||||
for idx, code in dex.class_methods(cls):
|
||||
edits = unchecked_casts(dex, code)
|
||||
for pos, units in edits:
|
||||
for i, unit in enumerate(units):
|
||||
dex.data[code + 16 + 2 * (pos + i): code + 18 + 2 * (pos + i)] = unit.to_bytes(2, "little")
|
||||
if edits:
|
||||
done.append(f"{_short(cls)}.{dex.method(idx)[1]} (clipboard cast)")
|
||||
if not done:
|
||||
return []
|
||||
# the property was typed non-null, so nothing checks it before use: its users return at once
|
||||
users = {i for i in range(dex.methods_n) if dex.method(i)[0] == CLIPBOARD}
|
||||
for cls in classes:
|
||||
for idx, code in dex.class_methods(cls):
|
||||
if dex.invoked(code) & users and dex.return_early(code, dex.return_type(idx)):
|
||||
done.append(f"{_short(cls)}.{dex.method(idx)[1]} (returns at once)")
|
||||
return done
|
||||
|
||||
|
||||
def _short(cls: str) -> str:
|
||||
return cls[1:-1].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
class GodotClipboard(Patch):
|
||||
id = "frame.godot_clipboard"
|
||||
title = "Godot apps: start without a clipboard service"
|
||||
description = ("Lepton's Android has no clipboard service, and the Android code of Godot 4.2-4.4 (for example "
|
||||
"EndoparasiticVR) casts the missing service to a ClipboardManager without checking, so the app "
|
||||
"crashes at start with a NullPointerException in Godot.<init>. That check is skipped and Godot's "
|
||||
"clipboard functions do nothing instead (an in-place edit of classes.dex).")
|
||||
order = 44
|
||||
needs_vr = False
|
||||
|
||||
def applies(self, a):
|
||||
return bool((a.extra or {}).get("godot_clipboard"))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "Godot 4 app: it crashes at start in Lepton, which has no clipboard service.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
changed = False
|
||||
for name in sorted(n for n in ctx.ws.names() if n.startswith("classes") and n.endswith(".dex")):
|
||||
data = ctx.ws.read(name)
|
||||
if MESSAGE.encode() not in data:
|
||||
continue
|
||||
dex = Dex(data)
|
||||
done = patch_dex(dex)
|
||||
if done:
|
||||
ctx.ws.put(name, dex.finish())
|
||||
ctx.notes.append(f"{name}: Godot runs without a clipboard service ({', '.join(done)})")
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
register(GodotClipboard)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Unreal (UE4/UE5) games with an OBB start through Epic's DownloaderActivity (GameActivity.onResume starts it for a
|
||||
result before the engine's main init). It checks that every OBB exists with the name and size compiled into the APK
|
||||
(OBBData), and with the manifest meta-data `com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp` = true it then reads
|
||||
the whole OBB and compares each zip entry's CRC32 (once: the result is cached in files/cacheFile.txt by the OBB's
|
||||
modified time). A CRC mismatch shows an error screen that waits for a button, and the check itself shows a progress
|
||||
screen while it reads gigabytes; Lepton runs VR apps headless, so neither is visible and the game seems to hang on
|
||||
the downloader (GitHub #159, Contractors). Setting the meta-data to false skips only that CRC pass: the name + size
|
||||
check stays (a missing or wrong-size OBB still stops at the downloader). One 4-byte manifest value changes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis.detect import UE_VERIFY_OBB_KEY
|
||||
from ...apk import axml
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
|
||||
MANIFEST = "AndroidManifest.xml"
|
||||
|
||||
|
||||
class UnrealSkipObbCheck(Patch):
|
||||
id = "frame.unreal_skip_obb_check"
|
||||
title = "Unreal: start without the OBB check"
|
||||
description = ("Unreal games can check their whole data file (.obb) before starting (bVerifyOBBOnStartUp). On "
|
||||
"the Frame that check's progress and error screens are invisible (Lepton runs VR games without "
|
||||
"an Android window), so the game seems to hang on its DownloaderActivity, for example Contractors. "
|
||||
"This turns the check off in the manifest; Unreal still checks that the OBB is there with the "
|
||||
"right size, so a missing or wrong-version OBB still stops the game.")
|
||||
order = 23
|
||||
needs_vr = False
|
||||
|
||||
def applies(self, a):
|
||||
return bool((a.extra or {}).get("unreal_verify_obb"))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the "
|
||||
"Frame a failed or slow check can't be seen and the game seems to hang.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
fixed = axml.set_meta_data_bool(ctx.ws.read(MANIFEST), UE_VERIFY_OBB_KEY, False)
|
||||
if fixed:
|
||||
ctx.ws.put(MANIFEST, fixed)
|
||||
ctx.notes.append("bVerifyOBBOnStartUp=false")
|
||||
return bool(fixed)
|
||||
|
||||
def validate(self, ctx):
|
||||
meta = axml.meta_data(ctx.ws.read(MANIFEST))
|
||||
on = [k for k, v in meta.items() if k.endswith(UE_VERIFY_OBB_KEY) and v is True]
|
||||
return [("OBB check at start off", not on, ", ".join(on) or "bVerifyOBBOnStartUp=false")]
|
||||
|
||||
|
||||
register(UnrealSkipObbCheck)
|
||||
@@ -221,16 +221,29 @@ SETTINGS = [
|
||||
("gl_hide_multiview", "int", 1, "GL shim: hide multiview",
|
||||
"GL shim only: hide GL_OVR_multiview so all passes use single-view shaders. For GLES games whose multiview "
|
||||
"shaders fail on single-view render targets (for example Path of the Warrior)."),
|
||||
("gl_int_attribs", "int", 0, "GL shim: integer vertex inputs",
|
||||
"GL shim (frame.gl_shim): shader inputs declared as integers (int/uint, ivecN/uvecN) whose vertex data the game "
|
||||
"set up with glVertexAttribPointer (as floats) are set up again with glVertexAttribIPointer before each draw. "
|
||||
"Qualcomm's Quest driver read the raw integers anyway; the Frame's GL driver converts them to floats, so skinned "
|
||||
"models using bone indices vanish (for example BlazeRush's car bodies and controllers; Klownicle's guide, GitHub "
|
||||
"#57). Comes from a game's recipe."),
|
||||
("gl_highp_markers", "str", "", "GL shim: high-precision vertex shaders",
|
||||
"GL shim (frame.gl_shim): vertex shaders whose source contains every '|'-separated text compute in highp: "
|
||||
"whole-word mediump becomes highp on every line without the words in, out or uniform (the shader's interface "
|
||||
"keeps its precision). For effects that draw streaks or flashes from mediump rounding on the Frame (for example "
|
||||
"BlazeRush's particles: in uvec2 inBoneIndices;|uniform InstanceData|void main()). Pair it with a new shader "
|
||||
"cache name if the game caches compiled programs. Comes from a game's recipe."),
|
||||
]
|
||||
|
||||
|
||||
# settings that need a new build, not only new settings files: the Vulkan shim learned vk_shader_fix in 0.6.4 and
|
||||
# vk_query_slots and vk_spec_fixes in 0.10.0. haptic_fix stays at 2 although the shim also converts PCM vibrations
|
||||
# since 0.12.1: no game is known to need that, and a bump would mark every OVRPlugin game's build outdated
|
||||
REVISIONS = {"vk_shader_fix": 2, "vk_query_slots": 3, "vk_spec_fixes": 2, "haptic_fix": 2, "pose_consistency": 2}
|
||||
REVISIONS = {"vk_shader_fix": 2, "vk_query_slots": 3, "vk_spec_fixes": 2, "haptic_fix": 2, "pose_consistency": 2,
|
||||
"gl_int_attribs": 2, "gl_highp_markers": 2} # the GL shim learned these in 1.0.1
|
||||
|
||||
# Settings a game's recipe sets that the Game settings dialog doesn't show (engine-specific, not user choices)
|
||||
HIDDEN = {"surface_native"}
|
||||
HIDDEN = {"surface_native", "gl_int_attribs"}
|
||||
|
||||
# How the "Game settings" dialog shows each setting to non-technical users: group, level (common settings are always
|
||||
# shown; advanced ones only under "Show advanced settings"), a plain label and one-line help, the control, and the
|
||||
@@ -489,6 +502,8 @@ class AdapterSetting(Patch):
|
||||
"swapchain_fix": ap.is_gles,
|
||||
"gl_hide_multiview": lambda a: a.direct_vrapi and ap.is_gles(a),
|
||||
"gl_hide_msrtt": ap.is_gles,
|
||||
"gl_int_attribs": ap.is_gles,
|
||||
"gl_highp_markers": ap.is_gles,
|
||||
"gl_mv_debug": lambda a: bool((a.extra or {}).get("gl_multiview_libs")) and ap.is_gles(a),
|
||||
"ovrp_begin_gate": lambda a: a.engine == "Unity" and "libOVRPlugin.so" in a.libs,
|
||||
"ovrp_hold_physics": lambda a: a.engine == "Unity" and "libOVRPlugin.so" in a.libs,
|
||||
@@ -557,6 +572,31 @@ class DeviceFiles(Patch):
|
||||
ctx.files[rel] = content.encode() if isinstance(content, str) else content
|
||||
|
||||
|
||||
class ConfigSync(Patch):
|
||||
id = "device.config_sync"
|
||||
title = "Keep the game's saved resolution in step"
|
||||
description = ("Some games save their render resolution in a settings file and load it after creating the "
|
||||
"headset's eye buffers; after a resolution scale change the saved size no longer matches and the "
|
||||
"picture is displaced or stretched, with black borders (for example BlazeRush's user_config.xml). "
|
||||
"FramePort writes the values its recipe names into the game's file while the game isn't running "
|
||||
"(after an install and after a Game settings change), from the Frame's own eye-buffer size times "
|
||||
"the resolution scale: w and h = the eye buffer's width and height, for example h*16/9. Only "
|
||||
"existing entries change; the previous file is kept as <file>.frameport-backup. The size is "
|
||||
"learned from the game's last start, so a file the game wrote before is corrected from the next "
|
||||
"install or settings change on.")
|
||||
category = "device"
|
||||
needs_vr = True
|
||||
stage = "install"
|
||||
params = [Param("files", "text", {}, "path relative to files/ -> {setting name: w | h | expression}")]
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
rules = {}
|
||||
for rel, values in (ctx.params.get("files") or {}).items():
|
||||
if isinstance(rel, str) and isinstance(values, dict) and values:
|
||||
rules[rel] = {str(k): str(v) for k, v in values.items()}
|
||||
ctx.config_sync.update(rules)
|
||||
|
||||
|
||||
class LeptonEnv(Patch):
|
||||
id = "device.lepton_env"
|
||||
title = "Extra Lepton settings"
|
||||
@@ -689,6 +729,7 @@ class TextInputWindow(Patch):
|
||||
for _spec in SETTINGS:
|
||||
register(AdapterSetting(*_spec))
|
||||
register(DeviceFiles)
|
||||
register(ConfigSync)
|
||||
register(LeptonEnv)
|
||||
register(HideNavBar)
|
||||
register(SteamGamepad)
|
||||
|
||||
@@ -69,6 +69,9 @@ SUMMARIES = {
|
||||
"frame.unity_runtime_msaa_off": "Keeps a smoothing setting off that the game turns on itself and that can freeze "
|
||||
"the Frame.",
|
||||
"frame.sdl_clipboard": "Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboard).",
|
||||
"frame.godot_clipboard": "Lets Godot games start on the Frame (they'd crash looking for a clipboard).",
|
||||
"frame.unreal_skip_obb_check": "Starts Unreal games without re-checking their data file (they'd seem to hang "
|
||||
"on an invisible check screen).",
|
||||
"frame.vivox_audio_route": "Lets games with Vivox voice chat start on the Frame (they'd crash on an Android 12 "
|
||||
"audio call).",
|
||||
"frame.unity_oculus_check": "Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D "
|
||||
@@ -88,6 +91,8 @@ SUMMARIES = {
|
||||
"frame.avatar_stub": "Lets games start that would stop at Meta's avatars (for example BlazeRush). No Meta "
|
||||
"avatars.",
|
||||
"frame.vrapi_bridge": "Lets games built on Meta's oldest VR interface run on the Frame.",
|
||||
"frame.blazerush": "Makes BlazeRush's menus and controllers work without Meta's avatars, and lets its shader "
|
||||
"fixes take effect.",
|
||||
"frame.gl_shim": "Corrects graphics code the Frame's drivers reject (black screen with sound).",
|
||||
"frame.metaxr_telemetry": "Skips a Quest-only reporting step in Meta's audio library that crashes some games.",
|
||||
"frame.oculusos": "Provides stand-ins for Quest system calls some games make at start.",
|
||||
@@ -104,6 +109,8 @@ SUMMARIES = {
|
||||
# Files and environment on the Frame
|
||||
"device.files": "Puts settings files next to the game on the Frame, for example to turn off an unsupported "
|
||||
"effect.",
|
||||
"device.config_sync": "Keeps the resolution the game saves in step with the headset's picture size (else the "
|
||||
"picture is shifted, with black borders, after a sharpness change).",
|
||||
"device.hide_navbar": "Hides Android's back, home and recents buttons, which cover the app's own buttons.",
|
||||
"device.text_input_window": "Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard. VR "
|
||||
"keeps working.",
|
||||
@@ -129,5 +136,9 @@ SUMMARIES = {
|
||||
"pcvr.steamvr_tuning": "Picks a refresh rate and motion smoothing that suit the game on this PC.",
|
||||
"pcvr.proton_env": "Extra settings for Proton (for testing).",
|
||||
# Game settings the Game settings dialog doesn't show (the others use their help line there)
|
||||
"adapter.gl_int_attribs": "Shows models whose shapes come from bone data the Frame's graphics driver misreads "
|
||||
"(for example BlazeRush's cars). Comes from a game's recipe.",
|
||||
"adapter.gl_highp_markers": "Computes some of the game's effects more precisely, so they don't draw streaks or "
|
||||
"flashes (for example BlazeRush's particles). Comes from a game's recipe.",
|
||||
"adapter.vk_shader_fix": "Corrects specific shaders that hang the game on the Frame. Comes from a game's recipe.",
|
||||
}
|
||||
+122
-6
@@ -25,7 +25,7 @@ from .core.paths import output_dir
|
||||
from .patches import upstream
|
||||
from .recommend import engine
|
||||
from .sources import quest_dump, rift_dump
|
||||
from .targets.base import Target
|
||||
from .targets.base import PC_LABEL, Target
|
||||
|
||||
|
||||
def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_rift: bool = False,
|
||||
@@ -38,7 +38,9 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
|
||||
path = Path(path)
|
||||
added = []
|
||||
known_apks = {str(Path(g["apk"]).resolve()) for g in library.games() if g.get("apk")} if only_new else set()
|
||||
known_packages = {g["package"] for g in library.games()} if only_new else set()
|
||||
# entries made from AXRB's PC-patched copies (no OBBs; older FramePort scanned them) get the original download
|
||||
known_packages = {g["package"] for g in library.games()
|
||||
if not (g.get("apk") and quest_dump._axrb_patched(Path(g["apk"])))} if only_new else set()
|
||||
|
||||
def done(entry):
|
||||
if only_new and entry["package"] in known_packages: # already in the library (e.g. an unchanged Rift folder)
|
||||
@@ -177,6 +179,8 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
|
||||
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
|
||||
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
|
||||
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
|
||||
stored = library.upsert_game(package, **fields)
|
||||
if art and not _has_art(package):
|
||||
_rift_art(stored, reporter)
|
||||
@@ -208,8 +212,8 @@ def _rift_art(entry: dict, reporter: Reporter | None = None) -> dict:
|
||||
update["quest_package"] = found["quest_package"]
|
||||
if found.get("oculus_app_id"):
|
||||
update["oculus_app_id"] = found["oculus_app_id"]
|
||||
if found.get("title") and not entry.get("title_locked"):
|
||||
update["title"] = found["title"]
|
||||
if found.get("title"): # a name the user chose stays (a reset of it brings the store's)
|
||||
update["auto_title" if entry.get("title_locked") else "title"] = found["title"]
|
||||
library.upsert_game(pkg, **update)
|
||||
try:
|
||||
thumbs.prewarm(pkg)
|
||||
@@ -277,6 +281,92 @@ def steam_title(entry: dict) -> str:
|
||||
return display_title(entry, twins(library.games()))
|
||||
|
||||
|
||||
TITLE_MAX = 128 # (the agent cuts Steam shortcut names to the same length)
|
||||
|
||||
|
||||
def clean_title(title: str | None) -> str:
|
||||
"""A name as the user typed it, made one line: no control characters, spaces collapsed, at most TITLE_MAX."""
|
||||
text = " ".join("".join(c if c.isprintable() else " " for c in str(title or "")).split())
|
||||
return text[:TITLE_MAX]
|
||||
|
||||
|
||||
def store_title(entry: dict) -> str | None:
|
||||
"""The store's name for a game when FramePort fetched one (Meta store art comes with it), else None."""
|
||||
from .artwork.fetch import artwork_dir
|
||||
|
||||
try:
|
||||
f = artwork_dir(entry["package"]) / "title.txt"
|
||||
return clean_title(f.read_text(encoding="utf-8")) or None if f.is_file() else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def automatic_title(entry: dict) -> str:
|
||||
"""The name FramePort gives a game by itself (what Rename… → reset brings back): the current title unless the user
|
||||
(or an install link) chose one; then the automatic name remembered at that moment, else the store's name or the
|
||||
APK's label."""
|
||||
if not entry.get("title_locked"):
|
||||
return entry.get("title") or entry["package"]
|
||||
return entry.get("auto_title") or store_title(entry) or (entry.get("analysis") or {}).get("label") or \
|
||||
entry["package"]
|
||||
|
||||
|
||||
def title_suggestions(entry: dict) -> list[str]:
|
||||
"""Other names the Rename dialog offers: the automatic name and the store's name (when they differ from the
|
||||
current title)."""
|
||||
current = entry.get("title") or entry["package"]
|
||||
out = []
|
||||
for t in (automatic_title(entry), store_title(entry)):
|
||||
if t and t != current and t not in out:
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def rename_game(package: str, title: str | None) -> dict:
|
||||
"""Rename a game in the library. The new title is locked (scans, artwork and store lookups and catalog updates
|
||||
keep it); an empty title or the automatic name unlocks it again. Returns the entry. Installed copies keep their
|
||||
old Steam name until sync_title (or the next install) updates them."""
|
||||
if library.game(package) is None:
|
||||
raise ValueError(f"{package} is not in the library")
|
||||
title = clean_title(title)
|
||||
old: dict = {}
|
||||
|
||||
def change(g: dict) -> None:
|
||||
old.update(title=g.get("title"))
|
||||
auto = automatic_title(g)
|
||||
if not title or title == auto:
|
||||
g["title"] = auto
|
||||
g.pop("title_locked", None)
|
||||
g.pop("auto_title", None)
|
||||
else:
|
||||
g.setdefault("auto_title", auto)
|
||||
g["title"] = title
|
||||
g["title_locked"] = True
|
||||
if g["title"] != old["title"] and any(w != PC_LABEL for w in g.get("installs") or {}):
|
||||
g["steam_name_stale"] = True # the Frame's Steam library shows the old name until sync_title
|
||||
entry = library.update_game(package, change)
|
||||
if entry.get("title") != old.get("title"):
|
||||
try: # FramePort's own cover/banner (no store art) are coloured by the name: make them again
|
||||
from .artwork.steam import ensure_cover
|
||||
|
||||
ensure_cover(package)
|
||||
except Exception: # noqa: BLE001 - artwork is optional
|
||||
pass
|
||||
return entry
|
||||
|
||||
|
||||
def sync_title(package: str, target: Target, reporter: Reporter) -> dict:
|
||||
"""Give an installed game its library name in Steam (after Rename…): the Frame (agent v77 `rename`, same shortcut
|
||||
appid, Steam restarts once) or this PC's Steam (PC VR games)."""
|
||||
entry = library.game(package)
|
||||
title = steam_title(entry)
|
||||
if target.kind == "pc":
|
||||
return target.rename(package, title, reporter)
|
||||
result = target.update_steam_art(package, reporter, title=title)
|
||||
library.update_game(package, lambda g: (g.pop("steam_name_stale", None), g.pop("steam_art_stale", None)))
|
||||
return result
|
||||
|
||||
|
||||
def is_rift(entry: dict) -> bool:
|
||||
return entry.get("kind") == "rift"
|
||||
|
||||
@@ -432,6 +522,8 @@ def add_from_link(manifest, path: Path, reporter: Reporter | None = None, icon:
|
||||
fields = {"link": {"source": manifest.source, "name": manifest.name, "time": time.time()}}
|
||||
if not manifest.direct: # a manifest (not a bare file link) names it
|
||||
fields.update(title=manifest.name, title_locked=True)
|
||||
if not entry.get("title_locked"): # what Rename… → reset brings back
|
||||
fields["auto_title"] = entry.get("title") or pkg
|
||||
# FramePort's manifest extension: a description where no store has one, the icon unless the user picked one
|
||||
details = dict(library.game(pkg).get("details") or {})
|
||||
if manifest.description and not details.get("description"):
|
||||
@@ -509,8 +601,11 @@ def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
a.extra["lang_packs"] = langpacks.find_tags(_data_folder(src))
|
||||
a.extra["asset_files"] = langpacks.find_content_files(_data_folder(src))
|
||||
recipe = engine.suggest(a)
|
||||
old = library.game(a.package) or {}
|
||||
# a name the user chose (Rename…, an install link) stays when the game is scanned again
|
||||
names = {"auto_title": recipe.title or a.label} if old.get("title_locked") else {"title": recipe.title or a.label}
|
||||
return library.upsert_game(
|
||||
a.package, title=recipe.title or a.label, name=src.name, apk=str(src.apk),
|
||||
a.package, **names, name=src.name, apk=str(src.apk),
|
||||
data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files, data_bytes=src.data_bytes(),
|
||||
origin=str(src.origin),
|
||||
analysis=a.to_dict(), recipe=library.recipe_to_dict(recipe), suggested=library.recipe_to_dict(recipe),
|
||||
@@ -771,6 +866,21 @@ def build_game(package: str, reporter: Reporter, outdir: Path | None = None) ->
|
||||
return build_info
|
||||
|
||||
|
||||
def _find_late_data(entry: dict, reporter: Reporter | None = None) -> dict:
|
||||
"""A game added without data files: look for them again next to its APK (the user may have copied the OBBs in
|
||||
afterwards, or added the APK alone). Found → stored, like a fresh scan would."""
|
||||
src_apk = Path(entry["apk"])
|
||||
if not src_apk.is_file():
|
||||
return entry
|
||||
src = quest_dump.from_path(src_apk)
|
||||
if not src or not src.data_dir:
|
||||
return entry
|
||||
if reporter:
|
||||
reporter.log(f"found the game's data files: {src.data_dir}")
|
||||
return library.upsert_game(entry["package"], data_dir=str(src.data_dir), data_files=src.data_files,
|
||||
data_bytes=src.data_bytes())
|
||||
|
||||
|
||||
def install_game(package: str, target: Target, reporter: Reporter, apk_only: bool = False,
|
||||
add_to_library: bool = True, apk: Path | None = None) -> dict:
|
||||
"""Install the game's last build (or `apk`, e.g. a test build of the same package signed with the same key)."""
|
||||
@@ -791,6 +901,8 @@ def install_game(package: str, target: Target, reporter: Reporter, apk_only: boo
|
||||
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"]))
|
||||
if not entry.get("data_dir") and entry.get("apk") and not apk_only:
|
||||
entry = _find_late_data(entry, reporter) # OBBs copied next to the APK after it was added (GitHub #156)
|
||||
data_dir = Path(entry["data_dir"]) if entry.get("data_dir") else None
|
||||
title = steam_title(entry)
|
||||
result = target.install(package, title, apk, data_dir, recipe, reporter, apk_only,
|
||||
@@ -930,7 +1042,11 @@ def remove_converted_copies(package: str) -> int:
|
||||
|
||||
def _record_install(package: str, where: str, record: dict) -> None:
|
||||
"""Merge into the game's current install records (the entry read before a long install may be stale)."""
|
||||
library.update_game(package, lambda g: g.setdefault("installs", {}).__setitem__(where, record))
|
||||
def change(g: dict) -> None:
|
||||
g.setdefault("installs", {})[where] = record
|
||||
if where != PC_LABEL: # the install gave the Frame's shortcut the current name
|
||||
g.pop("steam_name_stale", None)
|
||||
library.update_game(package, change)
|
||||
|
||||
|
||||
def install_rift(package: str, target: Target, reporter: Reporter, add_to_library: bool = True) -> dict:
|
||||
|
||||
@@ -41,6 +41,8 @@ class CatalogEntry:
|
||||
device: list[str] = field(default_factory=list) # device.* toggles, e.g. device.text_input_window
|
||||
adapter: dict = field(default_factory=dict)
|
||||
device_files: dict = field(default_factory=dict)
|
||||
# device.config_sync: settings file (relative to files/) -> {setting: w | h | expression of the eye-buffer size}
|
||||
config_sync: dict = field(default_factory=dict)
|
||||
lepton_env: dict = field(default_factory=dict)
|
||||
foveation: str = "" # device.foveation: fixed / off (Valve's eye-tracked foveation layer)
|
||||
pcvr_alternative: str | None = None
|
||||
@@ -350,6 +352,7 @@ def entry_from_library(g: dict, status: str | None = None, notes: str | None = N
|
||||
device=[p for p in r.patches if p in TOGGLED_DEVICE],
|
||||
adapter={p.split(".", 1)[1]: v.get("value") for p, v in r.patches.items() if p.startswith("adapter.")},
|
||||
device_files=r.params("device.files").get("files", {}),
|
||||
config_sync=r.params("device.config_sync").get("files", {}),
|
||||
foveation=r.params("device.foveation").get("mode") or "")
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
recipe.reasons["device.files"] = why
|
||||
if entry.lepton_env:
|
||||
recipe.patches["device.lepton_env"] = {"env": dict(entry.lepton_env)}
|
||||
if entry.config_sync:
|
||||
recipe.patches["device.config_sync"] = {"files": {k: dict(v) for k, v in entry.config_sync.items()}}
|
||||
recipe.reasons["device.config_sync"] = why
|
||||
if entry.foveation:
|
||||
recipe.patches["device.foveation"] = {"mode": entry.foveation}
|
||||
recipe.reasons["device.foveation"] = why
|
||||
|
||||
@@ -48,7 +48,22 @@ def _package_of(apk: Path) -> str | None:
|
||||
return _apk_ids(apk)[0]
|
||||
|
||||
|
||||
def _axrb_patched(path: Path) -> bool:
|
||||
"""AXRB's own PC builds (OVRPort-converted for its Windows runtime, without the game's OBBs): <name>-axrb.apk,
|
||||
AXRB/patched/<package>/ or an axrb-patched folder. The original download next to them is the game."""
|
||||
parts = [p.lower() for p in path.parts]
|
||||
return path.stem.lower().endswith("-axrb") or "axrb-patched" in parts or any(
|
||||
a == "axrb" and b == "patched" for a, b in zip(parts, parts[1:], strict=False))
|
||||
|
||||
|
||||
def _axrb_download(folder: Path, apk: Path) -> bool:
|
||||
"""AXRB's launcher download: <app id>/<binary id>/base.apk (both folder names numbers)."""
|
||||
return apk.name.lower() == "base.apk" and folder.name.isdigit() and folder.parent.name.isdigit()
|
||||
|
||||
|
||||
def _is_apk(path: Path) -> bool:
|
||||
if _axrb_patched(path):
|
||||
return False
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
return "AndroidManifest.xml" in z.namelist()
|
||||
@@ -176,7 +191,9 @@ def _obb_folders(apk_dir: Path, pkg: str, depth: int = 3) -> list[tuple[Path, li
|
||||
|
||||
def below(d: Path, level: int, skip: Path | None = None) -> None:
|
||||
for sub in _subdirs(d):
|
||||
if sub == skip or _has_apks(sub):
|
||||
# a folder with APKs is another game, except an obb folder holding this package's files (SideQuest's VR4
|
||||
# backup has Unreal's VR4-Android-Shipping-arm64.apk next to its OBBs)
|
||||
if sub == skip or (_has_apks(sub) and not (sub.name.lower() in OBB_FOLDERS and _named_obbs(sub, pkg))):
|
||||
continue
|
||||
visit(sub)
|
||||
if level > 1:
|
||||
@@ -227,6 +244,8 @@ def find_data_dir(apk_dir: Path, pkg: str | None, version_code: int | None = Non
|
||||
|
||||
# a folder that only holds the APK inside a game folder (<game>/apk/x.apk + <game>/obb/…): the game is the parent
|
||||
APK_FOLDERS = ("apk", "apks")
|
||||
# SideQuest's backup folder names: 2026-10-08T12-08-41-153Z (also with ':' / '.' separators)
|
||||
BACKUP_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}[-:.]\d{2}[-:.]\d{2}([-:.]\d+)?Z?$")
|
||||
|
||||
|
||||
def from_path(path: Path) -> SourceGame | None:
|
||||
@@ -245,7 +264,15 @@ def from_path(path: Path) -> SourceGame | None:
|
||||
pkg, vc = _apk_ids(apk)
|
||||
data, files = find_data(path, pkg, vc)
|
||||
game_dir = path.parent if path.name.lower() in APK_FOLDERS else path
|
||||
return SourceGame(display_name(game_dir.name), apk, data, game_dir, [a for a in apks if a != apk], files)
|
||||
# SideQuest backups: <package>/<backup time>/apk/x.apk; the game is named after the package folder, not the time.
|
||||
# AXRB downloads: <app id>/<binary id>/base.apk: numbers, so the package names it (the store title comes later)
|
||||
name = game_dir.parent.name if BACKUP_TIME.match(game_dir.name) else game_dir.name
|
||||
if name.isdigit() and pkg:
|
||||
name = pkg
|
||||
if _axrb_download(path, apk) and data == path and files is not None:
|
||||
# AXRB puts the game's other store files (DLC, extra assets) next to its OBBs: they belong in Android/obb too
|
||||
files = sorted(f.name for f, is_dir in _list(path) if not is_dir and f.suffix.lower() != ".apk")
|
||||
return SourceGame(display_name(name), apk, data, game_dir, [a for a in apks if a != apk], files)
|
||||
|
||||
|
||||
def _no_quest_games_below(d: Path) -> bool:
|
||||
@@ -304,4 +331,7 @@ def scan(root: Path, depth: int = 5) -> list[SourceGame]:
|
||||
walk(root, 0)
|
||||
finally:
|
||||
_LISTINGS.reset(token)
|
||||
return found
|
||||
# an APK inside another game's data folder is part of that game's data, not a game (SideQuest's VR4 backup keeps
|
||||
# Unreal's VR4-Android-Shipping-arm64.apk next to its OBBs)
|
||||
data = [(g, g.data_dir.resolve()) for g in found if g.data_dir and g.data_dir.resolve() != g.apk.resolve().parent]
|
||||
return [g for g in found if not any(h is not g and d in g.apk.resolve().parents for h, d in data)]
|
||||
@@ -8,6 +8,8 @@ from pathlib import Path
|
||||
from ..core.events import Reporter
|
||||
from ..core.models import Recipe
|
||||
|
||||
PC_LABEL = "This PC" # PcReviveTarget.label: the key of PC installs in a library entry's "installs"
|
||||
|
||||
|
||||
class Target(ABC):
|
||||
kind: str = ""
|
||||
|
||||
@@ -48,8 +48,8 @@ class FrameLeptonTarget(Target):
|
||||
def add_to_library(self, packages, reporter):
|
||||
return installer.add_to_steam(self.connect().frame, packages, reporter)
|
||||
|
||||
def update_steam_art(self, package, reporter):
|
||||
return installer.update_steam_art(self.connect().frame, package, reporter)
|
||||
def update_steam_art(self, package, reporter, title=None):
|
||||
return installer.update_steam_art(self.connect().frame, package, reporter, title=title)
|
||||
|
||||
def launch_test(self, package, reporter, seconds=45):
|
||||
return device.launch_test(self.connect().frame, package, reporter, seconds)
|
||||
@@ -96,7 +96,8 @@ class FrameLeptonTarget(Target):
|
||||
return self.connect().frame.agent("desktop_entry", package=package, enabled=enabled)
|
||||
|
||||
def set_vr_overlay(self, package: str, autostart: bool) -> dict:
|
||||
"""An installed SteamVR overlay app's auto-start with SteamVR (agent >= 75)."""
|
||||
"""An installed SteamVR overlay app's auto-start with SteamVR (agent >= 75; >= 76: FramePort's own
|
||||
frameport-vr-overlays service starts it at every SteamVR start, kept by the agent from this flag)."""
|
||||
return self.connect().frame.agent("register_vr_overlay", package=package, autostart=autostart)
|
||||
|
||||
def proton_status(self, tool: str | None = None) -> dict:
|
||||
|
||||
@@ -19,7 +19,7 @@ from ..core.models import Recipe
|
||||
from ..core.paths import agent_file, user_data_dir
|
||||
from ..patches.pcvr import game_args
|
||||
from ..validate.triage import triage
|
||||
from .base import Target
|
||||
from .base import PC_LABEL, Target
|
||||
|
||||
TAG = "FramePort PC VR" # marks the Steam shortcuts FramePort made (for updates and cleanup)
|
||||
OLD_TAGS = ("Rift via Revive",) # earlier name of the same tag
|
||||
@@ -86,7 +86,7 @@ def local_installs() -> dict[str, dict]:
|
||||
|
||||
class PcReviveTarget(Target):
|
||||
kind = "pc"
|
||||
label = "This PC"
|
||||
label = PC_LABEL
|
||||
|
||||
def __init__(self):
|
||||
if not winhost.available():
|
||||
@@ -126,6 +126,24 @@ class PcReviveTarget(Target):
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeError(f"{package} is not installed on this PC") from None
|
||||
|
||||
@staticmethod
|
||||
def _save_dep(package: str, dep: dict) -> None:
|
||||
path = pc_dir() / package / "deployment.json"
|
||||
tmp = path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(dep, indent=2), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
def rename(self, package: str, title: str, reporter: Reporter) -> dict:
|
||||
"""A new name for an installed game's Steam shortcut on this PC (after Rename…): the shortcut is matched by
|
||||
its Exe, so it keeps its appid (Play, grid art); Steam restarts once if it runs."""
|
||||
dep = self._dep(package)
|
||||
if dep.get("title") == title:
|
||||
reporter.check("Steam library (PC)", True, f"already named {title}")
|
||||
return {"state": "done", "added": [], "errors": [], "unchanged": True}
|
||||
dep["title"] = title
|
||||
self._save_dep(package, dep)
|
||||
return self.add_to_library([package], reporter)
|
||||
|
||||
def collect_diag(self, package=None):
|
||||
files = {}
|
||||
log_path = (winhost.env_path("LOCALAPPDATA") or Path("/nonexistent")) / REVIVE_LOG
|
||||
@@ -175,8 +193,13 @@ class PcReviveTarget(Target):
|
||||
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
|
||||
exe_field, _, _ = shortcut_fields(dep)
|
||||
dep["appid"] = _vdf().shortcut_appid(exe_field, title)
|
||||
d = pc_dir() / package
|
||||
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
|
||||
old = json.loads((d / "deployment.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
old = {}
|
||||
same_exe = bool(old.get("appid") and old.get("exe_win") and shortcut_fields(old)[0] == exe_field)
|
||||
dep["appid"] = old["appid"] if same_exe else _vdf().shortcut_appid(exe_field, title)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "deployment.json").write_text(json.dumps(dep, indent=2), encoding="utf-8")
|
||||
reporter.log(f"ready to run from {dep['exe_win']}" + (" through Revive" if rdir else ""))
|
||||
@@ -214,7 +237,7 @@ class PcReviveTarget(Target):
|
||||
for old in grid_files(grid, stale):
|
||||
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"])
|
||||
ident = dep.get("appid") or vdf_mod.shortcut_appid(exe, dep["title"])
|
||||
icon = dep["exe_win"]
|
||||
if "icon" in art: # Steam wants a local path for the shortcut icon
|
||||
icon_path = grid / f"{ident}_icon.png"
|
||||
@@ -229,6 +252,9 @@ class PcReviveTarget(Target):
|
||||
for old in grid.glob(f"{got}{suffix}.*"):
|
||||
old.unlink()
|
||||
shutil.copy(f, grid / f"{got}{suffix}{f.suffix}")
|
||||
if got != dep.get("appid"): # Play starts the shortcut by this id (a renamed game keeps its own)
|
||||
dep["appid"] = got
|
||||
self._save_dep(pkg, dep)
|
||||
added.append({"package": pkg, "appid": got})
|
||||
reporter.check(f"Steam library (PC): {dep['title']}", True, f"shortcut {got}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
|
||||
+68
-2
@@ -1100,6 +1100,7 @@ class FramePortApp:
|
||||
"settings": lambda e: self.settings_dialog(pkg),
|
||||
"screenshots": lambda e: self.go("screenshots", pkg),
|
||||
"files": lambda e: self.go("files", pkg),
|
||||
"rename": lambda e: self.rename_game(pkg),
|
||||
"find_art": lambda e: self.find_artwork(pkg),
|
||||
"custom_art": lambda e: self.custom_artwork(pkg),
|
||||
"steam_art": lambda e: self.update_steam_art(pkg),
|
||||
@@ -1576,8 +1577,7 @@ class FramePortApp:
|
||||
def update_steam_art(self, pkg: str) -> Job:
|
||||
"""Send the game's current artwork to its Steam entry on the Frame (Steam restarts once)."""
|
||||
def run(job: Job):
|
||||
self._target_for("frame").update_steam_art(pkg, job.reporter)
|
||||
library.update_game(pkg, lambda e: e.pop("steam_art_stale", None))
|
||||
pipeline.sync_title(pkg, self._target_for("frame"), job.reporter) # the art, and a new name if any
|
||||
return tr("{title}: Steam artwork updated on the Frame").format(title=self._title(pkg))
|
||||
return self.submit(tr("Update Steam art: {title}").format(title=self._title(pkg)), run, pkg, "art")
|
||||
|
||||
@@ -1710,6 +1710,72 @@ class FramePortApp:
|
||||
tr("Removes FramePort's entry. Anything installed on the Frame stays."), tr("Remove"), remove,
|
||||
danger=True, extra=extra)
|
||||
|
||||
def rename_game(self, pkg: str) -> None:
|
||||
"""Rename…: a new name for the game in the library and in Steam (GitHub feature request: Quest games are
|
||||
often named like their APK). The name is kept from then on; the automatic name brings the old behaviour."""
|
||||
g = library.game(pkg)
|
||||
if not g:
|
||||
return
|
||||
page = self.page
|
||||
current = g.get("title") or pkg
|
||||
field = C.field(label=tr("Name"), value=current, autofocus=True, width=T.DIALOG_S,
|
||||
max_length=pipeline.TITLE_MAX)
|
||||
done = []
|
||||
|
||||
def save(e=None, value=None):
|
||||
if done: # Enter and the button at once: act once
|
||||
return
|
||||
done.append(True)
|
||||
page.pop_dialog()
|
||||
self._apply_rename(pkg, field.value if value is None else value)
|
||||
|
||||
def use(name):
|
||||
return lambda e: save(value=name)
|
||||
|
||||
field.on_submit = save
|
||||
rows: list[ft.Control] = [field]
|
||||
suggestions = pipeline.title_suggestions(g)
|
||||
if suggestions:
|
||||
rows.append(C.meta(tr("Or use:")))
|
||||
rows += [C.ghost(name, ft.Icons.HISTORY_ROUNDED, use(name),
|
||||
tooltip=tr("The store's name") if name == pipeline.store_title(g) else
|
||||
tr("FramePort's own name for it")) for name in suggestions]
|
||||
page.show_dialog(C.dialog(tr("Rename {title}").format(title=current),
|
||||
ft.Column(rows, spacing=T.S2, tight=True),
|
||||
[C.ghost(tr("Cancel"), on_click=lambda e: page.pop_dialog()),
|
||||
C.primary(tr("Save"), on_click=save)], size="s"))
|
||||
|
||||
def _apply_rename(self, pkg: str, value: str) -> None:
|
||||
"""Store the new name, then update the game's Steam entry where it's installed and reachable now (the Frame:
|
||||
same shortcut, Steam restarts once; this PC's Steam for PC VR games). A Frame that isn't connected gets it
|
||||
with the game page's "Update name on Frame" or the next install."""
|
||||
before = (library.game(pkg) or {}).get("title")
|
||||
try:
|
||||
g = pipeline.rename_game(pkg, value)
|
||||
except ValueError as exc:
|
||||
self.toast(str(exc), error=True)
|
||||
return
|
||||
title = self._title(pkg)
|
||||
if g.get("title") == before:
|
||||
return
|
||||
on_frame = self.frame_state == "connected" and \
|
||||
C.install_state(g, self.frame_info) in ("installed", "outdated")
|
||||
on_pc = g.get("kind") == "rift" and pkg in self.pc_installs()
|
||||
if on_frame:
|
||||
self.sync_title(pkg, "frame")
|
||||
if on_pc:
|
||||
self.sync_title(pkg, "pc")
|
||||
self.toast(tr("Renamed to {title}. Steam restarts once to show it.").format(title=title)
|
||||
if on_frame or on_pc else tr("Renamed to {title}").format(title=title))
|
||||
self.refresh_view()
|
||||
|
||||
def sync_title(self, pkg: str, to: str = "frame") -> Job:
|
||||
"""A job that gives the installed game its library name in Steam (pipeline.sync_title)."""
|
||||
def run(job: Job):
|
||||
pipeline.sync_title(pkg, self._target_for(to), job.reporter)
|
||||
return tr("{title}: name updated in Steam").format(title=self._title(pkg))
|
||||
return self.submit(tr("Rename in Steam: {title}").format(title=self._title(pkg)), run, pkg, "art", to=to)
|
||||
|
||||
def _target_for(self, to: str):
|
||||
if to == "pc":
|
||||
from ..targets.pc_revive import PcReviveTarget
|
||||
|
||||
@@ -17,6 +17,9 @@ gets in the way:
|
||||
the window's corner and waves back.
|
||||
- The Frame reaching 100 % on the charger: its battery ring pulses.
|
||||
- Long uploads (over two and a half minutes): the cover riding the progress bar does a hop now and then.
|
||||
- The homepage's example "Install with FramePort" button (deeplink.DEMO_MANIFEST): its install question is a demo
|
||||
for "Cool Game", and Install sends Cool Game's cover out of the logo's portal instead of installing anything
|
||||
(link_dialog.show_demo; with easter eggs off it just says the link works).
|
||||
|
||||
Off with library setting `ui.easter_eggs` = False (the docs screenshots and videos turn them off; there's no
|
||||
switch in Settings). Animations respect Reduce motion (the words stay, the motion goes). `FRAMEPORT_TODAY`
|
||||
@@ -254,6 +257,38 @@ class Typed:
|
||||
return self.text.endswith(HELLO)
|
||||
|
||||
|
||||
def demo_finale(eggs: bool, reduce_motion: bool) -> str:
|
||||
"""What the demo install link's Install button does: "toss" (Cool Game's cover out of the portal, then a toast),
|
||||
"words" (Reduce motion: only the toast) or "plain" (easter eggs off: a plain "it works")."""
|
||||
if not eggs:
|
||||
return "plain"
|
||||
return "words" if reduce_motion else "toss"
|
||||
|
||||
|
||||
def demo_message(finale: str) -> str:
|
||||
if finale == "plain":
|
||||
return tr("Nice, it works! FramePort receives install links. (Demo: nothing was installed.)")
|
||||
return tr("Cool Game went through the portal. It was only a demo: nothing was downloaded or installed.")
|
||||
|
||||
|
||||
def demo_install(app: FramePortApp, cover_src: str) -> None:
|
||||
"""The demo link's Install: the toast, after (motion and easter eggs allowed) the logo's portal tosses Cool
|
||||
Game's cover across the window."""
|
||||
finale = demo_finale(enabled(), app.reduce_motion)
|
||||
logo = getattr(app, "logo_egg", None)
|
||||
if finale != "toss" or logo is None or logo.busy:
|
||||
app.toast(demo_message(finale))
|
||||
return
|
||||
logo.busy = True
|
||||
|
||||
async def run():
|
||||
try:
|
||||
await logo._toss(cover_src)
|
||||
finally:
|
||||
app.toast(demo_message(finale))
|
||||
app.page.run_task(run)
|
||||
|
||||
|
||||
def says_hello(text: str | None) -> bool:
|
||||
return HELLO in (text or "").lower()
|
||||
|
||||
@@ -383,7 +418,7 @@ class Logo:
|
||||
self.busy = True
|
||||
self.app.page.run_task(self._toss)
|
||||
|
||||
async def _toss(self) -> None:
|
||||
async def _toss(self, cover_url: str | None = None) -> None:
|
||||
"""The wordmark's portal (between "Frame" and "Port") lights up: a blue-and-orange glow swells around it and
|
||||
two rings run out of it; then a game's cover comes out of it small, grows to full size and falls down across
|
||||
the window on a random arc, tumbling; the glow fades. Themes without the wordmark's portal use the logo's."""
|
||||
@@ -392,7 +427,8 @@ class Logo:
|
||||
page = self.app.page
|
||||
mark = getattr(self.app, "_portal_mark", None) # the wordmark's portal (dual themes)
|
||||
try:
|
||||
cover_url = await asyncio.to_thread(_a_cover) # (may make a thumbnail: not on the event loop)
|
||||
if cover_url is None: # a random game's (the demo install link brings its own)
|
||||
cover_url = await asyncio.to_thread(_a_cover) # (may make a thumbnail: not on the event loop)
|
||||
if cover_url is None:
|
||||
return
|
||||
width = float(getattr(page, "width", None) or 1440)
|
||||
@@ -425,7 +461,8 @@ class Logo:
|
||||
dt = 0.06
|
||||
cover = ft.Container(width=cw, height=ch, left=cx - cw / 2, top=cy - ch / 2, scale=0.08, opacity=0,
|
||||
rotate=0, border_radius=T.px(5), clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
|
||||
image=ft.DecorationImage(src=cover_url, fit=ft.BoxFit.COVER),
|
||||
# (an Image, not a DecorationImage: the demo's cover is an SVG)
|
||||
content=ft.Image(src=cover_url, width=cw, height=ch, fit=ft.BoxFit.COVER),
|
||||
border=ft.Border.all(1, T.soft("#FFFFFF", 0.25)),
|
||||
shadow=ft.BoxShadow(blur_radius=14, spread_radius=1, color=T.soft("#000000", 0.55),
|
||||
offset=ft.Offset(0, 4)),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="96" viewBox="0 0 72 96"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2D7FF9"/><stop offset=".55" stop-color="#7A4BD8"/><stop offset="1" stop-color="#FF8A3D"/></linearGradient><radialGradient id="sun" cx=".4" cy=".35" r=".75"><stop offset="0" stop-color="#FFE48A"/><stop offset="1" stop-color="#FFB42E"/></radialGradient></defs><rect width="72" height="96" fill="url(#bg)"/><path d="M0 74 18 60 30 68 46 54 72 72V96H0z" fill="#101218" fill-opacity=".35"/><circle cx="36" cy="40" r="20" fill="url(#sun)" stroke="#101218" stroke-width="2.4"/><path d="M17.5 35.5h37M20 35.5h13l-1.4 6.2a3.4 3.4 0 0 1-3.3 2.6h-4.6A3.4 3.4 0 0 1 20.4 42zM39 35.5h13l-.4 6.5a3.4 3.4 0 0 1-3.3 2.3h-4.6a3.4 3.4 0 0 1-3.3-2.6z" fill="#101218" stroke="#101218" stroke-width="2" stroke-linejoin="round"/><path d="M23.5 37.5h4" stroke="#FFFFFF" stroke-opacity=".7" stroke-width="1.4" stroke-linecap="round"/><path d="M29 50.5c4 3.2 10 3.2 14 0" fill="none" stroke="#101218" stroke-width="2.4" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -53,7 +53,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
|
||||
if st.movable and st.on_frame and not job:
|
||||
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
|
||||
|
||||
artwork = [(tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, act("find_art")),
|
||||
artwork = [(tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")),
|
||||
(tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, act("find_art")),
|
||||
(tr("Use your own artwork…"), ft.Icons.UPLOAD_FILE_OUTLINED, act("custom_art"))]
|
||||
if not job and st.on_frame:
|
||||
artwork.append((tr("Update art on Frame"), ft.Icons.WALLPAPER_ROUNDED, act("steam_art")))
|
||||
@@ -77,7 +78,7 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
|
||||
danger = [(tr("Remove from library…"), ft.Icons.REMOVE_CIRCLE_OUTLINE_ROUNDED, act("remove"))]
|
||||
|
||||
out: list = list(top)
|
||||
for head, items in ((tr("Artwork"), artwork), (tr("Recipe"), recipe), (tr("Troubleshoot"), trouble)):
|
||||
for head, items in ((tr("Name and artwork"), artwork), (tr("Recipe"), recipe), (tr("Troubleshoot"), trouble)):
|
||||
if items:
|
||||
out += [None, Header(head), *items]
|
||||
return out + [None, *danger]
|
||||
@@ -96,6 +97,7 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
out += st.installs[:1]
|
||||
if st.settings:
|
||||
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
|
||||
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
|
||||
out += [None, (tr("More actions…"), ft.Icons.MORE_HORIZ_ROUNDED, act("more")), None]
|
||||
if st.on_frame:
|
||||
out.append((tr("Uninstall from Frame…"), ft.Icons.DELETE_OUTLINE_ROUNDED, act("uninstall_frame")))
|
||||
|
||||
@@ -384,7 +384,13 @@ class GameView:
|
||||
"settings."), T.TEXT, expand=True),
|
||||
C.secondary(tr("Use the new recipe"), ft.Icons.AUTO_FIX_HIGH_ROUNDED, take_update)],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER), "info", ft.Icons.NEW_RELEASES_OUTLINED))
|
||||
if g.get("steam_art_stale") and installed:
|
||||
if g.get("steam_name_stale") and installed:
|
||||
out.append(C.callout(ft.Row([
|
||||
C.body(tr("The Frame's Steam library still shows the old name."), T.TEXT, expand=True),
|
||||
C.secondary(tr("Update name on Frame"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED,
|
||||
lambda e: self.app.update_steam_art(pkg))]), "info",
|
||||
ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED))
|
||||
elif g.get("steam_art_stale") and installed:
|
||||
out.append(C.callout(ft.Row([
|
||||
C.body(tr("The Frame's Steam library still shows the old artwork."), T.TEXT, expand=True),
|
||||
C.secondary(tr("Update art on Frame"), ft.Icons.IMAGE_OUTLINED,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Install links ("Install with FrameDrop" buttons, frameport:// links, pasted links): read the manifest, ask the user,
|
||||
download, add to the library and install on the Frame. The protocol itself is in deeplink.py (no Flet)."""
|
||||
download, add to the library and install on the Frame. The protocol itself is in deeplink.py (no Flet).
|
||||
The homepage's example button (deeplink.DEMO_MANIFEST) gets a demo question instead (show_demo): no job, no network,
|
||||
nothing added; its Install plays an easter egg (ui/easter.demo_install)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -46,6 +48,12 @@ def show_paste_dialog(app: FramePortApp) -> None:
|
||||
C.primary(tr("Continue"), ft.Icons.ARROW_FORWARD_ROUNDED, on_click=go)]))
|
||||
|
||||
|
||||
def route(req: deeplink.InstallRequest) -> str:
|
||||
""""demo" (the homepage's example button: answered here, nothing fetched) or "fetch" (read the manifest in a
|
||||
job, then ask)."""
|
||||
return "demo" if req.demo else "fetch"
|
||||
|
||||
|
||||
def open_link(app: FramePortApp, text: str, pasted: bool = False) -> None:
|
||||
"""A link from a web page (framedrop://, frameport://) or pasted: read what it offers in the background, then ask
|
||||
before anything is downloaded."""
|
||||
@@ -54,6 +62,9 @@ def open_link(app: FramePortApp, text: str, pasted: bool = False) -> None:
|
||||
except deeplink.LinkError as exc:
|
||||
app.toast(tr("Can't use that link: {reason}").format(reason=str(exc)), error=True)
|
||||
return
|
||||
if route(req) == "demo":
|
||||
show_demo(app, pasted)
|
||||
return
|
||||
|
||||
def run(job: Job):
|
||||
job.reporter.stage(tr("Reading the install link"))
|
||||
@@ -135,3 +146,30 @@ def download_and_install(app: FramePortApp, m: deeplink.Manifest, icon=None) ->
|
||||
app.page.run_thread(lambda: app.install(pkg, "frame")) # asks the usual install questions, then queues it
|
||||
return tr("Downloaded {name}").format(name=g.get("title") or m.name)
|
||||
return app.submit(tr("Download {name}").format(name=m.name), run, None, "task")
|
||||
|
||||
|
||||
DEMO_COVER = "demo-cool-game" # ui/icons/demo-cool-game.svg (also the site's site/public/demo/cool-game.svg)
|
||||
|
||||
|
||||
def show_demo(app: FramePortApp, pasted: bool = False) -> None:
|
||||
"""The example button's install question: the usual layout for the placeholder "Cool Game", marked as a demo.
|
||||
Install plays easter.demo_install; nothing is downloaded, added or queued."""
|
||||
from .. import easter
|
||||
|
||||
m = deeplink.demo_manifest()
|
||||
cover = C._asset_src(DEMO_COVER)
|
||||
header = ft.Row([
|
||||
ft.Image(src=cover, width=T.px(48), height=T.px(64), fit=ft.BoxFit.COVER, border_radius=T.RADIUS_SM),
|
||||
ft.Column([
|
||||
ft.Row([C.pill(tr("Demo"), T.SECONDARY)], tight=True),
|
||||
C.body(tr("This is the example button from frameport.app. Real buttons install real games."), T.TEXT),
|
||||
], spacing=T.S1, tight=True, expand=True),
|
||||
], spacing=T.S3, vertical_alignment=ft.CrossAxisAlignment.START)
|
||||
f = m.main
|
||||
file_row = ft.Column([
|
||||
C.body(f.filename, T.TEXT, weight=ft.FontWeight.W_500, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS),
|
||||
C.meta(" · ".join((kind_label(f.kind), tr("demo: nothing is downloaded")))),
|
||||
], spacing=T.px(2), tight=True)
|
||||
intro = tr("You pasted a link to {name}.") if pasted else tr("A web page asked FramePort to install {name}.")
|
||||
C.confirm(app.page, tr("Install {name}?").format(name=m.name), intro.format(name=m.name), tr("Install"),
|
||||
lambda: easter.demo_install(app, cover), extra=ft.Column([header, file_row], spacing=T.S3, tight=True))
|
||||
@@ -75,6 +75,9 @@ def game_lines(log: str, package: str | None = None) -> list[str]:
|
||||
pids.add(m[1])
|
||||
if not pids:
|
||||
return lines
|
||||
# system_server's lines about the game's activities (which one was started / shown), e.g. Unreal's
|
||||
# DownloaderActivity (unreal-obb-check-stuck)
|
||||
own_activity = re.compile(r"(Displayed |START u0 .*cmp=)" + re.escape(package) + "/")
|
||||
out = []
|
||||
for ln in lines:
|
||||
f = ln.split()
|
||||
@@ -82,7 +85,8 @@ def game_lines(log: str, package: str | None = None) -> list[str]:
|
||||
# "Boot complete!", which the container-not-started signature checks for: it used to be dropped here)
|
||||
if not (len(f) > 3 and LOGCAT_LINE.match(ln) and f[2].isdigit()):
|
||||
out.append(ln)
|
||||
elif f[2] in pids or "lepton" in ln.lower() or "APP_ACTIVITY" in ln or " FramePortVideo" in ln:
|
||||
elif (f[2] in pids or "lepton" in ln.lower() or "APP_ACTIVITY" in ln or " FramePortVideo" in ln
|
||||
or own_activity.search(ln)):
|
||||
# FramePortVideo: the hardware codec plugin logs from Android's media service, not the game's process
|
||||
out.append(ln)
|
||||
return out
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
// Stand-in GLES 3 + EGL for the host test of native/glshim/glshim.c (gl_int_attribs, gl_highp_markers): just enough
|
||||
// state for programs with active attributes, vertex attribute arrays per vertex array object, buffer bindings and
|
||||
// draws, plus test hooks (fake_*) to set up programs and read back what the shim did.
|
||||
#define _GNU_SOURCE
|
||||
#define GL_GLES_PROTOTYPES 0
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl32.h>
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define API __attribute__((visibility("default")))
|
||||
#define NOBJ 16
|
||||
#define NLOC 16
|
||||
|
||||
struct attrib { GLint enabled, integer, buffer, size, type, stride, normalized; const void *ptr; };
|
||||
struct program { int linked, n; char names[4][32]; GLenum types[4]; GLint sizes[4], locs[4]; };
|
||||
static struct program programs[NOBJ];
|
||||
static GLenum shader_types[NOBJ];
|
||||
static char *shader_sources[NOBJ];
|
||||
static int shader_counts[NOBJ];
|
||||
static struct attrib vaos[4][NLOC];
|
||||
static GLuint cur_vao, cur_program, array_buffer;
|
||||
static void *context = (void *)0x1000;
|
||||
|
||||
// hooks for the test
|
||||
struct fake_draw { int draws; GLint integer[NLOC]; GLint normalized[NLOC]; GLuint i4ui_loc; GLuint i4ui[4]; };
|
||||
static struct fake_draw last;
|
||||
API void fake_program(GLuint id, int linked, int n, const char *const *names, const GLenum *types, const GLint *locs) {
|
||||
struct program *p = &programs[id];
|
||||
memset(p, 0, sizeof(*p));
|
||||
p->linked = linked;
|
||||
p->n = n;
|
||||
for (int i = 0; i < n; ++i) {
|
||||
strncpy(p->names[i], names[i], sizeof(p->names[i]) - 1);
|
||||
p->types[i] = types[i];
|
||||
p->sizes[i] = 1;
|
||||
p->locs[i] = locs[i];
|
||||
}
|
||||
}
|
||||
API void fake_shader(GLuint id, GLenum type) { shader_types[id] = type; }
|
||||
API const char *fake_source(GLuint id, int *count) { if (count) *count = shader_counts[id]; return shader_sources[id]; }
|
||||
API struct fake_draw *fake_last(void) { return &last; }
|
||||
API void fake_context(void *ctx) { context = ctx; }
|
||||
API struct attrib *fake_attrib(GLuint vao, GLuint loc) { return &vaos[vao][loc]; }
|
||||
|
||||
static void draw(void) {
|
||||
++last.draws;
|
||||
for (int i = 0; i < NLOC; ++i) {
|
||||
last.integer[i] = vaos[cur_vao][i].integer;
|
||||
last.normalized[i] = vaos[cur_vao][i].normalized;
|
||||
}
|
||||
}
|
||||
|
||||
API const GLubyte *glGetString(GLenum name) { return (const GLubyte *)(name == GL_EXTENSIONS ? "GL_EXT_a GL_EXT_b" : "fake"); }
|
||||
API const GLubyte *glGetStringi(GLenum name, GLuint i) { (void)name; return (const GLubyte *)(i ? "GL_EXT_b" : "GL_EXT_a"); }
|
||||
API void glGetIntegerv(GLenum pname, GLint *v) {
|
||||
switch (pname) {
|
||||
case GL_CURRENT_PROGRAM: *v = (GLint)cur_program; break;
|
||||
case GL_ARRAY_BUFFER_BINDING: *v = (GLint)array_buffer; break;
|
||||
case GL_VERTEX_ARRAY_BINDING: *v = (GLint)cur_vao; break;
|
||||
case GL_NUM_EXTENSIONS: *v = 2; break;
|
||||
default: *v = 0;
|
||||
}
|
||||
}
|
||||
API void glShaderSource(GLuint sh, GLsizei count, const GLchar *const *strs, const GLint *lens) {
|
||||
size_t total = 0;
|
||||
for (GLsizei i = 0; i < count; ++i) total += lens && lens[i] >= 0 ? (size_t)lens[i] : strlen(strs[i]);
|
||||
free(shader_sources[sh]);
|
||||
char *s = shader_sources[sh] = calloc(total + 1, 1);
|
||||
for (GLsizei i = 0; i < count; ++i) {
|
||||
size_t n = lens && lens[i] >= 0 ? (size_t)lens[i] : strlen(strs[i]);
|
||||
memcpy(s, strs[i], n);
|
||||
s += n;
|
||||
}
|
||||
shader_counts[sh] = count;
|
||||
}
|
||||
API void glGetShaderiv(GLuint sh, GLenum pname, GLint *v) { *v = pname == GL_SHADER_TYPE ? (GLint)shader_types[sh] : 1; }
|
||||
API void glLinkProgram(GLuint p) { programs[p].linked = 1; }
|
||||
API void glProgramBinary(GLuint p, GLenum f, const void *b, GLsizei n) { (void)f; (void)b; (void)n; programs[p].linked = 1; }
|
||||
API void glDeleteProgram(GLuint p) { memset(&programs[p], 0, sizeof(programs[p])); }
|
||||
API void glUseProgram(GLuint p) { cur_program = p; }
|
||||
API void glGetProgramiv(GLuint p, GLenum pname, GLint *v) {
|
||||
*v = pname == GL_LINK_STATUS ? programs[p].linked : pname == GL_ACTIVE_ATTRIBUTES ? programs[p].n : 0;
|
||||
}
|
||||
API void glGetActiveAttrib(GLuint p, GLuint i, GLsizei cap, GLsizei *len, GLint *size, GLenum *type, GLchar *name) {
|
||||
strncpy(name, programs[p].names[i], (size_t)cap - 1);
|
||||
if (len) *len = (GLsizei)strlen(name);
|
||||
*size = programs[p].sizes[i];
|
||||
*type = programs[p].types[i];
|
||||
}
|
||||
API GLint glGetAttribLocation(GLuint p, const GLchar *name) {
|
||||
for (int i = 0; i < programs[p].n; ++i)
|
||||
if (!strcmp(programs[p].names[i], name)) return programs[p].locs[i];
|
||||
return -1;
|
||||
}
|
||||
API void glBindVertexArray(GLuint v) { cur_vao = v; }
|
||||
API void glBindBuffer(GLenum target, GLuint b) { if (target == GL_ARRAY_BUFFER) array_buffer = b; }
|
||||
API void glEnableVertexAttribArray(GLuint l) { vaos[cur_vao][l].enabled = 1; }
|
||||
API void glDisableVertexAttribArray(GLuint l) { vaos[cur_vao][l].enabled = 0; }
|
||||
static void set(GLuint l, GLint size, GLenum type, GLboolean norm, GLsizei stride, const void *ptr, int integer) {
|
||||
struct attrib *a = &vaos[cur_vao][l];
|
||||
a->buffer = (GLint)array_buffer;
|
||||
a->size = size;
|
||||
a->type = (GLint)type;
|
||||
a->normalized = norm;
|
||||
a->stride = stride;
|
||||
a->ptr = ptr;
|
||||
a->integer = integer;
|
||||
}
|
||||
API void glVertexAttribPointer(GLuint l, GLint size, GLenum type, GLboolean norm, GLsizei stride, const void *ptr) {
|
||||
set(l, size, type, norm, stride, ptr, 0);
|
||||
}
|
||||
API void glVertexAttribIPointer(GLuint l, GLint size, GLenum type, GLsizei stride, const void *ptr) {
|
||||
set(l, size, type, GL_FALSE, stride, ptr, 1);
|
||||
}
|
||||
API void glVertexAttribI4i(GLuint l, GLint x, GLint y, GLint z, GLint w) {
|
||||
last.i4ui_loc = l + 100; // signed: marked with +100
|
||||
last.i4ui[0] = (GLuint)x, last.i4ui[1] = (GLuint)y, last.i4ui[2] = (GLuint)z, last.i4ui[3] = (GLuint)w;
|
||||
}
|
||||
API void glVertexAttribI4ui(GLuint l, GLuint x, GLuint y, GLuint z, GLuint w) {
|
||||
last.i4ui_loc = l;
|
||||
last.i4ui[0] = x, last.i4ui[1] = y, last.i4ui[2] = z, last.i4ui[3] = w;
|
||||
}
|
||||
API void glGetVertexAttribiv(GLuint l, GLenum pname, GLint *v) {
|
||||
struct attrib *a = &vaos[cur_vao][l];
|
||||
switch (pname) {
|
||||
case GL_VERTEX_ATTRIB_ARRAY_ENABLED: *v = a->enabled; break;
|
||||
case GL_VERTEX_ATTRIB_ARRAY_INTEGER: *v = a->integer; break;
|
||||
case GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING: *v = a->buffer; break;
|
||||
case GL_VERTEX_ATTRIB_ARRAY_SIZE: *v = a->size; break;
|
||||
case GL_VERTEX_ATTRIB_ARRAY_TYPE: *v = a->type; break;
|
||||
case GL_VERTEX_ATTRIB_ARRAY_STRIDE: *v = a->stride; break;
|
||||
case GL_VERTEX_ATTRIB_ARRAY_NORMALIZED: *v = a->normalized; break;
|
||||
default: *v = 0;
|
||||
}
|
||||
}
|
||||
API void glGetVertexAttribPointerv(GLuint l, GLenum pname, void **v) { (void)pname; *v = (void *)vaos[cur_vao][l].ptr; }
|
||||
API void glDrawArrays(GLenum m, GLint f, GLsizei c) { (void)m; (void)f; (void)c; draw(); }
|
||||
API void glDrawElements(GLenum m, GLsizei c, GLenum t, const void *i) { (void)m; (void)c; (void)t; (void)i; draw(); }
|
||||
API void glDrawArraysInstanced(GLenum m, GLint f, GLsizei c, GLsizei n) { (void)m; (void)f; (void)c; (void)n; draw(); }
|
||||
API void glDrawElementsInstanced(GLenum m, GLsizei c, GLenum t, const void *i, GLsizei n) {
|
||||
(void)m; (void)c; (void)t; (void)i; (void)n; draw();
|
||||
}
|
||||
API void glDrawRangeElements(GLenum m, GLuint s, GLuint e, GLsizei c, GLenum t, const void *i) {
|
||||
(void)m; (void)s; (void)e; (void)c; (void)t; (void)i; draw();
|
||||
}
|
||||
API void glDrawElementsBaseVertexOES(GLenum m, GLsizei c, GLenum t, const void *i, GLint b) {
|
||||
(void)m; (void)c; (void)t; (void)i; (void)b; draw();
|
||||
}
|
||||
API EGLContext eglGetCurrentContext(void) { return context; }
|
||||
API __eglMustCastToProperFunctionPointerType eglGetProcAddress(const char *name) {
|
||||
Dl_info info;
|
||||
if (!dladdr((void *)eglGetProcAddress, &info)) return NULL;
|
||||
void *self = dlopen(info.dli_fname, RTLD_NOW | RTLD_NOLOAD);
|
||||
__eglMustCastToProperFunctionPointerType fn = self ? (__eglMustCastToProperFunctionPointerType)dlsym(self, name) : NULL;
|
||||
if (self) dlclose(self);
|
||||
return fn;
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
// Host test of native/glshim/glshim.c's per-game fixes against glshim_fake_gles.c, driven like BlazeRush drives GL:
|
||||
// glShaderSource / glDrawElements / glDrawArrays as direct imports (the shim's exports), glDrawElementsBaseVertexOES
|
||||
// through eglGetProcAddress. Usage: glshim_host_test <shim> <fake GL> on|off (settings: FRAMEBRIDGE_CONFIG)
|
||||
#define GL_GLES_PROTOTYPES 0
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl32.h>
|
||||
#include <dlfcn.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
struct attrib { GLint enabled, integer, buffer, size, type, stride, normalized; const void *ptr; };
|
||||
struct fake_draw { int draws; GLint integer[16]; GLint normalized[16]; GLuint i4ui_loc; GLuint i4ui[4]; };
|
||||
typedef __eglMustCastToProperFunctionPointerType (*GPA)(const char *);
|
||||
typedef void (*DEBV)(GLenum, GLsizei, GLenum, const void *, GLint);
|
||||
typedef void (*FAKE_PROGRAM)(GLuint, int, int, const char *const *, const GLenum *, const GLint *);
|
||||
typedef void (*FAKE_SHADER)(GLuint, GLenum);
|
||||
typedef const char *(*FAKE_SOURCE)(GLuint, int *);
|
||||
typedef struct fake_draw *(*FAKE_LAST)(void);
|
||||
typedef void (*FAKE_CONTEXT)(void *);
|
||||
typedef struct attrib *(*FAKE_ATTRIB)(GLuint, GLuint);
|
||||
|
||||
static int failures;
|
||||
#define CHECK(cond, ...) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
++failures; \
|
||||
fprintf(stderr, "FAIL line %d: ", __LINE__); \
|
||||
fprintf(stderr, __VA_ARGS__); \
|
||||
fputc('\n', stderr); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static const char *PARTICLE_VS_A =
|
||||
"#version 300 es\n"
|
||||
"precision mediump float;\n"
|
||||
"layout(location = 3) in uvec2 inBoneIndices;\n"
|
||||
"in mediump vec4 inPosition;\n"
|
||||
"out mediump vec4 vColor;\n";
|
||||
static const char *PARTICLE_VS_B =
|
||||
"uniform InstanceData { mediump vec4 data[64]; };\n"
|
||||
"uniform mediump float uScale;\n"
|
||||
"void main()\n"
|
||||
"{\n"
|
||||
" mediump vec4 p = data[inBoneIndices.x] * uScale;\n"
|
||||
" mediump float d = p.x - p.y; // mediump_not_a_word stays\n"
|
||||
" vColor = vec4(d);\n"
|
||||
" gl_Position = inPosition + p;\n"
|
||||
"}\n";
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 4) return 2;
|
||||
int on = !strcmp(argv[3], "on");
|
||||
void *shim = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);
|
||||
if (!shim) { fprintf(stderr, "%s\n", dlerror()); return 2; }
|
||||
void *fake = dlopen(argv[2], RTLD_NOW | RTLD_NOLOAD);
|
||||
if (!fake) { fprintf(stderr, "fake GL not loaded with the shim: %s\n", dlerror()); return 2; }
|
||||
#define S(type, name) type name = (type)dlsym(shim, #name)
|
||||
#define F(type, name) type name = (type)dlsym(fake, #name)
|
||||
S(PFNGLSHADERSOURCEPROC, glShaderSource);
|
||||
S(PFNGLDRAWELEMENTSPROC, glDrawElements);
|
||||
S(PFNGLDRAWARRAYSPROC, glDrawArrays);
|
||||
S(PFNGLLINKPROGRAMPROC, glLinkProgram);
|
||||
S(PFNGLPROGRAMBINARYPROC, glProgramBinary);
|
||||
S(GPA, eglGetProcAddress);
|
||||
F(PFNGLUSEPROGRAMPROC, glUseProgram);
|
||||
F(PFNGLBINDVERTEXARRAYPROC, glBindVertexArray);
|
||||
F(PFNGLBINDBUFFERPROC, glBindBuffer);
|
||||
F(PFNGLVERTEXATTRIBPOINTERPROC, glVertexAttribPointer);
|
||||
F(PFNGLENABLEVERTEXATTRIBARRAYPROC, glEnableVertexAttribArray);
|
||||
F(PFNGLDISABLEVERTEXATTRIBARRAYPROC, glDisableVertexAttribArray);
|
||||
F(PFNGLGETINTEGERVPROC, glGetIntegerv);
|
||||
F(FAKE_PROGRAM, fake_program);
|
||||
F(FAKE_SHADER, fake_shader);
|
||||
F(FAKE_SOURCE, fake_source);
|
||||
F(FAKE_LAST, fake_last);
|
||||
F(FAKE_CONTEXT, fake_context);
|
||||
F(FAKE_ATTRIB, fake_attrib);
|
||||
if (!glShaderSource || !glDrawElements || !eglGetProcAddress || !fake_program || !glUseProgram) return 2;
|
||||
|
||||
// ---- gl_highp_markers: two strings (one with an explicit length), all three markers, vertex stage only
|
||||
const GLchar *parts[2] = {PARTICLE_VS_A, PARTICLE_VS_B};
|
||||
GLint lens[2] = {(GLint)strlen(PARTICLE_VS_A), -1};
|
||||
fake_shader(1, GL_VERTEX_SHADER);
|
||||
fake_shader(2, GL_FRAGMENT_SHADER);
|
||||
fake_shader(3, GL_VERTEX_SHADER);
|
||||
glShaderSource(1, 2, parts, lens);
|
||||
glShaderSource(2, 2, parts, lens);
|
||||
const char *plain[1] = {"#version 300 es\nprecision mediump float;\nin vec4 p;\nvoid main() { mediump float x = 1.0; gl_Position = p * x; }\n"};
|
||||
glShaderSource(3, 1, plain, NULL);
|
||||
int count = 0;
|
||||
const char *vs = fake_source(1, &count);
|
||||
if (on) {
|
||||
CHECK(count == 1, "the fixed source is one string (%d)", count);
|
||||
CHECK(strstr(vs, "precision highp float;\n"), "default precision raised: %s", vs);
|
||||
CHECK(strstr(vs, " highp vec4 p = data[inBoneIndices.x] * uScale;"), "calculation raised: %s", vs);
|
||||
CHECK(strstr(vs, " highp float d = p.x - p.y; // mediump_not_a_word stays"), "whole words only: %s", vs);
|
||||
CHECK(strstr(vs, "in mediump vec4 inPosition;"), "inputs keep their precision: %s", vs);
|
||||
CHECK(strstr(vs, "out mediump vec4 vColor;"), "outputs keep theirs: %s", vs);
|
||||
CHECK(strstr(vs, "uniform InstanceData { mediump vec4 data[64]; };"), "uniform blocks keep theirs: %s", vs);
|
||||
CHECK(strstr(vs, "uniform mediump float uScale;"), "uniforms keep theirs: %s", vs);
|
||||
} else {
|
||||
CHECK(count == 2 && strstr(vs, "precision mediump float;"), "off: unchanged (%d strings)", count);
|
||||
}
|
||||
CHECK(fake_source(2, &count) && count == 2 && !strstr(fake_source(2, NULL), "highp"), "fragment shader unchanged");
|
||||
CHECK(strstr(fake_source(3, NULL), "mediump float x"), "a shader without the markers is unchanged");
|
||||
|
||||
// ---- gl_int_attribs
|
||||
const char *names1[2] = {"inPosition", "inBoneIndices"};
|
||||
const GLenum types1[2] = {GL_FLOAT_VEC4, GL_UNSIGNED_INT_VEC4};
|
||||
const GLint locs1[2] = {0, 3};
|
||||
fake_program(1, 1, 2, names1, types1, locs1);
|
||||
const char *names2[2] = {"inPosition", "inColor"};
|
||||
const GLenum types2[2] = {GL_FLOAT_VEC4, GL_FLOAT_VEC4};
|
||||
fake_program(2, 1, 2, names2, types2, locs1);
|
||||
glBindVertexArray(1);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 5);
|
||||
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 16, (void *)0);
|
||||
glVertexAttribPointer(3, 4, GL_UNSIGNED_BYTE, GL_TRUE, 16, (void *)8);
|
||||
glEnableVertexAttribArray(0);
|
||||
glEnableVertexAttribArray(3);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 7);
|
||||
glUseProgram(1);
|
||||
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, NULL);
|
||||
struct fake_draw *last = fake_last();
|
||||
struct attrib *a3 = fake_attrib(1, 3);
|
||||
GLint bound = 0;
|
||||
glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &bound);
|
||||
CHECK(last->draws == 1, "drawn once (%d)", last->draws);
|
||||
CHECK(last->integer[3] == on, "integer input fed as integers at the draw: %d", last->integer[3]);
|
||||
CHECK(last->integer[0] == 0, "float input unchanged");
|
||||
CHECK(a3->buffer == 5 && a3->size == 4 && a3->type == GL_UNSIGNED_BYTE && a3->stride == 16 && a3->ptr == (void *)8,
|
||||
"same buffer/size/type/stride/offset (%d %d 0x%x %d %p)", a3->buffer, a3->size, a3->type, a3->stride, a3->ptr);
|
||||
CHECK(bound == 7, "the game's GL_ARRAY_BUFFER binding is restored (%d)", bound);
|
||||
// a program with a float input at that location gets floats again, with the game's normalization
|
||||
glUseProgram(2);
|
||||
glDrawArrays(GL_TRIANGLES, 0, 3);
|
||||
CHECK(last->integer[3] == 0 && last->normalized[3] == 1, "back to normalized floats: %d %d", last->integer[3],
|
||||
last->normalized[3]);
|
||||
// eglGetProcAddress draws (BlazeRush looks up glDrawElementsBaseVertex*)
|
||||
DEBV bv = (DEBV)eglGetProcAddress("glDrawElementsBaseVertexOES");
|
||||
glUseProgram(1);
|
||||
CHECK(bv != NULL, "glDrawElementsBaseVertexOES found");
|
||||
if (bv) bv(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, NULL, 0);
|
||||
CHECK(last->integer[3] == on, "eglGetProcAddress draw converts too: %d", last->integer[3]);
|
||||
// a disabled integer input gets the integer default (0, 0, 0, 1)
|
||||
glDisableVertexAttribArray(3);
|
||||
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, NULL);
|
||||
if (on)
|
||||
CHECK(last->i4ui_loc == 3 && last->i4ui[3] == 1 && !last->i4ui[0], "typed default (loc %u)", last->i4ui_loc);
|
||||
glEnableVertexAttribArray(3);
|
||||
// float vertex data can't be read as integers: left alone
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 5);
|
||||
glVertexAttribPointer(3, 2, GL_FLOAT, GL_FALSE, 8, (void *)4);
|
||||
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, NULL);
|
||||
CHECK(last->integer[3] == 0, "float data stays float");
|
||||
// relinked with a float input there (program names are reused): reflected again, nothing converted
|
||||
glVertexAttribPointer(3, 4, GL_UNSIGNED_BYTE, GL_FALSE, 16, (void *)8);
|
||||
fake_program(1, 1, 2, names2, types2, locs1);
|
||||
glLinkProgram(1);
|
||||
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, NULL);
|
||||
CHECK(last->integer[3] == 0, "relinked program re-reflected: %d", last->integer[3]);
|
||||
// loaded from a program binary with the integer input again
|
||||
fake_program(1, 1, 2, names1, types1, locs1);
|
||||
glProgramBinary(1, 0, "", 0);
|
||||
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, NULL);
|
||||
CHECK(last->integer[3] == on, "program binary re-reflected: %d", last->integer[3]);
|
||||
// another (not shared) context with its own program 1 that has float inputs: its own cache entry
|
||||
glVertexAttribPointer(3, 4, GL_UNSIGNED_BYTE, GL_FALSE, 16, (void *)8);
|
||||
fake_context((void *)0x2000);
|
||||
fake_program(1, 1, 2, names2, types2, locs1);
|
||||
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_SHORT, NULL);
|
||||
CHECK(last->integer[3] == 0, "per-context reflection: %d", last->integer[3]);
|
||||
fprintf(stderr, "glshim host test (%s): %d failure(s)\n", argv[3], failures);
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
@@ -1652,3 +1652,61 @@ def test_launch_test_window_starts_when_the_app_starts(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(a, "run", lambda *x, **k: type("R", (), {"returncode": 0, "stderr": ""})())
|
||||
res = a.cmd_launch_test({"package": "com.x.y", "seconds": 45})
|
||||
assert res["state"] == "RUNNING" and res["elapsed"] >= 90 + 45
|
||||
|
||||
|
||||
def test_rename_keeps_the_shortcut_appid(monkeypatch, tmp_path):
|
||||
"""Renaming an installed game (agent v77) changes deployment.json's title and the shortcut's name; the shortcut
|
||||
keeps its appid (Play, grid art and the game's container depend on it)."""
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
anchor.mkdir(parents=True)
|
||||
exe = f'"{anchor}/launch.sh"'
|
||||
appid = a.shortcut_appid(exe, "com.x.y")
|
||||
(anchor / "deployment.json").write_text(json.dumps(
|
||||
{"package": "com.x.y", "appid": appid, "base": str(anchor), "title": "com.x.y", "kind": "quest"}))
|
||||
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
|
||||
cfg.mkdir(parents=True)
|
||||
vdf = cfg / "shortcuts.vdf"
|
||||
assert a.upsert_shortcut(str(vdf), exe, "com.x.y", str(anchor)) == appid
|
||||
started = []
|
||||
monkeypatch.setattr(a, "cmd_shortcuts", lambda args: (started.append(args), {"started": True})[1])
|
||||
with pytest.raises(a.AgentError, match="empty"):
|
||||
a.cmd_rename({"package": "com.x.y", "title": " \n\t "})
|
||||
with pytest.raises(a.AgentError, match="not installed"):
|
||||
a.cmd_rename({"package": "com.other", "title": "X"})
|
||||
r = a.cmd_rename({"package": "com.x.y", "title": " Vader Immortal:\nEpisode II "})
|
||||
assert r["renamed"] and r["title"] == "Vader Immortal: Episode II" and r["appid"] == appid
|
||||
assert started == [{"packages": ["com.x.y"], "restart": True}]
|
||||
dep = json.loads((anchor / "deployment.json").read_text())
|
||||
assert dep["title"] == "Vader Immortal: Episode II" and dep["appid"] == appid
|
||||
# what the shortcuts worker then does with Steam closed: same appid, new name, one entry
|
||||
monkeypatch.setattr(a, "devkit_gameid", lambda pkg: None)
|
||||
result = {"added": [], "errors": []}
|
||||
a.update_library("42", ["com.x.y"], result)
|
||||
assert not result["errors"] and result["added"][0]["appid"] == appid
|
||||
entries = list(a.vdf_decode(vdf.read_bytes())["shortcuts"].values())
|
||||
assert len(entries) == 1 and entries[0]["appname"] == "Vader Immortal: Episode II"
|
||||
assert entries[0]["appid"] & 0xFFFFFFFF == appid
|
||||
# the same name again: nothing to do, no Steam restart
|
||||
started.clear()
|
||||
r = a.cmd_rename({"package": "com.x.y", "title": "Vader Immortal: Episode II"})
|
||||
assert not r["renamed"] and not started
|
||||
assert len(a.cmd_rename({"package": "com.x.y", "title": "x" * 300, "shortcuts": False})["title"]) == a.TITLE_MAX
|
||||
|
||||
|
||||
def test_two_revive_games_keep_their_own_shortcuts(monkeypatch, tmp_path):
|
||||
"""Every Revive game on a PC starts ReviveInjector.exe: shortcuts are told apart by the game in the launch
|
||||
options (one overwrote the other before); a changed injector flag still updates the same shortcut."""
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
vdf = tmp_path / "shortcuts.vdf"
|
||||
exe = '"C:\\\\Revive\\\\ReviveInjector.exe"'
|
||||
one = a.upsert_shortcut(str(vdf), exe, "Game One", '"D:\\\\G1\\\\"', "", "Rift via Revive",
|
||||
launch_options='/openxr "D:\\\\G1\\\\One.exe"')
|
||||
two = a.upsert_shortcut(str(vdf), exe, "Game Two", '"D:\\\\G2\\\\"', "", "Rift via Revive",
|
||||
launch_options='"D:\\\\G2\\\\Two.exe"')
|
||||
assert one != two
|
||||
again = a.upsert_shortcut(str(vdf), exe, "Game One", '"D:\\\\G1\\\\"', "", "Rift via Revive",
|
||||
launch_options='"D:\\\\G1\\\\One.exe"')
|
||||
assert again == one
|
||||
names = sorted(v["appname"] for v in a.vdf_decode(vdf.read_bytes())["shortcuts"].values())
|
||||
assert names == ["Game One", "Game Two"]
|
||||
@@ -0,0 +1,481 @@
|
||||
"""BlazeRush (GitHub #57, Klownicle's guide): frame.blazerush's exact-match library edits, the GL shim's integer
|
||||
attributes and highp markers, the VrApi bridge's saved FOV, device.config_sync in the agent, and the catalog recipe."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.analysis import elf
|
||||
from frameport.core.models import Analysis
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame import artifact
|
||||
from frameport.patches.frame import blazerush as br
|
||||
from frameport.patches.settings import adapter_settings
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = Path(__file__).with_name("fixtures")
|
||||
AGENT = ROOT / "agent" / "frameport_agent.py"
|
||||
MARKERS = "in uvec2 inBoneIndices;|uniform InstanceData|void main()"
|
||||
|
||||
|
||||
def _analysis(**kw):
|
||||
d = dict(package=br.PACKAGE, version="1.0.349", label="BlazeRush", abis=["arm64-v8a"], engine="Other", xr="VrApi",
|
||||
graphics="GLES or unknown (no Vulkan declaration)", direct_vrapi=True,
|
||||
libs=["libtargemapp.so", "libvrapi.so", "libovravatarloader.so", "libovrplatformloader.so"],
|
||||
launcher_activity="ru.targem.BootstrappedNativeActivity", has_info_category=False, meta_permissions=[],
|
||||
uses_glad_gl=False, unity_msaa_levels=0, oculus_os_classes=False, is_overport_output=False,
|
||||
debuggable=True, extra={})
|
||||
d.update(kw)
|
||||
return Analysis(**d)
|
||||
|
||||
|
||||
# ---- a small AArch64 shared object laid out like libtargemapp.so: read-only data first (vaddr == file offset), code
|
||||
# in a second PT_LOAD whose virtual addresses are 0x1000 above its file offsets
|
||||
TEXT_OFF, TEXT_VA, TEXT_SIZE = 0x2000, 0x3000, 0x1000
|
||||
SITE_A, SITE_B, CACHE_VA = 0x3100, 0x3200, 0x1100
|
||||
|
||||
|
||||
def _fake_lib(word_a=0x36000AE0, word_b=0xF0002368, cache=br.OLD_CACHE, extra_cache=False):
|
||||
data = bytearray(TEXT_OFF + TEXT_SIZE)
|
||||
phoff, phnum = 64, 2
|
||||
data[:16] = b"\x7fELF\x02\x01\x01" + bytes(9)
|
||||
struct.pack_into("<HHIQQQIHHHHHH", data, 16, 3, br.EM_AARCH64, 1, 0, phoff, 0, 0, 64, 56, phnum, 64, 0, 0)
|
||||
struct.pack_into("<IIQQQQQQ", data, phoff, 1, 4, 0, 0, 0, 0x2000, 0x2000, 0x1000) # R
|
||||
struct.pack_into("<IIQQQQQQ", data, phoff + 56, 1, 5, TEXT_OFF, TEXT_VA, TEXT_VA, TEXT_SIZE, TEXT_SIZE, 0x1000)
|
||||
for i in range(TEXT_SIZE // 4): # some code
|
||||
struct.pack_into("<I", data, TEXT_OFF + 4 * i, 0xD503201F ^ (i * 2654435761 & 0xFFFF))
|
||||
struct.pack_into("<I", data, SITE_A - TEXT_VA + TEXT_OFF, word_a)
|
||||
struct.pack_into("<I", data, SITE_B - TEXT_VA + TEXT_OFF, word_b)
|
||||
data[CACHE_VA:CACHE_VA + len(cache)] = cache
|
||||
if extra_cache:
|
||||
data[0x1800:0x1800 + len(br.OLD_CACHE)] = br.OLD_CACHE
|
||||
return bytes(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_build(monkeypatch):
|
||||
lib = _fake_lib()
|
||||
code = lib[TEXT_OFF:TEXT_OFF + TEXT_SIZE]
|
||||
build = br.Build("1.0.349", hashlib.sha256(code).hexdigest(),
|
||||
((SITE_A, 0x36000AE0, br.NOP, "wait"), (SITE_B, 0xF0002368, br.RET, "render")), CACHE_VA)
|
||||
monkeypatch.setattr(br, "BUILDS", (build,))
|
||||
return lib
|
||||
|
||||
|
||||
def test_edits_use_the_segments_file_offsets(fake_build):
|
||||
new, site = br.patch_library(fake_build)
|
||||
assert site and site["build"] == "1.0.349" and not site["done"]
|
||||
# file offset = vaddr - 0x1000 here: computed from PT_LOAD, not assumed
|
||||
assert struct.unpack_from("<I", new, SITE_A - 0x1000)[0] == br.NOP
|
||||
assert struct.unpack_from("<I", new, SITE_B - 0x1000)[0] == br.RET
|
||||
assert new[CACHE_VA:CACHE_VA + len(br.NEW_CACHE)] == br.NEW_CACHE and br.OLD_CACHE not in new
|
||||
assert sum(a != b for a, b in zip(new, fake_build, strict=True)) <= 8 + len(br.NEW_CACHE) # only the edits
|
||||
again, site2 = br.patch_library(new) # an already patched library is recognised and left alone
|
||||
assert again == new and site2["done"] and site2["pending"] == []
|
||||
|
||||
|
||||
def test_partly_patched_library_is_completed(fake_build):
|
||||
half = bytearray(fake_build)
|
||||
struct.pack_into("<I", half, SITE_A - 0x1000, br.NOP)
|
||||
new, site = br.patch_library(bytes(half))
|
||||
assert site["pending"] == ["render", "shader cache namespace"]
|
||||
assert br.find_sites(new)["done"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kw", [dict(word_a=0x12345678), dict(word_b=br.NOP), dict(cache=b"cache/shader_cachX\0"),
|
||||
dict(extra_cache=True)])
|
||||
def test_anything_unexpected_is_refused(fake_build, kw):
|
||||
lib = _fake_lib(**kw)
|
||||
assert br.find_sites(lib) is None
|
||||
assert br.patch_library(lib) == (lib, None)
|
||||
|
||||
|
||||
def test_other_code_is_refused(fake_build):
|
||||
lib = bytearray(fake_build)
|
||||
lib[TEXT_OFF + 0x800] ^= 1 # another build: same words at the sites, different code
|
||||
assert br.find_sites(bytes(lib)) is None
|
||||
assert br.find_sites(fake_build.replace(b"\xb7\x00", b"\x3e\x00", 1)) is None # not AArch64
|
||||
|
||||
|
||||
class _Ws:
|
||||
abi = "arm64-v8a"
|
||||
|
||||
def __init__(self, libs):
|
||||
self.files = {f"lib/arm64-v8a/{k}": v for k, v in libs.items()}
|
||||
|
||||
def lib(self, name):
|
||||
return f"lib/arm64-v8a/{name}"
|
||||
|
||||
def has(self, path):
|
||||
return path in self.files
|
||||
|
||||
def read_lib(self, name):
|
||||
return self.files[self.lib(name)]
|
||||
|
||||
def put(self, path, data):
|
||||
self.files[path] = data
|
||||
|
||||
|
||||
def test_patch_apply_and_validate(fake_build):
|
||||
from types import SimpleNamespace
|
||||
|
||||
p = base.get("frame.blazerush")
|
||||
ctx = SimpleNamespace(ws=_Ws({br.LIB: fake_build}), notes=[])
|
||||
assert p.apply(ctx) and "cache/br_shader_v1" in ctx.notes[0]
|
||||
assert p.validate(ctx) == [("BlazeRush avatar wait skipped and own shader cache", True, "")]
|
||||
assert not p.apply(ctx) # nothing left to do
|
||||
other = SimpleNamespace(ws=_Ws({br.LIB: _fake_lib(word_a=0)}), notes=[])
|
||||
assert not p.apply(other) and "left unchanged" in other.notes[0]
|
||||
assert other.ws.read_lib(br.LIB) == _fake_lib(word_a=0)
|
||||
|
||||
|
||||
def test_detect_and_applies():
|
||||
p = base.get("frame.blazerush")
|
||||
assert p.applies(_analysis()) and p.detect(_analysis()).recommended
|
||||
assert p.detect(_analysis(version="1.0.350")) is None # another build: not suggested (apply would refuse it)
|
||||
assert not p.applies(_analysis(package="com.other.game"))
|
||||
assert not p.applies(_analysis(abis=["armeabi-v7a"]))
|
||||
|
||||
|
||||
def test_known_build_fingerprint_is_the_shipped_library():
|
||||
"""The fingerprint and sites come from the shipped 1.0.349 library (SHA-256 89686ffc...): its code segment is
|
||||
0x2c29d0 + 0x606230 in the file, the edit sites at 0x4b1dbc / 0x4b1adc, the string at 0x13af70."""
|
||||
(build,) = br.BUILDS
|
||||
assert build.version == "1.0.349" and build.cache_va == 0x13AF70
|
||||
assert [s[:3] for s in build.sites] == [(0x4B2DBC, 0x36000AE0, br.NOP), (0x4B2ADC, 0xF0002368, br.RET)]
|
||||
assert len(br.OLD_CACHE) == len(br.NEW_CACHE) == 19
|
||||
|
||||
|
||||
def _blazerush_apk():
|
||||
root = os.environ.get("FRAMEPORT_GAMES")
|
||||
if not root:
|
||||
pytest.skip("set FRAMEPORT_GAMES")
|
||||
found = sorted(Path(root).glob("**/ru.targem.blazerush.apk"))
|
||||
if not found:
|
||||
pytest.skip("no BlazeRush APK in FRAMEPORT_GAMES")
|
||||
return found[0]
|
||||
|
||||
|
||||
@pytest.mark.games
|
||||
def test_real_library(tmp_path):
|
||||
with zipfile.ZipFile(_blazerush_apk()) as z:
|
||||
lib = z.read("lib/arm64-v8a/libtargemapp.so")
|
||||
assert hashlib.sha256(lib).hexdigest() == "89686ffc5105f5149adf9bf08d88eafdd4e8e99a3cd14d1ac9cc0d3317dfe568"
|
||||
new, site = br.patch_library(lib)
|
||||
assert [e[0] for e in site["edits"]] == [0x4B1DBC, 0x4B1ADC] and site["cache_offset"] == 0x13AF70
|
||||
# still recognised after the GL shim's DT_NEEDED entry (a new segment; nothing that exists moves)
|
||||
shimmed = elf.add_needed(new, "libglshim.so")
|
||||
assert br.find_sites(shimmed)["done"] and elf.needed(shimmed)[0] == "libglshim.so"
|
||||
|
||||
|
||||
# ---- recipe
|
||||
def test_catalog_recipe():
|
||||
from frameport.core.library import recipe_to_dict
|
||||
from frameport.install.installer import install_context
|
||||
from frameport.recommend import catalog
|
||||
from frameport.recommend.engine import suggest
|
||||
|
||||
entry = catalog.lookup(br.PACKAGE)
|
||||
assert entry.status == "works" and "Klownicle" in entry.notes and entry.min_app == "1.0.0"
|
||||
recipe = suggest(_analysis())
|
||||
for pid in ("frame.vrapi_bridge", "frame.avatar_stub", "frame.gl_shim", "frame.blazerush", "device.config_sync"):
|
||||
assert pid in recipe.patches, pid
|
||||
s = adapter_settings(recipe.patches)
|
||||
assert s["scale"] == 1.7 and s["gl_int_attribs"] == 1 and s["gl_highp_markers"] == MARKERS
|
||||
assert s["gl_hide_multiview"] == 0 and s["gl_hide_msrtt"] == 0 # the game's GL extensions stay as tested
|
||||
ctx = install_context(recipe)
|
||||
assert ctx.config_sync == {"user_config.xml": {"r_3dwidth": "w", "r_3dheight": "h", "r_width": "h*16/9",
|
||||
"r_height": "h"}}
|
||||
shared = catalog.entry_from_library({"package": br.PACKAGE, "title": "BlazeRush", "recipe": recipe_to_dict(recipe),
|
||||
"analysis": {"version": "1.0.349"}})
|
||||
assert shared.config_sync == entry.config_sync and catalog.unusable_reason(
|
||||
{**entry.to_dict(), "min_app": ""}) is None
|
||||
|
||||
|
||||
def test_issue_validator_accepts_config_sync():
|
||||
spec = importlib.util.spec_from_file_location("catalog_from_issue", ROOT / "scripts" / "catalog_from_issue.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
d = {"package": br.PACKAGE, "title": "BlazeRush", "config_sync": {"user_config.xml": {"r_width": "h*16/9"}}}
|
||||
assert mod.validate(d).config_sync == d["config_sync"]
|
||||
with pytest.raises(mod.Invalid):
|
||||
mod.validate({**d, "config_sync": {"../x.xml": {"a": "w"}}})
|
||||
|
||||
|
||||
def test_new_settings_need_a_rebuild():
|
||||
assert base.get("adapter.gl_int_attribs").revision == 2 and base.get("adapter.gl_highp_markers").revision == 2
|
||||
|
||||
|
||||
def test_triage_signatures():
|
||||
log = ("10-10 10:00:00.000 1 1 I OVRPortVrApi: FOV properties: 90.00 x 90.00 degrees (default, nothing known "
|
||||
"before tracking)\n10-10 10:00:01.000 1 1 I GLShim: int attribs: program 12 location 3: floats -> "
|
||||
"glVertexAttribIPointer (vao 4 buffer 9 size 4 type 0x1401 stride 32 offset 0x18)\n")
|
||||
ids = {f.id for f in triage(log).findings}
|
||||
assert {"vrapi-fov-before-tracking", "gl-integer-attribs"} <= ids
|
||||
saved = "I OVRPortVrApi: FOV properties: 118.14 x 119.55 degrees (saved by an earlier session)\n"
|
||||
assert "vrapi-fov-before-tracking" not in {f.id for f in triage(saved).findings}
|
||||
|
||||
|
||||
# ---- native code built for the host
|
||||
def _cc():
|
||||
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
||||
if not cc or os.name == "nt" or not Path("/proc").exists():
|
||||
pytest.skip("needs a Linux host C compiler")
|
||||
return cc
|
||||
|
||||
|
||||
def _khronos_headers(dest: Path) -> Path:
|
||||
candidates = [Path("/usr/include")]
|
||||
candidates += sorted((ROOT / "native/.cache").glob("ndk-*/toolchains/llvm/prebuilt/*/sysroot/usr/include"))
|
||||
for inc in candidates:
|
||||
if all((inc / d).is_dir() for d in ("GLES3", "EGL", "KHR")):
|
||||
for d in ("GLES3", "EGL", "KHR"):
|
||||
shutil.copytree(inc / d, dest / d)
|
||||
return dest
|
||||
pytest.skip("needs the GLES3/EGL/KHR headers (libgles-dev, or the NDK in native/.cache)")
|
||||
|
||||
|
||||
def test_gl_shim_fixes_against_a_stand_in_gl(tmp_path):
|
||||
"""glshim.c itself, built for the host: BlazeRush's particle shader source gets highp calculations (interface
|
||||
lines and other shaders untouched); an integer input fed with glVertexAttribPointer is set up again with
|
||||
glVertexAttribIPointer before direct and eglGetProcAddress draws (same buffer/offset, GL_ARRAY_BUFFER restored),
|
||||
back to normalized floats for a float input, typed default when disabled, re-reflected after a relink or a program
|
||||
binary, cached per context. With the settings off, nothing changes."""
|
||||
cc = _cc()
|
||||
inc = _khronos_headers(tmp_path / "include")
|
||||
src, out = FIXTURES / "src", tmp_path / "lib"
|
||||
out.mkdir()
|
||||
warn = ["-Wall", "-Wextra", "-Werror"]
|
||||
subprocess.run([cc, "-std=gnu11", *warn, "-shared", "-fPIC", "-fvisibility=hidden", "-I", str(inc),
|
||||
str(src / "glshim_fake_gles.c"), "-Wl,-soname,libglshim_fake.so", "-ldl",
|
||||
"-o", str(out / "libglshim_fake.so")], check=True)
|
||||
subprocess.run([cc, "-std=gnu11", *warn, "-Wno-unused-function", "-Wno-unused-variable",
|
||||
"-Wno-unused-but-set-variable", "-Wno-incompatible-pointer-types", "-shared", "-fPIC",
|
||||
"-fvisibility=hidden", "-DEGL_NO_X11", '-DGLSHIM_GLES_LIB="libglshim_fake.so"',
|
||||
'-DGLSHIM_EGL_LIB="libglshim_fake.so"', "-I", str(src / "host_include"), "-I", str(inc),
|
||||
str(ROOT / "native/glshim/glshim.c"), "-Wl,--no-as-needed", "-L", str(out), "-lglshim_fake",
|
||||
"-Wl,--as-needed", "-Wl,-rpath,$ORIGIN", "-ldl", "-lpthread",
|
||||
"-o", str(out / "libglshim_host.so")], check=True)
|
||||
subprocess.run([cc, "-std=gnu11", *warn, "-I", str(inc), str(src / "glshim_host_test.c"), "-ldl",
|
||||
"-o", str(out / "glshim_host_test")], check=True)
|
||||
conf = tmp_path / "settings.conf"
|
||||
conf.write_text(f"scale=1.7\ngl_hide_multiview=0\ngl_hide_msrtt=0\ngl_int_attribs=1\ngl_highp_markers={MARKERS}\n")
|
||||
env = {k: v for k, v in os.environ.items() if k != "FRAMEBRIDGE_CONFIG"}
|
||||
cmd = [str(out / "glshim_host_test"), str(out / "libglshim_host.so"), str(out / "libglshim_fake.so")]
|
||||
run = subprocess.run([*cmd, "on"], capture_output=True, text=True, env={**env, "FRAMEBRIDGE_CONFIG": str(conf)})
|
||||
assert run.returncode == 0, run.stdout + run.stderr
|
||||
assert f"GL shim per-game fixes: gl_int_attribs=1 gl_highp_markers={MARKERS}" in run.stderr
|
||||
assert "GLShim: highp: vertex shader 1: 3 mediump -> highp" in run.stderr
|
||||
assert "GLShim: int attribs: program 1 has integer input inBoneIndices (unsigned) at location 3" in run.stderr
|
||||
assert "location 3: floats -> glVertexAttribIPointer" in run.stderr and "location 3: back to floats" in run.stderr
|
||||
run = subprocess.run([*cmd, "off"], capture_output=True, text=True, env=env)
|
||||
assert run.returncode == 0, run.stdout + run.stderr
|
||||
assert "per-game fixes" not in run.stderr and "int attribs" not in run.stderr
|
||||
|
||||
|
||||
def test_bridge_fov_cache_helpers(tmp_path):
|
||||
cxx = shutil.which("c++") or shutil.which("g++") or shutil.which("clang++")
|
||||
if not cxx or os.name == "nt":
|
||||
pytest.skip("needs a host C++ compiler")
|
||||
test = tmp_path / "fov.cpp"
|
||||
test.write_text(r'''
|
||||
#include "fov_cache.h"
|
||||
#include <cstdio>
|
||||
using namespace ovp::fovcache;
|
||||
#define CHECK(c) do { if (!(c)) { std::printf("FAIL %d\n", __LINE__); return 1; } } while (0)
|
||||
int main() {
|
||||
float fov[2], none[2] = {0, 0};
|
||||
CHECK(parse("fov_h=118.140\nfov_v=119.550\n", fov) && fov[0] > 118.13f && fov[1] < 119.56f);
|
||||
CHECK(parse("# x\nfov_v=100\r\nfov_h=101\n", fov) && fov[0] == 101 && fov[1] == 100);
|
||||
CHECK(!parse("fov_h=118\n", fov)); // both needed
|
||||
CHECK(!parse("fov_h=30\nfov_v=118\n", fov)); // implausible
|
||||
CHECK(!parse("fov_h=nan\nfov_v=118\n", fov));
|
||||
char text[96];
|
||||
const float live[2] = {118.14f, 119.55f};
|
||||
format(text, sizeof(text), live);
|
||||
CHECK(parse(text, fov) && fov[0] > 118.13f && fov[0] < 118.15f);
|
||||
CHECK(changed(false, none, live) && !changed(true, live, live));
|
||||
const float near[2] = {118.145f, 119.55f}, far[2] = {118.2f, 119.55f};
|
||||
CHECK(!changed(true, live, near) && changed(true, live, far));
|
||||
char buf[300];
|
||||
CHECK(path("ru.targem.blazerush", buf, sizeof(buf)) &&
|
||||
!std::strcmp(buf, "/sdcard/Android/data/ru.targem.blazerush/files/framebridge-vrapi-fov.txt"));
|
||||
CHECK(path("com.x.y:remote", buf, sizeof(buf)) && std::strstr(buf, "/com.x.y/files/"));
|
||||
CHECK(!path("", buf, sizeof(buf)) && !path("/system/bin/app_process", buf, sizeof(buf)) &&
|
||||
!path("..", buf, sizeof(buf)));
|
||||
std::printf("ok\n");
|
||||
return 0;
|
||||
}
|
||||
''')
|
||||
subprocess.run([cxx, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "native/vrapi-bridge"),
|
||||
str(test), "-o", str(tmp_path / "fov")], check=True)
|
||||
run = subprocess.run([str(tmp_path / "fov")], capture_output=True, text=True)
|
||||
assert run.returncode == 0 and run.stdout.strip() == "ok", run.stdout
|
||||
|
||||
|
||||
def test_artifacts_carry_the_fixes():
|
||||
shim = artifact("arm64-v8a", "libglshim.so")
|
||||
exports = elf.dyn_symbols(shim, True)
|
||||
assert {"glShaderSource", "glDrawArrays", "glDrawElements", "glLinkProgram", "glProgramBinary",
|
||||
"glDeleteProgram", "eglGetProcAddress"} <= exports
|
||||
assert not {s for s in exports if not s.startswith(("gl", "egl"))}
|
||||
for text in (b"gl_int_attribs=", b"gl_highp_markers=", b"glDrawElementsBaseVertexOES"):
|
||||
assert text in shim
|
||||
bridge = artifact("arm64-v8a", "libvrapi.so")
|
||||
assert b"framebridge-vrapi-fov.txt" in bridge and b"FOV properties: %.2f x %.2f degrees (%s)" in bridge
|
||||
adapter = artifact("arm64-v8a", "libopenxr_loader_generic.so")
|
||||
assert b"runtime recommended eye size %ux%u" in adapter
|
||||
|
||||
|
||||
# ---- agent: device.config_sync
|
||||
def _agent(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
spec = importlib.util.spec_from_file_location("frameport_agent", AGENT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
USER_CONFIG = """<?xml version="1.0" encoding="utf-8"?>\r
|
||||
<config>\r
|
||||
\t<cvar name="r_3dwidth" value="1728" />\r
|
||||
\t<cvar value="1728" name="r_3dheight"/>\r
|
||||
\t<cvar name="r_width" type="int" value="3072" />\r
|
||||
\t<r_height>1728</r_height>\r
|
||||
\t<cvar name="snd_volume" value="0.8" />\r
|
||||
\t<save slot="1" name="Ünïcode \xff" />\r
|
||||
</config>\r
|
||||
"""
|
||||
RULES = {"user_config.xml": {"r_3dwidth": "w", "r_3dheight": "h", "r_width": "h*16/9", "r_height": "h",
|
||||
"r_missing": "w"}}
|
||||
|
||||
|
||||
def _installed(a, tmp_path, log_lines, scale="1.7"):
|
||||
pkg = br.PACKAGE
|
||||
base = tmp_path / "Applications/quest-frame" / pkg
|
||||
files = base / "lepton-data/external/Android/data" / pkg / "files"
|
||||
files.mkdir(parents=True)
|
||||
(files / "user_config.xml").write_bytes(USER_CONFIG.encode("utf-8", "surrogateescape"))
|
||||
os.chmod(files / "user_config.xml", 0o640)
|
||||
(base / "settings.conf").write_text(f"scale={scale}\nfoveation_fix=1\ncontroller_fix=1\n")
|
||||
(base / "launch.log").write_text("".join(f"\x1b[38;5;40m10-10 12:00:00.000 1 1 I {x}\x1b[0m\n" for x in log_lines))
|
||||
dep = {"package": pkg, "appid": 123, "base": str(base), "config_sync": RULES}
|
||||
(base / "deployment.json").write_text(json.dumps(dep))
|
||||
return pkg, base, files
|
||||
|
||||
|
||||
def test_config_value_expressions(monkeypatch, tmp_path):
|
||||
a = _agent(monkeypatch, tmp_path)
|
||||
assert a.config_value("w", 2938, 2938) == 2938 and a.config_value("h*16/9", 2938, 2938) == 5223
|
||||
assert a.config_value(" h * 16 / 9 ", 1728, 1728) == 3072 and a.config_value("w/2", 2938, 10) == 1469
|
||||
for bad in ("__import__('os')", "w+1", "x", "h*0", ""):
|
||||
with pytest.raises(a.AgentError):
|
||||
a.config_value(bad, 2938, 2938)
|
||||
|
||||
|
||||
def test_runtime_eye_size_from_the_last_start(monkeypatch, tmp_path):
|
||||
a = _agent(monkeypatch, tmp_path)
|
||||
(tmp_path / "launch.log").write_text(
|
||||
"I FrameBridge: scale=1.70 foveation_fix=1 controller_fix=1\nI FrameBridge: runtime recommended eye size "
|
||||
"1728x1728 (max 4096x4096, scale 1.70)\nI OVRPortVrApi: Initialized Vulkan/GLES OpenXR adapter (gles=1), "
|
||||
"stereo=2938x2938\n")
|
||||
assert a.runtime_eye_size(str(tmp_path)) == (1728, 1728)
|
||||
# builds before the new FrameBridge line: the bridge's (scaled) size divided by the logged scale
|
||||
(tmp_path / "launch.log").write_text(
|
||||
"I FrameBridge: scale=1.70 foveation_fix=1 controller_fix=1 swapchain_fix=1\nI OVRPortVrApi: Initialized "
|
||||
"Vulkan/GLES OpenXR adapter (gles=1), stereo=2938x2938\n")
|
||||
assert a.runtime_eye_size(str(tmp_path)) == (1728, 1728)
|
||||
(tmp_path / "launch.log").write_text("nothing\n")
|
||||
assert a.runtime_eye_size(str(tmp_path)) is None
|
||||
|
||||
|
||||
def test_config_sync_writes_the_eye_size(monkeypatch, tmp_path):
|
||||
a = _agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
pkg, base, files = _installed(a, tmp_path, ["FrameBridge: runtime recommended eye size 1728x1728 (max 4096x4096, "
|
||||
"scale 1.00)"])
|
||||
report = a.cmd_config_sync({"package": pkg})
|
||||
assert report["eye"] == [2938, 2938] and report["runtime"] == [1728, 1728] and report["scale"] == 1.7
|
||||
assert report["changed"] == {"r_3dwidth": ("1728", "2938"), "r_3dheight": ("1728", "2938"),
|
||||
"r_width": ("3072", "5223"), "r_height": ("1728", "2938")}
|
||||
assert report["missing"] == ["user_config.xml:r_missing"]
|
||||
text = (files / "user_config.xml").read_bytes()
|
||||
expected = (USER_CONFIG.replace('"r_3dwidth" value="1728"', '"r_3dwidth" value="2938"')
|
||||
.replace('value="1728" name="r_3dheight"', 'value="2938" name="r_3dheight"')
|
||||
.replace('type="int" value="3072"', 'type="int" value="5223"')
|
||||
.replace("<r_height>1728<", "<r_height>2938<"))
|
||||
assert text == expected.encode("utf-8", "surrogateescape") # everything else byte for byte (CRLF, bytes)
|
||||
assert (files / "user_config.xml.frameport-backup").read_bytes() == USER_CONFIG.encode("utf-8", "surrogateescape")
|
||||
assert (files / "user_config.xml").stat().st_mode & 0o777 == 0o640
|
||||
assert not list(files.glob("*.frameport-tmp"))
|
||||
assert json.loads((base / "deployment.json").read_text())["eye_base"] == [1728, 1728]
|
||||
# nothing to do the second time; the learned size is used once launch.log is gone
|
||||
(base / "launch.log").unlink()
|
||||
assert a.cmd_config_sync({"package": pkg})["changed"] == {}
|
||||
(base / "settings.conf").write_text("scale=1.0\n")
|
||||
assert a.cmd_config_sync({"package": pkg})["changed"]["r_width"] == ("5223", "3072")
|
||||
|
||||
|
||||
def test_config_sync_waits_for_a_stopped_game_and_a_known_size(monkeypatch, tmp_path):
|
||||
a = _agent(monkeypatch, tmp_path)
|
||||
pkg, base, files = _installed(a, tmp_path, ["nothing about sizes"])
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: True)
|
||||
assert "running" in a.cmd_config_sync({"package": pkg})["skipped"]
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
assert "isn't known yet" in a.cmd_config_sync({"package": pkg})["skipped"]
|
||||
assert (files / "user_config.xml").read_bytes() == USER_CONFIG.encode("utf-8", "surrogateescape")
|
||||
# outside the game's files folder: never written
|
||||
dep = json.loads((base / "deployment.json").read_text())
|
||||
dep["config_sync"] = {"../../../../x.xml": {"a": "w"}}
|
||||
(base / "deployment.json").write_text(json.dumps(dep))
|
||||
(base / "launch.log").write_text("I FrameBridge: runtime recommended eye size 1728x1728 (max 1x1, scale 1.0)\n")
|
||||
assert a.cmd_config_sync({"package": pkg})["missing"] == ["../../../../x.xml"]
|
||||
|
||||
|
||||
def test_launcher_line_and_upgrade(monkeypatch, tmp_path):
|
||||
a = _agent(monkeypatch, tmp_path)
|
||||
pkg, base, files = _installed(a, tmp_path, ["FrameBridge: runtime recommended eye size 1728x1728 (x)"])
|
||||
a.write_launcher(str(base), str(base), pkg, "BlazeRush", 123, "/lepton", {}, config_sync=True)
|
||||
script = (base / "launch.sh").read_text()
|
||||
line = next(x for x in script.splitlines() if " _config_sync " in x)
|
||||
assert line.endswith(f"_config_sync {pkg} >\"$app_dir/config-sync.log\" 2>&1 9>&- || true")
|
||||
assert script.index(" _config_sync ") < script.index("\nsetsid ") # before Lepton starts
|
||||
assert script.index(".launch.lock") < script.index(" _config_sync ") # after the single-launch lock
|
||||
a.write_launcher(str(base), str(base), pkg, "BlazeRush", 123, "/lepton", {})
|
||||
assert " _config_sync " not in (base / "launch.sh").read_text()
|
||||
# older launchers get the line from upgrade_launchers when the deployment has rules
|
||||
assert pkg in a.upgrade_launchers()
|
||||
assert (base / "launch.sh").read_text().count(" _config_sync ") == 1
|
||||
assert pkg not in a.upgrade_launchers()
|
||||
# the launcher's call: the game isn't running (no podman call)
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: pytest.fail("no podman call from the launcher"))
|
||||
a.config_sync_worker(pkg)
|
||||
assert b'value="2938"' in (files / "user_config.xml").read_bytes()
|
||||
|
||||
|
||||
def test_finalize_and_set_settings_sync(monkeypatch, tmp_path):
|
||||
a = _agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "container_running", lambda appid: False)
|
||||
pkg, base, files = _installed(a, tmp_path, ["FrameBridge: runtime recommended eye size 1728x1728 (x)"])
|
||||
app = base / "lepton-app"
|
||||
app.mkdir()
|
||||
(app / "game.apk").write_bytes(b"APK")
|
||||
monkeypatch.setattr(a, "cmd_prepare", lambda args: {"base": str(base), "anchor": str(base), "appid": 123,
|
||||
"incoming": str(base / "incoming"), "lepton": "/lepton"})
|
||||
monkeypatch.setattr(a, "install_controller_models", lambda *x: {})
|
||||
r = a.cmd_finalize({"package": pkg, "title": "BlazeRush", "settings": {"scale": 1.7}, "config_sync": RULES,
|
||||
"recipe": {"patches": ["device.config_sync"]}})
|
||||
assert r["config_sync"]["changed"]["r_3dwidth"] == ("1728", "2938")
|
||||
dep = json.loads((base / "deployment.json").read_text())
|
||||
assert dep["config_sync"] == RULES and dep["eye_base"] == [1728, 1728] and dep["agent_version"] >= 78
|
||||
assert " _config_sync " in (base / "launch.sh").read_text()
|
||||
r = a.cmd_set_settings({"package": pkg, "settings": {"scale": 1.0}})
|
||||
assert r["config_sync"]["changed"]["r_width"] == ("5223", "3072")
|
||||
@@ -0,0 +1,96 @@
|
||||
"""The homepage's example "Install with FramePort" button (deeplink.DEMO_MANIFEST): every form of the link is
|
||||
recognised as the demo, and nothing about it ever reaches the network."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport import deeplink
|
||||
from frameport.deeplink import DEMO_MANIFEST, LinkError
|
||||
|
||||
ENC = quote(DEMO_MANIFEST, safe="")
|
||||
SITE = Path(__file__).resolve().parents[1] / "site" / "public" / "demo"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("link", [
|
||||
f"https://frameport.app/install/?manifest={ENC}", # the homepage button
|
||||
f"https://frameport.app/install?manifest={ENC}",
|
||||
f"https://www.frameport.app/install/?manifest={DEMO_MANIFEST}",
|
||||
f"frameport://install?manifest={ENC}",
|
||||
f"framedrop://install?manifest={ENC}",
|
||||
f"https://framedropvr.com/install?manifest={ENC}",
|
||||
DEMO_MANIFEST, # pasted on its own
|
||||
f' "{DEMO_MANIFEST}" ',
|
||||
"https://www.frameport.app/demo/cool-game.json",
|
||||
])
|
||||
def test_demo_link_forms(link):
|
||||
req = deeplink.parse(link)
|
||||
assert req.demo and req.manifest_url == DEMO_MANIFEST
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"https://frameport.app/demo/cool-game.json?x=1",
|
||||
"https://frameport.app/demo/other.json",
|
||||
"https://frameport.app:8443/demo/cool-game.json",
|
||||
"https://frameport.app.evil.com/demo/cool-game.json",
|
||||
"http://frameport.app/demo/cool-game.json",
|
||||
"https://cdn.example.com/demo/cool-game.json",
|
||||
])
|
||||
def test_lookalikes_are_not_the_demo(url):
|
||||
assert not deeplink.is_demo(url)
|
||||
try:
|
||||
req = deeplink.parse(f"frameport://install?manifest={quote(url, safe='')}")
|
||||
except LinkError:
|
||||
return
|
||||
assert not req.demo
|
||||
|
||||
|
||||
def test_the_demo_never_touches_the_network(monkeypatch):
|
||||
from frameport.core import cache
|
||||
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("network used for the demo link")
|
||||
monkeypatch.setattr(cache._session, "get", boom)
|
||||
monkeypatch.setattr(cache._session, "head", boom)
|
||||
monkeypatch.setattr(deeplink.socket, "getaddrinfo", boom)
|
||||
m = deeplink.fetch_manifest(deeplink.parse(f"frameport://install?manifest={ENC}"))
|
||||
assert m.demo and m.name == deeplink.DEMO_NAME and m.main.kind == deeplink.APK and m.icon is None
|
||||
assert deeplink.fetch_icon(m) is None
|
||||
with pytest.raises(LinkError, match="demo"):
|
||||
deeplink.download(m)
|
||||
# also a manifest that only names the demo address
|
||||
with pytest.raises(LinkError, match="demo"):
|
||||
deeplink.download(deeplink.Manifest("x", m.files, DEMO_MANIFEST))
|
||||
|
||||
|
||||
def test_the_site_publishes_the_same_demo():
|
||||
data = json.loads((SITE / "cool-game.json").read_text(encoding="utf-8"))
|
||||
site = deeplink.manifest_from_data(data, DEMO_MANIFEST)
|
||||
built = deeplink.demo_manifest()
|
||||
assert (site.name, [f.url for f in site.files], site.description) == \
|
||||
(built.name, [f.url for f in built.files], built.description)
|
||||
assert (SITE / "cool-game.svg").read_bytes() == \
|
||||
(Path(deeplink.__file__).parent / "ui" / "icons" / "demo-cool-game.svg").read_bytes()
|
||||
|
||||
|
||||
def test_cli_demo_does_nothing(monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
def boom(*a, **k):
|
||||
raise AssertionError("the demo link must not fetch or download")
|
||||
monkeypatch.setattr(deeplink, "fetch_manifest", boom)
|
||||
monkeypatch.setattr(deeplink, "download", boom)
|
||||
res = CliRunner().invoke(cli.app, ["open-link", f"https://frameport.app/install/?manifest={ENC}"])
|
||||
assert res.exit_code == 0, res.output
|
||||
assert "demo link" in res.output
|
||||
|
||||
|
||||
def test_dialog_route():
|
||||
from frameport.ui.views import link_dialog
|
||||
|
||||
assert link_dialog.route(deeplink.parse(DEMO_MANIFEST)) == "demo"
|
||||
assert link_dialog.route(deeplink.parse("https://cdn.example.com/game.framedrop.json")) == "fetch"
|
||||
assert (Path(link_dialog.__file__).parents[1] / "icons" / f"{link_dialog.DEMO_COVER}.svg").is_file()
|
||||
@@ -210,3 +210,17 @@ def test_charge_ring_empties_at_once_then_refills():
|
||||
steps = easter.ring_fill_steps(fill=0.4, dt=0.025)
|
||||
assert steps[0] == 0.0 and steps[-1] == 1.0 # empty at once (no draining), full at the end
|
||||
assert steps == sorted(steps) and len(steps) == 17 # then only filling, in 0.4 s
|
||||
|
||||
|
||||
@pytest.mark.parametrize("eggs, reduce, finale", [
|
||||
(True, False, "toss"), (True, True, "words"), (False, False, "plain"), (False, True, "plain"),
|
||||
])
|
||||
def test_demo_link_finale(eggs, reduce, finale):
|
||||
assert easter.demo_finale(eggs, reduce) == finale
|
||||
assert "nothing" in easter.demo_message(finale)
|
||||
assert ("Nice, it works" in easter.demo_message(finale)) == (finale == "plain")
|
||||
|
||||
|
||||
def test_demo_link_is_listed_in_the_docstring():
|
||||
assert 'example "Install with FramePort"' in easter.__doc__
|
||||
assert "show_demo" in easter.__doc__
|
||||
@@ -0,0 +1,153 @@
|
||||
"""frame.godot_clipboard (GitHub #134, EndoparasiticVR): Godot 4.2-4.4's Kotlin `as ClipboardManager` throws in Lepton
|
||||
(no clipboard service). The null check before that cast becomes nops and Godot's clipboard methods return at once.
|
||||
|
||||
The fixtures are synthetic dex files; the code units copy what kotlinc + D8 emit in the real godot-lib 4.3.0 / 4.4.1
|
||||
(Godot.<init>, getClipboard, hasClipboard, setClipboard; Maven Central and the release export templates), with this
|
||||
dex's own indices. The real libraries were checked by hand (see docs/PLAYBOOK.md); nothing is downloaded here."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import zipfile
|
||||
|
||||
from test_vivox_audio_route import build_dex
|
||||
|
||||
from frameport.analysis import detect
|
||||
from frameport.apk.dex import Dex
|
||||
from frameport.patches.frame.godot_clipboard import MESSAGE, GodotClipboard, patch_dex
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
GODOT = "Lorg/godotengine/godot/Godot;"
|
||||
LAMBDA = "Lorg/godotengine/godot/Godot$mClipboard$2;"
|
||||
CM = "Landroid/content/ClipboardManager;"
|
||||
NPE = "Ljava/lang/NullPointerException;"
|
||||
SENSOR_MSG = "null cannot be cast to non-null type android.hardware.SensorManager"
|
||||
STRINGS = ("", MESSAGE, SENSOR_MSG) # string ids 0, 1, 2
|
||||
TYPES = (CM, NPE, "Landroid/hardware/SensorManager;") # type ids 0, 1, 2
|
||||
REFS = [("Lkotlin/jvm/internal/Intrinsics;", "checkNotNull", "V", None), # method 0
|
||||
(NPE, "<init>", "V", None), # 1
|
||||
(CM, "hasPrimaryClip", "Z", None), # 2
|
||||
(CM, "getPrimaryClip", "Landroid/content/ClipData;", None), # 3
|
||||
(CM, "setPrimaryClip", "V", None)] # 4
|
||||
|
||||
INIT = [0x0012, # const/4 v0, 0 (getSystemService("clipboard") on Lepton)
|
||||
0x011A, 1, # const-string v1, MESSAGE
|
||||
0x2071, 0, 0x0010, # invoke-static {v0, v1}, Intrinsics.checkNotNull(Object, String)
|
||||
0x001F, 0, # check-cast v0, ClipboardManager
|
||||
0x011A, 2, # const-string v1, SENSOR_MSG
|
||||
0x2071, 0, 0x0010, # invoke-static {v0, v1}, Intrinsics.checkNotNull (another service: left alone)
|
||||
0x001F, 2, # check-cast v0, SensorManager
|
||||
0x000E]
|
||||
GET = [0x2254, 0, 0x106E, 3, 0x0002, 0x020C, 0x011A, 0, 0x0011] # iget-object; getPrimaryClip; ...; return-object
|
||||
HAS = [0x0054, 0, 0x106E, 2, 0x0000, 0x000A, 0x000F] # iget-object; hasPrimaryClip; move-result; return
|
||||
SET = [0x1054, 0, 0x206E, 4, 0x0021, 0x000E] # iget-object; setPrimaryClip; return-void
|
||||
# kotlinc without Intrinsics.checkNotNull: if-nez v0, :cast; new-instance v1, NPE; const-string v2, MESSAGE;
|
||||
# invoke-direct {v1, v2}, NPE.<init>; throw v1; :cast check-cast v0, ClipboardManager; return-object v0
|
||||
LEGACY = [0x0039, 10, 0x0122, 1, 0x021A, 1, 0x2070, 1, 0x0021, 0x0127, 0x001F, 0, 0x0011]
|
||||
# the same with the throw block after the return: if-eqz v0, :throw; check-cast v0; return-object v0; :throw ...
|
||||
EQZ = [0x0038, 5, 0x001F, 0, 0x0011, 0x0122, 1, 0x021A, 1, 0x2070, 1, 0x0021, 0x0127]
|
||||
|
||||
|
||||
def _dex(*methods, strings=STRINGS) -> Dex:
|
||||
return Dex(build_dex(REFS + list(methods), strings_first=strings, types_first=TYPES))
|
||||
|
||||
|
||||
def _code(dex: Dex, cls: str, name: str) -> list[int]:
|
||||
off = dex.code_items(cls, name)[0]
|
||||
n = struct.unpack_from("<I", dex.data, off + 12)[0]
|
||||
return list(struct.unpack_from(f"<{n}H", dex.data, off + 16))
|
||||
|
||||
|
||||
def test_godot_4_3_constructor_and_clipboard_methods():
|
||||
dex = _dex((GODOT, "<init>", "V", INIT), (GODOT, "getClipboard", "Ljava/lang/String;", GET),
|
||||
(GODOT, "hasClipboard", "Z", HAS), (GODOT, "setClipboard", "V", SET))
|
||||
raw = bytes(dex.data)
|
||||
assert patch_dex(dex) == ["Godot.<init> (clipboard cast)", "Godot.getClipboard (returns at once)",
|
||||
"Godot.hasClipboard (returns at once)", "Godot.setClipboard (returns at once)"]
|
||||
assert _code(dex, GODOT, "<init>") == INIT[:3] + [0, 0, 0] + INIT[6:] # only the clipboard's null check
|
||||
assert _code(dex, GODOT, "getClipboard") == [0x001A, 0, 0x0011, 0, 0] + GET[5:] # const-string v0, ""; return
|
||||
assert _code(dex, GODOT, "hasClipboard") == [0x0012, 0x000F] + HAS[2:] # const/4 v0, 0; return v0
|
||||
assert _code(dex, GODOT, "setClipboard") == [0x000E, 0] + SET[2:]
|
||||
out = dex.finish()
|
||||
assert len(out) == len(raw) and out[12:32] != raw[12:32] # same size, new signature
|
||||
assert patch_dex(Dex(out)) == [] # a second run changes nothing
|
||||
|
||||
|
||||
def test_older_kotlin_branch_forms():
|
||||
dex = _dex((LAMBDA, "invoke", CM, LEGACY), (LAMBDA, "invoke2", CM, EQZ))
|
||||
assert sorted(patch_dex(dex)) == ["Godot$mClipboard$2.invoke (clipboard cast)",
|
||||
"Godot$mClipboard$2.invoke2 (clipboard cast)"]
|
||||
assert _code(dex, LAMBDA, "invoke") == [0x0029, 10] + LEGACY[2:] # if-nez -> goto/16, same target
|
||||
assert _code(dex, LAMBDA, "invoke2") == [0, 0] + EQZ[2:] # if-eqz -> nops
|
||||
|
||||
|
||||
def test_no_blind_edits():
|
||||
other_msg = INIT[:2] + [2] + INIT[3:6] + INIT[6:8] + [0x000E] # checkNotNull with another message before the cast
|
||||
wrong_reg = INIT[:3] + [0x2071, 0, 0x0001] + INIT[6:8] + [0x000E] # checkNotNull {v1, v0}: not the cast's value
|
||||
dex = _dex((GODOT, "a", "V", other_msg), (GODOT, "b", "V", wrong_reg), (GODOT, "hasClipboard", "Z", HAS),
|
||||
("Lcom/example/Other;", "<init>", "V", INIT)) # outside org/godotengine
|
||||
assert patch_dex(dex) == []
|
||||
assert _code(dex, GODOT, "a") == other_msg and _code(dex, GODOT, "b") == wrong_reg
|
||||
assert _code(dex, GODOT, "hasClipboard") == HAS and _code(dex, "Lcom/example/Other;", "<init>") == INIT
|
||||
# Godot 4.5+ (`as?`, no message in the dex): its null-safe clipboard methods stay as they are
|
||||
dex = _dex((GODOT, "hasClipboard", "Z", HAS), strings=("",))
|
||||
assert patch_dex(dex) == [] and _code(dex, GODOT, "hasClipboard") == HAS
|
||||
|
||||
|
||||
def test_apply_on_workspace():
|
||||
raw = build_dex(REFS + [(GODOT, "<init>", "V", INIT)], strings_first=STRINGS, types_first=TYPES)
|
||||
|
||||
class Ws:
|
||||
files = {"classes.dex": b"dex\n035\0", "classes2.dex": raw}
|
||||
|
||||
def names(self):
|
||||
return list(self.files)
|
||||
|
||||
def read(self, n):
|
||||
return self.files[n]
|
||||
|
||||
def put(self, n, data):
|
||||
self.files[n] = data
|
||||
|
||||
class Ctx:
|
||||
ws = Ws()
|
||||
notes: list[str] = []
|
||||
|
||||
assert GodotClipboard().apply(Ctx()) is True
|
||||
assert Ctx.ws.files["classes2.dex"] != raw and Ctx.notes == [
|
||||
"classes2.dex: Godot runs without a clipboard service (Godot.<init> (clipboard cast))"]
|
||||
|
||||
|
||||
def test_detect_and_triage():
|
||||
class A:
|
||||
extra = {"godot_clipboard": True}
|
||||
assert GodotClipboard().applies(A()) and GodotClipboard().detect(A()).recommended
|
||||
A.extra = {"sdl_java": True}
|
||||
assert not GodotClipboard().applies(A()) and GodotClipboard().detect(A()) is None
|
||||
pre = "10-09 21:00:01.000 900 900 E AndroidRuntime: "
|
||||
log = (f"{pre}FATAL EXCEPTION: main\n"
|
||||
f"{pre}Process: org.godotengine.endoparasitic_vr, PID: 900\n"
|
||||
f"{pre}java.lang.RuntimeException: Unable to start activity ComponentInfo{{org.godotengine.endoparasitic_vr/"
|
||||
"com.godot.game.GodotApp}: java.lang.NullPointerException: null cannot be cast to non-null type "
|
||||
"android.content.ClipboardManager\n"
|
||||
f"{pre}\tat android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3449)\n"
|
||||
f"{pre}Caused by: java.lang.NullPointerException: null cannot be cast to non-null type "
|
||||
"android.content.ClipboardManager\n"
|
||||
f"{pre}\tat org.godotengine.godot.Godot.<init>(Godot.kt:96)\n"
|
||||
f"{pre}\tat org.godotengine.godot.GodotFragment.onCreate(GodotFragment.java:194)\n")
|
||||
r = triage(log, "EXITED", "org.godotengine.endoparasitic_vr")
|
||||
hits = [f for f in r.findings if f.id == "godot-no-clipboard"]
|
||||
assert hits and hits[0].suggest == ["frame.godot_clipboard"]
|
||||
assert "sdl-no-clipboard" not in [f.id for f in r.findings]
|
||||
|
||||
|
||||
def test_analysis_flags_only_the_throwing_cast(tmp_path, quest_manifest):
|
||||
assert detect.ANALYSIS_VERSION >= 10 # older library entries are analysed again for the new field
|
||||
cases = [(b"Lorg/godotengine/godot/Godot;..." + MESSAGE.encode(), True), # Godot 4.2-4.4
|
||||
(b"Lorg/godotengine/godot/Godot;...Landroid/content/ClipboardManager;", False), # 4.1 / 4.5+
|
||||
(MESSAGE.encode(), False)] # another Kotlin app
|
||||
for i, (dex, expected) in enumerate(cases):
|
||||
path = tmp_path / f"{i}.apk"
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", quest_manifest)
|
||||
z.writestr("classes.dex", b"dex\n035\0" + dex)
|
||||
assert detect.analyze(path).extra["godot_clipboard"] is expected
|
||||
+5
-2
@@ -80,6 +80,7 @@ def test_library_menu_is_short():
|
||||
if st.installs:
|
||||
assert st.installs[0][0] in labels
|
||||
assert ("Game settings…" in labels) == st.settings
|
||||
assert "Rename…" in labels and labels.index("Rename…") < labels.index("More actions…")
|
||||
assert ("Uninstall from Frame…" in labels) == st.on_frame
|
||||
assert ("Uninstall from this PC…" in labels) == st.on_pc
|
||||
|
||||
@@ -98,6 +99,7 @@ def test_page_menu_is_the_full_one_without_its_buttons():
|
||||
if isinstance(e, Header):
|
||||
assert i + 1 < len(entries) and entries[i + 1] is not None and not isinstance(entries[i + 1], Header)
|
||||
assert all(e[1] for e in _items(entries)), name
|
||||
assert "Rename…" in labels, name # every kind of game (Quest, PC VR, Linux), also while a job runs
|
||||
|
||||
|
||||
def test_sections_and_conditions():
|
||||
@@ -105,9 +107,10 @@ def test_sections_and_conditions():
|
||||
st.quick = False
|
||||
entries = menu_sections(g, st, _key)
|
||||
heads = [e.label for e in entries if isinstance(e, Header)]
|
||||
assert heads == ["Artwork", "Recipe", "Troubleshoot"]
|
||||
assert heads == ["Name and artwork", "Recipe", "Troubleshoot"]
|
||||
assert _section_of(entries, "Analyze again") == "Troubleshoot"
|
||||
assert _section_of(entries, "Update art on Frame") == "Artwork"
|
||||
assert _section_of(entries, "Update art on Frame") == "Name and artwork"
|
||||
assert _section_of(entries, "Rename…") == "Name and artwork"
|
||||
assert _section_of(entries, "Share working recipe…") == "Recipe"
|
||||
linux_st = _states()[3][2]
|
||||
linux_st.quick = False
|
||||
|
||||
@@ -320,3 +320,97 @@ def test_missing_obb_is_flagged_before_install_and_in_launch_tests():
|
||||
assert [f.id for f in r.findings] == ["missing-obb"] and r.verdict == "fail"
|
||||
running = TriageResult("RUNNING", "Submitting frames", fps=72.0) # an OBB from an earlier install is there
|
||||
assert not add_missing_obb(running).findings and running.verdict == "pass"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stray_pkg", ["com.Armature.VR4", "com.epicgames.ue4"])
|
||||
def test_sidequest_desktop_backups_folder(tmp_path, stray_pkg):
|
||||
"""SideQuest's "SideQuest Backups" folder, scanned whole: <package>/<timestamp>/apk/<package>.apk +
|
||||
obb/(main|patch).N.<package>.obb + data/ (the app's private files) + icon.png + manifest.json. VR4's backup also
|
||||
holds a second APK in its obb folder (Unreal's VR4-Android-Shipping-arm64.apk), which must not become the game
|
||||
or a second game (reddit, 2026-10-11)."""
|
||||
root = tmp_path / "SideQuest Backups"
|
||||
cabin = root / "com.pixeltoys.cabin" / "2026-10-08T12-08-41-153Z"
|
||||
apk(cabin / "apk" / "com.pixeltoys.cabin.apk")
|
||||
IDS["com.pixeltoys.cabin.apk"] = ("com.pixeltoys.cabin", 11479)
|
||||
obb(cabin / "obb", "main.11479.com.pixeltoys.cabin.obb")
|
||||
for d in ("cloud", "il2cpp", "Unity"):
|
||||
(cabin / "data" / d).mkdir(parents=True)
|
||||
(cabin / "icon.png").write_bytes(b"p")
|
||||
(cabin / "manifest.json").write_text("{}")
|
||||
vr4 = root / "com.Armature.VR4" / "2026-10-07T02-10-19-171Z"
|
||||
apk(vr4 / "apk" / "com.Armature.VR4.apk")
|
||||
IDS["com.Armature.VR4.apk"] = ("com.Armature.VR4", 203)
|
||||
obb(vr4 / "obb", "main.203.com.Armature.VR4.obb")
|
||||
obb(vr4 / "obb", "patch.203.com.Armature.VR4.obb")
|
||||
apk(vr4 / "obb" / "VR4-Android-Shipping-arm64.apk")
|
||||
IDS["VR4-Android-Shipping-arm64.apk"] = (stray_pkg, 1)
|
||||
dungeon = root / "de.erthu.ancientdungeonfull" / "2026-10-06T22-31-00-000Z"
|
||||
apk(dungeon / "apk" / "de.erthu.ancientdungeonfull.apk") # no obb folder at all
|
||||
found = {g.apk.name: g for g in quest_dump.scan(root)}
|
||||
assert set(found) == {"com.pixeltoys.cabin.apk", "com.Armature.VR4.apk", "de.erthu.ancientdungeonfull.apk"}
|
||||
assert found["com.pixeltoys.cabin.apk"].data_dir == cabin / "obb"
|
||||
assert found["com.pixeltoys.cabin.apk"].name == "com.pixeltoys.cabin" # not the backup's time stamp
|
||||
v = found["com.Armature.VR4.apk"]
|
||||
assert v.data_dir == vr4 / "obb"
|
||||
assert sorted(v.data_files or [p.name for p in (vr4 / "obb").glob("*.obb")]) == [
|
||||
"main.203.com.Armature.VR4.obb", "patch.203.com.Armature.VR4.obb"]
|
||||
assert found["de.erthu.ancientdungeonfull.apk"].data_dir is None
|
||||
|
||||
|
||||
def test_axrb_download_folder(tmp_path):
|
||||
"""AXRB's launcher downloads to <Downloads>/AXRB/<app id>/<binary id>/: base.apk + the OBBs by their store names
|
||||
(+ other asset files); its own patched builds go to AXRB/patched/<package>/<name>-axrb.apk (reddit, 2026-10-11)."""
|
||||
root = tmp_path / "AXRB"
|
||||
d = root / "1234567890" / "987654321"
|
||||
a = apk(d / "base.apk")
|
||||
IDS["base.apk"] = ("com.Armature.VR4", 203)
|
||||
obb(d, "main.203.com.Armature.VR4.obb")
|
||||
obb(d, "patch.203.com.Armature.VR4.obb")
|
||||
(d / "extra_asset.dat").write_bytes(b"a")
|
||||
p = apk(root / "patched" / "com.Armature.VR4" / "base-axrb.apk")
|
||||
IDS["base-axrb.apk"] = ("com.Armature.VR4", 203)
|
||||
found = quest_dump.scan(root)
|
||||
games = {g.apk: g for g in found}
|
||||
# the OBBs and AXRB's other store files (DLC, assets) go to Android/obb/<package>; the APK stays
|
||||
assert games[a].data_dir == d and games[a].data_files == ["extra_asset.dat", "main.203.com.Armature.VR4.obb",
|
||||
"patch.203.com.Armature.VR4.obb"]
|
||||
assert p not in games and len(games) == 1 # AXRB's PC-patched copy isn't a second game
|
||||
assert games[a].name == "com.Armature.VR4" # not the binary id
|
||||
|
||||
|
||||
def test_rescan_replaces_an_entry_made_from_axrbs_patched_copy(tmp_path, monkeypatch):
|
||||
"""Older FramePort scanned AXRB/patched/<package>/*-axrb.apk (no OBBs) over the original download; a rescan of
|
||||
new games only must still add the original for that package."""
|
||||
from frameport import pipeline
|
||||
from frameport.core import library
|
||||
|
||||
root = tmp_path / "AXRB"
|
||||
d = root / "1" / "2"
|
||||
apk(d / "base.apk")
|
||||
IDS["base.apk"] = ("com.x.game", 5)
|
||||
obb(d, "main.5.com.x.game.obb")
|
||||
old = tmp_path / "AXRB" / "patched" / "com.x.game" / "base-axrb.apk"
|
||||
games = [{"package": "com.x.game", "apk": str(old)}, {"package": "com.y.other", "apk": str(tmp_path / "y.apk")}]
|
||||
monkeypatch.setattr(library, "games", lambda: games)
|
||||
monkeypatch.setattr(quest_dump, "_package_of", lambda a: quest_dump._apk_ids(a)[0])
|
||||
added = []
|
||||
monkeypatch.setattr(pipeline, "add_game", lambda src, rep=None: added.append(src) or {"package": "com.x.game"})
|
||||
monkeypatch.setattr(pipeline.rift_dump, "scan", lambda *a, **k: [])
|
||||
pipeline.add_path(root, only_new=True)
|
||||
assert [s.apk.name for s in added] == ["base.apk"] and added[0].data_files == ["main.5.com.x.game.obb"]
|
||||
|
||||
|
||||
def test_install_looks_for_data_copied_in_after_the_apk_was_added(tmp_path, monkeypatch):
|
||||
"""GitHub #156: the APK was added alone; the OBB was copied next to it later. Installing finds and sends it."""
|
||||
from frameport import pipeline
|
||||
from frameport.core import library
|
||||
|
||||
a = apk(tmp_path / "BnS" / "BladeAndSorcery.apk")
|
||||
IDS[a.name] = ("com.Warpfrog.BladeAndSorcery", 260730005)
|
||||
obb(tmp_path / "BnS", "main.260730005.com.Warpfrog.BladeAndSorcery.obb")
|
||||
stored = {}
|
||||
monkeypatch.setattr(library, "upsert_game", lambda pkg, **kw: stored.update(kw) or {"package": pkg, **kw})
|
||||
out = pipeline._find_late_data({"package": "com.Warpfrog.BladeAndSorcery", "apk": str(a), "data_dir": None})
|
||||
assert out["data_dir"] == str(tmp_path / "BnS") and stored["data_files"] == [
|
||||
"main.260730005.com.Warpfrog.BladeAndSorcery.obb"]
|
||||
assert pipeline._find_late_data({"package": "x", "apk": str(tmp_path / "gone.apk")})["apk"].endswith("gone.apk")
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Rename… (a tester's request: Quest games were named like their APK): the library title is locked against every
|
||||
automatic source of names, and installed copies get the new name in Steam without a new shortcut appid."""
|
||||
import json
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.core import library
|
||||
from frameport.core.titles import display_title, twins
|
||||
from frameport.targets.base import PC_LABEL
|
||||
|
||||
|
||||
def _apk(tmp_path, manifest, name="game.apk"):
|
||||
path = tmp_path / name
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest)
|
||||
return path
|
||||
|
||||
|
||||
class Rep:
|
||||
def __init__(self):
|
||||
self.checks = []
|
||||
|
||||
def stage(self, *a):
|
||||
pass
|
||||
|
||||
def log(self, *a):
|
||||
pass
|
||||
|
||||
def progress(self, *a):
|
||||
pass
|
||||
|
||||
def check(self, name, ok, msg=""):
|
||||
self.checks.append((name, ok, msg))
|
||||
|
||||
|
||||
def test_rename_locks_the_title_and_reset_unlocks_it():
|
||||
library.upsert_game("com.v.two", title="VaderImmortal2", kind="quest")
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.rename_game("com.not.there", "X")
|
||||
g = pipeline.rename_game("com.v.two", " Vader Immortal:\n Episode II ")
|
||||
assert g["title"] == "Vader Immortal: Episode II" and g["title_locked"] and g["auto_title"] == "VaderImmortal2"
|
||||
assert pipeline.automatic_title(g) == "VaderImmortal2"
|
||||
assert pipeline.title_suggestions(g) == ["VaderImmortal2"]
|
||||
g = pipeline.rename_game("com.v.two", "Vader 2") # a second rename keeps the automatic name
|
||||
assert g["auto_title"] == "VaderImmortal2"
|
||||
g = pipeline.rename_game("com.v.two", "VaderImmortal2") # choosing the automatic name = reset
|
||||
assert g["title"] == "VaderImmortal2" and "title_locked" not in g and "auto_title" not in g
|
||||
pipeline.rename_game("com.v.two", "Mine")
|
||||
g = pipeline.rename_game("com.v.two", None) # --reset
|
||||
assert g["title"] == "VaderImmortal2" and not g.get("title_locked")
|
||||
assert len(pipeline.rename_game("com.v.two", "x" * 500)["title"]) == pipeline.TITLE_MAX
|
||||
|
||||
|
||||
def test_store_name_is_offered(tmp_path):
|
||||
from frameport.artwork.fetch import artwork_dir
|
||||
|
||||
library.upsert_game("com.v.two", title="VaderImmortal2")
|
||||
(artwork_dir("com.v.two") / "title.txt").write_text("Vader Immortal: Episode II", encoding="utf-8")
|
||||
g = library.game("com.v.two")
|
||||
assert pipeline.store_title(g) == "Vader Immortal: Episode II"
|
||||
assert pipeline.title_suggestions(g) == ["Vader Immortal: Episode II"] # (the current name is the automatic one)
|
||||
g = pipeline.rename_game("com.v.two", "Vader Immortal: Episode II")
|
||||
assert g["title_locked"] and pipeline.title_suggestions(g) == ["VaderImmortal2"]
|
||||
|
||||
|
||||
def test_rescan_keeps_the_users_name(tmp_path, quest_manifest):
|
||||
apk = _apk(tmp_path, quest_manifest)
|
||||
[g] = pipeline.add_path(apk)
|
||||
pkg, auto = g["package"], g["title"]
|
||||
pipeline.rename_game(pkg, "My name")
|
||||
[g] = pipeline.add_path(apk) # scanning the folder again (a newer APK) used to replace the title
|
||||
assert g["title"] == "My name" and g["title_locked"] and g["auto_title"] == auto
|
||||
pipeline.reanalyze(pkg)
|
||||
assert library.game(pkg)["title"] == "My name"
|
||||
|
||||
|
||||
def test_rift_store_match_doesnt_overwrite_a_locked_name(monkeypatch, tmp_path):
|
||||
from frameport.artwork import sources
|
||||
|
||||
monkeypatch.setattr(sources, "fetch_rift", lambda *a, **k: {"source": "oculusdb", "title": "Store Name"})
|
||||
monkeypatch.setattr("frameport.artwork.thumbs.prewarm", lambda pkg: None)
|
||||
entry = library.upsert_game("rift.x", kind="rift", title="mine", title_locked=True, auto_title="x",
|
||||
game_dir=str(tmp_path), exe="x.exe", analysis={"extra": {}})
|
||||
pipeline._rift_art(entry)
|
||||
g = library.game("rift.x")
|
||||
assert g["title"] == "mine" and g["auto_title"] == "Store Name"
|
||||
entry = library.upsert_game("rift.y", kind="rift", title="y", game_dir=str(tmp_path), exe="y.exe",
|
||||
analysis={"extra": {}})
|
||||
pipeline._rift_art(entry)
|
||||
assert library.game("rift.y")["title"] == "Store Name" # not renamed: the store's name as before
|
||||
|
||||
|
||||
def test_a_chosen_name_has_no_twin_suffix():
|
||||
games = [{"package": "com.rr", "title": "Robo Recall"},
|
||||
{"package": "rift.rr", "kind": "rift", "title": "Robo Recall", "quest_package": "com.rr"}]
|
||||
tw = twins(games)
|
||||
assert display_title(games[0], tw) == "Robo Recall (Quest)"
|
||||
games[0].update(title="Robo Recall Unplugged", title_locked=True)
|
||||
tw = twins(games)
|
||||
assert display_title(games[0], tw) == "Robo Recall Unplugged"
|
||||
assert display_title(games[1], tw) == "Robo Recall (Rift)" # still linked by quest_package
|
||||
|
||||
|
||||
def test_frame_name_stale_until_synced_or_installed():
|
||||
library.upsert_game("com.a", title="A", installs={"steamos@frame": {"time": 1}})
|
||||
library.upsert_game("rift.b", kind="rift", title="B", installs={PC_LABEL: {"time": 1}})
|
||||
assert pipeline.rename_game("com.a", "A2")["steam_name_stale"]
|
||||
assert not pipeline.rename_game("rift.b", "B2").get("steam_name_stale") # only on this PC: synced right away
|
||||
pipeline._record_install("com.a", PC_LABEL, {"time": 2})
|
||||
assert library.game("com.a")["steam_name_stale"]
|
||||
pipeline._record_install("com.a", "steamos@frame", {"time": 2}) # the install named the shortcut
|
||||
assert "steam_name_stale" not in library.game("com.a")
|
||||
|
||||
|
||||
def test_sync_title_frame_and_pc():
|
||||
library.upsert_game("com.a", title="A", title_locked=True, steam_name_stale=True, steam_art_stale=True)
|
||||
calls = []
|
||||
frame = SimpleNamespace(kind="frame", label="steamos@frame",
|
||||
update_steam_art=lambda pkg, rep, title=None: calls.append(("frame", pkg, title)))
|
||||
pc = SimpleNamespace(kind="pc", label=PC_LABEL, rename=lambda pkg, title, rep: calls.append(("pc", pkg, title)))
|
||||
pipeline.sync_title("com.a", frame, Rep())
|
||||
pipeline.sync_title("com.a", pc, Rep())
|
||||
assert calls == [("frame", "com.a", "A"), ("pc", "com.a", "A")]
|
||||
g = library.game("com.a")
|
||||
assert "steam_name_stale" not in g and "steam_art_stale" not in g
|
||||
|
||||
|
||||
def test_update_steam_art_renames_on_the_frame_first(monkeypatch):
|
||||
from frameport.install import installer
|
||||
|
||||
calls = []
|
||||
|
||||
class Frame:
|
||||
def agent(self, cmd, **kw):
|
||||
calls.append((cmd, kw))
|
||||
if cmd == "list_installed":
|
||||
return {"games": [{"package": "com.a", "title": "Old", "anchor": "/a"}]}
|
||||
if cmd == "rename":
|
||||
return {"renamed": True, "title": kw["title"], "appid": 123}
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("frameport.artwork.steam.steam_set_for", lambda pkg: {})
|
||||
monkeypatch.setattr(installer, "add_to_steam", lambda frame, pkgs, rep: calls.append(("shortcuts", pkgs)))
|
||||
installer.update_steam_art(Frame(), "com.a", Rep(), title="New")
|
||||
assert calls[1] == ("rename", {"package": "com.a", "title": "New", "shortcuts": False})
|
||||
assert calls[-1] == ("shortcuts", ["com.a"]) # one Steam restart, after the rename
|
||||
calls.clear()
|
||||
with pytest.raises(RuntimeError, match="no artwork"): # same name and no art: nothing to send
|
||||
installer.update_steam_art(Frame(), "com.a", Rep(), title="Old")
|
||||
assert [c[0] for c in calls] == ["list_installed"]
|
||||
|
||||
|
||||
def test_pc_rename_keeps_the_shortcut_appid(monkeypatch):
|
||||
from frameport.targets import pc_revive
|
||||
|
||||
d = pc_revive.pc_dir() / "rift.x"
|
||||
d.mkdir(parents=True)
|
||||
(d / "deployment.json").write_text(json.dumps({"package": "rift.x", "title": "Old", "appid": 77,
|
||||
"exe_win": "C:\\G\\x.exe"}))
|
||||
t = object.__new__(pc_revive.PcReviveTarget)
|
||||
added = []
|
||||
monkeypatch.setattr(t, "add_to_library", lambda pkgs, rep: (added.append(pkgs), {"state": "done"})[1])
|
||||
t.rename("rift.x", "New", Rep())
|
||||
dep = json.loads((d / "deployment.json").read_text())
|
||||
assert dep["title"] == "New" and dep["appid"] == 77 and added == [["rift.x"]]
|
||||
assert t.rename("rift.x", "New", Rep())["unchanged"] and len(added) == 1
|
||||
|
||||
|
||||
def test_cli_rename_and_reset(monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport.cli import app
|
||||
|
||||
library.upsert_game("com.v.two", title="VaderImmortal2")
|
||||
r = CliRunner().invoke(app, ["rename", "com.v.two", "Vader Immortal: Episode II"])
|
||||
assert r.exit_code == 0, r.output
|
||||
assert "VaderImmortal2 -> Vader Immortal: Episode II" in r.output
|
||||
assert library.game("com.v.two")["title_locked"]
|
||||
r = CliRunner().invoke(app, ["rename", "com.v.two", "--reset"])
|
||||
assert r.exit_code == 0 and "(automatic name)" in r.output
|
||||
assert library.game("com.v.two")["title"] == "VaderImmortal2"
|
||||
assert CliRunner().invoke(app, ["rename", "com.v.two"]).exit_code == 2 # neither a name nor --reset
|
||||
# installed on a Frame: synced through the target, unless --no-sync
|
||||
library.upsert_game("com.v.two", installs={"steamos@frame": {"time": 1}})
|
||||
synced = []
|
||||
monkeypatch.setattr("frameport.cli._target", lambda frame, password=None, to="frame": to)
|
||||
monkeypatch.setattr(pipeline, "sync_title", lambda pkg, target, rep: synced.append((pkg, target)))
|
||||
r = CliRunner().invoke(app, ["rename", "Vader", "V2", "--no-sync"])
|
||||
assert r.exit_code == 0 and not synced and "keeps the old name" in r.output
|
||||
r = CliRunner().invoke(app, ["rename", "com.v.two", "V3"])
|
||||
assert r.exit_code == 0 and synced == [("com.v.two", "frame")]
|
||||
|
||||
|
||||
def test_rename_dialog_wiring(monkeypatch):
|
||||
"""The dialog: prefilled, Enter/Save store the typed name, a suggestion stores that one, and the installed copy on
|
||||
the connected Frame is renamed by a job."""
|
||||
from frameport.ui.app import FramePortApp
|
||||
|
||||
library.upsert_game("com.v.two", title="VaderImmortal2")
|
||||
dialogs, toasts, synced = [], [], []
|
||||
app = object.__new__(FramePortApp)
|
||||
app.page = SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None)
|
||||
app.toast = lambda msg, **k: toasts.append(msg)
|
||||
app.refresh_view = lambda: None
|
||||
app._title = lambda p: library.game(p)["title"]
|
||||
app.frame_state, app.frame_info = "connected", {"installed": [{"package": "com.v.two", "sha256": "a"}]}
|
||||
app.pc_installs = lambda: {}
|
||||
app.sync_title = lambda pkg, to="frame": synced.append((pkg, to))
|
||||
app.rename_game("com.v.two")
|
||||
field = dialogs[0].content.content.controls[0]
|
||||
assert field.value == "VaderImmortal2"
|
||||
field.value = "Vader Immortal: Episode II"
|
||||
save = dialogs[0].actions[-1]
|
||||
save.on_click(None)
|
||||
field.on_submit(None) # Enter right after the click: acts once
|
||||
assert library.game("com.v.two")["title"] == "Vader Immortal: Episode II"
|
||||
assert synced == [("com.v.two", "frame")] and "Steam restarts once" in toasts[-1]
|
||||
app.rename_game("com.v.two") # the automatic name is offered back
|
||||
suggestion = dialogs[1].content.content.controls[2]
|
||||
assert suggestion.content == "VaderImmortal2" or getattr(suggestion, "text", None) == "VaderImmortal2"
|
||||
suggestion.on_click(None)
|
||||
g = library.game("com.v.two")
|
||||
assert g["title"] == "VaderImmortal2" and not g.get("title_locked")
|
||||
app.rename_game("com.v.two")
|
||||
dialogs[2].actions[-1].on_click(None) # unchanged: nothing to sync
|
||||
assert len(synced) == 2
|
||||
@@ -355,3 +355,20 @@ def test_cards_escape_text():
|
||||
assert "Fast <b>" in html and "A & B" in html
|
||||
card = postprod.card_html("FramePort", "line", "footer", "<svg/>", colors)
|
||||
assert ">Frame</span>" in card and ">Port</span>" in card and "<svg/>" in card
|
||||
|
||||
|
||||
|
||||
def test_fake_pairing_server_has_what_the_gui_uses():
|
||||
"""The showcase's pretend setup server must offer every PairingServer attribute the GUI uses (the install video
|
||||
broke when open_asks() was added to the real one)."""
|
||||
from showcase import fakes
|
||||
|
||||
from frameport.frame.pairing import PairingServer
|
||||
|
||||
real = {n for n in dir(PairingServer) if not n.startswith("_")}
|
||||
used = set()
|
||||
for f in (REPO / "src/frameport/ui").rglob("*.py"): # any `.name` of a PairingServer method in the GUI
|
||||
used |= set(re.findall(r"\.([a-z_]+)\b", f.read_text(encoding="utf-8"))) & real
|
||||
fake = fakes.FakePairingServer()
|
||||
missing = sorted(n for n in used & real if not hasattr(fake, n))
|
||||
assert not missing, missing
|
||||
@@ -0,0 +1,126 @@
|
||||
"""frame.unreal_skip_obb_check (GitHub #159, Contractors): Unreal's DownloaderActivity CRC-checks the OBB when the
|
||||
manifest says bVerifyOBBOnStartUp=true; its screens are invisible in Lepton, so the game seems to hang."""
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
|
||||
from conftest import build_axml
|
||||
|
||||
from frameport.analysis.detect import analyze, unreal_verify_obb
|
||||
from frameport.apk import axml
|
||||
from frameport.apk.workspace import ApkWorkspace
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.core.models import Analysis
|
||||
from frameport.patches import base
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
PKG = "com.CaveManStudio.ContractorsVR"
|
||||
VERIFY = "com.epicgames.ue4.GameActivity.bVerifyOBBOnStartUp"
|
||||
|
||||
|
||||
def _manifest(verify=True, kind="bool"):
|
||||
return build_axml([
|
||||
("start", "manifest", [("package", "str", PKG)]),
|
||||
("start", "application", []),
|
||||
("start", "meta-data", [("name", "str", "com.epicgames.ue4.GameActivity.bHasOBBFiles"),
|
||||
("value", "bool", True)]),
|
||||
("end", "meta-data"),
|
||||
("start", "meta-data", [("name", "str", VERIFY), ("value", kind, verify if kind == "bool" else str(verify))]),
|
||||
("end", "meta-data"),
|
||||
("start", "meta-data", [("name", "str", "com.epicgames.ue4.GameActivity.bShouldHideUI"),
|
||||
("value", "bool", True)]),
|
||||
("end", "meta-data"),
|
||||
("end", "application"),
|
||||
("end", "manifest"),
|
||||
])
|
||||
|
||||
|
||||
def _apk(tmp_path, manifest):
|
||||
p = tmp_path / "game.apk"
|
||||
with zipfile.ZipFile(p, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", manifest)
|
||||
z.writestr("classes.dex", b"dex\n035\0")
|
||||
return p
|
||||
|
||||
|
||||
def _analysis(**extra):
|
||||
return Analysis(package=PKG, version="3.9", label="Contractors", abis=["arm64-v8a"], engine="Unreal", xr="OpenXR",
|
||||
graphics="GLES", direct_vrapi=False, libs=["libUE4.so", "libOVRPlugin.so"],
|
||||
launcher_activity=None, has_info_category=False, meta_permissions=[], uses_glad_gl=False,
|
||||
unity_msaa_levels=0, oculus_os_classes=False, is_overport_output=False, debuggable=False,
|
||||
extra={"size": 1, **extra})
|
||||
|
||||
|
||||
def test_set_meta_data_bool_changes_only_that_value():
|
||||
m = _manifest()
|
||||
out = axml.set_meta_data_bool(m, ".GameActivity.bVerifyOBBOnStartUp", False)
|
||||
assert out is not None and len(out) == len(m)
|
||||
assert sum(a != b for a, b in zip(m, out, strict=True)) == 4 # one 32-bit boolean
|
||||
meta = axml.meta_data(out)
|
||||
assert meta[VERIFY] is False
|
||||
assert meta["com.epicgames.ue4.GameActivity.bHasOBBFiles"] is True # the others stay
|
||||
assert meta["com.epicgames.ue4.GameActivity.bShouldHideUI"] is True
|
||||
assert axml.set_meta_data_bool(out, ".GameActivity.bVerifyOBBOnStartUp", False) is None # already off
|
||||
assert axml.set_meta_data_bool(out, ".GameActivity.bVerifyOBBOnStartUp", True) == m # round trip
|
||||
|
||||
|
||||
def test_set_meta_data_bool_leaves_string_values():
|
||||
"""Bundle.getBoolean reads a string "true" as false: nothing to change."""
|
||||
assert axml.set_meta_data_bool(_manifest(kind="str"), ".GameActivity.bVerifyOBBOnStartUp", False) is None
|
||||
|
||||
|
||||
def test_analysis_flag(tmp_path):
|
||||
assert unreal_verify_obb({VERIFY: True})
|
||||
assert unreal_verify_obb({"com.epicgames.unreal.GameActivity.bVerifyOBBOnStartUp": True}) # UE5's name
|
||||
assert not unreal_verify_obb({VERIFY: False})
|
||||
assert not unreal_verify_obb({VERIFY: "true"}) # not a boolean: Unreal reads false
|
||||
assert not unreal_verify_obb({"com.epicgames.ue4.GameActivity.bHasOBBFiles": True})
|
||||
assert analyze(_apk(tmp_path, _manifest())).extra["unreal_verify_obb"] is True
|
||||
other = tmp_path / "off"
|
||||
other.mkdir()
|
||||
assert analyze(_apk(other, _manifest(verify=False))).extra["unreal_verify_obb"] is False
|
||||
|
||||
|
||||
def test_patch_detects_applies_and_validates(tmp_path):
|
||||
base.load_all()
|
||||
patch = base.get("frame.unreal_skip_obb_check")
|
||||
assert patch.detect(_analysis(unreal_verify_obb=True)).recommended
|
||||
assert patch.detect(_analysis()) is None and not patch.applies(_analysis())
|
||||
with ApkWorkspace(_apk(tmp_path, _manifest())) as ws:
|
||||
ctx = base.ApkContext(ws, _analysis(unreal_verify_obb=True), {}, Reporter(), {patch.id: {}})
|
||||
assert patch.validate(ctx)[0][1] is False
|
||||
assert patch.apply(ctx)
|
||||
assert patch.validate(ctx) == [("OBB check at start off", True, "bVerifyOBBOnStartUp=false")]
|
||||
assert axml.meta_data(ws.read("AndroidManifest.xml"))[VERIFY] is False
|
||||
assert not patch.apply(ctx) # nothing left to change
|
||||
|
||||
|
||||
# the launch log of GitHub #159 (Lepton's logcat mirror; system_server lines have another pid than the game)
|
||||
STUCK = f"""Boot complete!
|
||||
10-09 18:02:11.120 1201 1230 I ActivityManager: Start proc 2345:{PKG}/u0a120 for pre-top-activity
|
||||
10-09 18:02:12.004 2345 2345 D UE4 : [GameActivity] Target SDK is 32. This may cause issues if below 27.
|
||||
10-09 18:02:12.410 1201 1802 I ActivityTaskManager: START u0 {{flg=0x10000 cmp={PKG}/.DownloaderActivity}}
|
||||
10-09 18:02:12.900 1201 1240 I ActivityTaskManager: Displayed {PKG}/.DownloaderActivity: +394ms
|
||||
"""
|
||||
|
||||
|
||||
def test_triage_flags_the_stuck_downloader():
|
||||
res = triage(STUCK, "RUNNING", PKG)
|
||||
f = next(f for f in res.findings if f.id == "unreal-obb-check-stuck")
|
||||
assert f.severity == "fatal" and f.suggest == ["frame.unreal_skip_obb_check"]
|
||||
assert "DownloaderActivity" in f.evidence
|
||||
assert res.verdict == "fail" # used to pass with only "Lepton container started"
|
||||
|
||||
|
||||
def test_triage_ignores_a_downloader_that_returned():
|
||||
later = STUCK + ("10-09 18:02:14.000 2345 2380 I FrameBridge: xrCreateInstance result=0\n"
|
||||
"10-09 18:02:20.000 2345 2380 I FrameBridge: pacing: 72.0 fps\n")
|
||||
assert not any(f.id == "unreal-obb-check-stuck" for f in triage(later, "RUNNING", PKG).findings)
|
||||
crashed = STUCK + "10-09 18:02:14.000 2345 2380 F libc : Fatal signal 11 (SIGSEGV), code 1\n"
|
||||
ids = [f.id for f in triage(crashed, "EXITED", PKG).findings]
|
||||
assert "unreal-obb-check-stuck" not in ids and "native-crash" in ids
|
||||
|
||||
|
||||
def test_triage_keeps_only_the_games_own_activity_lines():
|
||||
other = STUCK.replace(f"{PKG}/.DownloaderActivity", "com.other.app/.DownloaderActivity")
|
||||
assert not any(f.id == "unreal-obb-check-stuck" for f in triage(other, "RUNNING", PKG).findings)
|
||||
@@ -19,10 +19,12 @@ def _uleb(n: int) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_dex(methods: list[tuple[str, str, str, list[int] | None]]) -> bytes:
|
||||
def build_dex(methods: list[tuple[str, str, str, list[int] | None]], strings_first: tuple[str, ...] = (),
|
||||
types_first: tuple[str, ...] = ()) -> bytes:
|
||||
"""A minimal dex: methods = (class, name, return type, code units or None for a referenced-only method). Every
|
||||
method with code belongs to one class_def per class (virtual methods)."""
|
||||
strings: list[str] = []
|
||||
method with code belongs to one class_def per class (virtual methods). strings_first / types_first get the first
|
||||
string / type indices, in that order (for const-string / check-cast operands)."""
|
||||
strings: list[str] = list(strings_first)
|
||||
|
||||
def s(x: str) -> int:
|
||||
if x not in strings:
|
||||
@@ -37,6 +39,9 @@ def build_dex(methods: list[tuple[str, str, str, list[int] | None]]) -> bytes:
|
||||
types.append(i)
|
||||
return types.index(i)
|
||||
|
||||
for x in types_first:
|
||||
t(x)
|
||||
|
||||
protos: list[tuple[int, int]] = []
|
||||
|
||||
def p(ret: str) -> int:
|
||||
|
||||
+133
-1
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from test_drives import linux_install, load
|
||||
@@ -57,9 +58,33 @@ class FakeApps:
|
||||
def fake_steamvr(monkeypatch, a, running=True):
|
||||
apps = FakeApps()
|
||||
monkeypatch.setattr(a.SteamVRApps, "open", classmethod(lambda cls: apps if running else None))
|
||||
fake_systemctl(monkeypatch, a)
|
||||
return apps
|
||||
|
||||
|
||||
class Systemctl(list):
|
||||
"""`systemctl --user` calls recorded (never the host's own user manager); enable --now / restart make the unit
|
||||
active, disable --now inactive."""
|
||||
active = False
|
||||
|
||||
def __call__(self, *args):
|
||||
self.append(args)
|
||||
if args[0] in ("restart", "enable") and (args[0] == "restart" or "--now" in args):
|
||||
self.active = True
|
||||
if args[0] == "disable":
|
||||
self.active = False
|
||||
code = 0 if args[0] != "is-active" or self.active else 3
|
||||
return subprocess.CompletedProcess(args, code, "", "")
|
||||
|
||||
|
||||
def fake_systemctl(monkeypatch, a):
|
||||
calls = getattr(a, "_fake_systemctl", None)
|
||||
if calls is None:
|
||||
calls = a._fake_systemctl = Systemctl()
|
||||
monkeypatch.setattr(a, "user_systemctl", calls)
|
||||
return calls
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ detection
|
||||
def test_bundled_manifest_marks_an_overlay_app(tmp_path):
|
||||
(tmp_path / "app").mkdir()
|
||||
@@ -172,7 +197,8 @@ def test_overlay_registered_later_when_steamvr_was_off(monkeypatch, tmp_path):
|
||||
fake_steamvr(monkeypatch, a, running=False)
|
||||
_, res = linux_install(a, tmp_path, overlay=overlay_arg())
|
||||
assert res["overlay"] == {"key": "temporalreality.overlay", "manifest": res["overlay"]["manifest"],
|
||||
"registered": False, "autostart": False, "error": "steamvr"}
|
||||
"registered": False, "autostart": False, "error": "steamvr",
|
||||
"service": "installed"} # FramePort's own autostart doesn't need SteamVR now
|
||||
apps = fake_steamvr(monkeypatch, a)
|
||||
assert a.overlay_registrations() == ["linux.true"]
|
||||
assert apps.installed("temporalreality.overlay") and a.deployment("linux.true")["overlay"]["registered"]
|
||||
@@ -194,3 +220,109 @@ def test_pcvr_overlay_install(monkeypatch, tmp_path):
|
||||
man = json.loads((Path(prep["anchor"]) / a.OVERLAY_MANIFEST).read_text())["applications"][0]
|
||||
assert man["binary_path_linux_arm"].endswith("/rift.fpsvr/launch.sh")
|
||||
assert man["strings"]["en_us"]["name"] == "fpsVR"
|
||||
# autostart off: no autostart service
|
||||
assert not os.path.exists(a.VR_OVERLAY_UNIT_PATH) and res["overlay"]["service"] is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ autostart service (v76)
|
||||
def test_autostart_service_follows_the_overlay_flags(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
fake_steamvr(monkeypatch, a)
|
||||
calls = fake_systemctl(monkeypatch, a)
|
||||
unit = Path(a.VR_OVERLAY_UNIT_PATH)
|
||||
assert a.ensure_overlay_service() is None and not unit.exists() and calls == [] # no overlay apps: nothing
|
||||
_, res = linux_install(a, tmp_path, overlay=overlay_arg(autostart=True))
|
||||
assert res["overlay"]["service"] == "installed"
|
||||
text = unit.read_text()
|
||||
# systemd quoting: only a path with spaces (or %) gets quotes (this repo's path has spaces, CI's doesn't)
|
||||
assert f'{a.systemd_quote(os.path.abspath(a.__file__))} _vr_overlay_watch' in text
|
||||
assert a.systemd_quote("/usr/bin/python3") == "/usr/bin/python3" and a.systemd_quote("a%b c") == '"a%%b c"'
|
||||
assert "Restart=always" in text and "WantedBy=default.target" in text
|
||||
assert ("enable", "frameport-vr-overlays.service") in calls and calls.active
|
||||
assert a.ensure_overlay_service() is None # unchanged and running
|
||||
# the game page's switch off: the service goes (no other overlay app has autostart)
|
||||
out = a.cmd_register_vr_overlay({"package": "linux.true", "autostart": False})
|
||||
assert out["service"] == "removed" and not unit.exists() and not calls.active
|
||||
assert not any("overlay" in c for c in a.ensure_host_fixes()) # stays removed
|
||||
a.cmd_register_vr_overlay({"package": "linux.true", "autostart": True})
|
||||
assert unit.exists() and calls.active
|
||||
# kill switch
|
||||
Path(a.VR_OVERLAY_DISABLED).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(a.VR_OVERLAY_DISABLED).write_text("")
|
||||
assert "overlay autostart service removed" in a.ensure_host_fixes() and not unit.exists()
|
||||
Path(a.VR_OVERLAY_DISABLED).unlink()
|
||||
assert "overlay autostart service installed" in a.ensure_host_fixes()
|
||||
# uninstalling the last overlay app removes it
|
||||
a.cmd_uninstall({"package": "linux.true", "keep_data": False})
|
||||
assert not unit.exists() and not calls.active
|
||||
|
||||
|
||||
def test_new_steamvr_starts_autostart_overlays_once(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
apps = fake_steamvr(monkeypatch, a)
|
||||
linux_install(a, tmp_path, overlay=overlay_arg(autostart=True))
|
||||
monkeypatch.setattr(a, "VR_OVERLAY_GRACE", 0)
|
||||
monkeypatch.setattr(a, "run_overlay_round", a.overlay_autostart_round) # in-process (no child agent)
|
||||
running = []
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: list(running))
|
||||
vrserver = ["100:5"]
|
||||
monkeypatch.setattr(a, "vrserver_process", lambda known=None: vrserver[0])
|
||||
state = {}
|
||||
# a new vrserver (the watcher's first look counts too): the app isn't running → started once
|
||||
assert a.overlay_watch_tick(state) == {"linux.true": "started"}
|
||||
assert apps.launched == ["temporalreality.overlay"]
|
||||
assert a.overlay_watch_tick(state) is None and len(apps.launched) == 1 # same SteamVR: nothing more
|
||||
# a restarted watcher (agent update) remembers the SteamVR it handled
|
||||
assert json.load(open(a.VR_OVERLAY_STATE))["vrserver"] == "100:5"
|
||||
# SteamVR restarted, the app still (or already) runs: not started a second time
|
||||
vrserver[0], running[:] = "200:9", ["555"]
|
||||
assert a.overlay_watch_tick(state) == {"linux.true": "running"} and len(apps.launched) == 1
|
||||
# SteamVR knows its process (it started the app itself): skipped too
|
||||
running.clear()
|
||||
monkeypatch.setattr(apps, "pid", lambda key: 777)
|
||||
vrserver[0] = "250:1"
|
||||
assert a.overlay_watch_tick(state) == {"linux.true": "running"} and len(apps.launched) == 1
|
||||
monkeypatch.setattr(apps, "pid", lambda key: 4242 if key in apps.launched else 0)
|
||||
# autostart off: a new SteamVR starts nothing
|
||||
a.cmd_register_vr_overlay({"package": "linux.true", "autostart": False})
|
||||
vrserver[0] = "300:1"
|
||||
assert a.overlay_watch_tick(state) is None and len(apps.launched) == 1
|
||||
# no SteamVR: nothing
|
||||
a.cmd_register_vr_overlay({"package": "linux.true", "autostart": True})
|
||||
vrserver[0] = None
|
||||
assert a.overlay_watch_tick(state) is None
|
||||
# kill switch
|
||||
monkeypatch.setenv("FRAMEPORT_NO_OVERLAY_AUTOSTART", "1")
|
||||
vrserver[0] = "400:2"
|
||||
assert a.overlay_watch_tick(state) is None and len(apps.launched) == 1
|
||||
assert "linux.true: started" in open(a.VR_OVERLAY_LOG).read()
|
||||
|
||||
|
||||
def test_overlay_log_is_capped(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "VR_OVERLAY_LOG_MAX", 200)
|
||||
for i in range(40):
|
||||
a.overlay_log(f"line {i}")
|
||||
assert os.path.getsize(a.VR_OVERLAY_LOG) <= 240 and os.path.getsize(a.VR_OVERLAY_LOG + ".1") <= 240
|
||||
|
||||
|
||||
def test_vrserver_found_in_proc(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path)
|
||||
found = a.vrserver_process() # the test host has no SteamVR (or a real one: "<pid>:<start>")
|
||||
assert found is None or found.split(":")[0].isdigit()
|
||||
assert a.vrserver_ident(os.getpid()) is None # pytest isn't a vrserver
|
||||
vr = tmp_path / "vrserver"
|
||||
os.symlink("/bin/sleep", vr)
|
||||
p = subprocess.Popen([str(vr), "30"])
|
||||
try:
|
||||
for _ in range(50):
|
||||
ident = a.vrserver_ident(p.pid)
|
||||
if ident:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert ident and ident.startswith(f"{p.pid}:") and ident.split(":")[1].isdigit()
|
||||
assert a.vrserver_process(ident) == ident # the known one is checked first
|
||||
assert a.vrserver_process("1:0") is not None
|
||||
finally:
|
||||
p.kill()
|
||||
p.wait()
|
||||
Reference in new issue
Block a user