Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbd737f7a9 | ||
|
|
9e1ef3bfae | ||
|
|
940a2c15cb | ||
|
|
7daf14b795 | ||
|
|
26db4468ec | ||
|
|
cff8867f53 | ||
|
|
cb62dacd01 | ||
|
|
08f23f236c | ||
|
|
136656b142 | ||
|
|
82a14c3bcf | ||
|
|
11743e4b2c | ||
|
|
757671b389 | ||
|
|
8a2029c079 | ||
|
|
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
|
||||
@@ -463,6 +470,56 @@ maps a symlink to the already-loaded module) gets past it to **-3021 = ovrError_
|
||||
an Oculus-signed runtime. Next step (not done): make the runtime check pass without Detours, e.g. an injected helper
|
||||
that patches the game's import table (IAT) for LoadLibrary*/WinVerifyTrust, or a prefix-level override. This is
|
||||
runtime interop (what Revive does), not the Platform SDK licence check (which FramePort never touches).
|
||||
**Rift via x86_64 Wine under FEX (research, 2026-10-08, headless on the dev Frame, SteamOS 0.4.5; no product code):**
|
||||
why Revive failed is confirmed upstream: x64 Detours returns ERROR_NOT_SUPPORTED on ARM64EC targets (microsoft/Detours
|
||||
PR #388; only an ARM64EC build of Detours can hook them). A fully x86_64 Wine avoids it: GE-Proton11-7 **x86_64** runs
|
||||
under the FEX tool (`fex-compat-tool run -- <x86 binary>`; RootFS /usr/share/guestos/fex-mesa, no FreeType there)
|
||||
— prefix boot 35 s, kernelbase.dll is x86-64. Proton's script needs help without the x86_64 SLR (app 4183110, not
|
||||
installed): its Python runs natively (the RootFS has none), so `files/bin/wine{,server}` and the two direct
|
||||
`wine-preloader` argv calls must go through a FEX wrapper that also unsets `FEX_APP_CONFIG(_LOCATION)` (Proton sets
|
||||
them on aarch64 for its own ARM64EC FEX → "RootFS path set to ''"); GE's game drive needs STEAM_COMPAT_INSTALL_PATH
|
||||
unset. SteamVR now ships `bin/linux64/vrclient.so` + `bin/vrclient.so` (i386) + `vrclient_x64.dll` (Oct 6 build; not
|
||||
there on 09-30): Proton's x86_64 vrclient bridge reaches the arm64 vrserver. **Rick and Morty ran as the scene app**
|
||||
("OpenVR initialized!", controller tracking, 9176 presents / 6 dropped, CPU 11.6 / GPU 17.9 ms, target 72).
|
||||
**ReviveInjector: "Succesfully injected!"**, LibRevive64.dll loaded next to OVRPlugin.dll in Lies Beneath, which then
|
||||
died on 0xc06d007e = delay-loaded `libovrplatform64_1.dll` (Platform SDK) before connecting to SteamVR — so Revive's
|
||||
signature hook and Revive→SteamVR are still unproven. Every Rift game in the library except Rick and Morty uses the
|
||||
Platform SDK → the Meta (Link) app in the prefix, logged in with the user's own account, is the real blocker; the
|
||||
only public attempt (github.com/michauMiau/oculus-wine-linux, 2026-10) never got OculusSetup.exe (32-bit .NET)
|
||||
past its HTTPS config fetch. Scratch files on the Frame: `~/frameport-rifttest/` (scripts t1-t4.sh).
|
||||
Meta runtime in the same Wine (2026-10-09): Platform SDK games reach OVRServer's IPC on the Frame. The apparent
|
||||
intermittent handshake hang was **OVRLibrarian.exe** (.NET, Wine Mono): under FEX it finishes its work but never exits,
|
||||
its AppTracker entry stays, and the runtime then tracks no later client (Meta app, game), so the game waits forever
|
||||
for the reply event the runtime only sets after identifying it (found with wineserver `+server` traces PC vs Frame).
|
||||
Ending OVRLibrarian before the game → game tracked, "Missing entitlement" exactly as on the PC (needs a login).
|
||||
Logged in (2026-10-09, owner's own account, browser sign-in; the `oculus://` answer had to be forwarded into the Wine
|
||||
app's `\\.\pipe\oculus`), the runtime also needed a WinRT `Windows.Devices.WiFi.WiFiAdapter` stand-in (Air Link code
|
||||
fail-fasts without it), Wine's DeviceWatcher add/remove_Updated/Removed implemented, and the C: volume name
|
||||
(`\\?\Volume{...0043}`) linked in dosdevices. Copying the logged-in `sessions/` + CoreData to the Frame prefix kept the
|
||||
login. **Oculus First Contact then ran in VR on the Frame** (Revive in OpenVR mode; `/openxr` failed in wineopenxr):
|
||||
scene app at 72 Hz target, but 92 % of frames "timed out" (game CPU-bound under FEX) → stutter/flicker/lag.
|
||||
Performance (2026-10-10, headless with patched Revive visibility + SteamVR `pauseCompositorOnStandby=false`, numbers from
|
||||
`IVRCompositor::GetFrameTimings`): the frame loop waited on sync round trips through the FEX-run x86_64 wineserver.
|
||||
`native/fexwine/`: a **native aarch64 wineserver** built from GE's exact source (protocol 938) + **ntsync** (works once
|
||||
OVRLibrarian is ended) took hitches 17 → 0-4 per 20 s. The rest were Unreal's large-block allocator committing,
|
||||
filling (one `rep stos`) and freeing 514 MB every 1-8 s: ~130k 4 KB page faults each under FEX. `fp_mem.so`
|
||||
(`FP_BIGCACHE_MB=256`, guest preload via `FEX_ENV=LD_PRELOAD=…`: fex-compat-tool deletes LD_PRELOAD) keeps that block
|
||||
→ 0 hitches, steady 36 fps + motion smoothing, ~5.5 % timed out. 72 fps would need ~30 % less GPU (game ~55 % busy
|
||||
at 36 fps); THP (fragmentation), MSAA off, Turnip gmem don't help. Also needed: Unreal's
|
||||
`Slate.DeferWindowsMessageProcessing` = 0 (exe patch; the default deadlocks Wine's IME window ~1/3 of starts).
|
||||
Headset (owner, 2026-10-10): with motion smoothing off for the game (SteamVR per-app `motionSmoothingOverride=2`:
|
||||
smoothing cost ~15 % GPU) "much much better", 95 % of frames new at 72 Hz; MSAA off (DXVK `d3d11.disableMsaa`, -25 %
|
||||
GPU) helped but the owner misses smooth edges → the game's MSAA CVar is `r.MobileMSAA` (Oculus' clustered-forward
|
||||
branch; no r.MSAACount in the exe), 2x untested. Headless tests need the headset worn once per boot (else "IMU
|
||||
fallback" and the game waits at startup forever).
|
||||
**Experimental mode on branch `rift-on-frame` (the owner's single linkable branch for this work):** CLI only,
|
||||
`FRAMEPORT_EXPERIMENTAL_FEX=1 frameport fex setup | import-login <signed-in PC prefix> | install [oculus-first-contact]
|
||||
[--msaa N] | status` (hidden group). PC `src/frameport/fexrift.py` uploads `agent/fexrift.py` + `artifacts/fexrift`
|
||||
(sources native/fexwine) + Revive 3.2.0 with 6 byte patches (refused for other builds); the Frame helper downloads
|
||||
GE-Proton11-7 x86_64 (SHA-512), x86 GnuTLS (Arch archive), installs Meta's runtime (Meta's signature checks kept) and
|
||||
writes the game's launcher. Agent v79 `fex_prepare/status/setup/import_login/install`, kind `frame_fex` (uninstall
|
||||
keeps the shared prefix; launch tests refused). Everything under `~/.local/share/frameport/fexrift` (purge removes it,
|
||||
login tokens included). Not yet run end to end on the device; the login still comes from a PC prefix signed in by hand.
|
||||
Uninstall (Quest, saves kept) used to leave `deployment.json` → still "installed"; fixed (agent v16).
|
||||
|
||||
**Discovery/network:** Developer-Mode SteamOS devices announce `_steamos-devkit._tcp` (TXT `login=steamos`) — use it;
|
||||
@@ -622,7 +679,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 +772,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 +839,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 +856,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 +923,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`.
|
||||
|
||||
@@ -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 = 79
|
||||
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}}
|
||||
@@ -1088,7 +1102,7 @@ def shortcut_args(pkg):
|
||||
icon = app_icon_for(dep, anchor, steam=True)[0] # a Linux app's own icon unless the user chose one
|
||||
flat = dep.get("vr") is False
|
||||
kind = dep.get("kind")
|
||||
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if kind == "pcvr" else \
|
||||
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if kind in ("pcvr", "frame_fex") else \
|
||||
"Linux app on Frame" if kind == "linux" else "Quest on Frame"
|
||||
return f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag, dep.get("tags") or [], not flat
|
||||
|
||||
@@ -1817,7 +1831,7 @@ def app_media_dirs(ext):
|
||||
|
||||
def lepton_external(pkg):
|
||||
dep = deployment(pkg)
|
||||
if not dep or dep.get("kind") == "pcvr":
|
||||
if not dep or dep.get("kind") in ("pcvr", "frame_fex"):
|
||||
raise AgentError(f"{pkg} is not an installed Quest (Lepton) game")
|
||||
return os.path.join(dep["base"], "lepton-data", "external")
|
||||
|
||||
@@ -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):
|
||||
@@ -4572,8 +5076,11 @@ def cmd_uninstall(args):
|
||||
base = dep["base"]
|
||||
pcvr = dep.get("kind") == "pcvr"
|
||||
linux = dep.get("kind") == "linux"
|
||||
if (pcvr_pids(base) if pcvr or linux else container_running(dep["appid"])):
|
||||
fex = dep.get("kind") == "frame_fex"
|
||||
if (pcvr_pids(base) if pcvr or linux or fex else container_running(dep["appid"])):
|
||||
raise AgentError("the game is running")
|
||||
if fex: # the game lives in the shared Meta runtime prefix (with the user's login): only the launcher goes
|
||||
return fex_uninstall(pkg, dep, args)
|
||||
if is_overlay(dep) or os.path.exists(os.path.join(ANCHORS, pkg, OVERLAY_MANIFEST)):
|
||||
unregister_overlay(pkg) # SteamVR forgets the overlay app (before its manifest's folder goes)
|
||||
keep_data = args.get("keep_data", True)
|
||||
@@ -4607,6 +5114,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}
|
||||
|
||||
|
||||
@@ -4921,6 +5430,8 @@ def cmd_stop(args):
|
||||
run(["systemctl", "--user", "stop", f"frameport-test-{dep['appid']}"])
|
||||
if dep.get("kind") == "pcvr":
|
||||
stop_pcvr(dep)
|
||||
elif dep.get("kind") == "frame_fex": # the launcher's EXIT trap stops Wine (wineserver -k)
|
||||
run(["pkill", "-TERM", "-f", re.escape(os.path.join(ANCHORS, dep["package"], "launch.sh"))])
|
||||
elif dep.get("kind") == "linux":
|
||||
for pid in pcvr_pids(dep["base"]):
|
||||
run(["kill", "-TERM", pid])
|
||||
@@ -5033,6 +5544,8 @@ def cmd_launch_test(args):
|
||||
if is_overlay(dep): # nothing to see without a game; register_vr_overlay's SteamVR check is the test
|
||||
return {"state": "SKIPPED", "skipped": "overlay app", "elapsed": 0, "log": None, "log_size": 0,
|
||||
"crash_log": None, "kind": dep.get("kind")}
|
||||
if dep.get("kind") == "frame_fex":
|
||||
raise AgentError("launch tests aren't available for Rift games with Meta's runtime (experimental) yet")
|
||||
if dep.get("kind") == "pcvr":
|
||||
return launch_test_pcvr(dep, anchor, log, seconds)
|
||||
if dep.get("kind") == "linux":
|
||||
@@ -5560,6 +6073,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:
|
||||
@@ -6788,12 +7309,122 @@ def monitor_session(stdin, stdout, monitor=None, sleep=time.sleep, max_ticks=Non
|
||||
return 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Rift via FEX (experimental)
|
||||
# Oculus Rift games that need Meta's PC runtime, on the Frame alone: x86_64 GE-Proton under FEX + Meta's runtime +
|
||||
# Revive in one shared Wine prefix. The work is done by fexrift.py (uploaded with its binaries by the PC, see
|
||||
# src/frameport/fexrift.py and native/fexwine/README.md); these commands only start it and track games (kind
|
||||
# "frame_fex"; the game's files stay inside the prefix where Meta's app put them).
|
||||
FEX_ROOT = os.path.join(HOME, ".local/share/frameport/fexrift")
|
||||
FEX_ARTIFACTS = os.path.join(FEX_ROOT, "artifacts")
|
||||
FEX_HELPER = os.path.join(FEX_ARTIFACTS, "fexrift.py")
|
||||
|
||||
|
||||
def fex_helper(command, args=None, timeout=900):
|
||||
if not os.path.isfile(FEX_HELPER):
|
||||
raise AgentError("the files for Rift games on the Frame aren't here yet (run the setup first)")
|
||||
p = run([sys.executable, "-I", FEX_HELPER, command, json.dumps(args or {})], timeout=timeout)
|
||||
try:
|
||||
out = json.loads(p.stdout.strip().splitlines()[-1])
|
||||
except (IndexError, ValueError):
|
||||
raise AgentError(f"fexrift {command} failed: {(p.stderr or p.stdout)[-400:]}")
|
||||
if not out.get("ok"):
|
||||
raise AgentError(out.get("error") or f"fexrift {command} failed")
|
||||
return out["result"]
|
||||
|
||||
|
||||
def cmd_fex_prepare(args):
|
||||
"""Where the PC uploads fexrift.py + its binaries (artifacts/) and the login archive (incoming/), and what's
|
||||
already there (unchanged files aren't re-sent)."""
|
||||
incoming = os.path.join(FEX_ROOT, "incoming")
|
||||
for d in (FEX_ARTIFACTS, incoming):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
os.chmod(incoming, 0o700)
|
||||
st = os.statvfs(FEX_ROOT)
|
||||
return {"root": FEX_ROOT, "artifacts": FEX_ARTIFACTS, "incoming": incoming,
|
||||
"existing": tree_manifest(FEX_ARTIFACTS), "free_bytes": st.f_bavail * st.f_frsize,
|
||||
"fex": os.path.isdir(os.path.join(STEAM, "steamapps/common/FEX-Emu"))}
|
||||
|
||||
|
||||
def cmd_fex_status(args):
|
||||
if not os.path.isfile(FEX_HELPER):
|
||||
return {"helper": False}
|
||||
res = fex_helper("status", timeout=60)
|
||||
res["helper"] = True
|
||||
return res
|
||||
|
||||
|
||||
def cmd_fex_setup(args):
|
||||
"""Start the setup (GE-Proton download, Meta's runtime from Meta's CDN, the prefix): ~10-20 min, detached;
|
||||
progress via fex_status (setup.state / step / error)."""
|
||||
if not os.path.isfile(FEX_HELPER):
|
||||
raise AgentError("upload the files first (fex_prepare)")
|
||||
steps = [s for s in args.get("steps") or [] if re.fullmatch(r"[a-z]+", str(s))]
|
||||
unit = f"frameport-fexsetup-{int(time.time())}"
|
||||
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={unit}", "--setenv=HOME=" + HOME,
|
||||
sys.executable, "-I", FEX_HELPER, "setup", json.dumps({"steps": steps})])
|
||||
if p.returncode != 0:
|
||||
raise AgentError(f"couldn't start the setup: {p.stderr.strip()[-300:]}")
|
||||
return {"started": True, "unit": unit}
|
||||
|
||||
|
||||
def cmd_fex_import_login(args):
|
||||
"""The login archive the PC uploaded to incoming/login.tar (fexrift.py deletes it afterwards)."""
|
||||
return fex_helper("import_login", {"tar": os.path.join(FEX_ROOT, "incoming", "login.tar")})
|
||||
|
||||
|
||||
def cmd_fex_install(args):
|
||||
"""A game Meta's app downloaded (now in the prefix) as a Steam game: per-game fixes + launch.sh +
|
||||
deployment.json (kind frame_fex). The Steam shortcut is added like for other games (shortcuts)."""
|
||||
pkg = check_pkg(args["package"])
|
||||
app = str(args.get("app") or "")
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]+", app):
|
||||
raise AgentError(f"bad app name {app!r}")
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
dep = deployment(pkg)
|
||||
if dep and dep.get("kind") != "frame_fex":
|
||||
raise AgentError(f"{pkg} is already installed in another way")
|
||||
msaa = args.get("msaa")
|
||||
res = fex_helper("install", {"app": app, "anchor": anchor, "msaa": msaa if msaa in (1, 2, 4) else None})
|
||||
title = str(args.get("title") or res.get("title") or pkg).replace("\n", " ")
|
||||
appid = dep["appid"] if dep else shortcut_appid(f'"{anchor}/launch.sh"', title)
|
||||
dep = {"package": pkg, "kind": "frame_fex", "app": app, "appid": int(appid), "title": title,
|
||||
"base": res["game_dir"], "exe": res["exe"], "tags": args.get("tags") or [], "vr": True,
|
||||
"fixes": {k: res.get(k) for k in ("ime_patch", "msaa")},
|
||||
"agent_version": AGENT_VERSION, "installed": int(time.time())}
|
||||
path = os.path.join(anchor, "deployment.json")
|
||||
with open(path + ".tmp", "w") as f:
|
||||
json.dump(dep, f, indent=1)
|
||||
os.replace(path + ".tmp", path)
|
||||
return {"package": pkg, "appid": dep["appid"], "title": title, "launcher": res["launcher"], "fixes": dep["fixes"]}
|
||||
|
||||
|
||||
def fex_uninstall(pkg, dep, args):
|
||||
"""Only the launcher, artwork and shortcut go: the game stays in the shared prefix (Meta's app owns it)."""
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
removed_sc = False
|
||||
if args.get("remove_shortcut") and steam_users():
|
||||
removed_sc = cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"', "appid": dep.get("appid")}]})["started"]
|
||||
if args.get("remove_shortcut"):
|
||||
try:
|
||||
devkit_unregister(pkg, steam_running=run(["pgrep", "-x", "steam"]).returncode == 0)
|
||||
except Exception: # noqa: BLE001 (the files go anyway at the next purge)
|
||||
pass
|
||||
remove_tree(anchor)
|
||||
return {"removed": True, "kept_saves": True, "shortcut_removed": removed_sc}
|
||||
|
||||
|
||||
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 +7445,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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
3e9099b5015e8f486c00bcea9d111ee721faba355a89bcf1df69561e3dc6325c ./digicert-assured-id-root-ca.der
|
||||
e04db8044185c1c77941252e61794e84af7156c449fedcda4a826d7a9fc91989 ./dlls/crypt32.dll
|
||||
ec1387fad1f9b65503077e5aa24e3a3b60b3b04b85fd9412e0f2c451772f9b25 ./dlls/dnsapi.dll
|
||||
00ebfab0f3a066d9c85820332e0ec91cf4040797e580516dcafa048dfe758aa7 ./dlls/sechost.dll
|
||||
41584c41db41991f20a0b55011efa94618e0817ab351579f793ca01dbb488a0e ./dlls/windows.devices.enumeration.dll
|
||||
f59f409fba8f668e2316bfc3b636043fd471dd77e76944c2dfd2d4454587270b ./fp_mem.so
|
||||
13d455b700b08f40eb3e3452ab264bf535cb3d176c92ec9a3324a635ce4f8588 ./windows.devices.wifi.dll
|
||||
422f0cd556a6291d87611d7034fef716e3bc996c6f3b80b74a4dd0daca082b13 ./wineserver.arm64
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
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 |
|
After Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 690 KiB After Width: | Height: | Size: 6.8 MiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 288 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,36 @@
|
||||
# x86_64 Wine under FEX: Rift games with Meta's runtime on the Frame (experimental)
|
||||
|
||||
Sources of `artifacts/fexrift/` (prebuilt, with `SHA256SUMS`) for the experimental mode `frameport fex ...`
|
||||
(FRAMEPORT_EXPERIMENTAL_FEX=1; PC side `src/frameport/fexrift.py`, Frame side `agent/fexrift.py`). Research notes:
|
||||
CLAUDE.md, "Rift via x86_64 Wine under FEX". Rebuild order: `prep_ge_wine.sh` (GE's exact Wine source) ->
|
||||
`build_pe_dlls.sh` (x86_64 host, llvm-mingw) + `build_wineserver.sh` (aarch64 host, e.g. the Frame; then `strip`) +
|
||||
fp_mem.so (command below).
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `prep_ge_wine.sh` | Recreates the exact Wine source of a GE-Proton release (its Wine and wine-staging submodules plus GE's patches) by running only the WINE section of GE's `protonprep-valve-staging.sh`. |
|
||||
| `build_wineserver.sh` | Builds a **native aarch64 wineserver** from that tree on an aarch64 host (the Frame has gcc, make, flex and bison). It speaks the same protocol as GE's x86_64 server (GE-Proton11-7: protocol 938, 321 requests; plain Valve Wine at the same commit lacks 15 of GE's requests). `-DFP_EMULATED_X86_64` gives it the x86_64 machine model, because its clients are x86_64 processes under FEX. Use it by having the `wineserver` wrapper exec it instead of the FEX-run x86_64 server. |
|
||||
| `build_pe_dlls.sh` + `patches/` | The four Wine DLLs FramePort replaces in GE-Proton, built from GE's tree + only these patches: `crypt32-chain-newest-issuer` (Windows' newest-issuer rule between equal chains: Meta's OculusAppFramework signer check needs the 4-certificate chain), `sechost-realtime-trace-consumer` (OpenTraceW real-time sessions: the runtime's ETW USB tracing), `dnsapi-dnsservice-functions` (DnsService* from Wine 11.19: Air Link mDNS, OVRServer quits without them), `devenum-devicewatcher-handlers` (DeviceWatcher Updated/Removed handler lists). Exports equal GE's (+8 DnsService*). Also builds `windows.devices.wifi.dll` from `wifi_stub.c`: a Windows.Devices.WiFi.WiFiAdapter class (GetDeviceSelector matches nothing) that the logged-in runtime needs. |
|
||||
| `fp_mem.c` | x86_64 `LD_PRELOAD` for the guest. `FP_BIGCACHE_MB=<n>` keeps released anonymous regions of at least n MB (up to 12, 6 GB in total) and returns them when the same start address is mapped again; a shorter request releases the rest, a longer one maps only the extra part fresh. In the headset First Contact allocated 0.5-1.6 GB blocks several times at once, which the first, single-block version missed. `FP_THP=1` sets `MADV_HUGEPAGE` on large writable regions. Build it with `gcc -O2 -shared -fPIC -o fp_mem.so fp_mem.c -ldl -lpthread` on an x86_64 host (needs glibc 2.34). **Under FEX, pass it as `FEX_ENV=LD_PRELOAD=<path>`**: Valve's `fex-compat-tool` deletes `LD_PRELOAD`. |
|
||||
|
||||
## Measurements: Oculus First Contact, headless, dev Frame, 2026-10-10
|
||||
|
||||
SteamVR compositor frame timings were read through `IVRCompositor::GetFrameTimings`.
|
||||
|
||||
| Setup | Hitches per 20 s | Frames timed out | Game CPU per frame |
|
||||
|---|---|---|---|
|
||||
| FEX-run x86_64 wineserver, server sync | 17 | 40 % | 10.4 ms |
|
||||
| Native wineserver, server sync | 9 | 32 % | 10.4 ms |
|
||||
| Native wineserver + ntsync | 0-4 | 9-10 % | 6.2 ms |
|
||||
| Native wineserver + ntsync + `FP_BIGCACHE_MB=256` | **0** (3 of 3 runs) | 5.4-5.9 % | 5.3 ms |
|
||||
|
||||
Each remaining hitch (one app frame shown about 9 times) was Unreal's large-block allocator:
|
||||
1. It calls VirtualAlloc for 514 MB (First Contact's exe RVA 0x1a188e) every 1-8 s.
|
||||
2. A game task fills the block with a single `rep stos`/`rep movs` while the game thread waits for it.
|
||||
3. Another thread then frees the block.
|
||||
|
||||
Under FEX every 4 KiB page of it faults in and gets zeroed and charged by the kernel: about 130k faults and 110-140 ms per hitch.
|
||||
|
||||
Huge pages work in isolation (514 MB in 22 ms instead of 212 ms), but fail in practice. On a Frame that has been up for a while, memory is fragmented, so allocations fall back to 4 KiB pages (`thp_fault_fallback` far above `thp_fault_alloc`). The kernel's `defrag=madvise` setting also lets those attempts stall in compaction.
|
||||
|
||||
The block cache returns the region with stale contents instead of zeroed pages. That is only safe for games that overwrite the whole block, so it has to stay a per-game setting.
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Build the four patched Wine PE DLLs FramePort adds to GE-Proton for the Meta runtime (see README.md), from GE's
|
||||
# exact Wine source (prep_ge_wine.sh) + patches/*.patch, on an x86_64 Linux host with llvm-mingw on PATH.
|
||||
# Only these DLLs are built: generated headers they don't use (Vulkan thunks) are empty stand-ins, the syscall
|
||||
# tables come from Wine's own tools/make_specfiles.
|
||||
# usage: build_pe_dlls.sh <patched GE wine dir (prep_ge_wine.sh)> <work dir> <out dir>
|
||||
set -e
|
||||
SRC=$(cd "$1" && pwd); W=$2; OUT=$3
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
[ -n "$OUT" ] || { echo "usage: $0 <wine dir> <work dir> <out dir>"; exit 2; }
|
||||
command -v x86_64-w64-mingw32-clang >/dev/null || { echo "llvm-mingw (x86_64-w64-mingw32-clang) must be on PATH"; exit 2; }
|
||||
mkdir -p "$W" "$OUT"; W=$(cd "$W" && pwd); OUT=$(cd "$OUT" && pwd)
|
||||
if [ ! -d "$W/wine" ]; then
|
||||
cp -a "$SRC" "$W/wine"
|
||||
for p in "$HERE"/patches/*.patch; do patch -d "$W/wine" -p1 -s < "$p"; echo "applied $(basename "$p")"; done
|
||||
(cd "$W/wine" && perl tools/make_specfiles >/dev/null)
|
||||
for f in include/wine/vulkan.h dlls/winevulkan/loader_thunks.c dlls/winevulkan/loader_thunks.h \
|
||||
dlls/winevulkan/vulkan_thunks.c dlls/winevulkan/vulkan_thunks.h dlls/winevulkan/winevulkan.json; do
|
||||
[ -e "$W/wine/$f" ] || : > "$W/wine/$f"
|
||||
done
|
||||
fi
|
||||
mkdir -p "$W/obj"
|
||||
if [ ! -f "$W/obj/Makefile" ]; then
|
||||
# no debug info and relative source paths: the DLLs are committed (no local paths may end up in them)
|
||||
(cd "$W/obj" && CROSSCFLAGS="-O2 -ffile-prefix-map=$W/wine=wine -ffile-prefix-map=$W/obj=obj" \
|
||||
"$W/wine/configure" --enable-archs=x86_64 --disable-tests --without-x --without-freetype \
|
||||
--without-wayland --without-gstreamer --without-pulse --without-alsa --without-oss --without-cups --without-dbus \
|
||||
--without-gnutls --without-krb5 --without-netapi --without-opencl --without-pcap --without-sane --without-udev \
|
||||
--without-usb --without-v4l2 --without-vulkan --without-sdl --without-capi --without-gphoto --without-fontconfig \
|
||||
--without-coreaudio --without-opengl --without-inotify --without-unwind --without-ffmpeg > configure.log 2>&1)
|
||||
fi
|
||||
DLLS="crypt32 sechost dnsapi windows.devices.enumeration"
|
||||
cd "$W/obj"
|
||||
make -j"$(nproc)" $(for d in $DLLS; do echo "dlls/$d/x86_64-windows/$d.dll"; done) > make.log 2>&1 || { tail -20 make.log; exit 1; }
|
||||
for d in $DLLS; do cp "dlls/$d/x86_64-windows/$d.dll" "$OUT/"; done
|
||||
# Wi-Fi WinRT stand-in (not a Wine DLL: goes into the prefix's system32 + its activation class registry key)
|
||||
x86_64-w64-mingw32-clang -O2 -shared -s -Wl,--no-insert-timestamp -o "$OUT/windows.devices.wifi.dll" \
|
||||
"$HERE/wifi_stub.c" "$HERE/wifi_stub.def" -lole32 -lwindowsapp
|
||||
ls -la "$OUT"
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# Build a native aarch64 wineserver from a patched GE Wine tree (prep_ge_wine.sh), for x86_64 Wine under FEX.
|
||||
# Run it ON an aarch64 Linux host (e.g. the Frame: gcc, make, flex, bison, autoconf are in SteamOS).
|
||||
# - Wine's configure insists on a PE cross compiler for aarch64 and on generated headers this tree lacks; the server
|
||||
# needs neither, so configure only produces include/config.h here and the server sources are compiled directly.
|
||||
# - -DFP_EMULATED_X86_64: the server takes the x86_64 branch of init_supported_machines (native machine AMD64), as
|
||||
# the clients are x86_64 processes under FEX; otherwise it would advertise ARM64 and refuse/misplace x86_64 images.
|
||||
# The result talks the same protocol as GE's x86_64 wineserver (check: SERVER_PROTOCOL_VERSION and the request list).
|
||||
# usage: build_wineserver.sh <patched wine dir> <build dir> → <build dir>/ws/wineserver
|
||||
set -e
|
||||
SRC=$(cd "$1" && pwd); B=$2
|
||||
[ -n "$B" ] || { echo "usage: $0 <wine dir> <build dir>"; exit 2; }
|
||||
[ "$(uname -m)" = aarch64 ] || { echo "run this on aarch64"; exit 2; }
|
||||
mkdir -p "$B/obj" "$B/ws"; B=$(cd "$B" && pwd)
|
||||
grep -q FP_EMULATED_X86_64 "$SRC/server/registry.c" || \
|
||||
sed -i 's/^#elif defined(__x86_64__)$/#elif defined(__x86_64__) || (defined(__aarch64__) \&\& defined(FP_EMULATED_X86_64))/' "$SRC/server/registry.c"
|
||||
grep -q FP_EMULATED_X86_64 "$SRC/server/registry.c" || { echo "registry.c layout changed"; exit 1; }
|
||||
if [ ! -f "$B/obj/include/config.h" ]; then
|
||||
cp "$SRC/configure" "$B/configure.fp"
|
||||
sed -i 's/as_fn_error $? "PE cross-compilation is required/echo "(ignored) PE cross-compilation is required/' "$B/configure.fp"
|
||||
# configure.fp must run from the source dir's point of view: copy it in, run, remove
|
||||
cp "$B/configure.fp" "$SRC/configure.fp"
|
||||
(cd "$B/obj" && "$SRC/configure.fp" --srcdir="$SRC" --without-mingw --without-x --without-wayland --without-freetype \
|
||||
--without-gstreamer --without-vulkan --without-opengl --without-pulse --without-alsa --without-sdl --without-udev \
|
||||
--without-usb --without-gnutls --without-cups --without-dbus --without-krb5 --without-netapi --without-pcap \
|
||||
--without-sane --without-v4l2 --without-gphoto --without-unwind --without-ffmpeg --disable-tests > configure.log 2>&1 || true)
|
||||
rm -f "$SRC/configure.fp"
|
||||
[ -f "$B/obj/include/config.h" ] || { echo "configure did not produce include/config.h (see $B/obj/configure.log)"; exit 1; }
|
||||
fi
|
||||
cd "$B/ws"
|
||||
CF="-c -O2 -g -pipe -fno-strict-aliasing -D__WINESRC__ -D_GNU_SOURCE -DFP_EMULATED_X86_64 -I. -I$B/obj/include -I$SRC/include -I$SRC/server"
|
||||
for s in $(grep -oE "^\s+[a-z_0-9]+\.c" "$SRC/server/Makefile.in" | tr -d "\t "); do
|
||||
extra=()
|
||||
case $s in
|
||||
unicode.c) extra=('-DBINDIR="/usr/bin"' '-DDATADIR="/usr/share"') ;; # nls dir = <bin>/../share/wine/nls
|
||||
user.c) extra=(-include stdarg.h) ;;
|
||||
esac
|
||||
gcc $CF "${extra[@]}" -o "${s%.c}.o" "$SRC/server/$s"
|
||||
done
|
||||
gcc -o wineserver ./*.o -lrt -lm
|
||||
echo "built $B/ws/wineserver (protocol $(grep -oE "SERVER_PROTOCOL_VERSION [0-9]+" "$SRC/include/wine/server_protocol.h" | awk '{print $2}'))"
|
||||
@@ -0,0 +1,183 @@
|
||||
/* fp_mem.so: x86_64 LD_PRELOAD for Wine under FEX (give it to the guest with FEX_ENV=LD_PRELOAD=..., the FEX
|
||||
* compat tool strips LD_PRELOAD).
|
||||
*
|
||||
* FP_BIGCACHE_MB=<n>: keep the pages of released anonymous regions of >= n MB (up to FP_BIGCACHE_SLOTS regions,
|
||||
* default 12, and FP_BIGCACHE_MAX_MB in total, default 6144) and hand them back when Wine maps memory at the
|
||||
* same start address again. Unreal's large-block allocator gives blocks above its cache limit straight back to
|
||||
* the OS, and some games (Oculus First Contact: 0.5-1.4 GB every few seconds while playing) fill such blocks
|
||||
* completely each time: under FEX every 4 KB page then faults and is zeroed by the kernel (~130k faults, ~110 ms)
|
||||
* and releasing them costs about as much again, on a game task the game thread waits for.
|
||||
* Same start, other size: shorter = the rest is released; longer = only the extra part is mapped fresh.
|
||||
* Reused memory is NOT zeroed (Windows guarantees zeroed commits), so this is only for games that overwrite
|
||||
* the whole block; any other use of a kept range drops it first (fresh zero pages).
|
||||
* Kept regions keep their protection (they are not touched while kept), so keeping and reusing is nearly free.
|
||||
* FP_THP=1: MADV_HUGEPAGE on large writable anonymous regions (only helps while 2 MB pages are free).
|
||||
* FP_MEM_DEBUG=1: log to stderr. */
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#define MAX_SLOTS 16
|
||||
static size_t cache_min, cache_max = (size_t)6144 << 20, thp_min = (size_t)32 << 20;
|
||||
static int debug, thp, slots = 12;
|
||||
static int (*real_mprotect)(void *, size_t, int);
|
||||
static int (*real_munmap)(void *, size_t);
|
||||
static void *(*real_mmap)(void *, size_t, int, int, int, off_t);
|
||||
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
struct entry { char *addr; size_t len; int prot, via_munmap, used; };
|
||||
static struct entry cache[MAX_SLOTS];
|
||||
static size_t kept_total;
|
||||
static unsigned long hits, partial, stores, drops;
|
||||
|
||||
static void resolve(void)
|
||||
{
|
||||
if (!real_mmap) real_mmap = dlsym(RTLD_NEXT, "mmap");
|
||||
if (!real_munmap) real_munmap = dlsym(RTLD_NEXT, "munmap");
|
||||
if (!real_mprotect) real_mprotect = dlsym(RTLD_NEXT, "mprotect");
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void fp_mem_init(void)
|
||||
{
|
||||
const char *e;
|
||||
resolve();
|
||||
debug = getenv("FP_MEM_DEBUG") != NULL;
|
||||
thp = getenv("FP_THP") != NULL;
|
||||
if ((e = getenv("FP_BIGCACHE_MB")) && atol(e) > 0) cache_min = (size_t)atol(e) << 20;
|
||||
if ((e = getenv("FP_BIGCACHE_MAX_MB")) && atol(e) > 0) cache_max = (size_t)atol(e) << 20;
|
||||
if ((e = getenv("FP_BIGCACHE_SLOTS")) && atoi(e) > 0) slots = atoi(e) > MAX_SLOTS ? MAX_SLOTS : atoi(e);
|
||||
}
|
||||
|
||||
static void advise(void *addr, size_t len)
|
||||
{
|
||||
uintptr_t s = ((uintptr_t)addr + 0x1fffff) & ~(uintptr_t)0x1fffff;
|
||||
uintptr_t e = ((uintptr_t)addr + len) & ~(uintptr_t)0x1fffff;
|
||||
if (e > s) madvise((void *)s, e - s, MADV_HUGEPAGE);
|
||||
}
|
||||
|
||||
/* lock held: give part of a kept range up the way the program asked for it originally */
|
||||
static void release_range(const struct entry *c, char *addr, size_t len)
|
||||
{
|
||||
if (c->via_munmap) real_munmap(addr, len);
|
||||
else real_mmap(addr, len, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED | MAP_NORESERVE, -1, 0);
|
||||
}
|
||||
|
||||
static void drop(struct entry *c, const char *why)
|
||||
{
|
||||
if (debug) fprintf(stderr, "fp_mem: drop %p+%zx (%s)\n", c->addr, c->len, why);
|
||||
release_range(c, c->addr, c->len);
|
||||
kept_total -= c->len; c->used = 0; drops++;
|
||||
}
|
||||
|
||||
/* lock held: drop every kept range overlapping [addr, len) */
|
||||
static void drop_overlaps(const void *addr, size_t len, const char *why)
|
||||
{
|
||||
for (int i = 0; i < slots; i++)
|
||||
if (cache[i].used && (const char *)addr < cache[i].addr + cache[i].len && cache[i].addr < (const char *)addr + len)
|
||||
drop(&cache[i], why);
|
||||
}
|
||||
|
||||
static int any_kept(void)
|
||||
{
|
||||
for (int i = 0; i < slots; i++) if (cache[i].used) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* lock held: keep [addr, len) instead of releasing it (its pages and protection stay as they are) */
|
||||
static int store(void *addr, size_t len, int via_munmap, int prot)
|
||||
{
|
||||
int free_slot = -1;
|
||||
if (kept_total + len > cache_max) return 0;
|
||||
for (int i = 0; i < slots; i++) {
|
||||
if (!cache[i].used) { if (free_slot < 0) free_slot = i; continue; }
|
||||
if ((char *)addr < cache[i].addr + cache[i].len && cache[i].addr < (char *)addr + len) return 0;
|
||||
}
|
||||
if (free_slot < 0) return 0;
|
||||
cache[free_slot] = (struct entry){ addr, len, prot, via_munmap, 1 };
|
||||
kept_total += len; stores++;
|
||||
if (debug) fprintf(stderr, "fp_mem: keep %p+%zx (%s, kept %zu MB in total; stores %lu, hits %lu, partial %lu, drops %lu)\n",
|
||||
addr, len, via_munmap ? "munmap" : "release", kept_total >> 20, stores, hits, partial, drops);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* protection of a region before it is released: unknown here, so assume read/write (Wine's committed views) */
|
||||
#define KEPT_PROT (PROT_READ | PROT_WRITE)
|
||||
|
||||
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t off)
|
||||
{
|
||||
resolve();
|
||||
if (cache_min && len >= cache_min / 4 && any_kept()) {
|
||||
pthread_mutex_lock(&lock);
|
||||
for (int i = 0; i < slots; i++) {
|
||||
struct entry *c = &cache[i];
|
||||
if (!c->used || (char *)addr != c->addr || fd != -1 || !(flags & MAP_ANONYMOUS) || !(prot & PROT_WRITE)) continue;
|
||||
if (len <= c->len) {
|
||||
if (prot != c->prot && real_mprotect(addr, len, prot)) break;
|
||||
if (len < c->len) { release_range(c, c->addr + len, c->len - len); partial++; }
|
||||
kept_total -= c->len; c->used = 0; hits++;
|
||||
if (debug) fprintf(stderr, "fp_mem: reuse %p+%zx of %zx\n", addr, len, c->len);
|
||||
pthread_mutex_unlock(&lock);
|
||||
return addr;
|
||||
}
|
||||
/* longer: map the extra part fresh behind the kept one */
|
||||
void *t = real_mmap(c->addr + c->len, len - c->len, prot, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
|
||||
if (t == (void *)(c->addr + c->len)) {
|
||||
if (prot != c->prot) real_mprotect(c->addr, c->len, prot);
|
||||
kept_total -= c->len; c->used = 0; hits++; partial++;
|
||||
if (debug) fprintf(stderr, "fp_mem: reuse %p+%zx of %zx (+ fresh tail)\n", addr, len, c->len);
|
||||
pthread_mutex_unlock(&lock);
|
||||
if (thp && len - c->len >= thp_min) advise(t, len - c->len);
|
||||
return addr;
|
||||
}
|
||||
if (t != MAP_FAILED) real_munmap(t, len - c->len);
|
||||
break;
|
||||
}
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
if (cache_min && len >= cache_min && (flags & MAP_ANONYMOUS) && (flags & MAP_FIXED) && prot == PROT_NONE && fd == -1) {
|
||||
/* Wine releasing (part of) a view inside its reserved areas */
|
||||
pthread_mutex_lock(&lock);
|
||||
int ok = store(addr, len, 0, KEPT_PROT);
|
||||
pthread_mutex_unlock(&lock);
|
||||
if (ok) return addr;
|
||||
}
|
||||
if (any_kept()) {
|
||||
pthread_mutex_lock(&lock);
|
||||
drop_overlaps(addr, len, "mmap overlap");
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
void *r = real_mmap(addr, len, prot, flags, fd, off);
|
||||
if (thp && r != MAP_FAILED && (flags & MAP_ANONYMOUS) && (prot & PROT_WRITE) && len >= thp_min) advise(r, len);
|
||||
return r;
|
||||
}
|
||||
|
||||
void *mmap64(void *addr, size_t len, int prot, int flags, int fd, off_t off) __attribute__((alias("mmap")));
|
||||
|
||||
int munmap(void *addr, size_t len)
|
||||
{
|
||||
resolve();
|
||||
if (cache_min && (len >= cache_min || any_kept())) {
|
||||
pthread_mutex_lock(&lock);
|
||||
drop_overlaps(addr, len, "munmap overlap");
|
||||
if (len >= cache_min && store(addr, len, 1, KEPT_PROT)) { pthread_mutex_unlock(&lock); return 0; }
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
return real_munmap(addr, len);
|
||||
}
|
||||
|
||||
int mprotect(void *addr, size_t len, int prot)
|
||||
{
|
||||
resolve();
|
||||
if (any_kept()) {
|
||||
pthread_mutex_lock(&lock);
|
||||
drop_overlaps(addr, len, "mprotect overlap");
|
||||
pthread_mutex_unlock(&lock);
|
||||
}
|
||||
int r = real_mprotect(addr, len, prot);
|
||||
if (thp && !r && (prot & PROT_WRITE) && len >= thp_min) advise(addr, len);
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
diff --git a/dlls/crypt32/chain.c b/dlls/crypt32/chain.c
|
||||
--- a/dlls/crypt32/chain.c
|
||||
+++ b/dlls/crypt32/chain.c
|
||||
@@ -2578,6 +2578,36 @@
|
||||
return quality;
|
||||
}
|
||||
|
||||
+/* Between two chains of equal quality, Windows prefers the one whose issuer is
|
||||
+ * the most recent certificate (latest NotBefore) where the chains first
|
||||
+ * differ, e.g. a cross-signed root shipped with a signature (2022) over an
|
||||
+ * older self-signed copy of the same root (2013). Returns TRUE if alternate
|
||||
+ * should be preferred over chain.
|
||||
+ */
|
||||
+static BOOL CRYPT_AlternateHasNewerIssuer(const CertificateChain *chain,
|
||||
+ const CertificateChain *alternate)
|
||||
+{
|
||||
+ DWORD i, j;
|
||||
+
|
||||
+ for (i = 0; i < chain->context.cChain && i < alternate->context.cChain; i++)
|
||||
+ {
|
||||
+ const CERT_SIMPLE_CHAIN *a = chain->context.rgpChain[i];
|
||||
+ const CERT_SIMPLE_CHAIN *b = alternate->context.rgpChain[i];
|
||||
+
|
||||
+ for (j = 0; j < a->cElement && j < b->cElement; j++)
|
||||
+ {
|
||||
+ PCCERT_CONTEXT ca = a->rgpElement[j]->pCertContext;
|
||||
+ PCCERT_CONTEXT cb = b->rgpElement[j]->pCertContext;
|
||||
+
|
||||
+ if (!CertCompareCertificate(X509_ASN_ENCODING, ca->pCertInfo,
|
||||
+ cb->pCertInfo))
|
||||
+ return CompareFileTime(&cb->pCertInfo->NotBefore,
|
||||
+ &ca->pCertInfo->NotBefore) > 0;
|
||||
+ }
|
||||
+ }
|
||||
+ return FALSE;
|
||||
+}
|
||||
+
|
||||
/* Chooses the highest quality chain among chain and its "lower quality"
|
||||
* alternate chains. Returns the highest quality chain, with all other
|
||||
* chains as lower quality chains of it.
|
||||
@@ -2598,7 +2628,12 @@
|
||||
CertificateChain *alternate =
|
||||
(CertificateChain*)chain->context.rgpLowerQualityChainContext[i];
|
||||
|
||||
- if (CRYPT_ChainQuality(alternate) > CRYPT_ChainQuality(chain))
|
||||
+ DWORD alternateQuality = CRYPT_ChainQuality(alternate);
|
||||
+ DWORD chainQuality = CRYPT_ChainQuality(chain);
|
||||
+
|
||||
+ if (alternateQuality > chainQuality ||
|
||||
+ (alternateQuality == chainQuality &&
|
||||
+ CRYPT_AlternateHasNewerIssuer(chain, alternate)))
|
||||
{
|
||||
alternate->context.cLowerQualityChainContext =
|
||||
chain->context.cLowerQualityChainContext;
|
||||
@@ -0,0 +1,70 @@
|
||||
diff --git a/dlls/windows.devices.enumeration/main.c b/dlls/windows.devices.enumeration/main.c
|
||||
--- a/dlls/windows.devices.enumeration/main.c
|
||||
+++ b/dlls/windows.devices.enumeration/main.c
|
||||
@@ -118,6 +118,8 @@
|
||||
struct weak_reference_source weak_reference_source;
|
||||
|
||||
struct list added_handlers;
|
||||
+ struct list updated_handlers;
|
||||
+ struct list removed_handlers;
|
||||
struct list enumerated_handlers;
|
||||
struct list stopped_handlers;
|
||||
IUnknown *query_params;
|
||||
@@ -177,6 +179,8 @@
|
||||
if (!ref)
|
||||
{
|
||||
typed_event_handlers_clear( &impl->added_handlers );
|
||||
+ typed_event_handlers_clear( &impl->updated_handlers );
|
||||
+ typed_event_handlers_clear( &impl->removed_handlers );
|
||||
typed_event_handlers_clear( &impl->enumerated_handlers );
|
||||
typed_event_handlers_clear( &impl->stopped_handlers );
|
||||
IUnknown_Release( impl->query_params );
|
||||
@@ -225,27 +229,31 @@
|
||||
static HRESULT WINAPI device_watcher_add_Updated( IDeviceWatcher *iface, ITypedEventHandler_DeviceWatcher_DeviceInformationUpdate *handler,
|
||||
EventRegistrationToken *token )
|
||||
{
|
||||
- FIXME( "iface %p, handler %p, token %p stub!\n", iface, handler, token );
|
||||
- return S_OK;
|
||||
+ struct device_watcher *impl = impl_from_IDeviceWatcher( iface );
|
||||
+ TRACE( "iface %p, handler %p, token %p\n", iface, handler, token );
|
||||
+ return typed_event_handlers_append( &impl->updated_handlers, (ITypedEventHandler_IInspectable_IInspectable *)handler, token );
|
||||
}
|
||||
|
||||
static HRESULT WINAPI device_watcher_remove_Updated( IDeviceWatcher *iface, EventRegistrationToken token )
|
||||
{
|
||||
- FIXME( "iface %p, token %#I64x stub!\n", iface, token.value );
|
||||
- return E_NOTIMPL;
|
||||
+ struct device_watcher *impl = impl_from_IDeviceWatcher( iface );
|
||||
+ TRACE( "iface %p, token %#I64x.\n", iface, token.value );
|
||||
+ return typed_event_handlers_remove( &impl->updated_handlers, &token );
|
||||
}
|
||||
|
||||
static HRESULT WINAPI device_watcher_add_Removed( IDeviceWatcher *iface, ITypedEventHandler_DeviceWatcher_DeviceInformationUpdate *handler,
|
||||
EventRegistrationToken *token )
|
||||
{
|
||||
- FIXME( "iface %p, handler %p, token %p stub!\n", iface, handler, token );
|
||||
- return E_NOTIMPL;
|
||||
+ struct device_watcher *impl = impl_from_IDeviceWatcher( iface );
|
||||
+ TRACE( "iface %p, handler %p, token %p\n", iface, handler, token );
|
||||
+ return typed_event_handlers_append( &impl->removed_handlers, (ITypedEventHandler_IInspectable_IInspectable *)handler, token );
|
||||
}
|
||||
|
||||
static HRESULT WINAPI device_watcher_remove_Removed( IDeviceWatcher *iface, EventRegistrationToken token )
|
||||
{
|
||||
- FIXME( "iface %p, token %#I64x stub!\n", iface, token.value );
|
||||
- return E_NOTIMPL;
|
||||
+ struct device_watcher *impl = impl_from_IDeviceWatcher( iface );
|
||||
+ TRACE( "iface %p, token %#I64x.\n", iface, token.value );
|
||||
+ return typed_event_handlers_remove( &impl->removed_handlers, &token );
|
||||
}
|
||||
|
||||
static HRESULT WINAPI device_watcher_add_EnumerationCompleted( IDeviceWatcher *iface, ITypedEventHandler_DeviceWatcher_IInspectable *handler,
|
||||
@@ -592,6 +600,8 @@
|
||||
if (FAILED(hr = devquery_params_create( type, expr, prop_keys, prop_keys_len, &impl->query_params ))) goto failed;
|
||||
|
||||
list_init( &impl->added_handlers );
|
||||
+ list_init( &impl->updated_handlers );
|
||||
+ list_init( &impl->removed_handlers );
|
||||
list_init( &impl->enumerated_handlers );
|
||||
list_init( &impl->stopped_handlers );
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
diff --git a/dlls/dnsapi/dnsapi.spec b/dlls/dnsapi/dnsapi.spec
|
||||
--- a/dlls/dnsapi/dnsapi.spec
|
||||
+++ b/dlls/dnsapi/dnsapi.spec
|
||||
@@ -104,12 +104,20 @@
|
||||
@ stdcall DnsReplaceRecordSetW(ptr long ptr ptr ptr)
|
||||
@ stub DnsReplaceRecordSet_W
|
||||
@ stdcall DnsServiceBrowse(ptr ptr)
|
||||
+@ stdcall DnsServiceBrowseCancel(ptr)
|
||||
+@ stdcall DnsServiceConstructInstance(wstr wstr ptr ptr long long long long ptr ptr)
|
||||
+@ stdcall DnsServiceDeRegister(ptr ptr)
|
||||
+@ stdcall DnsServiceFreeInstance(ptr)
|
||||
@ stub DnsServiceNotificationDeregister_A
|
||||
@ stub DnsServiceNotificationDeregister_UTF8
|
||||
@ stub DnsServiceNotificationDeregister_W
|
||||
@ stub DnsServiceNotificationRegister_A
|
||||
@ stub DnsServiceNotificationRegister_UTF8
|
||||
@ stub DnsServiceNotificationRegister_W
|
||||
+@ stdcall DnsServiceRegister(ptr ptr)
|
||||
+@ stdcall DnsServiceRegisterCancel(ptr)
|
||||
+@ stdcall DnsServiceResolve(ptr ptr)
|
||||
+@ stdcall DnsServiceResolveCancel(ptr)
|
||||
@ stub DnsSetMaxNumberOfAddressesToRegister
|
||||
@ stub DnsStatusString
|
||||
@ stub DnsStringCopyAllocateEx
|
||||
diff --git a/dlls/dnsapi/main.c b/dlls/dnsapi/main.c
|
||||
--- a/dlls/dnsapi/main.c
|
||||
+++ b/dlls/dnsapi/main.c
|
||||
@@ -243,3 +243,135 @@
|
||||
FIXME( "(%p, %p) stub\n", request, cancel );
|
||||
return ERROR_SUCCESS;
|
||||
}
|
||||
+
|
||||
+/******************************************************************************
|
||||
+ * DnsServiceConstructInstance [DNSAPI.@]
|
||||
+ *
|
||||
+ */
|
||||
+DNS_SERVICE_INSTANCE * WINAPI DnsServiceConstructInstance( const WCHAR *name, const WCHAR *host,
|
||||
+ IP4_ADDRESS *ip4, IP6_ADDRESS *ip6, WORD port, WORD priority, WORD weight,
|
||||
+ DWORD count, const WCHAR **keys, const WCHAR **values )
|
||||
+{
|
||||
+ DNS_SERVICE_INSTANCE *instance;
|
||||
+ DWORD i;
|
||||
+
|
||||
+ TRACE( "(%s, %s, %p, %p, %u, %u, %u, %lu, %p, %p)\n", debugstr_w(name), debugstr_w(host),
|
||||
+ ip4, ip6, port, priority, weight, count, keys, values );
|
||||
+
|
||||
+ if (!(instance = calloc( 1, sizeof(*instance) ))) return NULL;
|
||||
+
|
||||
+ if (name) instance->pszInstanceName = wcsdup( name );
|
||||
+ if (host) instance->pszHostName = wcsdup( host );
|
||||
+ if (ip4 && (instance->ip4Address = malloc( sizeof(*ip4) ))) *instance->ip4Address = *ip4;
|
||||
+ if (ip6 && (instance->ip6Address = malloc( sizeof(*ip6) ))) *instance->ip6Address = *ip6;
|
||||
+ instance->wPort = port;
|
||||
+ instance->wPriority = priority;
|
||||
+ instance->wWeight = weight;
|
||||
+ instance->dwPropertyCount = count;
|
||||
+
|
||||
+ if (count && (instance->keys = calloc( count, sizeof(*keys) )))
|
||||
+ for (i = 0; i < count; i++) instance->keys[i] = wcsdup( keys[i] );
|
||||
+ if (count && (instance->values = calloc( count, sizeof(*values) )))
|
||||
+ for (i = 0; i < count; i++) instance->values[i] = wcsdup( values[i] );
|
||||
+
|
||||
+ return instance;
|
||||
+}
|
||||
+
|
||||
+/******************************************************************************
|
||||
+ * DnsServiceFreeInstance [DNSAPI.@]
|
||||
+ *
|
||||
+ */
|
||||
+void WINAPI DnsServiceFreeInstance( DNS_SERVICE_INSTANCE *instance )
|
||||
+{
|
||||
+ DWORD i;
|
||||
+
|
||||
+ TRACE( "(%p)\n", instance );
|
||||
+
|
||||
+ if (!instance) return;
|
||||
+
|
||||
+ free( instance->pszInstanceName );
|
||||
+ free( instance->pszHostName );
|
||||
+ free( instance->ip4Address );
|
||||
+ free( instance->ip6Address );
|
||||
+ for (i = 0; i < instance->dwPropertyCount; i++)
|
||||
+ {
|
||||
+ if (instance->keys) free( instance->keys[i] );
|
||||
+ if (instance->values) free( instance->values[i] );
|
||||
+ }
|
||||
+ free( instance->keys );
|
||||
+ free( instance->values );
|
||||
+ free( instance );
|
||||
+}
|
||||
+
|
||||
+/******************************************************************************
|
||||
+ * DnsServiceRegister [DNSAPI.@]
|
||||
+ *
|
||||
+ */
|
||||
+DNS_STATUS WINAPI DnsServiceRegister( DNS_SERVICE_REGISTER_REQUEST *request, DNS_SERVICE_CANCEL *cancel )
|
||||
+{
|
||||
+ FIXME( "(%p, %p) stub\n", request, cancel );
|
||||
+
|
||||
+ if (!request) return ERROR_INVALID_PARAMETER;
|
||||
+
|
||||
+ /* Registration needs mDNS support, which is not implemented. Fail
|
||||
+ * synchronously: the completion callback must only run when this returns
|
||||
+ * DNS_REQUEST_PENDING, and calling it inline would re-enter the caller
|
||||
+ * while it still believes the operation is outstanding. */
|
||||
+ return ERROR_CALL_NOT_IMPLEMENTED;
|
||||
+}
|
||||
+
|
||||
+/******************************************************************************
|
||||
+ * DnsServiceDeRegister [DNSAPI.@]
|
||||
+ *
|
||||
+ */
|
||||
+DNS_STATUS WINAPI DnsServiceDeRegister( DNS_SERVICE_REGISTER_REQUEST *request, DNS_SERVICE_CANCEL *cancel )
|
||||
+{
|
||||
+ FIXME( "(%p, %p) stub\n", request, cancel );
|
||||
+
|
||||
+ if (!request) return ERROR_INVALID_PARAMETER;
|
||||
+
|
||||
+ return ERROR_CALL_NOT_IMPLEMENTED;
|
||||
+}
|
||||
+
|
||||
+/******************************************************************************
|
||||
+ * DnsServiceRegisterCancel [DNSAPI.@]
|
||||
+ *
|
||||
+ */
|
||||
+DNS_STATUS WINAPI DnsServiceRegisterCancel( DNS_SERVICE_CANCEL *cancel )
|
||||
+{
|
||||
+ FIXME( "(%p) stub\n", cancel );
|
||||
+ return ERROR_CALL_NOT_IMPLEMENTED;
|
||||
+}
|
||||
+
|
||||
+/******************************************************************************
|
||||
+ * DnsServiceResolve [DNSAPI.@]
|
||||
+ *
|
||||
+ */
|
||||
+DNS_STATUS WINAPI DnsServiceResolve( DNS_SERVICE_RESOLVE_REQUEST *request, DNS_SERVICE_CANCEL *cancel )
|
||||
+{
|
||||
+ FIXME( "(%p, %p) stub\n", request, cancel );
|
||||
+
|
||||
+ if (!request) return ERROR_INVALID_PARAMETER;
|
||||
+
|
||||
+ return ERROR_CALL_NOT_IMPLEMENTED;
|
||||
+}
|
||||
+
|
||||
+/******************************************************************************
|
||||
+ * DnsServiceResolveCancel [DNSAPI.@]
|
||||
+ *
|
||||
+ */
|
||||
+DNS_STATUS WINAPI DnsServiceResolveCancel( DNS_SERVICE_CANCEL *cancel )
|
||||
+{
|
||||
+ FIXME( "(%p) stub\n", cancel );
|
||||
+ return ERROR_CALL_NOT_IMPLEMENTED;
|
||||
+}
|
||||
+
|
||||
+/******************************************************************************
|
||||
+ * DnsServiceBrowseCancel [DNSAPI.@]
|
||||
+ *
|
||||
+ */
|
||||
+DNS_STATUS WINAPI DnsServiceBrowseCancel( DNS_SERVICE_CANCEL *cancel )
|
||||
+{
|
||||
+ FIXME( "(%p) stub\n", cancel );
|
||||
+ return ERROR_CALL_NOT_IMPLEMENTED;
|
||||
+}
|
||||
diff --git a/include/windns.h b/include/windns.h
|
||||
--- a/include/windns.h
|
||||
+++ b/include/windns.h
|
||||
@@ -106,7 +106,8 @@
|
||||
#define DNS_QUERY_APPEND_MULTILABEL 0x00800000
|
||||
#define DNS_QUERY_DNSSEC_OK 0x01000000
|
||||
#define DNS_QUERY_DNSSEC_CHECKING_DISABLED 0x02000000
|
||||
-#define DNS_QUERY_RESERVED 0xff000000
|
||||
+#define DNS_QUERY_DNSSEC_REQUIRED 0x04000000
|
||||
+#define DNS_QUERY_RESERVED 0xf0000000
|
||||
|
||||
#define INLINE_WORD_FLIP(out, in) { WORD _in = (in); (out) = (_in << 8) | (_in >> 8); }
|
||||
#define INLINE_HTONS(out, in) INLINE_WORD_FLIP(out, in)
|
||||
@@ -781,6 +782,73 @@
|
||||
void *reserved;
|
||||
} DNS_SERVICE_CANCEL, *PDNS_SERVICE_CANCEL;
|
||||
|
||||
+typedef struct _DNS_SERVICE_INSTANCE
|
||||
+{
|
||||
+ LPWSTR pszInstanceName;
|
||||
+ LPWSTR pszHostName;
|
||||
+ IP4_ADDRESS *ip4Address;
|
||||
+ IP6_ADDRESS *ip6Address;
|
||||
+ WORD wPort;
|
||||
+ WORD wPriority;
|
||||
+ WORD wWeight;
|
||||
+ DWORD dwPropertyCount;
|
||||
+ PWSTR *keys;
|
||||
+ PWSTR *values;
|
||||
+ DWORD dwInterfaceIndex;
|
||||
+} DNS_SERVICE_INSTANCE, *PDNS_SERVICE_INSTANCE;
|
||||
+
|
||||
+typedef void WINAPI DNS_SERVICE_REGISTER_COMPLETE(DWORD, void *, PDNS_SERVICE_INSTANCE);
|
||||
+typedef DNS_SERVICE_REGISTER_COMPLETE *PDNS_SERVICE_REGISTER_COMPLETE;
|
||||
+
|
||||
+typedef struct _DNS_SERVICE_REGISTER_REQUEST
|
||||
+{
|
||||
+ ULONG Version;
|
||||
+ ULONG InterfaceIndex;
|
||||
+ PDNS_SERVICE_INSTANCE pServiceInstance;
|
||||
+ PDNS_SERVICE_REGISTER_COMPLETE pRegisterCompletionCallback;
|
||||
+ PVOID pQueryContext;
|
||||
+ HANDLE hCredentials;
|
||||
+ BOOL unicastEnabled;
|
||||
+} DNS_SERVICE_REGISTER_REQUEST, *PDNS_SERVICE_REGISTER_REQUEST;
|
||||
+
|
||||
+typedef void WINAPI DNS_SERVICE_RESOLVE_COMPLETE(DWORD, void *, PDNS_SERVICE_INSTANCE);
|
||||
+typedef DNS_SERVICE_RESOLVE_COMPLETE *PDNS_SERVICE_RESOLVE_COMPLETE;
|
||||
+
|
||||
+typedef struct _DNS_SERVICE_RESOLVE_REQUEST
|
||||
+{
|
||||
+ ULONG Version;
|
||||
+ ULONG InterfaceIndex;
|
||||
+ LPWSTR QueryName;
|
||||
+ PDNS_SERVICE_RESOLVE_COMPLETE pResolveCompletionCallback;
|
||||
+ PVOID pQueryContext;
|
||||
+} DNS_SERVICE_RESOLVE_REQUEST, *PDNS_SERVICE_RESOLVE_REQUEST;
|
||||
+
|
||||
+typedef struct _MDNS_QUERY_HANDLE
|
||||
+{
|
||||
+ WCHAR nameBuf[DNS_MAX_NAME_BUFFER_LENGTH];
|
||||
+ WORD wType;
|
||||
+ PVOID pSubscription;
|
||||
+ PVOID pWnfCallbackParams;
|
||||
+ ULONG stateNameData[2];
|
||||
+} MDNS_QUERY_HANDLE, *PMDNS_QUERY_HANDLE;
|
||||
+
|
||||
+typedef void WINAPI MDNS_QUERY_CALLBACK(PVOID,PMDNS_QUERY_HANDLE,PDNS_QUERY_RESULT);
|
||||
+typedef MDNS_QUERY_CALLBACK *PMDNS_QUERY_CALLBACK;
|
||||
+
|
||||
+typedef struct _MDNS_QUERY_REQUEST
|
||||
+{
|
||||
+ ULONG Version;
|
||||
+ ULONG ulRefCount;
|
||||
+ PCWSTR Query;
|
||||
+ WORD QueryType;
|
||||
+ ULONG64 QueryOptions;
|
||||
+ ULONG InterfaceIndex;
|
||||
+ PMDNS_QUERY_CALLBACK pQueryCallback;
|
||||
+ PVOID pQueryContext;
|
||||
+ BOOL fAnswerReceived;
|
||||
+ ULONG ulResendCount;
|
||||
+} MDNS_QUERY_REQUEST, *PMDNS_QUERY_REQUEST;
|
||||
+
|
||||
DNS_STATUS WINAPI DnsAcquireContextHandle_A(DWORD,PVOID,PHANDLE);
|
||||
DNS_STATUS WINAPI DnsAcquireContextHandle_W(DWORD,PVOID,PHANDLE);
|
||||
#define DnsAcquireContextHandle WINELIB_NAME_AW(DnsAcquireContextHandle_)
|
||||
@@ -800,6 +868,7 @@
|
||||
#define DnsQuery WINELIB_NAME_AW(DnsQuery_)
|
||||
DNS_STATUS WINAPI DnsQueryEx(DNS_QUERY_REQUEST*,DNS_QUERY_RESULT*,DNS_QUERY_CANCEL*);
|
||||
DNS_STATUS WINAPI DnsCancelQuery(DNS_QUERY_CANCEL*);
|
||||
+BOOL WINAPI DnsGetCacheDataTable(PDNS_CACHE_ENTRY*);
|
||||
DNS_STATUS WINAPI DnsQueryConfig(DNS_CONFIG_TYPE,DWORD,PCWSTR,PVOID,PVOID,PDWORD);
|
||||
BOOL WINAPI DnsRecordCompare(PDNS_RECORD,PDNS_RECORD);
|
||||
PDNS_RECORD WINAPI DnsRecordCopyEx(PDNS_RECORD,DNS_CHARSET,DNS_CHARSET);
|
||||
@@ -813,13 +882,22 @@
|
||||
DNS_STATUS WINAPI DnsReplaceRecordSetUTF8(PDNS_RECORDA,DWORD,HANDLE,PVOID,PVOID);
|
||||
#define DnsReplaceRecordSet WINELIB_NAME_AW(DnsReplaceRecordSet)
|
||||
DNS_STATUS WINAPI DnsServiceBrowse(PDNS_SERVICE_BROWSE_REQUEST, PDNS_SERVICE_CANCEL);
|
||||
+DNS_STATUS WINAPI DnsServiceBrowseCancel(PDNS_SERVICE_CANCEL);
|
||||
+PDNS_SERVICE_INSTANCE WINAPI DnsServiceConstructInstance(PCWSTR,PCWSTR,PIP4_ADDRESS,PIP6_ADDRESS,WORD,WORD,WORD,DWORD,PCWSTR*,PCWSTR*);
|
||||
+DNS_STATUS WINAPI DnsServiceDeRegister(PDNS_SERVICE_REGISTER_REQUEST,PDNS_SERVICE_CANCEL);
|
||||
+VOID WINAPI DnsServiceFreeInstance(PDNS_SERVICE_INSTANCE);
|
||||
+DNS_STATUS WINAPI DnsServiceRegister(PDNS_SERVICE_REGISTER_REQUEST,PDNS_SERVICE_CANCEL);
|
||||
+DNS_STATUS WINAPI DnsServiceRegisterCancel(PDNS_SERVICE_CANCEL);
|
||||
+DNS_STATUS WINAPI DnsServiceResolve(PDNS_SERVICE_RESOLVE_REQUEST,PDNS_SERVICE_CANCEL);
|
||||
+DNS_STATUS WINAPI DnsServiceResolveCancel(PDNS_SERVICE_CANCEL);
|
||||
+DNS_STATUS WINAPI DnsStartMulticastQuery(PMDNS_QUERY_REQUEST,PMDNS_QUERY_HANDLE);
|
||||
+DNS_STATUS WINAPI DnsStopMulticastQuery(PMDNS_QUERY_HANDLE);
|
||||
DNS_STATUS WINAPI DnsValidateName_A(PCSTR,DNS_NAME_FORMAT);
|
||||
DNS_STATUS WINAPI DnsValidateName_W(PCWSTR, DNS_NAME_FORMAT);
|
||||
DNS_STATUS WINAPI DnsValidateName_UTF8(PCSTR,DNS_NAME_FORMAT);
|
||||
#define DnsValidateName WINELIB_NAME_AW(DnsValidateName_)
|
||||
BOOL WINAPI DnsWriteQuestionToBuffer_W(PDNS_MESSAGE_BUFFER,PDWORD,PCWSTR,WORD,WORD,BOOL);
|
||||
BOOL WINAPI DnsWriteQuestionToBuffer_UTF8(PDNS_MESSAGE_BUFFER,PDWORD,PCSTR,WORD,WORD,BOOL);
|
||||
-BOOL WINAPI DnsGetCacheDataTable(PDNS_CACHE_ENTRY*);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
diff --git a/dlls/sechost/trace.c b/dlls/sechost/trace.c
|
||||
--- a/dlls/sechost/trace.c
|
||||
+++ b/dlls/sechost/trace.c
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "evntrace.h"
|
||||
|
||||
#include "wine/debug.h"
|
||||
+#include "wine/exception.h"
|
||||
|
||||
WINE_DEFAULT_DEBUG_CHANNEL(eventlog);
|
||||
|
||||
@@ -116,14 +117,63 @@
|
||||
return ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
+/* A real-time consumer of a session. No ETW providers exist under Wine, so the
|
||||
+ * consumer never receives events: ProcessTrace() waits until CloseTrace(). */
|
||||
+#define REALTIME_CONSUMER_MAGIC 0x52544345 /* "ECTR" */
|
||||
+#ifndef PROCESS_TRACE_MODE_REAL_TIME
|
||||
+#define PROCESS_TRACE_MODE_REAL_TIME 0x00000100 /* shares LogFileMode in Wine's header */
|
||||
+#endif
|
||||
+
|
||||
+struct realtime_consumer
|
||||
+{
|
||||
+ DWORD magic;
|
||||
+ HANDLE closed;
|
||||
+};
|
||||
+
|
||||
+static struct realtime_consumer *get_realtime_consumer( TRACEHANDLE handle )
|
||||
+{
|
||||
+ struct realtime_consumer *consumer = (struct realtime_consumer *)(ULONG_PTR)handle;
|
||||
+
|
||||
+ if (!handle || handle == INVALID_PROCESSTRACE_HANDLE) return NULL;
|
||||
+ __TRY
|
||||
+ {
|
||||
+ if (consumer->magic != REALTIME_CONSUMER_MAGIC) consumer = NULL;
|
||||
+ }
|
||||
+ __EXCEPT_PAGE_FAULT
|
||||
+ {
|
||||
+ consumer = NULL;
|
||||
+ }
|
||||
+ __ENDTRY
|
||||
+ return consumer;
|
||||
+}
|
||||
+
|
||||
/******************************************************************************
|
||||
* OpenTraceW (sechost.@)
|
||||
*/
|
||||
TRACEHANDLE WINAPI OpenTraceW( EVENT_TRACE_LOGFILEW *logfile )
|
||||
{
|
||||
static int once;
|
||||
+ struct realtime_consumer *consumer;
|
||||
|
||||
- if (!once++) FIXME("%p: stub\n", logfile);
|
||||
+ /* a session name without a log file can only be consumed in real time */
|
||||
+ if (logfile && logfile->LoggerName &&
|
||||
+ ((logfile->LogFileMode & PROCESS_TRACE_MODE_REAL_TIME) || !logfile->LogFileName))
|
||||
+ {
|
||||
+ FIXME("%p (%s): real-time consumer without events\n", logfile, debugstr_w(logfile->LoggerName));
|
||||
+ if (!(consumer = HeapAlloc( GetProcessHeap(), 0, sizeof(*consumer) )))
|
||||
+ {
|
||||
+ SetLastError(ERROR_NOT_ENOUGH_MEMORY);
|
||||
+ return INVALID_PROCESSTRACE_HANDLE;
|
||||
+ }
|
||||
+ consumer->magic = REALTIME_CONSUMER_MAGIC;
|
||||
+ consumer->closed = CreateEventW( NULL, TRUE, FALSE, NULL );
|
||||
+ return (TRACEHANDLE)(ULONG_PTR)consumer;
|
||||
+ }
|
||||
+
|
||||
+ if (!once++) FIXME("%p: stub (logfile %s, logger %s, mode %#lx)\n", logfile,
|
||||
+ logfile ? debugstr_w(logfile->LogFileName) : "-",
|
||||
+ logfile ? debugstr_w(logfile->LoggerName) : "-",
|
||||
+ logfile ? logfile->LogFileMode : 0);
|
||||
SetLastError(ERROR_ACCESS_DENIED);
|
||||
return INVALID_PROCESSTRACE_HANDLE;
|
||||
}
|
||||
@@ -133,6 +183,25 @@
|
||||
*/
|
||||
ULONG WINAPI ProcessTrace( TRACEHANDLE *handles, ULONG count, FILETIME *start_time, FILETIME *end_time )
|
||||
{
|
||||
+ HANDLE events[MAXIMUM_WAIT_OBJECTS];
|
||||
+ ULONG i;
|
||||
+
|
||||
+ if (handles && count && count <= MAXIMUM_WAIT_OBJECTS)
|
||||
+ {
|
||||
+ for (i = 0; i < count; i++)
|
||||
+ {
|
||||
+ struct realtime_consumer *consumer = get_realtime_consumer( handles[i] );
|
||||
+ if (!consumer) break;
|
||||
+ events[i] = consumer->closed;
|
||||
+ }
|
||||
+ if (i == count)
|
||||
+ {
|
||||
+ TRACE("%p %lu: waiting for CloseTrace\n", handles, count);
|
||||
+ WaitForMultipleObjects( count, events, TRUE, INFINITE );
|
||||
+ return ERROR_SUCCESS;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
FIXME("%p %lu %p %p: stub\n", handles, count, start_time, end_time);
|
||||
return ERROR_CALL_NOT_IMPLEMENTED;
|
||||
}
|
||||
@@ -142,6 +211,15 @@
|
||||
*/
|
||||
ULONG WINAPI CloseTrace( TRACEHANDLE handle )
|
||||
{
|
||||
+ struct realtime_consumer *consumer = get_realtime_consumer( handle );
|
||||
+
|
||||
+ if (consumer)
|
||||
+ {
|
||||
+ /* the handle stays allocated: ProcessTrace() may still be waiting on it */
|
||||
+ SetEvent( consumer->closed );
|
||||
+ return ERROR_SUCCESS;
|
||||
+ }
|
||||
+
|
||||
FIXME("%s: stub\n", wine_dbgstr_longlong(handle));
|
||||
return ERROR_INVALID_HANDLE;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Reproduce the exact Wine source of a GE-Proton release (Wine submodule + wine-staging + GE's own patches): the WINE
|
||||
# section of GE's patches/protonprep-valve-staging.sh, without the DXVK/vkd3d/protonfixes steps.
|
||||
# usage: prep_ge_wine.sh <work dir> [tag=GE-Proton11-7] → <work dir>/src/wine
|
||||
# Needs git, patch, python3, autoconf/autoreconf (the WINE section ends with autoreconf + tools/make_requests).
|
||||
set -e
|
||||
W=$1; TAG=${2:-GE-Proton11-7}
|
||||
[ -n "$W" ] || { echo "usage: $0 <work dir> [tag]"; exit 2; }
|
||||
mkdir -p "$W" && cd "$W"
|
||||
[ -d src ] || git clone -q --depth 1 --branch "$TAG" https://github.com/GloriousEggroll/proton-ge-custom src
|
||||
cd src
|
||||
git submodule update --init --depth 1 wine wine-staging
|
||||
P=patches/protonprep-valve-staging.sh
|
||||
start=$(grep -n "^### (2) WINE PATCHING ###" $P | cut -d: -f1)
|
||||
end=$(grep -n "^### END WINE PATCHING ###" $P | cut -d: -f1)
|
||||
fn_end=$(grep -n "^### (1) PREP SECTION ###" $P | cut -d: -f1)
|
||||
[ -n "$start" ] && [ -n "$end" ] && [ -n "$fn_end" ] || { echo "unexpected layout of $P"; exit 1; }
|
||||
# the WINE section reverts commits from the Wine history: fetch them into the shallow clone first
|
||||
for c in $(sed -n "${start},${end}p" $P | grep -oE "git revert [^#]*" | grep -oE "[0-9a-f]{40}"); do
|
||||
git -C wine fetch -q --depth 2 origin "$c"
|
||||
done
|
||||
{ sed -n "1,$((fn_end - 1))p" $P; sed -n "${start},${end}p" $P; } > ../wine_only.sh
|
||||
bash ../wine_only.sh > ../prep.log 2>&1
|
||||
grep -n "SERVER_PROTOCOL_VERSION" wine/include/wine/server_protocol.h
|
||||
if find wine -name "*.rej" | grep -q .; then echo "rejected hunks, see ../prep.log"; exit 1; fi
|
||||
echo "patched Wine tree: $W/src/wine"
|
||||
@@ -0,0 +1,79 @@
|
||||
/* windows.devices.wifi.dll stand-in for Wine: activation factory of Windows.Devices.WiFi.WiFiAdapter with the
|
||||
* IWiFiAdapterStatics methods. Wine has no Wi-Fi WinRT class; Meta's runtime (OculusAppFramework, Air Link "Ari"
|
||||
* dongle code) calls WiFiAdapter.GetDeviceSelector() after login, enumerates devices with it and only calls
|
||||
* FromIdAsync for devices found. Without the class, C++/WinRT throws and the runtime fail-fasts (c0000409).
|
||||
* GetDeviceSelector returns a selector that matches no device; the async methods report E_NOTIMPL (never reached
|
||||
* when nothing is found). Unknown interface requests are logged to stderr.
|
||||
*/
|
||||
#define COBJMACROS
|
||||
#include <windows.h>
|
||||
#include <winstring.h>
|
||||
#include <stdio.h>
|
||||
|
||||
typedef struct IInspectableVtbl_ { void *s[6]; } dummy_t;
|
||||
|
||||
static const GUID IID_IUnknown_ = {0x00000000,0x0000,0x0000,{0xc0,0x00,0x00,0x00,0x00,0x00,0x00,0x46}};
|
||||
static const GUID IID_IInspectable_ = {0xaf86e2e0,0xb12d,0x4c6a,{0x9c,0x5a,0xd7,0xaa,0x65,0x10,0x1e,0x90}};
|
||||
static const GUID IID_IAgileObject_ = {0x94ea2b94,0xe9cc,0x49e0,{0xc0,0xff,0xee,0x64,0xca,0x8f,0x5b,0x90}};
|
||||
static const GUID IID_IActivationFactory_ = {0x00000035,0x0000,0x0000,{0xc0,0x00,0x00,0x00,0x00,0x00,0x00,0x46}};
|
||||
/* Windows.Devices.WiFi.IWiFiAdapterStatics */
|
||||
static const GUID IID_IWiFiAdapterStatics = {0xda25fddd,0xd24c,0x43e3,{0xaa,0xbd,0xc4,0x65,0x9f,0x73,0x0f,0x99}};
|
||||
|
||||
typedef struct statics { const void **vtbl; } statics;
|
||||
|
||||
static HRESULT WINAPI f_QueryInterface(statics *This, REFIID iid, void **out)
|
||||
{
|
||||
if (IsEqualGUID(iid, &IID_IUnknown_) || IsEqualGUID(iid, &IID_IInspectable_) || IsEqualGUID(iid, &IID_IAgileObject_)
|
||||
|| IsEqualGUID(iid, &IID_IActivationFactory_) || IsEqualGUID(iid, &IID_IWiFiAdapterStatics))
|
||||
{
|
||||
*out = This;
|
||||
return S_OK;
|
||||
}
|
||||
fprintf(stderr, "wifi_stub: unknown interface {%08lx-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x}\n", iid->Data1,
|
||||
iid->Data2, iid->Data3, iid->Data4[0], iid->Data4[1], iid->Data4[2], iid->Data4[3], iid->Data4[4],
|
||||
iid->Data4[5], iid->Data4[6], iid->Data4[7]);
|
||||
*out = NULL;
|
||||
return E_NOINTERFACE;
|
||||
}
|
||||
static ULONG WINAPI f_AddRef(statics *This) { return 2; }
|
||||
static ULONG WINAPI f_Release(statics *This) { return 1; }
|
||||
static HRESULT WINAPI f_GetIids(statics *This, ULONG *count, IID **iids)
|
||||
{
|
||||
IID *p = CoTaskMemAlloc(sizeof(IID));
|
||||
if (!p) return E_OUTOFMEMORY;
|
||||
*p = IID_IWiFiAdapterStatics; *count = 1; *iids = p;
|
||||
return S_OK;
|
||||
}
|
||||
static HRESULT WINAPI f_GetRuntimeClassName(statics *This, HSTRING *name)
|
||||
{
|
||||
static const WCHAR n[] = L"Windows.Devices.WiFi.WiFiAdapter";
|
||||
return WindowsCreateString(n, ARRAYSIZE(n) - 1, name);
|
||||
}
|
||||
static HRESULT WINAPI f_GetTrustLevel(statics *This, int *level) { *level = 0; return S_OK; }
|
||||
/* slot 6: IWiFiAdapterStatics::FindAllAdaptersAsync (and IActivationFactory::ActivateInstance: not activatable) */
|
||||
static HRESULT WINAPI f_FindAllAdaptersAsync(statics *This, void **op) { if (op) *op = NULL; return E_NOTIMPL; }
|
||||
static HRESULT WINAPI f_GetDeviceSelector(statics *This, HSTRING *value)
|
||||
{
|
||||
/* an interface class no device has: device enumeration with it finds nothing */
|
||||
static const WCHAR sel[] = L"System.Devices.InterfaceClassGuid:=\"{f0e2c0a5-7b3e-4f61-9b47-3d1c0f0e2a11}\" AND "
|
||||
L"System.Devices.InterfaceEnabled:=System.StructuredQueryType.Boolean#True";
|
||||
fprintf(stderr, "wifi_stub: GetDeviceSelector\n");
|
||||
return WindowsCreateString(sel, ARRAYSIZE(sel) - 1, value);
|
||||
}
|
||||
static HRESULT WINAPI f_FromIdAsync(statics *This, HSTRING id, void **op) { if (op) *op = NULL; return E_NOTIMPL; }
|
||||
static HRESULT WINAPI f_RequestAccessAsync(statics *This, void **op) { if (op) *op = NULL; return E_NOTIMPL; }
|
||||
|
||||
static const void *vtbl[] = { f_QueryInterface, f_AddRef, f_Release, f_GetIids, f_GetRuntimeClassName,
|
||||
f_GetTrustLevel, f_FindAllAdaptersAsync, f_GetDeviceSelector, f_FromIdAsync,
|
||||
f_RequestAccessAsync };
|
||||
static statics factory = { vtbl };
|
||||
|
||||
HRESULT WINAPI DllGetActivationFactory(HSTRING classid, void **out)
|
||||
{
|
||||
const WCHAR *s = WindowsGetStringRawBuffer(classid, NULL);
|
||||
fprintf(stderr, "wifi_stub: DllGetActivationFactory %ls\n", s ? s : L"(null)");
|
||||
if (!s || wcscmp(s, L"Windows.Devices.WiFi.WiFiAdapter")) { *out = NULL; return CLASS_E_CLASSNOTAVAILABLE; }
|
||||
*out = &factory;
|
||||
return S_OK;
|
||||
}
|
||||
HRESULT WINAPI DllCanUnloadNow(void) { return S_FALSE; }
|
||||
@@ -0,0 +1,4 @@
|
||||
LIBRARY windows.devices.wifi.dll
|
||||
EXPORTS
|
||||
DllGetActivationFactory
|
||||
DllCanUnloadNow PRIVATE
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -42,6 +42,7 @@ def stage_data():
|
||||
shutil.copytree(ROOT / name, DATA / name)
|
||||
# the Frame agent is uploaded as source; `flet build` turns every .py into .pyc (issue #2): keep a non-.py copy
|
||||
shutil.copy2(ROOT / "agent/frameport_agent.py", DATA / "agent/frameport_agent.py.txt")
|
||||
shutil.copy2(ROOT / "agent/fexrift.py", DATA / "agent/fexrift.py.txt") # experimental Rift-on-Frame helper
|
||||
print(f"staged data in {DATA}")
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -53,6 +54,9 @@ app.add_typer(tools_app, name="tools")
|
||||
app.add_typer(frame_app, name="frame")
|
||||
app.add_typer(pc_app, name="pc")
|
||||
app.add_typer(diag_app, name="diag")
|
||||
fex_app = typer.Typer(help="Experimental (FRAMEPORT_EXPERIMENTAL_FEX=1): Oculus Rift games with Meta's PC runtime on "
|
||||
"the Frame alone (x86_64 GE-Proton under FEX). Verified game: Oculus First Contact.")
|
||||
app.add_typer(fex_app, name="fex", hidden=True)
|
||||
|
||||
|
||||
def _show_version(value: bool):
|
||||
@@ -282,7 +286,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 +362,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."""
|
||||
@@ -902,5 +950,63 @@ def _describe(exc: BaseException) -> str:
|
||||
return explain(exc)
|
||||
|
||||
|
||||
def _fex_target(frame: Optional[str]):
|
||||
from . import fexrift
|
||||
|
||||
try:
|
||||
fexrift.require_enabled()
|
||||
except RuntimeError as e:
|
||||
typer.echo(str(e), err=True)
|
||||
raise typer.Exit(2)
|
||||
return _target(frame)
|
||||
|
||||
|
||||
@fex_app.command("status")
|
||||
def fex_status(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""What's set up on the Frame: GE-Proton, Meta's runtime, the login, the games in the prefix."""
|
||||
t = _fex_target(frame)
|
||||
typer.echo(json.dumps(t.frame.agent("fex_status", timeout=90), indent=1))
|
||||
|
||||
|
||||
@fex_app.command("setup")
|
||||
def fex_setup(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Upload FramePort's files, then the Frame downloads GE-Proton (x86_64) and Meta's runtime and builds the prefix
|
||||
(10-20 minutes, about 12 GiB)."""
|
||||
from . import fexrift
|
||||
|
||||
t = _fex_target(frame)
|
||||
fexrift.setup(t.frame, printing_reporter(False))
|
||||
typer.echo("setup done")
|
||||
|
||||
|
||||
@fex_app.command("import-login")
|
||||
def fex_import_login(prefix: str = typer.Argument(..., help="a Wine prefix on this PC where you signed in to Meta's "
|
||||
"app (and downloaded the game with it)"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Copy your Meta login and the games Meta's app downloaded into the Frame's prefix (sign-in tokens: sent over
|
||||
SSH, deleted on both sides afterwards)."""
|
||||
from pathlib import Path
|
||||
|
||||
from . import fexrift
|
||||
|
||||
t = _fex_target(frame)
|
||||
res = fexrift.import_login(t.frame, Path(prefix).expanduser(), printing_reporter(False))
|
||||
typer.echo(json.dumps(res.get("imported"), indent=1))
|
||||
|
||||
|
||||
@fex_app.command("install")
|
||||
def fex_install(app_name: str = typer.Argument("oculus-first-contact", metavar="APP",
|
||||
help="Meta's name for the game (verified: oculus-first-contact)"),
|
||||
msaa: Optional[int] = typer.Option(None, help="MSAA samples for the game (1, 2 or 4; game default 4). UNVERIFIED: with 2 the game never rendered in two launches"),
|
||||
no_steam: bool = typer.Option(False, "--no-steam", help="don't add it to the Steam library"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Make a game from the prefix playable from the Steam library (fixes, launcher, shortcut)."""
|
||||
from . import fexrift
|
||||
|
||||
t = _fex_target(frame)
|
||||
res = fexrift.install(t.frame, printing_reporter(False), app_name, msaa=msaa, add_to_steam=not no_steam)
|
||||
typer.echo(json.dumps(res, indent=1))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""Experimental: Oculus Rift games that need Meta's PC runtime, on the Steam Frame alone.
|
||||
|
||||
x86_64 GE-Proton under FEX runs Meta's runtime, Revive and the game in one Wine prefix on the Frame (research and
|
||||
measurements: native/fexwine/README.md, CLAUDE.md "Rift via x86_64 Wine under FEX"). CLI only, behind
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 (`frameport fex ...`); verified game: Oculus First Contact.
|
||||
|
||||
Steps: setup (uploads agent/fexrift.py + artifacts/fexrift + a patched Revive 3.2.0, then the Frame downloads
|
||||
GE-Proton and Meta's runtime itself) -> import-login (the Meta login and the games Meta's app downloaded, from a
|
||||
signed-in Wine prefix on this PC) -> install (per-game fixes, launcher, Steam shortcut).
|
||||
The login archive holds sign-in tokens: it is written owner-only, sent over SSH and deleted on both sides.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .core.paths import agent_dir, artifacts_dir, user_data_dir
|
||||
from .core.events import Reporter
|
||||
|
||||
EXPERIMENT_ENV = "FRAMEPORT_EXPERIMENTAL_FEX"
|
||||
|
||||
# Revive 3.2.0's LibRevive64.dll: the visibility fixes (IsVisible / HmdMounted / HasInputFocus always true: no pauses
|
||||
# on the Frame's brief focus dips) and eye textures at 67 % of the recommended size (ovr_GetFovTextureSize divides by
|
||||
# 1.5: the Frame's GPU can't fill the full size at 72 Hz). (file offset, original bytes, new bytes)
|
||||
REVIVE_VERSION = "3.2.0"
|
||||
REVIVE_DLL_SHA256 = "ef9ffd5342bb4afad9b1c0f4fee1ca3bf32f9d3159d49dd841cc9e22f02663e4"
|
||||
REVIVE_PATCHES = [
|
||||
(0x1008c, "740d", "9090"),
|
||||
(0x122af, "15", "f1"),
|
||||
(0x122b1, "04", "03"),
|
||||
(0x151f8, "ff9030010000", "b00190909090"),
|
||||
(0x1563a, "0f94c0", "b00190"),
|
||||
(0x15807, "ff9230010000", "b00190909090"),
|
||||
]
|
||||
|
||||
# games verified with this mode: Meta app name (folder under Software/Software) -> FramePort package + title
|
||||
GAMES = {"oculus-first-contact": {"package": "rift.oculus_first_contact", "title": "Oculus First Contact"}}
|
||||
|
||||
# what a signed-in prefix contributes (relative to drive_c), tar top-level name -> path
|
||||
META = "Program Files/Meta Horizon"
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return os.environ.get(EXPERIMENT_ENV) == "1"
|
||||
|
||||
|
||||
def require_enabled() -> None:
|
||||
if not enabled():
|
||||
raise RuntimeError(f"Rift games with Meta's runtime on the Frame are experimental: set {EXPERIMENT_ENV}=1")
|
||||
|
||||
|
||||
def artifact_files() -> list[tuple[Path, str]]:
|
||||
"""(local file, path under the Frame's artifacts/) for everything fexrift.py needs, except Revive."""
|
||||
root = artifacts_dir() / "fexrift"
|
||||
out = [(p, p.relative_to(root).as_posix()) for p in sorted(root.rglob("*")) if p.is_file()]
|
||||
helper = agent_dir() / "fexrift.py" # release bundles carry a .txt copy (their .py files are compiled away)
|
||||
out.append((helper if helper.exists() else agent_dir() / "fexrift.py.txt", "fexrift.py"))
|
||||
return out
|
||||
|
||||
|
||||
def patch_revive(src: Path, dest: Path) -> Path:
|
||||
"""Copy Revive's runtime files to dest with REVIVE_PATCHES applied to LibRevive64.dll (Revive 3.2.0 only)."""
|
||||
from .tools import revive as revive_tool
|
||||
|
||||
dll = src / "LibRevive64.dll"
|
||||
if not dll.is_file() or hashlib.sha256(dll.read_bytes()).hexdigest() != REVIVE_DLL_SHA256:
|
||||
raise RuntimeError(f"this mode needs Revive {REVIVE_VERSION} (its LibRevive64.dll is patched byte by byte); "
|
||||
f"found another build in {src}")
|
||||
tmp = dest.with_name(dest.name + ".tmp")
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
for f in revive_tool.runtime_files(src):
|
||||
target = tmp / f.relative_to(src)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(f, target) # content only: a system Revive's files are read-only
|
||||
data = bytearray((tmp / "LibRevive64.dll").read_bytes())
|
||||
for off, old, new in REVIVE_PATCHES:
|
||||
o, n = bytes.fromhex(old), bytes.fromhex(new)
|
||||
if bytes(data[off:off + len(o)]) != o:
|
||||
raise RuntimeError(f"Revive {REVIVE_VERSION}: unexpected bytes at {off:#x}")
|
||||
data[off:off + len(n)] = n
|
||||
(tmp / "LibRevive64.dll").write_bytes(bytes(data))
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
tmp.replace(dest)
|
||||
return dest
|
||||
|
||||
|
||||
def setup(frame, reporter: Reporter, wait: float = 3600) -> dict:
|
||||
"""Upload fexrift.py, its binaries and the patched Revive, then run the setup on the Frame and follow it."""
|
||||
from .install import installer
|
||||
from .tools import revive as revive_tool
|
||||
|
||||
require_enabled()
|
||||
reporter.stage("Prepare the Frame")
|
||||
prep = frame.agent("fex_prepare")
|
||||
if not prep.get("fex"):
|
||||
raise RuntimeError("FEX isn't installed on the Frame (Steam app 3127680, installed with Proton for x86 games)")
|
||||
if prep["free_bytes"] < 12 << 30:
|
||||
raise RuntimeError(f"needs about 12 GiB free on the Frame, have {prep['free_bytes'] / 2**30:.1f} GiB")
|
||||
src = revive_tool.revive_dir() or revive_tool.install()
|
||||
revive = patch_revive(src, user_data_dir() / "fexrift" / "revive")
|
||||
items = [(p, rel, p.stat().st_size) for p, rel in artifact_files()]
|
||||
items += [(p, "revive/" + p.relative_to(revive).as_posix(), p.stat().st_size)
|
||||
for p in sorted(revive.rglob("*")) if p.is_file()]
|
||||
total = sum(i[2] for i in items)
|
||||
reporter.stage(f"Upload FramePort's files ({len(items)} files, {total / 2**20:.0f} MiB)")
|
||||
installer.upload_files(frame, items, prep["artifacts"], reporter, max(total, 1))
|
||||
reporter.stage("Set up GE-Proton, FEX and Meta's runtime on the Frame (10-20 minutes)")
|
||||
frame.agent("fex_setup")
|
||||
end = time.time() + wait
|
||||
last = None
|
||||
while time.time() < end:
|
||||
time.sleep(10)
|
||||
try:
|
||||
st = frame.agent("fex_status", timeout=90)
|
||||
except Exception: # noqa: BLE001 - SSH hiccups while the Frame works hard
|
||||
continue
|
||||
setup_st = st.get("setup") or {}
|
||||
if setup_st.get("step") != last and setup_st.get("step"):
|
||||
last = setup_st["step"]
|
||||
reporter.log(f"setup step: {last}")
|
||||
if setup_st.get("state") == "failed":
|
||||
raise RuntimeError(f"setup failed: {setup_st.get('error')}")
|
||||
if setup_st.get("state") == "done":
|
||||
for d in setup_st.get("done", []):
|
||||
reporter.check(f"setup: {d['step']}", True, str(d.get("result")))
|
||||
return st
|
||||
raise RuntimeError("the setup on the Frame didn't finish in time; check again with `frameport fex status`")
|
||||
|
||||
|
||||
def find_user_dir(prefix: Path) -> Path:
|
||||
users = prefix / "drive_c" / "users"
|
||||
for u in sorted(users.iterdir()) if users.is_dir() else []:
|
||||
if (u / "AppData" / "Roaming" / "Oculus" / "sessions").is_dir():
|
||||
return u
|
||||
raise RuntimeError(f"no Meta login in {prefix} (sign in with Meta's app in that prefix first)")
|
||||
|
||||
|
||||
def login_archive(prefix: Path, apps: list[str], out: Path) -> dict:
|
||||
"""tar of the login (sessions/, CoreData/), the app manifests and the given apps, from a signed-in prefix."""
|
||||
user = find_user_dir(prefix)
|
||||
meta = prefix / "drive_c" / META
|
||||
trees = {"sessions": user / "AppData" / "Roaming" / "Oculus" / "sessions", "CoreData": meta / "CoreData"}
|
||||
for name, path in trees.items():
|
||||
if not path.is_dir() or not any(path.iterdir()):
|
||||
raise RuntimeError(f"{name} missing in {prefix}: sign in with Meta's app there first")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
old_umask = os.umask(0o077)
|
||||
apps_found = []
|
||||
try:
|
||||
with tarfile.open(out, "w") as t:
|
||||
for name, path in trees.items():
|
||||
t.add(path, arcname=name)
|
||||
manifests = meta / "Software" / "Manifests"
|
||||
for app in apps:
|
||||
for f in sorted(manifests.glob(f"{app}.json*")) if manifests.is_dir() else []:
|
||||
t.add(f, arcname=f"Manifests/{f.name}")
|
||||
game = meta / "Software" / "Software" / app
|
||||
if game.is_dir():
|
||||
t.add(game, arcname=f"Software/{app}")
|
||||
apps_found.append(app)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
return {"archive": out, "apps": apps_found, "bytes": out.stat().st_size}
|
||||
|
||||
|
||||
def import_login(frame, prefix: Path, reporter: Reporter, apps: list[str] | None = None) -> dict:
|
||||
require_enabled()
|
||||
apps = apps or list(GAMES)
|
||||
tmp = user_data_dir() / "fexrift" / "login.tar"
|
||||
try:
|
||||
reporter.stage("Pack the Meta login")
|
||||
info = login_archive(prefix, apps, tmp)
|
||||
missing = [a for a in apps if a not in info["apps"]]
|
||||
if missing:
|
||||
reporter.check("Games from the prefix", None, f"not downloaded there: {', '.join(missing)}")
|
||||
prep = frame.agent("fex_prepare")
|
||||
reporter.stage(f"Send the login ({info['bytes'] / 2**20:.0f} MiB)")
|
||||
frame.put(tmp, prep["incoming"] + "/login.tar", None)
|
||||
res = frame.agent("fex_import_login", timeout=1200)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
reporter.check("Meta login on the Frame", bool((res.get("status") or {}).get("login")))
|
||||
return res
|
||||
|
||||
|
||||
def install(frame, reporter: Reporter, app: str = "oculus-first-contact", msaa: int | None = None,
|
||||
add_to_steam: bool = True) -> dict:
|
||||
from .install import installer
|
||||
|
||||
require_enabled()
|
||||
if app not in GAMES:
|
||||
raise RuntimeError(f"{app} isn't verified for this mode (verified: {', '.join(GAMES)})")
|
||||
g = GAMES[app]
|
||||
reporter.stage(f"Install {g['title']}")
|
||||
res = frame.agent("fex_install", package=g["package"], app=app, title=g["title"], msaa=msaa)
|
||||
for k, v in (res.get("fixes") or {}).items():
|
||||
if v is not None:
|
||||
reporter.check(f"fix: {k}", v not in ("unknown build",), str(v))
|
||||
if add_to_steam:
|
||||
installer.add_to_steam(frame, [g["package"]], reporter)
|
||||
return res
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||