mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 19:01:24 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e28aeaeda | ||
|
|
43476bd180 | ||
|
|
939845d379 | ||
|
|
35eb12df08 | ||
|
|
570a98b91a | ||
|
|
f2f92cce70 | ||
|
|
dd74c17212 | ||
|
|
5647e266bd | ||
|
|
a5a86e591d | ||
|
|
7cd78cfa26 | ||
|
|
e04edf3600 | ||
|
|
c98aad5056 | ||
|
|
ae228ea4ea | ||
|
|
0599e55831 | ||
|
|
2e83970ad5 | ||
|
|
33a778c195 |
No files matched your search
@@ -19,6 +19,8 @@ CLAUDE.local.md
|
||||
# outputs of `frameport report` / `parity` / ruff
|
||||
REPORT.md
|
||||
.ruff_cache/
|
||||
.eval-home/
|
||||
.eval-results/
|
||||
.claude/worktrees/
|
||||
|
||||
# project page (site/)
|
||||
|
||||
@@ -548,61 +548,6 @@ 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> [--app <meta name>] | install
|
||||
[<meta name>] [--msaa N] [--force-unverified] | status` (hidden group). Generic per game: exe + arguments from Meta's
|
||||
manifest (`Software/Manifests/<app>.json` launchFile/launchParameters), the Unreal IME-deadlock fix found in the binaries
|
||||
(`find_ime_patch`: the CVar's UTF-16 name → its `lea` xref → `lea r8` member/global → `mov [reg+disp],1` / int 1;
|
||||
also modular ApplicationCore/Slate DLLs), `static_checks` (engine, imported graphics DLLs: monolithic Unreal imports
|
||||
every RHI, so it can't tell which runs). `agent/fexrift.py` `GAMES` = verified games + per-game opt-ins only (fp_mem's
|
||||
unzeroed big-block cache, `msaa_ini`); others need `--force-unverified`. 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;
|
||||
@@ -1110,6 +1055,13 @@ GitHub identity + credentials are maintainer-local (`CLAUDE.local.md`): check `g
|
||||
GitHub write. **The owner approves before posting, labelling or closing anything.** Reply style: "I"/neutral, never
|
||||
we/our/us; never mention agent version numbers or internal tool versions; show the owner every draft in full with a
|
||||
short recap of the thread so far; read **all** comments on a thread first (a later one often has the zip or the answer).
|
||||
- **Every triage also sweeps for unanswered comments**, not just new threads: open AND recently closed issues, PRs
|
||||
(incl. merged/closed ones) and discussions (top-level comments and their replies) whose latest comment isn't the
|
||||
maintainer's, plus earlier user comments a later maintainer reply skipped. Users often ask follow-ups after a fix or
|
||||
after closing (#294: two questions after the reporter closed it; #249: a Q&A discussion with no reply for days;
|
||||
#161/#110: requests posted after the close). Quick check: `gh issue list --state all --search "updated:>=<date>"
|
||||
--json number,comments --jq '.[] | select(.comments[-1].author.login != "spoopyghosty0")'`; discussions via GraphQL
|
||||
(`discussions(orderBy:UPDATED_AT)` with `comments{replies}`).
|
||||
- **Issue templates & labels** (`.github/ISSUE_TEMPLATE/`): `[Game report]` → `game-compatibility` (a game that
|
||||
doesn't work right); `[Working recipe]` (`working-config.yml`) → `working-config` (a headset-verified config to add);
|
||||
`[App]` → `bug` (FramePort itself). Mislabel/relabel to match.
|
||||
@@ -1155,9 +1107,15 @@ MR-only (PASSTHROUGH required + BOUNDARYLESS_APP) → force_passthrough, + USE_S
|
||||
hand tracking required → controller_fix=0; OVRPlugin + ≥20 GiB → disable_space_warp; Unreal ≤4.21 or Unreal Meta XR
|
||||
Audio → nodebug; Oculus-OS class referenced by the Unreal audio build or ≥2 Meta libs → oculusos; legacy-VrApi Unity
|
||||
GLES with MSAA → unity_no_msaa; CryEngine → user.cfg r_variable_rate_shading=0; direct VrApi → bridge (+GL shim for
|
||||
GLAD/GLES); Unreal → alternate no-ForceQuit build. Score changes with
|
||||
`python scripts/eval_heuristics.py "<dumps>"` (catalog off vs verified recipes; currently 33/34 exact — Phantom's
|
||||
"use the no-ForceQuit build" is only detectable at runtime via triage). Add a rule → re-run the eval + `pytest -m games`.
|
||||
GLAD/GLES); Unreal → alternate no-ForceQuit build. Scoring (docs/HEURISTICS_EVAL.md, 2026-10-15): analyse the dumps
|
||||
once (`scripts/heuristics_snapshot.py <out.json> "<VR CyberDeck Portable>" "<PATCHED>"`, ~30 min, keep out of git),
|
||||
then `scripts/eval_heuristics.py --snapshot <out.json> --split dev|holdout|all [--fit-report]` (catalog-only truth,
|
||||
per-patch precision/recall, a fixed 20% holdout to catch rules fitted to one game). Read the **dated** score: an
|
||||
"extra" on an entry verified before the patch existed is a catalog gap, and adapter/device picks are inherited by
|
||||
catalog recipes anyway. 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6%; remaining misses are
|
||||
one-game runtime fixes. Don't key a rule on one game's code fingerprint (an Unreal splash/depth "detect" matching
|
||||
only Star Wars Tales + FNAF HW was rejected: it would also have bumped ANALYSIS_VERSION for every user). Add a rule →
|
||||
re-run dev + holdout + `pytest -m games`.
|
||||
|
||||
## Frame operations
|
||||
- Find/connect: `frameport frame discover` / `frame info` (the remembered Frame is in `<user data>/frames.json`).
|
||||
|
||||
-1308
File diff suppressed because it is too large.
Load diff
@@ -1,133 +0,0 @@
|
||||
"""Data-driven FEX game compatibility. No entitlement/runtime-signature changes; stdlib only on the Frame."""
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
|
||||
STATUSES = {"verified", "likely", "untested", "blocked"}
|
||||
GRAPHICS = {"d3d11", "d3d9", "d3d12", "opengl", "vulkan"}
|
||||
|
||||
|
||||
def exe_facts(path):
|
||||
"""Read just the PE headers. A 32-bit client still needs GE's WoW64 and a working 32-bit VR/graphics bridge."""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(64)
|
||||
if head[:2] != b"MZ" or len(head) < 64:
|
||||
return {}
|
||||
f.seek(struct.unpack_from("<I", head, 0x3c)[0])
|
||||
pe = f.read(26)
|
||||
if len(pe) < 26 or pe[:4] != b"PE\0\0":
|
||||
return {}
|
||||
machine = struct.unpack_from("<H", pe, 4)[0]
|
||||
return {"exe_32bit": machine == 0x14c, "exe_machine": hex(machine)}
|
||||
except (OSError, struct.error):
|
||||
return {}
|
||||
|
||||
|
||||
def sha256(path):
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def match_profile(games, app, path):
|
||||
"""Exact canonical name OR SHA-256 OR (exe basename AND byte size). No fuzzy title matches: different store
|
||||
builds can need different routes. Multiple matches are refused rather than silently picking one."""
|
||||
hits, digest = [], None
|
||||
for key, profile in games.items():
|
||||
match = profile.get("match", {"canonical_names": [key]})
|
||||
hit = app in match.get("canonical_names", [])
|
||||
if not hit and path and os.path.isfile(path):
|
||||
hit = any(os.path.basename(path).lower() == e["name"].lower() and os.path.getsize(path) == e["size"]
|
||||
for e in match.get("executables", []))
|
||||
if not hit and match.get("sha256"):
|
||||
digest = digest or sha256(path)
|
||||
hit = digest in match["sha256"]
|
||||
if hit:
|
||||
hits.append((key, profile))
|
||||
if len(hits) > 1:
|
||||
raise ValueError("ambiguous FEX compatibility profiles: " + ", ".join(k for k, _ in hits))
|
||||
return hits[0] if hits else (None, None)
|
||||
|
||||
|
||||
def graphics_lines(route):
|
||||
if route not in GRAPHICS:
|
||||
raise ValueError(f"unknown FEX graphics route: {route}")
|
||||
# Proton owns DLL selection/copying: DXVK for D3D9/11, vkd3d-proton for D3D12. Do not override DLLs or copy
|
||||
# anything into the game. Clearing inherited flags also keeps a prior game's wined3d choice from leaking in.
|
||||
lines = ["unset PROTON_USE_WINED3D PROTON_NO_D3D12 PROTON_NO_D3D11"]
|
||||
if route == "opengl":
|
||||
lines.append("export PROTON_USE_WINED3D=1")
|
||||
lines.append(f"echo 'FramePort FEX: graphics={route}'")
|
||||
return lines
|
||||
|
||||
|
||||
def register(core):
|
||||
original_find = core.find_ime_target
|
||||
|
||||
def find_ime_target(bins):
|
||||
# Before ApplicationCore was split out, FWindowsApplication (including its deferral CVar) lived in Core.
|
||||
# Robo Recall's modular UE4 build has the SAME CVar and constructor instruction there. Search that module
|
||||
# with the existing anchored finder; never infer a member offset from bAllowedToDeferMessageProcessing.
|
||||
extra = []
|
||||
for folder in sorted({os.path.dirname(b) for b in bins}):
|
||||
extra += [os.path.join(folder, n) for n in sorted(os.listdir(folder))
|
||||
if re.search(r"(?:^|-)core(?:-win(?:32|64)-shipping)?\.dll$", n, re.I)]
|
||||
return original_find(list(dict.fromkeys(list(bins) + extra)))
|
||||
|
||||
def game_info(app, info):
|
||||
path = os.path.join(info.get("game_dir") or os.path.join(core.META_DIR, "Software", "Software", app),
|
||||
info.get("exe", ""))
|
||||
key, profile = match_profile(core.GAMES, app, path)
|
||||
if profile is None:
|
||||
return
|
||||
status = profile.get("status", "verified") # backward compatible with First Contact's original data
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"invalid FEX profile status: {status}")
|
||||
info.update({k: v for k, v in profile.items() if k != "match"})
|
||||
info.update(profile=key, status=status)
|
||||
|
||||
def launcher_env(app, info):
|
||||
lines = graphics_lines(info.get("graphics", "d3d11"))
|
||||
if info.get("fp_mem") == "enabled":
|
||||
if info.get("status") != "verified":
|
||||
raise ValueError("fp_mem requires a verified per-game opt-in")
|
||||
lines.append("export FP_MEM=1")
|
||||
lines.append("export FP_BIGCACHE_MB=" + str(int(info.get("bigcache_mb", 256))))
|
||||
# candidate is informational: never enable the unzeroed cache merely because the engine uses Binned2.
|
||||
for k, v in info.get("env", {}).items():
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", k) or not isinstance(v, str):
|
||||
raise ValueError("invalid FEX profile environment")
|
||||
# with the perf extension loaded, perf_prepare writes the game's one dxvk.conf (profile lines + its settings)
|
||||
if info.get("dxvk") and "perf_prepare" not in core.COMMANDS:
|
||||
lines += ["printf '%s\\n' " + " ".join(shlex.quote(line) for line in info["dxvk"]) + ' > "$A/dxvk.conf"',
|
||||
'export DXVK_CONFIG_FILE="$A/dxvk.conf"']
|
||||
return lines
|
||||
|
||||
def checks(app, info, result):
|
||||
facts = exe_facts(result["exe"])
|
||||
facts.update(profile=info.get("profile"), profile_status=info.get("status", "untested"),
|
||||
graphics=info.get("graphics", "d3d11"), fp_mem=info.get("fp_mem", "off"))
|
||||
# frameport_agent forwards checks to the PC and deployment.json; top-level helper warnings aren't forwarded.
|
||||
if result.get("warnings"):
|
||||
facts["profile_warnings"] = result["warnings"]
|
||||
if facts.get("exe_32bit"):
|
||||
facts["wow64"] = "untested: requires GE WoW64, 32-bit graphics and SteamVR vrclient bridge under FEX"
|
||||
if facts["graphics"] == "opengl":
|
||||
facts["opengl"] = "untested: no FEX GL thunk configured; rootfs GL may use software rendering or Zink"
|
||||
if facts["graphics"] == "d3d12":
|
||||
# GE versions have used both split lib/lib64 and architecture-qualified Wine directories. Report what
|
||||
# is actually installed; the Proton script remains responsible for selecting its own layout.
|
||||
paths = [os.path.join(core.GE, "files", lib, "wine", "vkd3d-proton", tail, "d3d12.dll")
|
||||
for lib in ("lib", "lib64") for tail in ("", "x86_64-windows", "i386-windows")]
|
||||
facts["vkd3d_dlls"] = [os.path.relpath(p, core.GE) for p in paths if os.path.isfile(p)]
|
||||
return facts
|
||||
|
||||
core.GAME_INFO_HOOKS.append(game_info)
|
||||
core.find_ime_target = find_ime_target
|
||||
core.LAUNCHER_ENV_HOOKS.append(launcher_env)
|
||||
core.INSTALL_CHECK_HOOKS.append(checks)
|
||||
@@ -1,396 +0,0 @@
|
||||
{
|
||||
"_comment": [
|
||||
"FEX compatibility profiles, loaded by fexrift.py and matched by fexrift_compat.py. No entitlement changes.",
|
||||
"match: OR of canonical_names (exact Meta names), sha256 (list of exe hashes), executables (list of {name,size}, both required). Without match the key is the exact canonical name. Ambiguous matches are refused.",
|
||||
"status: verified (headset checked), likely (static evidence; install with warning), untested (force_unverified required), blocked (refuse even force). graphics: d3d11|d3d9 (DXVK), d3d12 (vkd3d-proton), opengl (PROTON_USE_WINED3D), vulkan.",
|
||||
"env: string-valued environment; args: optional launch arguments override (absent keeps manifest). dxvk: dxvk.conf lines written only to the launcher anchor. notes: evidence and first-launch checks. revive: false for direct OpenVR games, otherwise true.",
|
||||
"ime_patch: optional fallback {rva,old,new}, RVA may be hex; generic finder always wins, including old modular Core.dll. Null means no fallback, never an exemption from IME checks.",
|
||||
"fp_mem: off|candidate|enabled; candidate is informational and NEVER enables the unzeroed cache. enabled requires verified status. bigcache_mb: cache size when enabled, default 256. env FP_MEM is an explicit opt-in retained for First Contact.",
|
||||
"exe is the verified First Contact fallback only; other profiles keep the Meta manifest launch path. msaa_ini allows --msaa for that verified game only."
|
||||
],
|
||||
"oculus-first-contact": {
|
||||
"title": "Oculus First Contact",
|
||||
"exe": "TouchNUX/Binaries/Win64/TouchNUX-Win64-Shipping.exe",
|
||||
"args": [
|
||||
"-gamemode=\"experienceonly\""
|
||||
],
|
||||
"ime_patch": {
|
||||
"rva": "0x22a605",
|
||||
"old": "c7862001000001000000",
|
||||
"new": "c7862001000000000000"
|
||||
},
|
||||
"_env": "Unreal's large-block allocator gives 0.5-1.6 GB blocks back every few seconds and this game overwrites them completely: fp_mem keeps them (native/fexwine/fp_mem.c)",
|
||||
"env": {
|
||||
"FP_MEM": "1",
|
||||
"FP_BIGCACHE_MB": "256"
|
||||
},
|
||||
"msaa_ini": true,
|
||||
"status": "verified",
|
||||
"graphics": "d3d11",
|
||||
"fp_mem": "enabled",
|
||||
"bigcache_mb": 256,
|
||||
"notes": "Headset verified; r.MobileMSAA=2 prevented rendering. Other games remain unverified."
|
||||
},
|
||||
"arktika-1": {
|
||||
"title": "Arktika 1",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "arktika1.exe",
|
||||
"size": 20181504
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "graphics API unclear (?); custom engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown; d3d11 is a trial default, not a detected API"
|
||||
},
|
||||
"asgards-wrath": {
|
||||
"title": "Asgards Wrath",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "WrathGame-Win64-Shipping.exe",
|
||||
"size": 63707648
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "candidate",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR/OpenVR); Media Foundation videos need a playback test"
|
||||
},
|
||||
"brass-tactics": {
|
||||
"title": "Brass Tactics",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "Brass-Win64-Shipping.exe",
|
||||
"size": 52790272
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR/OpenVR); Media Foundation videos need a playback test"
|
||||
},
|
||||
"chronos": {
|
||||
"title": "Chronos",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "Chronos-Win64-Shipping.exe",
|
||||
"size": 40722432
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": "off",
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "IME fix OFF: the generic finder matched member +0x100 in this UE 4.10 build and the patched game never submitted a frame; unpatched it renders (Frame headless 2026-10-16: 202 frames / 105 s, 1.9 fps while loading, headset not yet checked). Media Foundation videos need a playback test"
|
||||
},
|
||||
"defector": {
|
||||
"title": "Defector",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "DFCTR.exe",
|
||||
"size": 16204288
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "custom engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown"
|
||||
},
|
||||
"hubris": {
|
||||
"title": "Hubris",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "HubrisVR-Win64-Shipping.exe",
|
||||
"size": 85706240
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "candidate",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR/OpenXR/OpenVR); Media Foundation videos need a playback test"
|
||||
},
|
||||
"lies-beneath": {
|
||||
"title": "Lies Beneath",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "Kodiak-Win64-Shipping.exe",
|
||||
"size": 63537664
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "candidate",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR/OpenVR); Media Foundation videos need a playback test"
|
||||
},
|
||||
"lone-echo": {
|
||||
"title": "Lone Echo",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "loneecho.exe",
|
||||
"size": 22336512
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "custom engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown"
|
||||
},
|
||||
"lone-echo-2": {
|
||||
"title": "Lone Echo 2",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "loneecho2.exe",
|
||||
"size": 33436160
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "d3d12",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "D3D12: needs vkd3d-proton on Turnip under FEX (untested); custom engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown"
|
||||
},
|
||||
"rick-and-morty-virtual-rick-ality": {
|
||||
"title": "Rick and Morty - Virtual Rick-ality",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "RickAndMortyVR.exe",
|
||||
"size": 21284352
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "opengl",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "OpenGL: no FEX GL thunk configured; rootfs GL renderer unverified; OpenVR directly, no Platform SDK; guest GL and speech WinRT need a device check",
|
||||
"revive": false
|
||||
},
|
||||
"robo-recall": {
|
||||
"title": "Robo Recall",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "RoboRecall-Win64-Shipping.exe",
|
||||
"size": 198144
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR/OpenVR)"
|
||||
},
|
||||
"skydances-behemoth": {
|
||||
"title": "Skydances BEHEMOTH",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "BHM-Win64-Shipping.exe",
|
||||
"size": 144449024
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "candidate",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR/OpenXR); Media Foundation videos need a playback test"
|
||||
},
|
||||
"stormland": {
|
||||
"title": "Stormland",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "Stormland.exe",
|
||||
"size": 85195776
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "custom engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown; Media Foundation videos need a playback test"
|
||||
},
|
||||
"the-climb": {
|
||||
"title": "The Climb",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "Climb.exe",
|
||||
"size": 95534080
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "d3d9",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "D3D9: DXVK under FEX (untested); CryEngine engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown; D3D9 preferred over imported GL; confirm the actual selected renderer"
|
||||
},
|
||||
"the-unspoken": {
|
||||
"title": "The Unspoken",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "Unspoken.exe",
|
||||
"size": 69496320
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "custom engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown; Media Foundation videos need a playback test"
|
||||
},
|
||||
"vader-immortal-episode-i": {
|
||||
"title": "Vader Immortal - Episode I",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "WKND-Win64-Shipping.exe",
|
||||
"size": 58100736
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR); Media Foundation videos need a playback test"
|
||||
},
|
||||
"vader-immortal-episode-ii": {
|
||||
"title": "Vader Immortal - Episode II",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "WKND-Win64-Shipping.exe",
|
||||
"size": 59464192
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR); Media Foundation videos need a playback test"
|
||||
},
|
||||
"vader-immortal-episode-iii": {
|
||||
"title": "Vader Immortal - Episode III",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "WKND-Win64-Shipping.exe",
|
||||
"size": 59814912
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "likely",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "same profile as First Contact (x64, D3D11, LibOVR/OpenVR); Media Foundation videos need a playback test"
|
||||
},
|
||||
"wilsons-heart": {
|
||||
"title": "Wilsons Heart",
|
||||
"match": {
|
||||
"executables": [
|
||||
{
|
||||
"name": "WHVR.exe",
|
||||
"size": 15379968
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": "untested",
|
||||
"graphics": "d3d11",
|
||||
"env": {},
|
||||
"ime_patch": null,
|
||||
"fp_mem": "off",
|
||||
"bigcache_mb": 256,
|
||||
"dxvk": [],
|
||||
"notes": "custom engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown"
|
||||
}
|
||||
}
|
||||
@@ -1,361 +0,0 @@
|
||||
"""Local Rift copies in Meta libraries. Runs on the Frame, stdlib only; never touches entitlement or login data."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
import zlib
|
||||
|
||||
SEGMENT_SIZE = 10000000 # Meta's manifest format uses decimal 10 MB, not MiB
|
||||
|
||||
|
||||
def canonical(value):
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]+", value):
|
||||
raise ValueError("invalid Meta canonical name")
|
||||
return value
|
||||
|
||||
|
||||
def relative(value):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("invalid relative game path")
|
||||
value = value.replace("\\", "/")
|
||||
if (not value or value.startswith("/") or any(p in ("", ".", "..") for p in value.split("/"))
|
||||
or any(c in value for c in ':\x00\r\n')):
|
||||
raise ValueError("game path escapes its folder")
|
||||
return value
|
||||
|
||||
|
||||
def skipped(name):
|
||||
name = name.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
return (name.endswith((".7z", ".ag")) or bool(re.search(r"\.7z\.0", name))
|
||||
or name in ("data.bin", "agrepackinstaller.exe", "vd.bat"))
|
||||
|
||||
|
||||
def confined(path, root):
|
||||
"""Reject symlink components too: an upload or delete must never follow a game-controlled link."""
|
||||
root, path = os.path.abspath(root), os.path.abspath(path)
|
||||
if os.path.commonpath([path, root]) != root:
|
||||
raise ValueError("path escapes the library")
|
||||
cursor = path
|
||||
while True:
|
||||
if os.path.islink(cursor):
|
||||
raise ValueError("symlink in local game path")
|
||||
if cursor == root:
|
||||
break
|
||||
cursor = os.path.dirname(cursor)
|
||||
if os.path.realpath(root) != root:
|
||||
raise ValueError("symlink in library root")
|
||||
return path
|
||||
|
||||
|
||||
def hash_file(path):
|
||||
total, segments = hashlib.sha256(), []
|
||||
size = 0
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(SEGMENT_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
total.update(chunk)
|
||||
# The fixture's last element is the zlib-compressed length, not the uncompressed chunk size. The first
|
||||
# element is an opaque Meta segment identifier: 0 explicitly means unknown for a local-only copy. These
|
||||
# records register existing bytes; they are NOT a CDN download/repair recipe (device validation needed).
|
||||
segments.append([0, hashlib.sha256(chunk).hexdigest(), len(zlib.compress(chunk))])
|
||||
return {"sha256": total.hexdigest(), "size": size, "segmentSize": SEGMENT_SIZE, "segments": segments}
|
||||
|
||||
|
||||
def register(core):
|
||||
def record_path(app):
|
||||
return os.path.join(core.ROOT, "local-games", canonical(app) + ".json")
|
||||
|
||||
def read_record(app):
|
||||
return core.read_json(record_path(app)) or {}
|
||||
|
||||
def save_record(app, record):
|
||||
os.makedirs(os.path.dirname(record_path(app)), exist_ok=True)
|
||||
path = record_path(app)
|
||||
with open(path + ".tmp", "w") as f:
|
||||
json.dump(record, f, indent=1)
|
||||
os.replace(path + ".tmp", path)
|
||||
|
||||
def layout(app, dest):
|
||||
app = canonical(app)
|
||||
if dest:
|
||||
# The main agent validates this against list_drives / resolve_dest on EVERY command. No fallback when a
|
||||
# drive disappears. Keep the physical game path simple while Meta sees its required Software/ layout.
|
||||
if not os.path.isabs(dest) or not os.path.isdir(dest):
|
||||
raise ValueError("destination drive is not mounted")
|
||||
base = confined(os.path.join(dest, "FramePort", "fexrift-games"), dest)
|
||||
library = confined(os.path.join(dest, "FramePort", "fexrift-library"), dest)
|
||||
guid = str(uuid.uuid5(uuid.NAMESPACE_URL, "frameport-fex-library:" + dest))
|
||||
else:
|
||||
library = os.path.join(core.META_DIR, "Software")
|
||||
base, guid = os.path.join(library, "Software"), core.LIBRARY_GUID
|
||||
return {"game_dir": confined(os.path.join(base, app), base), "library": library,
|
||||
"library_guid": guid, "dest": dest}
|
||||
|
||||
def validated_record(app):
|
||||
rec = read_record(app)
|
||||
if not rec:
|
||||
raise ValueError("no local-copy upload reservation for this game")
|
||||
expected = layout(app, rec.get("dest"))
|
||||
if any(rec.get(k) != v for k, v in expected.items()):
|
||||
raise ValueError("local-copy reservation has an invalid storage path")
|
||||
return rec
|
||||
|
||||
def prepare(args):
|
||||
app = canonical(args["app"])
|
||||
package = args["package"]
|
||||
files, seen = {}, set()
|
||||
for name, st in args["files"].items():
|
||||
rel = relative(name)
|
||||
if skipped(rel) or rel.casefold() in seen:
|
||||
raise ValueError("skipped or duplicate file in upload inventory")
|
||||
if not isinstance(st["size"], int) or st["size"] < 0 or not isinstance(st["mtime_ns"], int):
|
||||
raise ValueError("invalid source file stat")
|
||||
seen.add(rel.casefold())
|
||||
files[rel] = st
|
||||
if not files:
|
||||
raise ValueError("no game files to upload")
|
||||
old = read_record(app)
|
||||
dest = args.get("dest") or None
|
||||
loc = layout(app, dest)
|
||||
if old and (old.get("dest") != dest or old.get("package") != package):
|
||||
raise ValueError("local copy already has another package or drive; uninstall it before changing either")
|
||||
if not old and (os.path.exists(os.path.join(loc["library"], "Manifests", app + ".json"))
|
||||
or os.path.exists(loc["game_dir"])):
|
||||
raise ValueError("game already exists outside FramePort's local-copy installer")
|
||||
existing = {}
|
||||
for rel, st in files.items():
|
||||
path = confined(os.path.join(loc["game_dir"], rel), loc["game_dir"])
|
||||
confined(path + ".part", loc["game_dir"])
|
||||
if old.get("files", {}).get(rel) != st:
|
||||
# Delete completed bytes too: if this new attempt is cancelled before uploading them, the next
|
||||
# prepare must not mistake the old same-size file for a completed transfer of the new source.
|
||||
for stale in (path, path + ".part"):
|
||||
if os.path.isfile(stale):
|
||||
os.unlink(stale)
|
||||
if (old.get("files", {}).get(rel) == st and os.path.isfile(path)
|
||||
and os.path.getsize(path) == st["size"]):
|
||||
existing[rel] = st["size"]
|
||||
if old and set(old.get("files", {})) - set(files):
|
||||
raise ValueError("source removed files since the last upload; uninstall the local copy before replacing it")
|
||||
os.makedirs(loc["game_dir"], exist_ok=True)
|
||||
st = os.statvfs(loc["game_dir"])
|
||||
save_record(app, dict(old, **loc, app=app, package=package, files=files))
|
||||
return dict(loc, existing=existing, free_bytes=st.f_bavail * st.f_frsize)
|
||||
|
||||
def registry_library(rec, remove=False):
|
||||
guid, dest = rec["library_guid"], rec.get("dest")
|
||||
key = rf"HKEY_CURRENT_USER\Software\Oculus VR, LLC\Oculus\Libraries\{guid}"
|
||||
if remove:
|
||||
text = f"Windows Registry Editor Version 5.00\r\n\r\n[-{key}]\r\n"
|
||||
else:
|
||||
if dest:
|
||||
devices = os.path.join(core.PFX, "dosdevices")
|
||||
os.makedirs(devices, exist_ok=True)
|
||||
volume = os.path.join(devices, "volume{" + guid + "}")
|
||||
if os.path.lexists(volume):
|
||||
if not os.path.islink(volume) or os.path.realpath(volume) != dest:
|
||||
raise ValueError("Wine volume mapping is already in use")
|
||||
else:
|
||||
os.symlink(dest, volume)
|
||||
# Do not appropriate a user's drive letter. Reuse our mount mapping, else pick the first unused one.
|
||||
letter = next((c for c in "defghijklmnopqrstuvwxy"
|
||||
if os.path.islink(os.path.join(devices, c + ":"))
|
||||
and os.path.realpath(os.path.join(devices, c + ":")) == dest), None)
|
||||
if not letter:
|
||||
letter = next((c for c in "defghijklmnopqrstuvwxy"
|
||||
if not os.path.lexists(os.path.join(devices, c + ":"))), None)
|
||||
if not letter:
|
||||
raise ValueError("no free Wine drive letter")
|
||||
os.symlink(dest, os.path.join(devices, letter + ":"))
|
||||
rec["drive_letter"] = letter
|
||||
original = letter.upper() + ":\\FramePort\\fexrift-library"
|
||||
path = "\\\\?\\Volume{" + guid + "}\\FramePort\\fexrift-library"
|
||||
else:
|
||||
original = "C:\\Program Files\\Meta Horizon\\Software"
|
||||
path = "\\\\?\\Volume{00000000-0000-0000-0000-000000000043}\\Program Files\\Meta Horizon\\Software"
|
||||
text = "Windows Registry Editor Version 5.00\r\n\r\n"
|
||||
if not dest:
|
||||
text += (r"[HKEY_CURRENT_USER\Software\Oculus VR, LLC\Oculus\Libraries]" + "\r\n"
|
||||
+ '"DefaultLibrary"=' + core.meta_reg_str(guid) + "\r\n\r\n")
|
||||
text += (f"[{key}]\r\n" + '"OriginalPath"=' + core.meta_reg_str(original) + "\r\n"
|
||||
+ '"Path"=' + core.meta_reg_str(path) + "\r\n")
|
||||
reg = os.path.join(core.ROOT, "local-library.reg")
|
||||
core.put_text(reg, text)
|
||||
core.wine("regedit", "/S", core.winpath(reg), timeout=90)
|
||||
|
||||
def register_local(args):
|
||||
app = canonical(args["app"])
|
||||
rec = validated_record(app)
|
||||
if args.get("package") != rec["package"]:
|
||||
raise ValueError("upload belongs to another package")
|
||||
exe = relative(args["exe"])
|
||||
if exe not in rec["files"]:
|
||||
raise ValueError("launch program is not an uploaded file")
|
||||
app_id = args["app_id"]
|
||||
if not isinstance(app_id, str) or not re.fullmatch(r"[1-9][0-9]*", app_id):
|
||||
raise ValueError("invalid Meta app ID")
|
||||
files = {}
|
||||
for rel, st in rec["files"].items():
|
||||
path = confined(os.path.join(rec["game_dir"], rel), rec["game_dir"])
|
||||
if not os.path.isfile(path) or os.path.getsize(path) != st["size"]:
|
||||
raise ValueError(f"upload incomplete: {rel}")
|
||||
files[rel] = hash_file(path)
|
||||
library = rec["library"]
|
||||
os.makedirs(library, exist_ok=True)
|
||||
if rec.get("dest"):
|
||||
software = os.path.join(library, "Software")
|
||||
target = os.path.dirname(rec["game_dir"])
|
||||
if os.path.lexists(software):
|
||||
if not os.path.islink(software) or os.path.realpath(software) != target:
|
||||
raise ValueError("external Meta library Software path is already in use")
|
||||
else:
|
||||
os.symlink(target, software)
|
||||
args_list = args.get("args") or []
|
||||
if not isinstance(args_list, list) or any(not isinstance(a, str) or "\x00" in a for a in args_list):
|
||||
raise ValueError("launch arguments must be strings")
|
||||
# list2cmdline follows Windows argv quoting; Meta's own launchFile uses forward slashes. The local launcher
|
||||
# keeps the original argv list in the reservation, avoiding POSIX reinterpretation of Windows quotes.
|
||||
manifest = core.dawn_manifest({"packageType": "APP", "isCore": False, "appId": app_id,
|
||||
"canonicalName": app, "launchFile": exe,
|
||||
"launchParameters": subprocess.list2cmdline(args_list),
|
||||
"launchParameters2D": "", "version": str(args.get("version") or "0.0.0"),
|
||||
"versionCode": 1, "files": files})
|
||||
# App fixtures serialize the manifest's derived properties before the package base properties. Match that
|
||||
# order (core's runtime package writer uses base-first); both use the same defaults, CRLF and indentation.
|
||||
first = ("parentCanonicalName", "files", "dlcs", "assetFiles")
|
||||
manifest = {**{k: manifest[k] for k in first}, **{k: v for k, v in manifest.items() if k not in first}}
|
||||
manifests = os.path.join(library, "Manifests")
|
||||
os.makedirs(manifests, exist_ok=True)
|
||||
for suffix, content in ((".json", manifest), (".json.mini", dict(manifest, files={
|
||||
k: v for k, v in manifest["files"].items() if k.lower().endswith(".exe")}))):
|
||||
path = os.path.join(manifests, app + suffix)
|
||||
core.write_manifest(path + ".tmp", content)
|
||||
os.replace(path + ".tmp", path)
|
||||
registry_library(rec)
|
||||
rec.update({k: args.get(k) for k in ("app_id", "app_id_source", "store_app_id", "skipped", "title")})
|
||||
rec.update(source="local_copy", exe=exe, args=args_list, registered=True)
|
||||
save_record(app, rec)
|
||||
return local_info({"app": app})
|
||||
|
||||
def local_info(args):
|
||||
rec = validated_record(args["app"])
|
||||
return {k: rec.get(k) for k in ("source", "app_id", "app_id_source", "store_app_id", "skipped", "dest",
|
||||
"package", "game_dir", "library_guid", "registered")}
|
||||
|
||||
def unregister_local(args):
|
||||
app = canonical(args["app"])
|
||||
rec = validated_record(app)
|
||||
if args.get("package") != rec["package"]:
|
||||
raise ValueError("local copy belongs to another package")
|
||||
# Shared/default library stays. An external library is removed from the registry only after its last local
|
||||
# game goes; other games, the Wine prefix, sign-in and save directories remain intact.
|
||||
peers = [core.read_json(os.path.join(core.ROOT, "local-games", f)) or {}
|
||||
for f in os.listdir(os.path.dirname(record_path(app))) if f.endswith(".json") and f != app + ".json"]
|
||||
if rec.get("dest") and not any(p.get("library_guid") == rec["library_guid"] for p in peers):
|
||||
registry_library(rec, remove=True)
|
||||
if os.path.isdir(rec["game_dir"]):
|
||||
shutil.rmtree(rec["game_dir"])
|
||||
for suffix in (".json", ".json.mini"):
|
||||
path = os.path.join(rec["library"], "Manifests", app + suffix)
|
||||
if os.path.isfile(path):
|
||||
os.unlink(path)
|
||||
os.unlink(record_path(app))
|
||||
return {"removed": True}
|
||||
|
||||
def runtime_state(args):
|
||||
app = canonical(args["app"])
|
||||
rec = validated_record(app) if read_record(app) else None
|
||||
# Read only newly appended runtime log text. Never return log lines: runtime logs can contain account data.
|
||||
logdir = os.path.join(core.USER_DIR, "AppData", "Local", "Oculus")
|
||||
def logs():
|
||||
return [os.path.join(logdir, n) for n in os.listdir(logdir)
|
||||
if n.lower().endswith((".txt", ".log")) and any(
|
||||
s in n.lower() for s in ("server", "service"))] if os.path.isdir(logdir) else []
|
||||
offsets = {p: os.path.getsize(p) for p in logs()}
|
||||
librarian = os.path.join(logdir, "OVRLibrarian.log")
|
||||
lib_offset = os.path.getsize(librarian) if os.path.isfile(librarian) else 0
|
||||
probe = subprocess.run(["pgrep", "-f", "[O]VRServer_x64.exe"], capture_output=True, timeout=5)
|
||||
started, proc = probe.returncode != 0, None
|
||||
end = time.monotonic() + 95 # leave room for wineserver's bounded cleanup, total <=120 s
|
||||
state = "timeout"
|
||||
try:
|
||||
if started:
|
||||
runtime = os.path.join(core.META_DIR, "Support", "oculus-runtime")
|
||||
proc = subprocess.Popen([os.path.join(core.GEF, "files/bin/wine"), "./OVRServer_x64.exe"],
|
||||
cwd=runtime, env=core.wine_env(), stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
while time.monotonic() < end:
|
||||
if started and os.path.isfile(librarian):
|
||||
with open(librarian, errors="replace") as f:
|
||||
if os.path.getsize(librarian) < lib_offset:
|
||||
lib_offset = 0
|
||||
f.seek(lib_offset)
|
||||
done = "invocation completed" in f.read(65536).lower()
|
||||
lib_offset = f.tell()
|
||||
if done:
|
||||
subprocess.run(["pkill", "-9", "-f", "[O]VRLibrarian.exe"], capture_output=True, timeout=3)
|
||||
for path in logs():
|
||||
with open(path, errors="replace") as f:
|
||||
off = offsets.get(path, 0)
|
||||
f.seek(off if os.path.getsize(path) >= off else 0)
|
||||
text = f.read(1 << 20)
|
||||
offsets[path] = f.tell()
|
||||
for line in text.splitlines():
|
||||
if app not in line and (not rec or rec.get("app_id", "_") not in line):
|
||||
continue
|
||||
match = re.search(r"\bstate\s*:\s*(ready|install_available)\b", line, re.I)
|
||||
if match:
|
||||
state = match[1].lower()
|
||||
# Meta's PackageManager lists every registered package at start ("Currently installed
|
||||
# package: <name>, version code: N"); the "state:" line only comes later (app tracker / sign-in)
|
||||
elif state == "timeout" and re.search(r"Currently installed package:\s*" + re.escape(app) + r"\s*,",
|
||||
line):
|
||||
state = "installed"
|
||||
if state != "timeout" or (proc is not None and proc.poll() is not None):
|
||||
break
|
||||
time.sleep(1)
|
||||
finally:
|
||||
if started:
|
||||
subprocess.run([os.path.join(core.GEF, "files/bin/wineserver"), "-k"], env=core.wine_env(),
|
||||
capture_output=True, timeout=10)
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return {"runtime_state": state, "reused_server": not started}
|
||||
|
||||
original_manifest = core.manifest_launch
|
||||
original_info = core.game_info
|
||||
|
||||
def manifest_launch(app):
|
||||
rec = read_record(app)
|
||||
if not rec.get("registered"):
|
||||
return original_manifest(app)
|
||||
rec = validated_record(app)
|
||||
manifest = core.read_json(os.path.join(rec["library"], "Manifests", app + ".json")) or {}
|
||||
if not manifest.get("launchFile"):
|
||||
raise ValueError("local-copy Meta manifest is missing; register the copy again")
|
||||
return {"exe": relative(manifest["launchFile"]), "args": rec["args"], "title": rec["title"],
|
||||
"game_dir": rec["game_dir"], "args_argv": True}
|
||||
|
||||
def game_info(app):
|
||||
rec = read_record(app)
|
||||
if not rec.get("registered"):
|
||||
return original_info(app)
|
||||
info = dict(core.GAMES.get(app) or {})
|
||||
info.update(manifest_launch(app))
|
||||
return info, app in core.GAMES
|
||||
|
||||
core.manifest_launch = manifest_launch
|
||||
core.game_info = game_info
|
||||
core.COMMANDS.update(prepare_local=prepare, register_local=register_local, unregister_local=unregister_local,
|
||||
local_info=local_info, runtime_state=runtime_state)
|
||||
core.INSTALL_CHECK_HOOKS.append(lambda app, info, result: {"local_copy": True} if read_record(app) else {})
|
||||
@@ -1,400 +0,0 @@
|
||||
"""Meta's own browser sign-in, in the Frame's shared prefix. No credential files are read.
|
||||
|
||||
The broker is a separate, owner-only Unix socket process: native_sso URLs live only in its memory, never in a
|
||||
status file, launcher, journal or argv sent over SSH. Helpers and Wine subprocesses have their output discarded.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
CORE = None
|
||||
DESKTOP = "frameport-oculus.desktop"
|
||||
REGKEY = r"HKCU\Software\Wine\WineBrowser"
|
||||
MAX_URL = 65536
|
||||
|
||||
|
||||
def folder():
|
||||
return Path(CORE.ROOT) / "login"
|
||||
|
||||
|
||||
def request(message):
|
||||
with socket.socket(socket.AF_UNIX) as s:
|
||||
s.settimeout(90)
|
||||
s.connect(str(folder() / "broker.sock"))
|
||||
s.sendall(json.dumps(message).encode())
|
||||
s.shutdown(socket.SHUT_WR)
|
||||
return json.loads(receive(s))
|
||||
|
||||
|
||||
def receive(s):
|
||||
data = bytearray()
|
||||
while True:
|
||||
part = s.recv(8192)
|
||||
if not part:
|
||||
return data.decode()
|
||||
data.extend(part)
|
||||
if len(data) > MAX_URL * 2:
|
||||
raise ValueError("sign-in message too long")
|
||||
|
||||
|
||||
def valid_url(url, callback=False):
|
||||
if not isinstance(url, str) or len(url) > MAX_URL or any(ord(c) < 32 for c in url):
|
||||
return False
|
||||
try:
|
||||
u = urllib.parse.urlsplit(url)
|
||||
return (u.scheme, u.netloc, u.path) == (("oculus", "link", "/frl_login") if callback else
|
||||
("https", "auth.meta.com", "/native_sso/confirm"))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def host_env():
|
||||
env = dict(os.environ)
|
||||
env.update(CORE.steam_display_env())
|
||||
env.update(XDG_RUNTIME_DIR=f"/run/user/{os.getuid()}",
|
||||
DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{os.getuid()}/bus")
|
||||
env.pop("LD_PRELOAD", None)
|
||||
return env
|
||||
|
||||
|
||||
def quiet(argv, **kw):
|
||||
return subprocess.run(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=60, **kw)
|
||||
|
||||
|
||||
def wine_env():
|
||||
env = CORE.wine_env({"ENABLE_GAMESCOPE_WSI": "0", "PROTON_NO_ZENITY": "1", "PROTONFIXES_DISABLE": "1",
|
||||
"STEAM_COMPAT_CLIENT_INSTALL_PATH": CORE.STEAM})
|
||||
env.pop("LD_PRELOAD", None)
|
||||
env.pop("STEAM_COMPAT_INSTALL_PATH", None)
|
||||
return env
|
||||
|
||||
|
||||
def snapshot(paths):
|
||||
return {str(p): base64.b64encode(p.read_bytes()).decode() if p.exists() else None for p in paths}
|
||||
|
||||
|
||||
def restore_files(saved):
|
||||
for name, data in saved.items():
|
||||
p = Path(name)
|
||||
if data is None:
|
||||
p.unlink(missing_ok=True)
|
||||
else:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(base64.b64decode(data))
|
||||
|
||||
|
||||
def desktop_paths():
|
||||
config = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
||||
apps = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local/share") / "applications"
|
||||
paths = {apps / DESKTOP, config / "mimeapps.list", apps / "mimeapps.list", apps / "defaults.list"}
|
||||
for base in (config, apps):
|
||||
paths.update(base.glob("*-mimeapps.list"))
|
||||
for desktop in os.environ.get("XDG_CURRENT_DESKTOP", "").lower().split(":"):
|
||||
if desktop and re.fullmatch(r"[a-z0-9_-]+", desktop):
|
||||
paths.add(base / f"{desktop}-mimeapps.list")
|
||||
return apps, paths
|
||||
|
||||
|
||||
def desktop_entry(helper):
|
||||
# Desktop Exec has its own quoting (single shell quotes are not valid here); %% is a literal percent.
|
||||
escaped = str(helper).replace("%", "%%")
|
||||
for c in ("\\", '"', "`", "$"):
|
||||
escaped = escaped.replace(c, "\\" + c)
|
||||
escaped = re.sub(r"[\r\n\t]", " ", escaped.replace("\\", "\\\\")) # Desktop string-value layer
|
||||
return ('[Desktop Entry]\nType=Application\nName=Meta sign-in (FramePort)\n'
|
||||
f'Exec="{escaped}" %u\nNoDisplay=true\nTerminal=false\nMimeType=x-scheme-handler/oculus;\n')
|
||||
|
||||
|
||||
def helper_script(op):
|
||||
# This tiny script never imports the core or prints exceptions (which could contain the supplied URL).
|
||||
return ("#!/usr/bin/python3 -I\nimport json,socket,sys\ntry:\n"
|
||||
" with socket.socket(socket.AF_UNIX) as s:\n"
|
||||
" s.settimeout(90)\n"
|
||||
f" s.connect({str(folder() / 'broker.sock')!r})\n"
|
||||
f" s.sendall(json.dumps({{'op': {op!r}, 'url': sys.argv[1]}}).encode())\n"
|
||||
" s.shutdown(socket.SHUT_WR)\n s.recv(4096)\n"
|
||||
"except Exception:\n sys.exit(1)\n")
|
||||
|
||||
|
||||
def launcher():
|
||||
q = shlex.quote
|
||||
env = wine_env()
|
||||
keys = ("STEAM_COMPAT_DATA_PATH", "WINEPREFIX", "WINEDLLOVERRIDES", "WINEDEBUG", "FP_NATIVE_WSERVER",
|
||||
"PROTON_NO_ZENITY", "PROTONFIXES_DISABLE", "STEAM_COMPAT_CLIENT_INSTALL_PATH", "XDG_RUNTIME_DIR",
|
||||
"DBUS_SESSION_BUS_ADDRESS", "ENABLE_GAMESCOPE_WSI")
|
||||
exports = "".join(f"export {k}={q(env[k])}\n" for k in keys)
|
||||
display = "".join(f"export {k}={q(v)}\n" for k, v in CORE.steam_display_env().items())
|
||||
return f'''#!/usr/bin/env bash
|
||||
# FramePort Meta sign-in: discard Electron output, which can contain native_sso URLs.
|
||||
exec >/dev/null 2>&1
|
||||
exec 9>{q(str(folder() / '.launch.lock'))}; flock -n 9 || exit 0
|
||||
{exports}{display}unset LD_PRELOAD STEAM_COMPAT_INSTALL_PATH STEAM_COMPAT_LIBRARY_PATHS PROTON_LOG PROTON_LOG_DIR
|
||||
python3 -I {q(str(Path(CORE.HERE) / 'fexrift.py'))} login_ping '{{}}' || exit 0
|
||||
G={q(CORE.GEF)}
|
||||
L="$WINEPREFIX/drive_c/users/steamuser/AppData/Local/Oculus"
|
||||
cleanup() {{
|
||||
kill $LK 2>/dev/null
|
||||
"$G/files/bin/wineserver" -k
|
||||
rm -f {q(str(folder() / 'launch.pid'))}
|
||||
}}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 0' TERM INT HUP
|
||||
# The start tick prevents --stop from signalling a reused PID after an interrupted launcher.
|
||||
printf '%s %s\\n' "$$" "$(awk '{{print $22}}' /proc/$$/stat)" > {q(str(folder() / 'launch.pid'))}
|
||||
cd {q(str(Path(CORE.META_DIR) / 'Support/oculus-runtime'))}
|
||||
"$G/files/bin/wine" ./OVRServer_x64.exe </dev/null &
|
||||
for i in $(seq 1 45); do
|
||||
sleep 2
|
||||
tail -n 3 "$L/OVRLibrarian.log" 2>/dev/null | grep -qi "invocation completed" && break
|
||||
done
|
||||
sleep 3; pkill -9 -f "[O]VRLibrarian.exe"; sleep 10
|
||||
( while sleep 5; do pkill -9 -f "[O]VRLibrarian.exe"; done ) & LK=$!
|
||||
cd {q(str(Path(CORE.META_DIR) / 'Support/oculus-client'))}
|
||||
"$G/proton" run ./Client.exe
|
||||
'''
|
||||
|
||||
|
||||
def signed_in():
|
||||
sessions = Path(CORE.PFX) / "drive_c/users/steamuser/AppData/Roaming/Oculus/sessions"
|
||||
return CORE.signed_in(str(sessions)) # sessions/<numeric user id>/, not Meta's _oaf framework folder
|
||||
|
||||
|
||||
def running(name):
|
||||
# /proc, rather than pgrep -f, avoids matching the command requesting status. Scope to this prefix.
|
||||
for p in Path("/proc").glob("[0-9]*/environ"):
|
||||
try:
|
||||
if (f"WINEPREFIX={CORE.PFX}".encode() in p.read_bytes().split(b"\0") and
|
||||
name.encode().lower() in p.with_name("cmdline").read_bytes().lower()):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def status(args):
|
||||
st = CORE.read_json(str(folder() / "state.json"), {}) or {}
|
||||
out = {"runtime_running": running("OVRServer_x64.exe"), "client_running": running("Client.exe"),
|
||||
"page_opened_at": st.get("page_opened_at"), "callback_received_at": st.get("callback_received_at"),
|
||||
"signed_in": signed_in()}
|
||||
if args.get("take_url"):
|
||||
try:
|
||||
out.update(request({"op": "take_url"}))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def deliver(url):
|
||||
if not valid_url(url, callback=True):
|
||||
return {"delivered": False}
|
||||
try:
|
||||
# Never put the callback in argv, even between two local processes. The PE reads UTF-8 on stdin.
|
||||
p = quiet([str(Path(CORE.GEF) / "proton"), "run", str(Path(CORE.HERE) / "fp_pipesend.exe")],
|
||||
input=url.encode(), env=wine_env())
|
||||
return {"delivered": p.returncode == 0}
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return {"delivered": False}
|
||||
|
||||
|
||||
class Broker:
|
||||
def __init__(self, via_pc):
|
||||
self.via_pc = via_pc
|
||||
self.pending = None
|
||||
self.stopped = False
|
||||
|
||||
def handle(self, msg):
|
||||
op = msg.get("op")
|
||||
if op in ("page", "callback"):
|
||||
url = msg.get("url")
|
||||
if not valid_url(url, callback=op == "callback"):
|
||||
return {"accepted": False}
|
||||
st = CORE.read_json(str(folder() / "state.json"), {}) or {}
|
||||
st["page_opened_at" if op == "page" else "callback_received_at"] = time.time()
|
||||
if op == "page":
|
||||
st["page_url"] = "https://auth.meta.com/native_sso/confirm"
|
||||
CORE.write_json(str(folder() / "state.json"), st)
|
||||
if op == "callback":
|
||||
return deliver(url)
|
||||
if self.via_pc:
|
||||
self.pending = url
|
||||
else:
|
||||
quiet(["xdg-open", url], env=host_env())
|
||||
return {"accepted": True}
|
||||
if op == "take_url":
|
||||
url, self.pending = self.pending, None
|
||||
return {"open_url": url} if url else {}
|
||||
if op == "stop":
|
||||
self.pending = None
|
||||
self.stopped = True
|
||||
return {"ready": True}
|
||||
|
||||
|
||||
def broker(args):
|
||||
path = folder() / "broker.sock"
|
||||
service = Broker(bool(args.get("via_pc")))
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX) as s:
|
||||
s.bind(str(path))
|
||||
os.chmod(path, 0o600)
|
||||
s.listen(4)
|
||||
s.settimeout(1)
|
||||
end = time.monotonic() + int(args.get("timeout", 900))
|
||||
while not service.stopped and time.monotonic() < end:
|
||||
try:
|
||||
conn, _ = s.accept()
|
||||
except TimeoutError:
|
||||
continue
|
||||
with conn:
|
||||
conn.settimeout(5)
|
||||
try:
|
||||
reply = service.handle(json.loads(receive(conn)))
|
||||
conn.sendall(json.dumps(reply).encode())
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
pass # do not serialize an exception that might contain a URL
|
||||
finally:
|
||||
service.pending = None
|
||||
restore()
|
||||
path.unlink(missing_ok=True)
|
||||
return {"stopped": True}
|
||||
|
||||
|
||||
def restore():
|
||||
path = folder() / "restore.json"
|
||||
saved = CORE.read_json(str(path), None)
|
||||
if saved is None:
|
||||
return
|
||||
# Keep the restore record until every step succeeds: --stop can repair an interrupted cleanup.
|
||||
launcher_pid = stop_launcher()
|
||||
CORE.wineserver_kill()
|
||||
if launcher_pid:
|
||||
for _ in range(100):
|
||||
if process_tick(launcher_pid[0]) != launcher_pid[1]:
|
||||
break
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise CORE.FexError("Meta sign-in launcher is still stopping; retry fex login --stop")
|
||||
(folder() / "launch.pid").unlink(missing_ok=True)
|
||||
old = saved.get("browser")
|
||||
if old:
|
||||
CORE.wine("reg", "add", REGKEY, "/v", "Browsers", "/t", old[0], "/d", old[1], "/f")
|
||||
else:
|
||||
CORE.wine("reg", "delete", REGKEY, "/v", "Browsers", "/f", check=False)
|
||||
restore_files(saved["files"])
|
||||
path.unlink()
|
||||
|
||||
|
||||
def stop_launcher():
|
||||
# Killing Wine alone while launch.sh is waiting for OVRLibrarian lets it start Client.exe *after* cleanup.
|
||||
# Signal the owning shell too; its pending TERM exits the startup loop as soon as the current sleep ends.
|
||||
path = folder() / "launch.pid"
|
||||
try:
|
||||
pid, tick = path.read_text().split()
|
||||
pid = int(pid)
|
||||
if pid <= 1:
|
||||
return
|
||||
if process_tick(pid) == tick:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
return pid, tick
|
||||
except (OSError, ValueError, IndexError):
|
||||
pass
|
||||
|
||||
|
||||
def process_tick(pid):
|
||||
try:
|
||||
return Path(f"/proc/{pid}/stat").read_text().rpartition(")")[2].split()[19]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def stop(args):
|
||||
try:
|
||||
request({"op": "stop"})
|
||||
for _ in range(900):
|
||||
if not (folder() / "broker.sock").exists():
|
||||
break
|
||||
time.sleep(.1)
|
||||
else:
|
||||
raise CORE.FexError("Meta sign-in is still stopping; retry fex login --stop")
|
||||
except (OSError, ValueError):
|
||||
restore()
|
||||
(folder() / "broker.sock").unlink(missing_ok=True)
|
||||
return {"stopped": True, "signed_in": signed_in()}
|
||||
|
||||
|
||||
def start(args):
|
||||
timeout = int(args.get("timeout") or 900)
|
||||
if not 1 <= timeout <= 86400:
|
||||
raise CORE.FexError("sign-in timeout must be between 1 and 86400 seconds")
|
||||
if not (Path(CORE.HERE) / "fp_pipesend.exe").is_file():
|
||||
raise CORE.FexError("fp_pipesend.exe missing: build native/fexwine/pipesend.c and upload it with fex setup")
|
||||
if not (Path(CORE.META_DIR) / "Support/oculus-client/Client.exe").is_file():
|
||||
raise CORE.FexError("Meta's PC app isn't installed yet; run fex setup first")
|
||||
if running("Client.exe") or running("OVRServer_x64.exe"):
|
||||
raise CORE.FexError("close the Rift game or Meta app before starting sign-in (the prefix is shared)")
|
||||
try:
|
||||
request({"op": "ping"})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
else:
|
||||
raise CORE.FexError("Meta sign-in already active; use fex login --stop first")
|
||||
stop({}) # repair a previous interrupted run before snapshotting its handler
|
||||
folder().mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
folder().chmod(0o700)
|
||||
apps, paths = desktop_paths()
|
||||
previous = subprocess.run(["xdg-mime", "query", "default", "x-scheme-handler/oculus"],
|
||||
capture_output=True, text=True, timeout=10, env=host_env()).stdout.strip()
|
||||
reg = CORE.wine("reg", "query", REGKEY, "/v", "Browsers", check=False)
|
||||
match = re.search(r"Browsers\s+(REG_\w+)\s+(.*)", reg.stdout)
|
||||
CORE.write_json(str(folder() / "restore.json"),
|
||||
{"browser": list(match.groups()) if match else None, "handler": previous,
|
||||
"files": snapshot(paths)})
|
||||
try:
|
||||
for name, text in (("fp-open-url", helper_script("page")),
|
||||
("fp-oculus-callback", helper_script("callback")), ("launch.sh", launcher())):
|
||||
CORE.put_text(str(folder() / name), text, 0o700)
|
||||
CORE.write_json(str(folder() / "state.json"), {})
|
||||
apps.mkdir(parents=True, exist_ok=True)
|
||||
(apps / DESKTOP).write_text(desktop_entry(folder() / "fp-oculus-callback"))
|
||||
if quiet(["xdg-mime", "default", DESKTOP, "x-scheme-handler/oculus"], env=host_env()).returncode:
|
||||
raise CORE.FexError("couldn't register the Frame's temporary oculus link handler")
|
||||
CORE.wine("reg", "add", REGKEY, "/v", "Browsers", "/t", "REG_SZ", "/d",
|
||||
str(folder() / "fp-open-url"), "/f")
|
||||
subprocess.Popen([sys.executable, "-I", str(Path(CORE.HERE) / "fexrift.py"), "login_broker",
|
||||
json.dumps({"via_pc": bool(args.get("via_pc")), "timeout": timeout + 180})],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
start_new_session=True)
|
||||
for _ in range(100):
|
||||
try:
|
||||
request({"op": "ping"})
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise CORE.FexError("couldn't start Meta sign-in's private URL broker")
|
||||
if args.get("start_now"):
|
||||
if quiet(["systemd-run", "--user", "--collect", "--quiet", "--unit=frameport-fexlogin",
|
||||
"--property=StandardOutput=null", "--property=StandardError=null",
|
||||
str(folder() / "launch.sh")], env=host_env()).returncode:
|
||||
raise CORE.FexError("couldn't start Meta's app; retry without --start-now and use Steam")
|
||||
return {"launcher": str(folder() / "launch.sh"), "started": bool(args.get("start_now")),
|
||||
"signed_in": signed_in()}
|
||||
except Exception:
|
||||
stop({})
|
||||
raise
|
||||
|
||||
|
||||
def register(core):
|
||||
global CORE
|
||||
CORE = core
|
||||
core.COMMANDS.update(login_start=start, login_status=status, login_stop=stop, login_broker=broker,
|
||||
login_ping=lambda args: request({"op": "ping"}),
|
||||
login_callback=lambda args: request({"op": "callback", "url": args.get("url")}))
|
||||
@@ -1,575 +0,0 @@
|
||||
"""Launch-time performance experiments. Frame side, stdlib only; no account data is read here.
|
||||
|
||||
Settings contain numbers/booleans and a small environment whitelist, never arbitrary shell. A launch prepares its
|
||||
own config and exports it before Proton starts. Bench owns the otherwise-idle shared prefix for at most 120 s.
|
||||
"""
|
||||
import collections
|
||||
import ctypes as C
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
CORE = None
|
||||
FEX_KEYS = {"FEX_TSOENABLED", "FEX_VECTORTSOENABLED", "FEX_MEMCPYSETTSOENABLED", "FEX_MULTIBLOCK",
|
||||
"FEX_X87REDUCEDPRECISION"}
|
||||
DEFAULTS = {"dxvk_disable_msaa": False, "dxgi_sync_interval": -1, "dxgi_max_frame_latency": 0,
|
||||
"eye_scale": 1.5, "motion_smoothing": 2, "cores": "4-7", "ntsync": True,
|
||||
"fp_mem": False, "bigcache_mb": 256, "fp_mem_zero": False, "fex": {},
|
||||
"wine_large_address_aware": False}
|
||||
BOOL_KEYS = {"dxvk_disable_msaa", "ntsync", "fp_mem", "fp_mem_zero", "wine_large_address_aware"}
|
||||
INT_KEYS = {"dxgi_sync_interval": (-1, 4), "dxgi_max_frame_latency": (0, 16), "motion_smoothing": (0, 2),
|
||||
"bigcache_mb": (1, 6144)}
|
||||
|
||||
|
||||
def app_name(app):
|
||||
if not isinstance(app, str) or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}", app):
|
||||
raise ValueError("invalid Meta app name")
|
||||
return app
|
||||
|
||||
|
||||
def validate(values):
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError("settings must be an object")
|
||||
out = {}
|
||||
for key, value in values.items():
|
||||
if key in BOOL_KEYS:
|
||||
if type(value) is not bool:
|
||||
raise ValueError(f"{key} must be true or false")
|
||||
elif key in INT_KEYS:
|
||||
lo, hi = INT_KEYS[key]
|
||||
if type(value) is not int or not lo <= value <= hi:
|
||||
raise ValueError(f"{key} must be an integer in {lo}..{hi}")
|
||||
elif key == "eye_scale":
|
||||
if type(value) not in (float, int) or value not in (1.0, 1.25, 1.5):
|
||||
raise ValueError("eye_scale must be 1.0, 1.25 or 1.5 (a divisor)")
|
||||
elif key == "cores":
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[0-7](?:-[0-7])?(?:,[0-7](?:-[0-7])?)*", value):
|
||||
raise ValueError("cores must be a taskset CPU list within 0..7")
|
||||
if any(len(p) == 3 and p[0] > p[2] for p in value.split(",")):
|
||||
raise ValueError("cores range is reversed")
|
||||
elif key == "fex":
|
||||
if not isinstance(value, dict) or any(k not in FEX_KEYS or type(v) not in (str, int) or
|
||||
str(v) not in ("0", "1") for k, v in value.items()):
|
||||
raise ValueError("fex permits only boolean TSO, MULTIBLOCK and X87REDUCEDPRECISION keys")
|
||||
value = {k: str(v) for k, v in value.items()}
|
||||
else:
|
||||
raise ValueError(f"unknown performance setting: {key}")
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def settings_path(app):
|
||||
return os.path.join(CORE.ROOT, "settings", app_name(app) + ".json")
|
||||
|
||||
|
||||
def settings_get(args):
|
||||
app = app_name(args["app"])
|
||||
info = CORE.GAMES.get(app, {})
|
||||
env = info.get("env", {})
|
||||
defaults = dict(DEFAULTS, fp_mem=env.get("FP_MEM", "") not in ("", "0"),
|
||||
bigcache_mb=int(env.get("FP_BIGCACHE_MB", 256)))
|
||||
defaults.update(validate(info.get("perf", {})))
|
||||
overrides = validate(CORE.read_json(settings_path(app), {}) or {})
|
||||
return {"app": app, "settings": dict(defaults, **overrides), "overrides": overrides}
|
||||
|
||||
|
||||
def settings_set(args):
|
||||
app = app_name(args["app"])
|
||||
# Validate the whole request before writing. Null removes an override (the catalog/default becomes active).
|
||||
updates = {k: v for k, v in args.items() if k != "app"}
|
||||
if any(k not in DEFAULTS for k in updates):
|
||||
raise ValueError("unknown performance setting")
|
||||
values = settings_get({"app": app})["overrides"]
|
||||
values.update(validate({k: v for k, v in updates.items() if v is not None}))
|
||||
for k, v in updates.items():
|
||||
if v is None:
|
||||
values.pop(k, None)
|
||||
CORE.write_json(settings_path(app), values)
|
||||
return settings_get({"app": app})
|
||||
|
||||
|
||||
def scale_folder(scale):
|
||||
return os.path.join(CORE.ROOT, "revive-scales", format(float(scale), "g"))
|
||||
|
||||
|
||||
def eye_variant(data, scale):
|
||||
"""Only the research Revive 3.2.0 eye-size site, with PE RVA translation. Never touch Oculus binaries.
|
||||
|
||||
Accept the stock eye-size load or the research divisor load. Refuse other instructions rather than guessing
|
||||
at a new Revive build. Use a dedicated padding slot, leaving shared constants in the data section unchanged.
|
||||
"""
|
||||
if data[:2] != b"MZ":
|
||||
raise ValueError("Revive eye scale: invalid PE")
|
||||
pe = struct.unpack_from("<I", data, 0x3c)[0]
|
||||
n, opt = struct.unpack_from("<H", data, pe + 6)[0], struct.unpack_from("<H", data, pe + 20)[0]
|
||||
|
||||
def offset(rva):
|
||||
for i in range(n):
|
||||
size, va, rawsize, raw = struct.unpack_from("<IIII", data, pe + 24 + opt + i * 40 + 8)
|
||||
if va <= rva < va + min(size, rawsize):
|
||||
return raw + rva - va
|
||||
raise ValueError("Revive eye scale: RVA outside sections")
|
||||
ins, slot = offset(0x12eab), offset(0x12e9c)
|
||||
# Original movss xmm4,[1.0], or the research patch's load of the existing 1.5 at RVA 0x4eaa4.
|
||||
# Put our divisor in verified function-alignment padding: changing a shared rdata constant affects other code.
|
||||
old = data[ins:ins + 8]
|
||||
original = bytes.fromhex("f30f102515bb0400")
|
||||
research = b"\xf3\x0f\x10\x25" + struct.pack("<i", 0x4eaa4 - 0x12eb3)
|
||||
if old not in (original, research) or data[offset(0x12e91)] != 0xc3 or data[slot:slot + 4] != b"\xcc" * 4:
|
||||
raise ValueError("Revive eye scale: unexpected 3.2.0 eye-size instruction/padding")
|
||||
out = bytearray(data)
|
||||
struct.pack_into("<f", out, slot, scale)
|
||||
out[ins:ins + 8] = b"\xf3\x0f\x10\x25" + struct.pack("<i", 0x12e9c - 0x12eb3)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def prepare_scales():
|
||||
src = Path(CORE.REVIVE)
|
||||
data = (src / "LibRevive64.dll").read_bytes()
|
||||
# PC setup validates stock 3.2.0 and applies REVIVE_PATCHES (src/frameport/fexrift.py): the visibility fixes and
|
||||
# the eye-size redirect to the 1.5 constant. Accept only that upload, or the same without the eye-size redirect.
|
||||
uploaded = {"eda2a449d230832c95734a03e8c83eeb531668ce676693f636e19c315765af4e", # all six patches
|
||||
"0a3a6733f5decec74abb16b85c1f72783a6f421160753f600ddae4e8cb6482c6"} # visibility fixes only
|
||||
if hashlib.sha256(data).hexdigest() not in uploaded:
|
||||
raise ValueError("Revive eye scale: needs FramePort's patched Revive 3.2.0 upload")
|
||||
for scale in (1.0, 1.25, 1.5):
|
||||
patched = eye_variant(data, scale) # validate before replacing any directory
|
||||
dest = Path(scale_folder(scale))
|
||||
tmp = dest.with_name(dest.name + ".new")
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
shutil.copytree(src, tmp, copy_function=os.link)
|
||||
# Break the hard link before changing the DLL: shared base and other variants stay intact.
|
||||
dll = tmp / "LibRevive64.dll"
|
||||
dll.unlink()
|
||||
dll.write_bytes(patched)
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
os.replace(tmp, dest)
|
||||
|
||||
|
||||
def perf_prepare(args):
|
||||
app = app_name(args["app"])
|
||||
s = settings_get({"app": app})["settings"]
|
||||
config = os.path.join(CORE.ROOT, "settings", app + ".dxvk.conf")
|
||||
os.makedirs(os.path.dirname(config), exist_ok=True) # first launch of a game nobody changed settings for
|
||||
managed = ("d3d11.disableMsaa", "dxgi.syncInterval", "dxgi.maxFrameLatency") # these settings win
|
||||
profile = [str(line) for line in args.get("dxvk", []) if "\n" not in str(line)
|
||||
and str(line).split("=", 1)[0].strip() not in managed]
|
||||
lines = profile + [f"{k} = {s[key]}" for k, key, default in (
|
||||
("d3d11.disableMsaa", "dxvk_disable_msaa", False), ("dxgi.syncInterval", "dxgi_sync_interval", -1),
|
||||
("dxgi.maxFrameLatency", "dxgi_max_frame_latency", 0)) if s[key] != default]
|
||||
# Defaults = exactly the launch verified in the headset (First Contact, 2026-10-10): no dxvk.conf, the uploaded
|
||||
# Revive (its eye-size patch is 1.5), no large-address-aware flag; only a changed setting adds something.
|
||||
if lines:
|
||||
CORE.put_text(config, "\n".join(lines + [""]))
|
||||
elif os.path.lexists(config):
|
||||
os.remove(config)
|
||||
if s["eye_scale"] == 1.5:
|
||||
revive = CORE.REVIVE
|
||||
else:
|
||||
revive = scale_folder(s["eye_scale"])
|
||||
if not os.path.isfile(os.path.join(revive, "LibRevive64.dll")):
|
||||
prepare_scales()
|
||||
exports = {"FP_CORES": s["cores"], "FP_REVIVE_DIR": revive,
|
||||
"FP_MEM": "1" if s["fp_mem"] else "", "FP_BIGCACHE_MB": str(s["bigcache_mb"]),
|
||||
"FP_MEM_ZERO": "1" if s["fp_mem_zero"] else "0", "PROTON_NO_NTSYNC": "0" if s["ntsync"] else "1",
|
||||
"FP_MOTION_SMOOTHING": json.dumps({"section": f"steam.app.{CORE.GAME_ID}",
|
||||
"key": "motionSmoothingOverride", "kind": "i",
|
||||
"value": s["motion_smoothing"]})}
|
||||
exports.update({k: str(s["fex"].get(k, "0" if "TSO" in k else "1" if k == "FEX_MULTIBLOCK" else "0"))
|
||||
for k in FEX_KEYS})
|
||||
if lines:
|
||||
exports["DXVK_CONFIG_FILE"] = config
|
||||
if s["wine_large_address_aware"]:
|
||||
exports["WINE_LARGE_ADDRESS_AWARE"] = "1"
|
||||
shell = "\n".join(f"export {k}={shlex.quote(v)}" for k, v in exports.items())
|
||||
if s["ntsync"]:
|
||||
shell += "\nunset PROTON_NO_NTSYNC"
|
||||
return {"shell": shell, "settings": s}
|
||||
|
||||
|
||||
def launcher_env(app, info):
|
||||
# stdout is an isolated helper's JSON, then a tiny decoder emits only validated exports. Failure aborts launch:
|
||||
# continuing with an old config would give misleading experimental results (and could enable stale fp_mem).
|
||||
# the compat profile's dxvk.conf lines (stream C) go into the same per-game config as these settings
|
||||
dxvk = [str(line) for line in info.get("dxvk", []) if "\n" not in str(line) and "\r" not in str(line)]
|
||||
arg = shlex.quote(json.dumps({"app": app_name(app), "dxvk": dxvk}))
|
||||
decode = 'import json,sys; print(json.load(sys.stdin)["result"]["shell"])'
|
||||
return [f'FP_PERF=$(python3 -I "$R/artifacts/fexrift.py" perf_prepare {arg}) || exit 1',
|
||||
f'eval "$(printf \'%s\' "$FP_PERF" | python3 -I -c {shlex.quote(decode)})"']
|
||||
|
||||
|
||||
# OpenVR IVRCompositor_022 Compositor_FrameTiming, including its 80-byte TrackedDevicePose_t. Keeping the tail
|
||||
# prevents GetFrameTimings overwriting a truncated ctypes structure. Versions are pinned, never probed by index.
|
||||
class FrameTiming(C.Structure):
|
||||
_fields_ = [(n, C.c_uint32) for n in ("size", "index", "presents", "mis_presents", "dropped", "reprojection")]
|
||||
_fields_ += [("system_time", C.c_double)]
|
||||
_fields_ += [(n, C.c_float) for n in ("pre_gpu", "post_gpu", "gpu_ms", "compositor_gpu", "compositor_cpu",
|
||||
"compositor_idle", "client_interval", "present_call", "wait_present",
|
||||
"submit", "poses_called", "poses_ready", "frame_ready", "update_start",
|
||||
"update_end", "render_start")]
|
||||
_fields_ += [("pose", C.c_byte * 80), ("vsyncs_ready", C.c_uint32), ("vsyncs_first", C.c_uint32)]
|
||||
|
||||
|
||||
class VR:
|
||||
def __init__(self):
|
||||
self.core, self.method, self.settings = CORE.vr_settings()
|
||||
try:
|
||||
self.compositor = self.interface(b"IVRCompositor_022")
|
||||
self.system = self.interface(b"IVRSystem_022")
|
||||
except BaseException:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def interface(self, name):
|
||||
err = C.c_int()
|
||||
obj = self.method(self.core, 3, C.c_void_p, C.c_char_p, C.POINTER(C.c_int))(name, C.byref(err))
|
||||
if not obj or err.value:
|
||||
raise ValueError("benchmark needs " + name.decode())
|
||||
return obj
|
||||
|
||||
def activity(self):
|
||||
# IVRSystem_022::GetTrackedDeviceActivityLevel(HMD=0). UserInteraction (1) and its timeout (2) refuse.
|
||||
return self.method(self.system, 15, C.c_int, C.c_uint32)(0)
|
||||
|
||||
def screen_timeout(self):
|
||||
err = C.c_int()
|
||||
value = self.method(self.settings, 7, C.c_float, C.c_char_p, C.c_char_p, C.POINTER(C.c_int))(
|
||||
b"power", b"turnOffScreensTimeout", C.byref(err))
|
||||
if err.value or not math.isfinite(value) or value <= 0:
|
||||
raise ValueError("cannot read screen timeout; benchmark refused")
|
||||
return value
|
||||
|
||||
def timings(self):
|
||||
rows = (FrameTiming * 128)()
|
||||
for row in rows:
|
||||
row.size = C.sizeof(FrameTiming)
|
||||
n = self.method(self.compositor, 9, C.c_uint32, C.POINTER(FrameTiming), C.c_uint32)(rows, 128)
|
||||
return [{"index": r.index, "presents": r.presents, "gpu_ms": r.gpu_ms,
|
||||
"cpu_ms": r.client_interval, "system_time": r.system_time} for r in rows[:min(n, 128)]]
|
||||
|
||||
def scene_pid(self):
|
||||
return self.method(self.compositor, 22, C.c_uint32)() # GetCurrentSceneFocusProcess
|
||||
|
||||
def close(self):
|
||||
self.method(self.core, 1, None)()
|
||||
|
||||
|
||||
def drm_engines(text):
|
||||
"""DRM fdinfo nanoseconds, deduplicated by drm-client-id by the caller (dup fds share one client)."""
|
||||
out = {}
|
||||
for line in text.splitlines():
|
||||
m = re.fullmatch(r"drm-engine-([\w-]+):\s*(\d+)\s+ns", line.strip())
|
||||
if m:
|
||||
out[m[1]] = int(m[2])
|
||||
return out
|
||||
|
||||
|
||||
def process_sample(pids, proc="/proc"):
|
||||
engines, waits, seen = {}, collections.Counter(), set()
|
||||
for pid in pids:
|
||||
base = Path(proc) / str(pid)
|
||||
for fd in (base / "fdinfo").glob("*"):
|
||||
try:
|
||||
text = fd.read_text()
|
||||
except OSError:
|
||||
continue
|
||||
client = re.search(r"^drm-client-id:\s*(\d+)", text, re.M)
|
||||
device = re.search(r"^drm-pdev:\s*(.+)", text, re.M)
|
||||
if not client:
|
||||
continue
|
||||
identity = (device[1] if device else "", client[1])
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
for engine, ns in drm_engines(text).items():
|
||||
engines[(identity, engine)] = ns
|
||||
for task in (base / "task").glob("*"):
|
||||
try:
|
||||
waits[task.joinpath("wchan").read_text().strip()] += 1
|
||||
except OSError:
|
||||
pass
|
||||
return engines, waits
|
||||
|
||||
|
||||
def gpu_delta(before, after):
|
||||
# Clients that open during the interval have no baseline; do not attribute their lifetime counters to one tick.
|
||||
return sum(max(0, v - before[k]) for k, v in after.items() if k in before)
|
||||
|
||||
|
||||
def processes(proc="/proc"):
|
||||
out = {}
|
||||
for p in Path(proc).glob("[0-9]*"):
|
||||
try:
|
||||
if p.stat().st_uid != os.getuid():
|
||||
continue
|
||||
cmd = p.joinpath("cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
|
||||
comm = p.joinpath("comm").read_text().strip()
|
||||
out[int(p.name)] = (comm, cmd)
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def busy_processes(rows):
|
||||
# Do not print cmdlines: another stream may be handling an Oculus browser sign-in.
|
||||
return [pid for pid, (comm, cmd) in rows.items() if
|
||||
(re.search(r"(?i)(\.exe(?: |$)|(?:^|/)(?:proton|wine(?:64)?(?:-preloader)?)(?: |$)|lepton-steamlaunch-)",
|
||||
cmd)
|
||||
or (comm == "reaper" and not re.search(r"\bAppId=250820\b", cmd))
|
||||
or comm.startswith("wineserver") or comm in ("FEXInterpreter", "FEXLoader", "pressure-vessel"))]
|
||||
|
||||
|
||||
def recent_steam_input(steam, now=None):
|
||||
"""Steam input log is a supplementary guard, HMD activity is the authoritative one. Never read login logs."""
|
||||
now = time.time() if now is None else now
|
||||
path = Path(steam) / "logs/controller.txt"
|
||||
try:
|
||||
# A recent controller log write can be a device reconnection, so conservatively refuse it too.
|
||||
return now - path.stat().st_mtime < 60
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def vr_set(section, key, kind, value):
|
||||
# In its own process: cmd_vrsetting in the process that holds VR()'s SteamVR connection segfaulted on the
|
||||
# second call (reproduced on the Frame 2026-10-16) after the first had turned the display's standby pause off.
|
||||
result = vrsetting_subprocess(section, key, kind, value)
|
||||
if result.get("error"):
|
||||
raise ValueError(f"SteamVR refused {section}/{key}")
|
||||
|
||||
|
||||
def stop_launcher(child):
|
||||
# The shared prefix was verified idle before launch, so -k cannot interrupt an earlier game or login.
|
||||
try:
|
||||
subprocess.run([os.path.join(CORE.GEF, "files/bin/wineserver"), "-k"], env=CORE.wine_env(),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5)
|
||||
finally:
|
||||
if child is not None:
|
||||
try:
|
||||
os.killpg(child.pid, signal.SIGTERM)
|
||||
child.wait(timeout=5)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
child.wait(timeout=5)
|
||||
|
||||
|
||||
START_WAIT = 180 # seconds the bench waits for the game's process after starting its launcher
|
||||
|
||||
|
||||
def bench_run(args, launcher, vr):
|
||||
seconds = args.get("seconds", 120)
|
||||
if type(seconds) is not int or not 1 <= seconds <= 120:
|
||||
raise ValueError("seconds must be an integer in 1..120")
|
||||
if busy_processes(processes()) or recent_steam_input(CORE.STEAM) or vr.activity() != 3:
|
||||
raise ValueError("benchmark refused: game, Wine, container, HMD activity or recent Steam input")
|
||||
screen = vr.screen_timeout() # no setting is changed until both safety checks and this read succeed
|
||||
vrserver_log = os.path.join(CORE.STEAM, "logs", "vrserver.txt")
|
||||
log_start = os.path.getsize(vrserver_log) if os.path.isfile(vrserver_log) else 0
|
||||
guard_write(screen=screen) # before any change: the parent restores from it if this process dies
|
||||
child, old_handler = None, None
|
||||
settings = settings_get(args)["settings"]
|
||||
frames, waits, gpu, gpu_seconds = {}, collections.Counter(), 0, 0.0
|
||||
started = time.monotonic()
|
||||
|
||||
def interrupted(signum, frame):
|
||||
raise ValueError("benchmark interrupted")
|
||||
try:
|
||||
old_handler = signal.signal(signal.SIGTERM, interrupted)
|
||||
vr_set("power", "pauseCompositorOnStandby", "b", False)
|
||||
vr_set("power", "turnOffScreensTimeout", "f", float(seconds + START_WAIT + 10))
|
||||
# Save the initial frame index so stale scene frames in the compositor ring are excluded.
|
||||
initial = {r["index"] for r in vr.timings()}
|
||||
with open(os.devnull, "wb") as sink:
|
||||
child = subprocess.Popen([launcher], stdin=subprocess.DEVNULL, stdout=sink, stderr=sink,
|
||||
start_new_session=True, close_fds=True)
|
||||
guard_write(screen=screen, pgid=getattr(child, "pid", None))
|
||||
# The launcher starts Meta's runtime and waits for it (60-100 s under FEX) before the game: the window
|
||||
# starts when the game's process appears, not at the launch.
|
||||
wait_end = time.monotonic() + START_WAIT
|
||||
while not [q for q, (_, cmd) in processes().items() if args["exe"].lower() in cmd.lower()]:
|
||||
if time.monotonic() > wait_end:
|
||||
raise ValueError(f"benchmark: the game didn't start within {START_WAIT} s; inspect launch.log")
|
||||
if child.poll() is not None:
|
||||
raise ValueError("benchmark launcher exited before the game started; inspect launch.log")
|
||||
level = vr.activity()
|
||||
if level == 1: # controller.txt isn't a signal here: the starting game loads its input bindings
|
||||
raise ValueError(f"benchmark stopped: owner interaction (activity {level})")
|
||||
time.sleep(1)
|
||||
before, previous = {}, time.monotonic()
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
# Before the launch the headset must be in standby (3); the game itself then wakes the compositor
|
||||
# (activity leaves standby), so during the run only real use stops it: 1 = user interaction (worn or
|
||||
# moved), or new controller input.
|
||||
level = vr.activity()
|
||||
if level == 1: # controller.txt isn't a signal here: the starting game loads its input bindings
|
||||
raise ValueError(f"benchmark stopped: owner interaction (activity {level})")
|
||||
if child.poll() is not None:
|
||||
raise ValueError("benchmark launcher exited before the sampling window ended; inspect launch.log")
|
||||
rows = processes()
|
||||
# Only the actual game's exe (or FEX's argv containing it), not the runtime/compositor GPU client.
|
||||
pids = [p for p, (_, cmd) in rows.items() if args["exe"].lower() in cmd.lower()]
|
||||
after, w = process_sample(pids)
|
||||
now = time.monotonic()
|
||||
if before.keys() & after.keys():
|
||||
gpu += gpu_delta(before, after)
|
||||
gpu_seconds += now - previous
|
||||
before, previous = after, now
|
||||
waits.update(w)
|
||||
focus = vr.scene_pid()
|
||||
for r in vr.timings():
|
||||
if focus in pids and r["index"] not in initial:
|
||||
frames[r["index"]] = r # ring revisits update presents, not another frame
|
||||
time.sleep(min(0.5, max(0, deadline - time.monotonic())))
|
||||
finally:
|
||||
# Each cleanup is attempted even when the preceding one fails. Display safety outranks benchmark success.
|
||||
try:
|
||||
stop_launcher(child)
|
||||
finally:
|
||||
try:
|
||||
vr_set("power", "pauseCompositorOnStandby", "b", True)
|
||||
finally:
|
||||
try:
|
||||
vr_set("power", "turnOffScreensTimeout", "f", screen)
|
||||
finally:
|
||||
if old_handler is not None:
|
||||
signal.signal(signal.SIGTERM, old_handler)
|
||||
records = list(frames.values())
|
||||
# Without a tracking universe (IMU fallback, e.g. after a boot without the headset worn) Rift games wait at
|
||||
# startup forever: SteamVR's own frames are all a run then counts (seen 2026-10-16, ~1.9/s, game GPU ~0 %).
|
||||
imu_fallback = False
|
||||
try:
|
||||
with open(vrserver_log, errors="replace") as f:
|
||||
f.seek(log_start if os.path.getsize(vrserver_log) >= log_start else 0)
|
||||
imu_fallback = "IMU fallback is active" in f.read(8 << 20)
|
||||
except OSError:
|
||||
pass
|
||||
duration = max(0.001, time.monotonic() - started)
|
||||
times = [r["system_time"] for r in records]
|
||||
span = max(times) - min(times) if len(times) > 1 else 0
|
||||
return {"app": args["app"], "seconds": seconds, "elapsed_s": round(duration, 3), "frames": len(records),
|
||||
"imu_fallback": imu_fallback, # true: the result says nothing about the game (put the headset on once)
|
||||
"valid": len(records) >= 2 and span > 0 and not imu_fallback, "sample_span_s": round(span, 3),
|
||||
"fps": round((len(records) - 1) / span, 2) if span > 0 else None,
|
||||
"hitches": sum(r["presents"] >= 3 for r in records),
|
||||
"hitches_per_20s": round(20 * sum(r["presents"] >= 3 for r in records) / span, 2) if span > 0 else None,
|
||||
"game_gpu_busy_pct": round(100 * gpu / (gpu_seconds * 1e9), 2) if gpu_seconds else None,
|
||||
"render_gpu_ms": round(sum(r["gpu_ms"] for r in records) / len(records), 3) if records else None,
|
||||
"client_interval_ms": round(sum(r["cpu_ms"] for r in records) / len(records), 3) if records else None,
|
||||
"thread_wchan_samples": dict(waits), "settings": settings}
|
||||
|
||||
|
||||
def bench(args):
|
||||
app = app_name(args["app"])
|
||||
# Launcher path is supplied by the owning agent's deployment record, not an arbitrary remote shell command.
|
||||
launcher = args["launcher"]
|
||||
if not os.path.isfile(launcher) or os.path.basename(launcher) != "launch.sh":
|
||||
raise ValueError("install the game before benchmarking")
|
||||
info, _ = CORE.game_info(app)
|
||||
exe = os.path.basename(info["exe"].replace("\\", "/"))
|
||||
os.makedirs(CORE.ROOT, exist_ok=True)
|
||||
with open(os.path.join(CORE.ROOT, ".bench.lock"), "w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return bench_guarded(dict(args, app=app, exe=exe), launcher)
|
||||
|
||||
|
||||
# The measurement talks to SteamVR through ctypes (vrclient.so). A native crash there kills the process without
|
||||
# running bench_run's finally, which once left pauseCompositorOnStandby=false (display kept on) on the Frame
|
||||
# (2026-10-16). So it runs in a forked child, and the parent restores the display settings and stops the game from
|
||||
# a guard file when the child returns no result, each restore in its own process (another crash can't skip it).
|
||||
def guard_path():
|
||||
return os.path.join(CORE.ROOT, ".bench.guard.json")
|
||||
|
||||
|
||||
def guard_write(**kw):
|
||||
CORE.write_json(guard_path(), kw)
|
||||
|
||||
|
||||
def vrsetting_subprocess(section, key, kind, value):
|
||||
"""fexrift.py vrsetting in a fresh process: {"error": code} from SteamVR, or {"error": "..."} if it failed."""
|
||||
arg = json.dumps({"section": section, "key": key, "kind": kind, "value": value})
|
||||
p = subprocess.run([sys.executable, "-I", os.path.join(CORE.HERE, "fexrift.py"), "vrsetting", arg],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
try:
|
||||
out = json.loads(p.stdout.strip().splitlines()[-1])
|
||||
except (ValueError, IndexError):
|
||||
return {"error": f"vrsetting exited {p.returncode}"}
|
||||
return out.get("result", {}) if out.get("ok") else {"error": out.get("error", "failed")}
|
||||
|
||||
|
||||
def crash_cleanup():
|
||||
guard = CORE.read_json(guard_path(), {}) or {}
|
||||
try:
|
||||
if guard.get("pgid"):
|
||||
try:
|
||||
os.killpg(int(guard["pgid"]), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
pass
|
||||
subprocess.run([os.path.join(CORE.GEF, "files/bin/wineserver"), "-k"], env=CORE.wine_env(),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
|
||||
finally:
|
||||
vrsetting_subprocess("power", "pauseCompositorOnStandby", "b", True)
|
||||
if guard.get("screen") is not None:
|
||||
vrsetting_subprocess("power", "turnOffScreensTimeout", "f", float(guard["screen"]))
|
||||
|
||||
|
||||
def bench_guarded(args, launcher):
|
||||
read_fd, write_fd = os.pipe()
|
||||
pid = os.fork()
|
||||
if pid == 0: # child: measure, report through the pipe
|
||||
os.close(read_fd)
|
||||
code = 0
|
||||
try:
|
||||
vr = VR()
|
||||
try:
|
||||
out = {"ok": True, "result": bench_run(args, launcher, vr)}
|
||||
finally:
|
||||
vr.close()
|
||||
except Exception as e: # noqa: BLE001 - reported to the parent as the command's error
|
||||
out, code = {"ok": False, "error": f"{type(e).__name__}: {e}"}, 1
|
||||
with os.fdopen(write_fd, "w") as w:
|
||||
w.write(json.dumps(out))
|
||||
os._exit(code)
|
||||
os.close(write_fd)
|
||||
try:
|
||||
with os.fdopen(read_fd) as r:
|
||||
raw = r.read()
|
||||
_, status = os.waitpid(pid, 0)
|
||||
out = json.loads(raw) if raw.strip() else None
|
||||
if out is None: # crashed or killed: bench_run's own cleanup didn't run
|
||||
crash_cleanup()
|
||||
raise ValueError(f"benchmark process died ({'signal ' + str(-os.waitstatus_to_exitcode(status)) if os.WIFSIGNALED(status) else 'no result'}); display settings restored, game stopped")
|
||||
if not out["ok"]:
|
||||
raise ValueError(out["error"])
|
||||
return out["result"]
|
||||
finally:
|
||||
try:
|
||||
os.remove(guard_path())
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def register(core):
|
||||
global CORE
|
||||
CORE = core
|
||||
core.COMMANDS.update(settings_get=settings_get, settings_set=settings_set, perf_prepare=perf_prepare, bench=bench)
|
||||
core.LAUNCHER_ENV_HOOKS.append(launcher_env)
|
||||
# Use the existing setup step so --steps revive prepares the variants too, without another download.
|
||||
def revive():
|
||||
result = core.step_revive()
|
||||
prepare_scales()
|
||||
return result
|
||||
core.STEPS = [(name, revive if name == "revive" else fn) for name, fn in core.STEPS]
|
||||
+4
-244
@@ -44,7 +44,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 94
|
||||
AGENT_VERSION = 91
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -1142,7 +1142,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 in ("pcvr", "frame_fex") else \
|
||||
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if kind == "pcvr" 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
|
||||
|
||||
@@ -1891,7 +1891,7 @@ def app_media_dirs(ext):
|
||||
|
||||
def lepton_external(pkg):
|
||||
dep = deployment(pkg)
|
||||
if not dep or dep.get("kind") in ("pcvr", "frame_fex"):
|
||||
if not dep or dep.get("kind") == "pcvr":
|
||||
raise AgentError(f"{pkg} is not an installed Quest (Lepton) game")
|
||||
return os.path.join(dep["base"], "lepton-data", "external")
|
||||
|
||||
@@ -5460,11 +5460,8 @@ def cmd_uninstall(args):
|
||||
base = dep["base"]
|
||||
pcvr = dep.get("kind") == "pcvr"
|
||||
linux = dep.get("kind") == "linux"
|
||||
fex = dep.get("kind") == "frame_fex"
|
||||
if (pcvr_pids(base) if pcvr or linux or fex else container_running(dep["appid"])):
|
||||
if (pcvr_pids(base) if pcvr or linux 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)
|
||||
@@ -5815,8 +5812,6 @@ 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])
|
||||
@@ -5929,8 +5924,6 @@ 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":
|
||||
@@ -7913,239 +7906,6 @@ 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)")
|
||||
# Game inventories can exceed Linux's per-argument limit; the helper already accepts JSON on stdin.
|
||||
p = run([sys.executable, "-I", FEX_HELPER, command], input=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:]}") from None
|
||||
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")})
|
||||
|
||||
|
||||
# Local-copy extension commands: validate removable storage here (the helper has no drive discovery). Never let a
|
||||
# missing mount silently turn an SD-card install into an upload to the root filesystem.
|
||||
def fex_local_dest(dest):
|
||||
if not dest or dest == "internal":
|
||||
return None
|
||||
for d in cmd_drives({})["drives"]:
|
||||
if not d["internal"] and d["path"] == dest:
|
||||
resolve_dest(dest) # existing filesystem / writable-mount checks
|
||||
return dest
|
||||
raise AgentError("--dest must name an available external drive's mount point (see frame drives)")
|
||||
|
||||
|
||||
def cmd_fex_prepare_local(args):
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg)
|
||||
if dep and (dep.get("kind") != "frame_fex" or dep.get("source") != "local_copy"
|
||||
or dep.get("app") != args.get("app")):
|
||||
raise AgentError(f"{pkg} is already installed in another way; uninstall it first")
|
||||
return fex_helper("prepare_local", dict(args, package=pkg, dest=fex_local_dest(args.get("dest"))))
|
||||
|
||||
|
||||
def cmd_fex_register_local(args):
|
||||
info = fex_helper("local_info", {"app": args["app"]})
|
||||
fex_local_dest(info.get("dest"))
|
||||
return fex_helper("register_local", args, timeout=7200) # hashes large copies on the Frame, not on NTFS
|
||||
|
||||
|
||||
def cmd_fex_runtime_state(args):
|
||||
return fex_helper("runtime_state", {"app": args["app"]}, timeout=120)
|
||||
|
||||
|
||||
# Browser sign-in: this block deliberately uses stdin, not fex_helper's JSON argv. A callback carries credentials.
|
||||
FEX_LOGIN_PACKAGE = "linux.frameport_meta_signin"
|
||||
|
||||
|
||||
def fex_login_call(command, args=None):
|
||||
if not os.path.isfile(FEX_HELPER):
|
||||
raise AgentError("run fex setup before signing in")
|
||||
p = subprocess.run([sys.executable, "-I", FEX_HELPER, command], input=json.dumps(args or {}),
|
||||
capture_output=True, text=True, timeout=120)
|
||||
try:
|
||||
out = json.loads(p.stdout.strip().splitlines()[-1])
|
||||
except (ValueError, IndexError):
|
||||
raise AgentError("Meta sign-in helper did not answer") from None
|
||||
if not out.get("ok"):
|
||||
# No raw subprocess output in errors for the commands handling URLs.
|
||||
raise AgentError(out.get("error", "Meta sign-in failed") if command == "login_start" else
|
||||
"Meta sign-in helper failed; retry fex login --stop")
|
||||
return out["result"]
|
||||
|
||||
|
||||
def cmd_fex_login(args):
|
||||
result = fex_login_call("login_start", {"start_now": bool(args.get("start_now")),
|
||||
"via_pc": bool(args.get("via_pc")),
|
||||
"timeout": int(args.get("login_timeout") or 900)})
|
||||
if not args.get("start_now"):
|
||||
anchor = os.path.join(ANCHORS, FEX_LOGIN_PACKAGE)
|
||||
os.makedirs(anchor, exist_ok=True)
|
||||
launcher = os.path.join(anchor, "launch.sh")
|
||||
if os.path.lexists(launcher):
|
||||
os.remove(launcher)
|
||||
os.symlink(result["launcher"], launcher)
|
||||
with open(os.path.join(anchor, "deployment.json"), "w") as f:
|
||||
json.dump({"package": FEX_LOGIN_PACKAGE, "kind": "linux", "title": "Meta sign-in (FramePort)",
|
||||
"base": os.path.join(FEX_ROOT, "login"), "vr": False, "desktop_entry": False,
|
||||
"appid": shortcut_appid(f'"{launcher}"', "Meta sign-in (FramePort)")}, f)
|
||||
result["package"] = FEX_LOGIN_PACKAGE
|
||||
return result
|
||||
|
||||
|
||||
def cmd_fex_login_status(args):
|
||||
return fex_login_call("login_status", {"take_url": bool(args.get("take_url"))})
|
||||
|
||||
|
||||
def cmd_fex_login_callback(args):
|
||||
return fex_login_call("login_callback", {"url": args.get("url")})
|
||||
|
||||
|
||||
def cmd_fex_login_cancel(args):
|
||||
result = fex_login_call("login_stop")
|
||||
anchor = os.path.join(ANCHORS, FEX_LOGIN_PACKAGE)
|
||||
if os.path.exists(os.path.join(anchor, "deployment.json")):
|
||||
cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"'}], "restart": True})
|
||||
devkit_unregister(FEX_LOGIN_PACKAGE, steam_running=run(["pgrep", "-x", "steam"]).returncode == 0)
|
||||
remove_tree(anchor)
|
||||
return result
|
||||
|
||||
|
||||
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")
|
||||
local = None
|
||||
if args.get("source") == "local_copy" or (dep or {}).get("source") == "local_copy":
|
||||
local = fex_helper("local_info", {"app": app})
|
||||
if local.get("package") != pkg or not local.get("registered"):
|
||||
raise AgentError("local copy must be registered for this package first")
|
||||
fex_local_dest(local.get("dest"))
|
||||
msaa = args.get("msaa")
|
||||
res = fex_helper("install", {"app": app, "anchor": anchor, "msaa": msaa if msaa in (1, 2, 4) else None,
|
||||
"force_unverified": bool(args.get("force_unverified"))})
|
||||
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")}, "verified": bool(res.get("verified")),
|
||||
"checks": res.get("checks") or {},
|
||||
"agent_version": AGENT_VERSION, "installed": int(time.time())}
|
||||
if local:
|
||||
dep.update({k: local.get(k) for k in ("source", "app_id", "app_id_source", "store_app_id", "skipped", "dest")})
|
||||
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"],
|
||||
"verified": dep["verified"], "checks": dep["checks"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- FEX launch-time settings / bounded bench
|
||||
# Kept separate from install: settings are read at launch and never require changing the game's files.
|
||||
def cmd_fex_settings(args):
|
||||
values = args.get("values")
|
||||
if values is not None and (not isinstance(values, dict) or "app" in values):
|
||||
raise AgentError("values must be a settings object without app")
|
||||
return fex_helper("settings_set" if values is not None else "settings_get",
|
||||
dict(values or {}, app=args["app"]), timeout=60)
|
||||
|
||||
|
||||
def cmd_fex_bench(args):
|
||||
seconds = args.get("seconds", 120)
|
||||
if type(seconds) is not int or not 1 <= seconds <= 120:
|
||||
raise AgentError("seconds must be an integer in 1..120")
|
||||
if game_running(): # includes Lepton containers even when their processes are in another PID namespace
|
||||
raise AgentError("benchmark refused: a game or container is running")
|
||||
app = args["app"]
|
||||
for pkg in sorted(os.listdir(ANCHORS)) if os.path.isdir(ANCHORS) else []:
|
||||
dep = deployment(pkg)
|
||||
if dep and dep.get("kind") == "frame_fex" and dep.get("app") == app:
|
||||
return fex_helper("bench", {"app": app, "seconds": seconds,
|
||||
"launcher": os.path.join(ANCHORS, pkg, "launch.sh")},
|
||||
timeout=seconds + 240) # + the wait for the game to start (START_WAIT)
|
||||
raise AgentError("install this FEX game before benchmarking")
|
||||
|
||||
|
||||
def fex_uninstall(pkg, dep, args):
|
||||
"""Meta-owned games stay; FramePort local copies lose their game folder and manifests, preserving the prefix."""
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
if dep.get("source") == "local_copy":
|
||||
fex_local_dest(dep.get("dest"))
|
||||
fex_helper("unregister_local", {"app": dep["app"], "package": pkg}, timeout=180)
|
||||
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_")}
|
||||
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1cc65695b223f7d56913275554c233f6b1d38a433068f26413fa97135f202a78 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
53a7d6340707766a7c7465f20084c520d259c76581f02a7e516a52cfeb624d51 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,9 +0,0 @@
|
||||
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
|
||||
420b21045ddc4f166733bf6d2d7227f0131e20f161c4568accb2b9c15a6d8e86 ./fp_mem.so
|
||||
f8d6aaaa123337c47837d0be5852f7f6687bb9c4bfaaa108aaab3f04af1ad5b9 ./fp_pipesend.exe
|
||||
13d455b700b08f40eb3e3452ab264bf535cb3d176c92ec9a3324a635ce4f8588 ./windows.devices.wifi.dll
|
||||
941a65274007db76b1c40880117fe7d881db32c5394ad09f80ccc44a08213a75 ./wineserver.arm64
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,8 +7,14 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: Uses Meta XR Audio via FMOD (not covered by patch_meta_xr_audio); if audio is missing, that is the likely
|
||||
cause.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 63fff470777f2753d55ad50f8355573ced8fa48bcaee0f5e6a0c10369f951a38
|
||||
updated: '2026-10-15'
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: VrApi
|
||||
status: issues
|
||||
notes: Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Legacy VrApi via OVRPlugin; overport swaps in an OpenXR OVRPlugin. Do not remove VrApi.
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 192ff250f5fbec38b9c4e898f0b8408edc25901f46bb4b227f7ca60bbd9f0bba
|
||||
updated: '2026-10-15'
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Needed the GLES/sRGB swapchain fallback.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Also available as a native PC SteamVR title.
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 158f7c4f5e8558029dc8cc5e8dcb4e67e846b261bf34e3e448fee7f657c74597
|
||||
updated: '2026-10-15'
|
||||
@@ -0,0 +1,10 @@
|
||||
package: com.FlatHeadStudio.DeadlyDelivery
|
||||
title: Deadly Delivery
|
||||
status: unsupported
|
||||
notes: "Quits after Meta's entitlement check fails: it needs Meta's online services."
|
||||
tested_version: 1.0.30
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-15'
|
||||
issue: 322
|
||||
@@ -6,8 +6,14 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Recommended pilot title.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 2300f628ce04c1da60b52fed18335d6a31b88cc88313fd6d2c0e1a8e7785126f
|
||||
updated: '2026-10-15'
|
||||
@@ -5,9 +5,14 @@ source_hint: 'Sniper Elite VR- Winter Warrior'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 6b8170da5826b9a35326a20967a0e5a242d46fb569a0a3c669e1cbd368102dd3
|
||||
updated: '2026-10-15'
|
||||
@@ -6,11 +6,14 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Passthrough + Meta scene/room emulated from the Steam guardian (scene_emul=1).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Mixed-reality-only; builds its level from the room model, which the adapter emulates from the Steam guardian.
|
||||
overport_extra:
|
||||
- patch_force_passthrough
|
||||
adapter:
|
||||
scene_emul: 1
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
@@ -18,3 +21,5 @@ verified:
|
||||
known_good_sha256: 9f900fc86fbb2663abe83c82ab79cc72bbf1f93fea54f7583fd274dd07e3d09c
|
||||
frame:
|
||||
- frame.meta_permissions
|
||||
- frame.unity_text_input
|
||||
updated: '2026-10-15'
|
||||
@@ -6,12 +6,18 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: issues
|
||||
notes: Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Hand-tracking-only game; Steam Frame has no optical hand tracking. Built with controller_fix=0 so hand
|
||||
data passes through if the runtime ever provides it.
|
||||
adapter:
|
||||
controller_fix: 0
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: a19b80db0c300d422cbba35a4924ab59211b39bcb6485e200d310e3e1372ddb7
|
||||
updated: '2026-10-15'
|
||||
@@ -7,10 +7,15 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: FrameBridge turns its upside-down UI panels upright (flip emulation, in place since FramePort 1.2.0). Ubisoft
|
||||
login may not work.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
|
||||
files; install to obb as-is.
|
||||
overport_extra:
|
||||
- patch_ac_nexus_no_appsw_90
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
@@ -18,5 +23,5 @@ verified:
|
||||
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
|
||||
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
|
||||
flip_in_place: '2026-10-14, owner in the headset: launch warnings and tutorial pop-ups upright (GitHub #138)'
|
||||
updated: '2026-10-14'
|
||||
updated: '2026-10-15'
|
||||
min_app: 1.2.0
|
||||
@@ -1,5 +1,5 @@
|
||||
package: com.asg.clockworkdev
|
||||
title: Clockwork
|
||||
title: The Last Clockwinder
|
||||
status: works
|
||||
tested_version: '1.29'
|
||||
engine: Unity
|
||||
|
||||
@@ -2,6 +2,7 @@ package: com.camouflaj.manta
|
||||
title: 'Batman: Arkham Shadow'
|
||||
status: works
|
||||
notes: Large data set (~27 GiB) incl. all language packs.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: 'Uses Meta XR Audio (Wwise), handled by patch_meta_xr_audio. Cutscenes are 8K HEVC panoramas: frame.hw_video_decode
|
||||
decodes them on the Frame''s hardware, adapter.surface_native shows them in stereo (PRs #96, #128). Includes all
|
||||
language packs (~28 GB). sync_guard (to test, GitHub #102) - a reporter''s game quit to Steam''s waiting screen
|
||||
@@ -13,15 +14,18 @@ overport_extra:
|
||||
- patch_disable_space_warp
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
- frame.unity_text_input
|
||||
adapter:
|
||||
sync_guard: 1
|
||||
surface_native: 1
|
||||
fade_quad: 1
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
|
||||
updated: '2026-10-13'
|
||||
updated: '2026-10-15'
|
||||
min_app: 1.1.0
|
||||
source_hint: Batman- Arkham Shadow
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Co-op unavailable.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: 'Release notes: for DLC use an older version. Co-op likely unavailable.'
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 06428a6328a49b3ca769e070dd0d2bb55c2122c8fba5d07a69b1b206a532c212
|
||||
updated: '2026-10-15'
|
||||
@@ -6,11 +6,17 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Passthrough emulated with the Frame cameras (greyscale).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Mixed-reality-only title; relies on passthrough (emulated).
|
||||
overport_extra:
|
||||
- patch_force_passthrough
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 65ca3a8f46cc9acfd268949418f9adeecb0cf9f59d11591ba95fe2ba9ea966d8
|
||||
updated: '2026-10-15'
|
||||
@@ -2,10 +2,17 @@ package: com.nexxr.nexplayer
|
||||
title: NEX Player
|
||||
status: works
|
||||
notes: 'Confirmed in the headset by the owner (2026-10-01): 72 fps, local 2D/360/3D videos play (it browses /sdcard/Movies
|
||||
itself). USB-drive browsing can''t work in Lepton.'
|
||||
itself). USB-drive browsing can''t work in Lepton. Typing (frame.unity_text_input + device.text_input_window,
|
||||
owner in the headset 2026-10-15): SteamVR''s keyboard button works and typed text reaches the fields; the keyboard
|
||||
doesn''t open by itself when a field is selected.'
|
||||
tested_version: 1.3.1.0
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-01'
|
||||
source_hint: NEX - NEXt-Gen Video Player
|
||||
updated: '2026-10-15'
|
||||
@@ -6,8 +6,14 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Layers with failed swapchains are dropped by the adapter.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: c3f8cba1c22762231b6a6fbd881668db859d83224884ebeae0c1a3ba716f298c
|
||||
updated: '2026-10-15'
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Use VR mode.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Has VR and MR modes; use VR mode (passthrough not forced).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 5ba09a2bc0046c8cb587272ab81e8d32739334fef523b10ab26531a733d54f12
|
||||
updated: '2026-10-15'
|
||||
@@ -5,9 +5,14 @@ source_hint: Toy Master
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: e951595c67b6e448f3fb4afd07e6b83e98e589094bb3b73374cfb28aa01e96ee
|
||||
updated: '2026-10-15'
|
||||
@@ -7,6 +7,9 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: Needs Oculus OS telemetry class stubs and the no-ForceQuit build (the normal build quits right after
|
||||
starting).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-02' # owner-confirmed with the no-ForceQuit build
|
||||
overport_cli: 1.2.3
|
||||
@@ -14,6 +17,8 @@ verified:
|
||||
known_good_sha256: 31469d50b8ee292597291db7cc3bc3eca8e851f533cd92bba0d64214893580cc
|
||||
frame:
|
||||
- frame.oculusos
|
||||
- frame.unity_text_input
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
updated: '2026-10-15'
|
||||
@@ -5,9 +5,14 @@ source_hint: H.U.N.T
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: c35373d0f7c870ade148e3259abcc74098b6bc89fe2c2f3a2e55168df642e391
|
||||
updated: '2026-10-15'
|
||||
+10
-44
@@ -6,55 +6,21 @@
|
||||
# with diagnostics. Launch tests and real play sessions (agent session_log, `frameport session`) are both matched.
|
||||
# Sources: every failure we hit porting 34 games to the Steam Frame (see docs/PLAYBOOK.md).
|
||||
signatures:
|
||||
# FEX Rift compatibility (stream C): PC runtime/renderer failures; never suggest entitlement workarounds.
|
||||
- id: fex-missing-entitlement
|
||||
kind: pcvr
|
||||
pattern: '(?i:Missing entitlement)'
|
||||
severity: info
|
||||
diagnosis: Meta reports that the signed-in account does not own this game. This is not a FramePort bug. Use an account that owns it; entitlement and Platform SDK checks remain intact.
|
||||
suggest: []
|
||||
- id: fex-registration-missing
|
||||
kind: pcvr
|
||||
pattern: '(?i:OVRServer[^\r\n]*install_available|install_available[^\r\n]*OVRServer)'
|
||||
severity: error
|
||||
diagnosis: Meta's runtime sees the app as available to install rather than registered as installed. Reinstall or register the game through the FEX install flow with its matching Meta manifest, then retry.
|
||||
suggest: []
|
||||
- id: fex-imu-fallback
|
||||
kind: pcvr
|
||||
pattern: 'Rejecting new committed universe since IMU fallback is active'
|
||||
severity: warning
|
||||
diagnosis: SteamVR has not acquired headset tracking since boot. Put the headset on once, wait for tracking, then retry the FEX game. A headless launch before that can wait forever.
|
||||
suggest: []
|
||||
- id: fex-librarian-stuck
|
||||
kind: pcvr
|
||||
pattern: '(?s)\A(?=.*(?i:Service invocation completed))(?=.*OVRLibrarian[^\r\n]*(?i:still running|did not exit))'
|
||||
severity: warning
|
||||
diagnosis: OVRLibrarian completed its work but remains alive under FEX and blocks later client registration. End only OVRLibrarian.exe after completion, then retry; the FEX launcher normally performs this cleanup.
|
||||
suggest: []
|
||||
- id: fex-vkd3d-init
|
||||
kind: pcvr
|
||||
pattern: '(?i:vkd3d[^\r\n]*(?:failed to (?:create|initialize)|could not (?:create|initialize)|no (?:suitable|compatible).*(?:device|adapter)))'
|
||||
severity: error
|
||||
diagnosis: The D3D12 route could not initialize vkd3d-proton or a Vulkan device. Check GE's shipped vkd3d-proton DLLs, clear PROTON_NO_D3D12 and PROTON_USE_WINED3D, and verify the FEX Vulkan thunk reaches Turnip. This route still needs a device test.
|
||||
suggest: []
|
||||
- id: fex-wined3d-init
|
||||
kind: pcvr
|
||||
pattern: '(?i:wined3d[^\r\n]*(?:failed to (?:create|initialize|find)|could not (?:create|initialize)|OpenGL support is disabled))'
|
||||
severity: error
|
||||
diagnosis: Wine's OpenGL renderer could not initialize. The FEX route has no configured GL thunk; verify the guest rootfs GL driver and display access. A Vulkan thunk alone does not provide host OpenGL. Prefer the game's D3D9 or D3D11 route when available.
|
||||
suggest: []
|
||||
- id: fex-revive-injector
|
||||
kind: pcvr
|
||||
pattern: '(?i:ReviveInjector[^\r\n]*(?:failed|unable|error (?:code|inject)))'
|
||||
severity: error
|
||||
diagnosis: ReviveInjector could not start or inject the game. Check the game executable and x86_64 Revive files in the shared GE-Proton prefix, and use OpenVR mode (without /openxr). An OpenVR-only game can launch directly. Do not change entitlement or Platform SDK checks.
|
||||
suggest: []
|
||||
# End FEX Rift compatibility (stream C).
|
||||
- id: meta-asset-status-error
|
||||
pattern: 'Asset status error\s+\(code 100\)'
|
||||
severity: error
|
||||
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
|
||||
suggest: [frame.asset_files_stub]
|
||||
- id: meta-entitlement
|
||||
# Quest games (the pcvr oculus-entitlement signature covers Rift games). Only explicit failure results: a bare
|
||||
# ovr_PlatformInitialize error isn't matched. `error`, not fatal: offline games may log it and play on; an online
|
||||
# game that then quits (e.g. Deadly Delivery, GitHub #322) fails the test through its exit.
|
||||
pattern: '(?i)entitlement check failed|\bnot entitled to (use|play)|ovrPlatformInitialize_(NotEntitled|UnableToVerify|FileInvalid|SignatureInvalid)'
|
||||
severity: error
|
||||
diagnosis: The game's Meta licence check (entitlement) failed. It needs Meta's online login and licence check,
|
||||
which the Frame doesn't have; games that depend on it (often online games) quit or stay offline. FramePort
|
||||
doesn't get around such checks.
|
||||
suggest: []
|
||||
- id: lua-lightuserdata-pointer
|
||||
pattern: 'bad light userdata pointer'
|
||||
severity: fatal
|
||||
|
||||
+20
-3
@@ -14,10 +14,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| Assassin's Creed Nexus | Quest | ✅ Works | |
|
||||
| Audica | Quest | ✅ Works | |
|
||||
| AugmentedEmpire | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
|
||||
| Batman: Arkham Shadow | Quest | ✅ Works | |
|
||||
| BattleGlide | Quest | ✅ Works | |
|
||||
| Battlemarked | Quest | ✅ Works | |
|
||||
| BattleSisters | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
@@ -29,7 +31,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| BONELAB | Quest | ✅ Works | |
|
||||
| Carve Snowboarding | Quest | ✅ Works | |
|
||||
| Caves | Quest | ✅ Works | |
|
||||
| Clockwork | Quest | ✅ Works | |
|
||||
| COMPOUND | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Cosmodread | Quest | ✅ Works | |
|
||||
@@ -37,6 +38,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| cubism | Quest | ✅ Works | |
|
||||
| Dance Central | Quest | ✅ Works | |
|
||||
| Deep Cuts | Quest | ✅ Works | |
|
||||
| Demeo | Quest | ✅ Works | |
|
||||
| Demeter | Quest | ✅ Works | |
|
||||
| Dinosaur Island | Quest | ✅ Works | |
|
||||
| Doom3Quest | Quest | ✅ Works | |
|
||||
@@ -46,10 +48,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Eleven Table Tennis | Quest | ✅ Works | |
|
||||
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Eye of the Temple | Quest | ✅ Works | |
|
||||
| FrenzyVR | Quest | ✅ Works | |
|
||||
| Fruit Ninja | Quest | ✅ Works | |
|
||||
| Fruit Salon | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| Ghost Town | Quest | ✅ Works | |
|
||||
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
|
||||
| GORN2 | Quest | ✅ Works | |
|
||||
| Gravity Lab | Quest | ✅ Works | |
|
||||
@@ -61,6 +65,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| In Death: Unchained | Quest | ✅ Works | |
|
||||
| Into The Radius 2 | Quest | ✅ Works | |
|
||||
| Job Simulator | Quest | ✅ Works | |
|
||||
| Journey of the Gods | Quest | ✅ Works | |
|
||||
| Jurassic World Aftermath Collection | Quest | ✅ Works | |
|
||||
| Keep Talking and Nobody Explodes | Quest | ✅ Works | |
|
||||
| Kill It With Fire VR | Quest | ✅ Works | |
|
||||
@@ -68,18 +73,23 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Lambda1VR | Quest | ✅ Works | |
|
||||
| LEGO® Bricktales | Quest | ✅ Works | |
|
||||
| Lucky's Tale | Quest | ✅ Works | |
|
||||
| Maestro | Quest | ✅ Works | |
|
||||
| Marvel's Deadpool VR | Quest | ✅ Works | |
|
||||
| Marvel's Iron Man VR | Quest | ✅ Works | |
|
||||
| Max Mustard | Quest | ✅ Works | |
|
||||
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
|
||||
| Metro Awakening | Quest | ✅ Works | |
|
||||
| Mini Motor Racing X | Quest | ✅ Works | |
|
||||
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
|
||||
| Moss | Quest | ✅ Works | |
|
||||
| Moss: Book II | Quest | ✅ Works | |
|
||||
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
|
||||
| Nano | Quest | ✅ Works | |
|
||||
| NEX Player | Quest | ✅ Works | |
|
||||
| NFL PRO ERA II | Quest | ✅ Works | |
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
| Ocean Rift | Quest | ✅ Works | |
|
||||
| Operation Wolf Returns: First Mission VR | Quest | ✅ Works | |
|
||||
| palazzo_santacruz | Quest | ✅ Works | |
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| Pistol Whip | Quest | ✅ Works | |
|
||||
@@ -97,9 +107,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Shadow Point | Quest | ✅ Works | |
|
||||
| Shoot The Zombirds VR | Quest | ✅ Works | |
|
||||
| Shores of Loci | Quest | ✅ Works | |
|
||||
| SilentSlayer | Quest | ✅ Works | |
|
||||
| Smash Drums | Quest | ✅ Works | |
|
||||
| Sniper Elite VR | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
@@ -112,9 +124,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| SUPERHOT VR | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| The Last Clockwinder | Quest | ✅ Works | |
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
| The Tale of Onogoro | Quest | ✅ Works | |
|
||||
| Time Crisis II VR (Test) | Quest | ✅ Works | |
|
||||
@@ -128,6 +142,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Vader Immortal: Episode II | Quest | ✅ Works | |
|
||||
| Vader Immortal: Episode III | Quest | ✅ Works | |
|
||||
| Vampire Survivors | Quest | ✅ Works | |
|
||||
| Vendetta Forever | Quest | ✅ Works | |
|
||||
| VR HOT Quest | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
| Walkabout Mini Golf | Quest | ✅ Works | |
|
||||
@@ -144,12 +159,15 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
|
||||
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
|
||||
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
|
||||
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
|
||||
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
|
||||
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
|
||||
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
|
||||
| Ragnarock | Quest | ⚠️ Works with issues | |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
|
||||
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
|
||||
@@ -158,9 +176,8 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
|
||||
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
|
||||
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
|
||||
| Deadly Delivery | Quest | ❌ Doesn't run | Quits after Meta's entitlement check fails: it needs Meta's online services. |
|
||||
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
|
||||
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
|
||||
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
|
||||
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
|
||||
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
@@ -0,0 +1,83 @@
|
||||
# Evaluating patch heuristics
|
||||
|
||||
`scripts/eval_heuristics.py` compares `engine.suggest(analysis, use_catalog=False)` with the bundled
|
||||
`catalog/games/*.yaml` entries. It does not merge personal or remote catalog recipes into the benchmark.
|
||||
It supports the original dumps command, or a precomputed `scripts/heuristics_snapshot.py` snapshot:
|
||||
|
||||
```sh
|
||||
.venv/bin/python scripts/eval_heuristics.py '<dumps folder>' --verbose
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
|
||||
```
|
||||
|
||||
Use a scratch `FRAMEPORT_HOME`, `FRAMEPORT_NO_CATALOG_UPDATE=1`, and an empty `FRAMEPORT_WINDOWS_DATA` for offline
|
||||
measurement. Snapshot mode reads no APKs or game data. Each snapshot read copies the source to a temporary file,
|
||||
then parses the copy, retrying incomplete JSON. To compare multiple splits against an actively growing snapshot,
|
||||
freeze one valid copy first and use that file for every run.
|
||||
|
||||
The default split is `dev`. `split_packages({package: analysis_engine})` sorts packages by SHA-256 within Unity,
|
||||
Unreal, and Other strata, then holds out the nearest 20% (half rounded up). Pass only packages with both an analysis
|
||||
and a catalog entry. The same input set always yields the same split regardless of iteration order; adding packages
|
||||
can move the cutoff. Very small strata can have no holdout. Unsupported entries participate in splitting.
|
||||
|
||||
## What is scored
|
||||
|
||||
Ground truth contains catalog choices and patch registry defaults, with no inherited heuristic adapter settings.
|
||||
Default expectations are determined per analysis, using each default patch's own detection in the same way as the
|
||||
engine's initial heuristic pass. Thus a conditional default that is not detected (e.g. LTW on a game without LTW)
|
||||
is not expected. Explicit catalog `overport_remove` and `frame_remove` entries remove expected defaults. An absent
|
||||
expected default is represented as `remove:<patch id>`; enabled defaults do not inflate patch scores.
|
||||
|
||||
Opt-in overport/frame/device/PC VR IDs, default removals, adapter values, device-file names, foveation parameters,
|
||||
and alternate-build patch IDs are compared. Alternate-build identities use `alt:<patch id>` so choosing one in a
|
||||
fallback is distinct from choosing it in the primary build. Device-file content is not compared. Adapter values
|
||||
must match: a wrong value contributes both an FP and an FN for `adapter.<key>`. Patch precision, recall and F1 are
|
||||
micro-averaged across games; an empty denominator is reported as zero. Exact-game accuracy also compares the
|
||||
unsupported status flag. Fields report matching games, including empty-field matches.
|
||||
|
||||
`RUNTIME_ONLY` lists choices excluded from the headline: `use_alt`, selected runtime adapter settings, Lepton
|
||||
environment variables, and config-sync settings. They have their own exact, field and patch metrics. Catalog
|
||||
entries with status `unsupported` are excluded from both headline and runtime supported-game metrics, and have
|
||||
separate metrics and per-game diffs. Converted analyses are included and marked. JSON contains all metrics per
|
||||
engine, all game signatures, split assignments, and missing/extra decisions.
|
||||
|
||||
## Dated score: catalog gaps vs. detection errors
|
||||
|
||||
The strict score treats the catalog as complete, but older entries were verified before many patches existed, and
|
||||
a catalog recipe inherits every heuristic adapter setting and device patch (except `device.text_input_window`)
|
||||
without listing them (`engine.suggest`), so the verified build had those. The dated score (`headline_dated`, printed
|
||||
under "Dated") sets aside, on both sides:
|
||||
|
||||
- patches first committed after the entry's `verified.date` (the verifier couldn't have chosen them), and
|
||||
- inherited adapter/device picks (`inherited_decisions`).
|
||||
|
||||
What remains are real disagreements: an extra on an entry verified after the patch existed, or a catalog choice
|
||||
the heuristics don't make. Gaps are counted per patch ("Catalog gaps by patch"). The dated score needs the intro
|
||||
date cache that `--fit-report` writes; without it only inherited picks are set aside.
|
||||
|
||||
Before tuning a rule to remove an "extra", check which kind it is: a gap on an old entry is a candidate for the
|
||||
catalog (fit report), not a detection error. Before adding a patch to a working entry, weigh its benefit (does it
|
||||
fix something the game hits?) against a rebuild prompt for everyone who installed it; adapter settings never need
|
||||
adding (inherited). Results 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6% (dev 90.5%, holdout
|
||||
79.5%). Remaining misses are one-game fixes found at runtime (e.g. Vader's gl_error_clear, BlazeRush's GL settings).
|
||||
|
||||
An evaluation writes JSON even when its headline has differences, then exits 1 for compatibility with the original
|
||||
script. It exits 0 when exact, or after producing a fit report.
|
||||
|
||||
## New patch fit
|
||||
|
||||
The fit report covers all catalog entries, independently of `--split`. Candidates come from the final heuristic
|
||||
recipe, preserving platform, flat-app, dependency, and 32-bit normalization rules. A candidate must also be opt-in,
|
||||
applicable and recommended by its own `detect()`. Entries already listing or explicitly removing the patch are
|
||||
excluded; a listed adapter key is excluded regardless of its value. This report proposes additions, not value edits.
|
||||
|
||||
Each candidate includes its reason, experimental/strict flags, first local git introduction date, catalog entry
|
||||
date (`verified.date`; `updated` only without one, since it also moves for unrelated edits), and whether the
|
||||
patch is newer. Unknown dates are marked unknown. Warnings
|
||||
come from the full catalog recipe plus that one candidate; they may include pre-existing conflicts. Missing-analysis
|
||||
entries appear as metadata-only rows. Intro dates, including unknown results, are cached in
|
||||
`patch-intro-dates.json` beside the snapshot (or dumps folder); subsequent runs only query uncached patch IDs.
|
||||
|
||||
`tests/test_eval_heuristics.py` uses synthetic data. The opt-in real-dump test in `tests/test_games.py` ratchets the
|
||||
supported all-split exact ratio and patch F1 against measured, rounded-down baseline constants. Machine-local
|
||||
`.eval-home/` and `.eval-results/` scratch outputs are ignored by git.
|
||||
+8
-7
@@ -82,7 +82,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
|
||||
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 12 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 28 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 85 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 102 |
|
||||
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
|
||||
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
|
||||
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
|
||||
@@ -175,7 +175,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | Files and settings on the Frame | |
|
||||
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
|
||||
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 88 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 105 |
|
||||
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
|
||||
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
|
||||
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
|
||||
@@ -904,7 +904,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
- `frame.unity_gl_shim`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 15 more.
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 15 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
|
||||
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
|
||||
@@ -973,7 +973,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
|
||||
- `frame.unity_runtime_msaa_off` · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 20 more.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 20 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
|
||||
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
|
||||
@@ -989,7 +989,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
- `frame.unity_text_input` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
|
||||
- **Catalog games (85):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked, BattleSisters and 77 more.
|
||||
- **Catalog games (102):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica, AugmentedEmpire and 94 more.
|
||||
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
|
||||
|
||||
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1000,6 +1000,7 @@ Lets Unity 6 games with many native libraries start when they run out of thread
|
||||
|
||||
- `frame.unity_tls_key_pool` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity 6 with Firebase Firestore and Gecko: these native runtimes can exhaust Android's 128 thread keys alongside the Frame's graphics and XR libraries. Pool Unity's keys to leave room for them.
|
||||
- **Offered (off):** Unity 6 with …: turn on if the game aborts at start with "cannot create thread specific key" (for example Wander).
|
||||
- **Catalog games (1):** Wander.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -1827,7 +1828,7 @@ For apps that only know Quest 3/Pro controllers: without it they get no buttons.
|
||||
- Game settings → Controllers → "Treat newer Quest controllers as Touch" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> 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.
|
||||
> 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), and the runtime's answer for each hand is reported to them as their own profile (Unity's OpenXR input only connects controllers of a profile it knows). Meta's detached-controller paths (XR_META_detached_controllers, which the Frame lacks) are left out of the app's actions and bindings: one such path made Unity attach no controller input at all. Without it such apps get no controller input.
|
||||
|
||||
### <a id="adapter-proximity_emul"></a>Animate fingers from touch
|
||||
|
||||
@@ -2334,7 +2335,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
|
||||
- `device.text_input_window`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
|
||||
- **Catalog games (88):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked and 80 more.
|
||||
- **Catalog games (105):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica and 97 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> 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".
|
||||
|
||||
+3
-16
@@ -24,6 +24,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
|
||||
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
|
||||
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
|
||||
| A Quest game renders, then logs `Entitlement check failed` / `ovrPlatformInitialize_NotEntitled` (often with retries) and quits, e.g. `SIG: 9` after `[Photon] Disconnected` (GitHub #322, Deadly Delivery) | The game's Meta licence check (entitlement) needs Meta's online login, which the Frame doesn't have; online-only games quit (triage `meta-entitlement`) | None: FramePort doesn't get around entitlement checks. Offline games that log it and play on are fine |
|
||||
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
|
||||
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
|
||||
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
|
||||
@@ -138,6 +139,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Unity game freezes on its loading screen ~10-15 s in (Yupitergrad, GitHub #235); launch.log has `GrantPermissionsActivity: Permission grant result … permission=android.permission.BLUETOOTH_CONNECT`, then OVRPlugin `SetupDisplayObjects(): Unable to find EGLConfig for configId 0` and no more frames; triage `bhaptics-permission-prompt` | bHaptics' vest SDK (`com.bhaptics.commons.PermissionUtils.requestBluetoothPermission`) asks for Android 12's BLUETOOTH_CONNECT; on Lepton's Android 11 it can't be granted at install, so Android's permission screen takes the foreground for a moment, the game pauses/resumes and OVRPlugin's GLES compositor stops drawing | `frame.bhaptics_permission` (suggested by analysis `bhaptics_permission`): the request returns at once (`Dex.return_early`) |
|
||||
| Unity game aborts at start: `Abort message: 'cannot create thread specific key for __cxa_get_globals()'` (SIGABRT in a library such as `libFirebaseCppApp-*.so`, for example Wander 0.3570, GitHub #198); triage `pthread-keys-exhausted` | Bionic allows 128 pthread keys per process; Unity 6 takes ~40 (most without a destructor), large native libraries (Gecko `libxul.so`, Firebase, avatar SDKs) and the Frame's own libraries (Mesa/Zink, FrameBridge, OVRPort, Valve's Vulkan layers) take the rest | `frame.unity_tls_key_pool` (opt-in; suggested for Unity 6 with Firebase C++ or Gecko): libunity.so's `pthread_key_*`/`pthread_get/setspecific` imports are renamed to `fp_tkey_*` (`elf.rename_imports`, versions set global) and `libfp_tkeys.so` (`native/tlskeys`) is added to its DT_NEEDED; destructor-less keys become slots behind one real key (logs `fp_tkeys: pooling Unity's thread keys`). Approach by Klownicle (#198) |
|
||||
| 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`) |
|
||||
| Controllers "don't sync": virtual hands/controllers lie on the floor below you, no buttons (Unity OpenXR games that only suggest Touch Plus/Pro, e.g. Hotel Infinity #327); log with `input_diag=1`: only `meta/touch_controller_plus` (or Pro) suggested, `controller profile … -> oculus/touch_controller` | the runtime then reports `oculus/touch_controller` for each hand, a profile the game never suggested: Unity's OpenXR input creates no device for it | adapter `profile_remap` (default on) reports the game's own profile for such hands (log: `profile_remap: /user/hand/left reported as meta/touch_controller_plus`) |
|
||||
| Controllers "don't sync": hands on the floor below you, no buttons, in a Unity OpenXR game with Meta's OpenXR features (e.g. Spatial Zombies #328); log: `xrCreateAction: XR_ERROR_PATH_UNSUPPORTED` (input_diag: `xrCreateAction -> XR_ERROR_PATH_UNSUPPORTED (thumbstick)`), then `xrGetCurrentInteractionProfile: XR_ERROR_ACTIONSET_NOT_ATTACHED` | Meta's "Detached … Controller" devices add `/user/detached_controller_meta/left|right` to Unity's merged actions; the Frame has no XR_META_detached_controllers, the action fails and Unity attaches no action set (OVRPlugin's input goes with it) | adapter `profile_remap` (default on) leaves those paths out of actions and bindings (log: `profile_remap: action thumbstick without 2 detached-controller path(s)`) |
|
||||
| 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>)` |
|
||||
| Unity game hangs or the whole Frame restarts (for example in a menu); log: `The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended level.` (for example Lucky's Tale) | Meta's OVRManager (`useRecommendedMSAALevel`) turns 4x MSAA on at runtime, past QualitySettings; multisampled render-to-texture on GLES/Zink hangs the GPU | `frame.unity_runtime_msaa_off` (Cpp2IL: `OVRDisplay.get_recommendedMSAALevel` → 0) |
|
||||
| Unity game: one eye shows only effects or gray, the other is fine (for example I Am Cat, Oculus XR Plugin on GLES) | the game's single-pass multiview rendering goes wrong for array slice 1 (the adapter submits both slices correctly) | `frame.unity_multipass` (Cpp2IL: `OculusSettings.GetStereoRenderingMode` → MultiPass); `swap_eyes=1` tells whether the game or the adapter is at fault |
|
||||
@@ -267,19 +270,3 @@ Scan a folder with Rift game dumps (Windows game folders) like Quest dumps; they
|
||||
| Proton game hangs at start in a headless/SSH launch; log `no driver could be loaded` / `explorer process failed to start` | no display session (DISPLAY/GAMESCOPE_WAYLAND_DISPLAY) | the Proton launch.sh takes them from the running Steam (built in) |
|
||||
| Unreal game starts (window created) then `CrashReportClient` runs | the game crashed under Proton; often no VR runtime reached it (Revive off) | keep `pcvr.revive` on (repacks' bundled LibRevive64.dll isn't loaded by itself); read the game log + crash summary in the launch log |
|
||||
| `VK_ERROR_DEVICE_LOST` under DXVK | freedreno GPU hang | `pcvr.proton_log` to capture; PC mode |
|
||||
|
||||
## Experimental Rift through FEX
|
||||
|
||||
These signatures apply to PC VR logs (`kind: pcvr`). Compatibility profiles describe static evidence until a
|
||||
headset test verifies them. Meta's entitlement and runtime signature checks remain intact.
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Meta runtime says `Missing entitlement` | The signed-in Meta account does not own the game | Informational, not a FramePort bug; use an account that owns it. No bypass or patch is offered (`fex-missing-entitlement`) |
|
||||
| `OVRServer` reports `install_available` | The matching app is not registered as installed | Reinstall/register through the FEX install flow with the matching Meta manifest (`fex-registration-missing`) |
|
||||
| `Rejecting new committed universe since IMU fallback is active` | SteamVR has not acquired headset tracking since boot | Wear the headset once and wait for tracking before retrying (`fex-imu-fallback`) |
|
||||
| `Service invocation completed`, followed by confirmation `OVRLibrarian still running` | Wine Mono's OVRLibrarian never exits under FEX and blocks later clients | End only OVRLibrarian.exe after completion; check that the FEX launcher's cleanup runs. Completion alone is healthy (`fex-librarian-stuck`) |
|
||||
| `vkd3d` fails to create/initialize a device | D3D12 renderer initialization failed | Check GE's bundled vkd3d-proton, clear inherited wined3d/no-D3D12 flags, verify FEX Vulkan thunk to Turnip (`fex-vkd3d-init`) |
|
||||
| `wined3d` fails to initialize OpenGL | No working guest GL/display route; the Vulkan thunk does not provide a GL thunk | Inspect guest Mesa renderer and display access; prefer the game's D3D9/11 route if available (`fex-wined3d-init`) |
|
||||
| `ReviveInjector` reports failure to start/inject | Wrong exe, missing x64 Revive files or injection failure | Check executable and shared GE prefix; use OpenVR mode, without `/openxr`. OpenVR-only games can run directly (`fex-revive-injector`) |
|
||||
| Robo Recall has no IME CVar in ApplicationCore or Slate | Older modular Unreal keeps FWindowsApplication in Core | Search `*-Core-Win64-Shipping.dll` with the same anchored CVar finder; this surveyed build has the constructor default at RVA `0x15ffe5`. Never patch an unanchored boolean based on an assumed member offset |
|
||||
@@ -1,276 +0,0 @@
|
||||
# Rift through FEX: compatibility stream C
|
||||
|
||||
Compatibility is static evidence until a headset test verifies a build. First Contact remains the only verified
|
||||
profile. No game entitlement, Platform SDK or Oculus signature checks are changed. A Meta account without ownership
|
||||
must receive **Missing entitlement**; this is an informational result, not a bug to work around.
|
||||
|
||||
## Implementation and profile schema
|
||||
|
||||
`agent/fexrift_compat.py` registers a `GAME_INFO_HOOKS` handler, launcher environment handler and install-check
|
||||
handler. `game_info()` obtains the exe and launch arguments from Meta's manifest, then applies a matching profile.
|
||||
The helper reports `verified: false` for likely/untested builds. Likely profiles install with a warning (also in
|
||||
`checks.profile_warnings`, which the parent agent forwards); untested builds need `force_unverified`; blocked
|
||||
profiles are refused even with force. `checks` records the selected profile, status, route, cache eligibility and
|
||||
32-bit warning. No agent version bump is included.
|
||||
|
||||
`agent/fexrift_games.json` is data, not executable configuration. Its `_comment` describes these fields:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `match.canonical_names` | List of exact Meta canonical app names; no fuzzy title matching |
|
||||
| `match.sha256` | List of exact executable SHA-256 strings |
|
||||
| `match.executables` | List of `{name, size}` objects: case-insensitive basename **and** exact byte size must match |
|
||||
| `status` | `verified`, `likely`, `untested`, `blocked` |
|
||||
| `graphics` | `d3d11` (default), `d3d9`, `d3d12`, `opengl`, `vulkan` |
|
||||
| `env` | String environment values |
|
||||
| `ime_patch` | Optional fallback `{rva, old, new}`, with integer/hex RVA; null means no fallback |
|
||||
| `fp_mem` | `off`, `candidate` (informational, no preload), `enabled` (verified per-game opt-in) |
|
||||
| `bigcache_mb` | Cache size when enabled, default 256 |
|
||||
| `dxvk` | List of dxvk.conf lines; launcher writes `$A/dxvk.conf` and exports `DXVK_CONFIG_FILE` |
|
||||
| `args` | Optional argument list override; omit to preserve manifest arguments |
|
||||
| `notes` | Evidence and first-launch cautions |
|
||||
| `revive` | Optional, defaults true; false launches an OpenVR-only game directly |
|
||||
|
||||
Match alternatives are OR, while basename and size within an executable match are AND. Multiple matching profiles
|
||||
are refused. With no `match`, the profile key is the exact canonical name (legacy First Contact compatibility).
|
||||
The surveyed profiles use basename+size because the owner's folders do not establish authoritative Meta canonical
|
||||
names; their keys identify profiles, **not** presumed Meta app IDs. They preserve the manifest's exe and arguments.
|
||||
A different store build/size needs a new survey or an explicit untested install; do not guess its identity.
|
||||
|
||||
A SHA-only identity should be rechecked against the original executable after any game-byte patch: patching changes
|
||||
its hash. Prefer canonical names or basename+size for profiles that need the IME fix. Size matches choose launch
|
||||
settings only; the anchored finder independently verifies the bytes before patching.
|
||||
|
||||
`fp_mem: candidate` is set only when the surveyed lower bound of the UE version is >=4.18 (Binned2 big-block path).
|
||||
It never enables the unzeroed cache. Every other game needs evidence that it fully overwrites reused memory before
|
||||
an opt-in. First Contact's existing explicit environment/cache settings remain enabled.
|
||||
|
||||
## Older Unreal: Robo Recall
|
||||
|
||||
The premise that Robo Recall lacks `Slate.DeferWindowsMessageProcessing` was a search-location error. Its modular
|
||||
`RoboRecall-Core-Win64-Shipping.dll` contains the UTF-16 CVar at file offset `0x1e1bb0`; the existing anchored finder
|
||||
returns RVA `0x15ffe5`, old `c7860002000001000000`, new `c7860002000000000000`. This changes the constructor's
|
||||
`mov dword ptr [rsi+0x200],1` immediate to zero, with the existing expected-byte guard and `.orig` backup.
|
||||
|
||||
Older Unreal placed `FWindowsApplication` in Runtime/Core; newer trees split it into Runtime/ApplicationCore.
|
||||
`bAllowedToDeferMessageProcessing` and `GetDeferMessages` control the same Windows message deferral path, so an old
|
||||
engine is not exempt from the IME deadlock merely because ApplicationCore/Slate lack the marker. The extension
|
||||
extends `find_ime_target()` to search `*-Core-Win64-Shipping.dll` (and Win32/Core.dll forms), using the unchanged
|
||||
CVar/xref/storage finder. It never patches a guessed member offset. Synthetic tests cover successful Core discovery,
|
||||
repeat installs, and a CVar name without an anchored reference. No Unreal source checkout was available offline;
|
||||
the observed binary is the evidence for this build, rather than an inferred engine-version exemption.
|
||||
|
||||
## Graphics and remaining device questions
|
||||
|
||||
DXVK handles D3D9/11; D3D12 uses GE's own vkd3d-proton selection. The launcher clears inherited
|
||||
`PROTON_USE_WINED3D`, `PROTON_NO_D3D12` and `PROTON_NO_D3D11`, keeps `FP_VKTHUNK=1`, and lets Proton select/copy its
|
||||
shipped DLLs. It makes no renderer DLL edits in a game. `checks.vkd3d_dlls` reports installed d3d12 DLLs under
|
||||
`files/{lib,lib64}/wine/vkd3d-proton/{,x86_64-windows/,i386-windows/}`. These are discovery candidates, not a hardcoded
|
||||
installation prerequisite. GE-Proton's script/archive was not available locally, and network/device access was
|
||||
prohibited: verify the exact 11-7 layout and its script logic on the device before claiming D3D12 support.
|
||||
|
||||
The `opengl` profile exports `PROTON_USE_WINED3D=1`. A native OpenGL game still calls OpenGL directly; this flag
|
||||
selects Wine's GL renderer for any D3D calls. There is **no configured FEX GL thunk**. Host acceleration would need
|
||||
FEX's matching guest libGL/libEGL thunk libraries, host counterparts and enabled GL thunk configuration, with a
|
||||
working display/GLX/EGL bridge. Guest rootfs Mesa might instead use software rendering or Zink through Vulkan;
|
||||
inspect the actual renderer before attributing a game failure to Wine or promising hardware acceleration.
|
||||
The Climb prefers its imported D3D9 route; imports alone do not prove which renderer its config selects.
|
||||
|
||||
A 32-bit exe is flagged in the survey and install checks. GE WoW64 under FEX still needs working 32-bit graphics
|
||||
and SteamVR's 32-bit vrclient bridge; it is not promoted to likely based on engine alone. XAudio2 and Bink are facts
|
||||
to watch; Media Foundation is flagged for a real movie-playback check. Anti-cheat detected by filenames/imports
|
||||
makes a generated profile blocked. Absence of a static marker is not proof that runtime-loaded middleware is absent.
|
||||
|
||||
## Reproduce the offline survey
|
||||
|
||||
Use an isolated `FRAMEPORT_HOME`; never set `FRAMEPORT_WINDOWS_DATA`. No program in a dump is executed, no archive
|
||||
is extracted, and no file in a game folder is written. The report contains local paths and must stay uncommitted.
|
||||
|
||||
```bash
|
||||
export FRAMEPORT_HOME="$PWD/.home"
|
||||
uv run python - <<'PY'
|
||||
import subprocess, sys
|
||||
from pathlib import Path
|
||||
root = Path('/mnt/d/Backup Data/Misc/emus/VR_META_QUEST/ARMGDDN_PCVR_OCULUS')
|
||||
cmd = [sys.executable, 'scripts/rift_fex_survey.py', '--out', '.home/survey']
|
||||
for folder in sorted(root.iterdir()):
|
||||
if folder.is_dir():
|
||||
cmd += ['--folder', str(folder)]
|
||||
subprocess.run(cmd, check=True)
|
||||
PY
|
||||
```
|
||||
|
||||
Outputs: `.home/survey/survey.json`, `survey.md`, and `profiles.json` (ready to paste, but review argument overrides
|
||||
and direct OpenVR routing). The Unreal pak/ini reader is deliberately left in place for stream B's extraction.
|
||||
|
||||
## Owner's library: static results
|
||||
|
||||
All 19 selected game exes are x86_64; no selected 32-bit client was found. Eighteen use the Platform SDK; Rick and
|
||||
Morty is the exception. The table contains **10** likely titles (including Robo Recall and all three Vader episodes),
|
||||
plus nine untested titles. Profile IDs below are data keys matched by exe basename+size, not canonical app names.
|
||||
`candidate` means cache eligibility only; it is off at launch. No surveyed game is promoted to verified.
|
||||
|
||||
| Game / profile ID | Route | Status | Platform SDK | fp_mem | Watch at first launch |
|
||||
|---|---|---|---|---|---|
|
||||
| Arktika 1 / `arktika-1` | d3d11 trial; API undetected | untested | yes | off | Establish actual renderer and custom-engine startup; do not infer D3D11 from this default |
|
||||
| Asgard's Wrath / `asgards-wrath` | d3d11 | likely | yes | candidate | Large-block stalls/memory, Media Foundation movies, entitlement |
|
||||
| Brass Tactics / `brass-tactics` | d3d11 | likely | yes | off | Media Foundation movies, tracking and IME startup |
|
||||
| Chronos / `chronos` | d3d11 | likely | yes | off | Older UE D3D11 path, movies and controller input |
|
||||
| Defector / `defector` | d3d11 | untested | yes | off | Custom-engine startup and Bink cutscenes |
|
||||
| Hubris / `hubris` | d3d11 | likely | yes | candidate | XAudio2 sound, Media Foundation movies, shader/GPU cost |
|
||||
| Lies Beneath / `lies-beneath` | d3d11 | likely | yes | candidate | Platform SDK delay load/entitlement; IME fix and movies |
|
||||
| Lone Echo / `lone-echo` | d3d11 (also imports d3d9) | untested | yes | off | Runtime tracks client, entitlement; custom-engine startup and allocator stalls |
|
||||
| Lone Echo II / `lone-echo-2` | d3d12 | untested | yes | off | vkd3d-proton DLL layout and Vulkan device creation on Turnip |
|
||||
| Rick and Morty / `rick-and-morty-virtual-rick-ality` | opengl; direct OpenVR | untested | **no** | off | Actual GL renderer/acceleration; SteamVR bridge; speech WinRT; never inject Revive |
|
||||
| Robo Recall / `robo-recall` | d3d11 | likely | yes | off | Core DLL IME fix at `0x15ffe5`; Meta entitlement and tracking |
|
||||
| Skydance's Behemoth / `skydances-behemoth` | d3d11 | likely | yes | candidate | XAudio2 sound, Media Foundation movies and heavy GPU/memory use |
|
||||
| Stormland / `stormland` | d3d11 | untested | yes | off | Custom engine, Media Foundation and Bink cutscenes |
|
||||
| The Climb / `the-climb` | d3d9 preferred; also imports GL | untested | yes | off | Confirm CryEngine selects D3D9 and DXVK; GL import alone is not a selected renderer |
|
||||
| The Unspoken / `the-unspoken` | d3d11 | untested | yes | off | Custom engine, Media Foundation and Bink cutscenes |
|
||||
| Vader Immortal I / `vader-immortal-episode-i` | d3d11 | likely | yes | off (UE range starts at 4.16) | Movies, controller input, startup IME; leave MSAA at its game default |
|
||||
| Vader Immortal II / `vader-immortal-episode-ii` | d3d11 | likely | yes | off (UE range starts at 4.16) | Movies, controller input, startup IME; leave MSAA at its game default |
|
||||
| Vader Immortal III / `vader-immortal-episode-iii` | d3d11 | likely | yes | off (UE range starts at 4.16) | Movies, controller input, startup IME; leave MSAA at its game default |
|
||||
| Wilson's Heart / `wilsons-heart` | d3d11 | untested | yes | off | Custom-engine startup and Bink cutscenes |
|
||||
|
||||
## Exact maintainer device checks (not executed here)
|
||||
|
||||
Only run one FEX game at a time: they share the Wine prefix and wineserver. The following uses the existing CLI;
|
||||
stream A/B may add a direct login/install path at integration. Set `FRAMEPORT_HOME` to the development data folder
|
||||
with the paired Frame configuration, then keep using that folder. The example `.home` is isolated from normal app
|
||||
data and needs a paired Frame first. Never set `FRAMEPORT_WINDOWS_DATA`.
|
||||
|
||||
1. On the development PC, upload the integrated helper/extensions/profiles and verify setup:
|
||||
|
||||
```bash
|
||||
export FRAMEPORT_HOME="$PWD/.home"
|
||||
export FRAMEPORT_EXPERIMENTAL_FEX=1
|
||||
uv run frameport fex setup
|
||||
uv run frameport fex status
|
||||
```
|
||||
|
||||
Wait for setup to finish. Expect `proton`, `prefix` and `fex` true; `apps` lists Meta canonical names. No token or
|
||||
sign-in URL is needed in the output. If importing from an already signed-in PC Wine prefix, use the exact app
|
||||
name from its Meta manifest filename (replace both example values):
|
||||
|
||||
```bash
|
||||
app='actual-meta-canonical-name'
|
||||
prefix='/path/to/signed-in/wine-prefix'
|
||||
uv run frameport fex import-login "$prefix" --app "$app"
|
||||
uv run frameport fex install "$app"
|
||||
```
|
||||
|
||||
For a matching likely profile, expect `verified: false`, `checks.profile_status: likely`, a profile ID and
|
||||
`checks.profile_warnings`; no `--force-unverified` should be needed. If the downloaded build differs from the
|
||||
surveyed basename+size, expect refusal and review its own survey before forcing. An explicitly blocked profile
|
||||
must still refuse with `--force-unverified`. An untested game needs that switch. The PC reporter also warns
|
||||
that the game has not been verified on the Frame.
|
||||
|
||||
2. On the Frame, wear the headset once after boot and wait for tracking. Launch from the Steam shortcut. For these
|
||||
checks in a Frame terminal, replace `app` with the same canonical name:
|
||||
|
||||
```bash
|
||||
app='actual-meta-canonical-name'
|
||||
pkg="rift.${app//-/_}"
|
||||
anchor="$HOME/Applications/quest-frame/$pkg"
|
||||
python3 - "$anchor/deployment.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
for key in ('profile', 'profile_status', 'profile_warnings', 'graphics', 'fp_mem', 'exe_32bit', 'wow64', 'vkd3d_dlls'):
|
||||
if key in d.get('checks', {}):
|
||||
print(key, d['checks'][key])
|
||||
PY
|
||||
rg 'FramePort FEX: graphics=' "$anchor/launch.log"
|
||||
rg 'PROTON_USE_WINED3D|PROTON_NO_D3D12|DXVK_CONFIG_FILE|export FP_MEM' "$anchor/launch.sh"
|
||||
```
|
||||
|
||||
Expect the profile route. Candidate caches must have no `export FP_MEM=1`. For Rick and Morty also run
|
||||
`rg 'taskset.*proton' "$anchor/launch.sh"`: the command must invoke the game directly with no ReviveInjector.
|
||||
A graphics route marker proves selection of launcher settings, not successful rendering. Check both eyes,
|
||||
tracking/controllers, sound and a movie in the headset before changing a profile to verified.
|
||||
|
||||
3. For Robo Recall, `checks.ime.file` should name `RoboRecall-Core-Win64-Shipping.dll` and `fixes.ime_patch` should be
|
||||
`patched` on first install and `already` on repeat. On the Frame, verify the original backup exists and the
|
||||
constructor immediate is zero (only the installed copy is read):
|
||||
|
||||
```bash
|
||||
python3 - "$anchor/deployment.json" <<'PY'
|
||||
import importlib.util, json, os, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
path = os.path.join(os.path.dirname(d['exe']), d['checks']['ime']['file'])
|
||||
helper = os.path.expanduser('~/.local/share/frameport/fexrift/artifacts/fexrift.py')
|
||||
spec = importlib.util.spec_from_file_location('fx', helper)
|
||||
fx = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(fx)
|
||||
b = open(path, 'rb').read()
|
||||
p, reason = fx.find_ime_patch(b)
|
||||
assert p and b[fx.pe_offset(b, p['rva']):fx.pe_offset(b, p['rva']) + len(bytes.fromhex(p['new']))] == bytes.fromhex(p['new']), reason
|
||||
assert os.path.isfile(path + '.orig')
|
||||
print('Core IME default zero; original backup present')
|
||||
PY
|
||||
```
|
||||
|
||||
4. For Lone Echo II (untested), install with `--force-unverified`. In a Frame terminal inspect only GE's files and
|
||||
script; no network fetch is needed:
|
||||
|
||||
```bash
|
||||
r="$HOME/.local/share/frameport/fexrift"
|
||||
rg --files "$r/ge/files" | rg '/vkd3d-proton/.*/?d3d12(core)?\.dll$|/vkd3d-proton/d3d12(core)?\.dll$'
|
||||
rg -n 'PROTON_USE_WINED3D|PROTON_NO_D3D12|vkd3d-proton|dxvk' "$r/ge/proton"
|
||||
```
|
||||
|
||||
Expect bundled vkd3d-proton and script selection when wined3d/no-D3D12 are unset. `checks.vkd3d_dlls` should name
|
||||
the installed layout. A vkd3d initialization failure is triaged `fex-vkd3d-init`; a successful Vulkan device plus
|
||||
frames in both eyes is the first actual evidence of this route. If the file layout differs, update discovery
|
||||
candidates without copying DLLs into the game. For GL, inspect the active **guest** renderer; an installed host
|
||||
Mesa library or the Vulkan thunk does not establish guest GL acceleration.
|
||||
|
||||
5. Exercise each triage signature offline on the PC with
|
||||
`uv run pytest -q tests/test_fexrift_compat.py -k triage`. On-device expected strings are `Missing entitlement`,
|
||||
`OVRServer ... install_available`, SteamVR's IMU-fallback message, vkd3d/wined3d initialization failure, or a
|
||||
ReviveInjector failure. Do not dump entire Meta log files: they may carry sign-in data. A Librarian completion
|
||||
line alone must not diagnose a hang. After completion, confirm persistence using `pgrep -f '[O]VRLibrarian.exe'`;
|
||||
only if it remains alive, record the safe diagnostic `OVRLibrarian still running` alongside the completion
|
||||
marker. The launcher now emits the fixed combined marker itself if Librarian persists after cleanup and
|
||||
completion is present; it never mirrors the log. The combined trace matches `fex-librarian-stuck`. Stop only that process after completion with
|
||||
`pkill -9 -f '[O]VRLibrarian.exe'`; the normal launcher already does this. For an unowned title, accept Meta's
|
||||
Missing entitlement result and stop that game test.
|
||||
|
||||
Undo: exit the game in Steam before touching files. Remove the selected profile from the device copy of
|
||||
`artifacts/fexrift_games.json` and rerun install with `--force-unverified` to regenerate the default D3D11/Revive
|
||||
launcher. For an OpenVR-only game, retain `revive: false` in an untested profile when comparing graphics routes.
|
||||
Remove the anchor's `dxvk.conf` when no longer used. To undo the IME fix, restore **only the installed binary**
|
||||
from its `.orig` copy after stopping the game; for example, on the Frame:
|
||||
|
||||
```bash
|
||||
python3 - "$anchor/deployment.json" <<'PY'
|
||||
import json, os, shutil, sys
|
||||
record = json.load(open(sys.argv[1]))
|
||||
path = os.path.join(os.path.dirname(record['exe']), record['checks']['ime']['file'])
|
||||
shutil.copy2(path + '.orig', path)
|
||||
print('Installed IME binary restored')
|
||||
PY
|
||||
```
|
||||
|
||||
Do not delete the shared prefix to undo a profile: it holds Meta's runtime and the owner's login/saves. Game dumps
|
||||
are never modified. Re-running install reapplies the anchored IME fix; to compare launches, restore after install
|
||||
and before Play.
|
||||
|
||||
## Validation in this worktree
|
||||
|
||||
- Offline survey completed for all 19 folders with no per-game errors. Every surveyed executable matched exactly
|
||||
its intended committed profile through the compatibility hook. Ten profiles are likely, nine untested; the
|
||||
separate First Contact profile stays verified. No selected or alternate candidate exe was 32-bit. No static
|
||||
anti-cheat marker was found; this does not rule out runtime-loaded middleware.
|
||||
- Focused FEX/survey/triage tests: **96 passed, 1 skipped**. Ruff passed for the new extension, survey and their tests.
|
||||
- `uv run python scripts/patch_docs.py` was run; `--check` passes and generated patch docs needed no committed change.
|
||||
- Final whole-suite run: **1,646 passed, 43 skipped, 11 failed, 16 setup errors**. All 27 unsuccessful cases failed
|
||||
at local `socket()` creation with `PermissionError: [Errno 1] Operation not permitted`. They are existing local
|
||||
HTTP/pairing/stream tests in `test_deeplink`, `test_devkit`, `test_install_links_extra`, `test_livestream`,
|
||||
`test_setup_url`, and `test_ui_state`. They need a runner allowing loopback sockets; no application or test code
|
||||
was changed to hide this restriction. Re-run the full suite there before integration.
|
||||
- The prebuilt virtual environment was used with `UV_CACHE_DIR=/tmp/frameport-rift-uv UV_NO_SYNC=1` because the default
|
||||
uv cache is read-only and offline metadata resolution cannot install hatchling. For example:
|
||||
`UV_CACHE_DIR=/tmp/frameport-rift-uv UV_NO_SYNC=1 uv run pytest -q`.
|
||||
- No device or external network access was attempted. D3D12, guest OpenGL acceleration, WoW64 and gameplay remain
|
||||
device questions; the steps above specify the evidence needed. The GE script and Unreal source-history checkout
|
||||
were unavailable locally; Robo Recall's actual Core DLL supplies direct evidence for the IME fix instead.
|
||||
@@ -1,228 +0,0 @@
|
||||
# Stream B: unchanged local Rift copies and Meta library registration
|
||||
|
||||
Implemented on `rift/install`. Device validation is pending: this workspace cannot reach the Frame or create local
|
||||
network sockets. No entitlement, Platform SDK, runtime signature check, sign-in data or repack crack is changed by
|
||||
this work. A Meta **Missing entitlement** response remains a failure to report to the owner.
|
||||
|
||||
## Implementation
|
||||
|
||||
`frameport fex install --from-library rift.<slug>` takes the Rift entry's `game_dir`, selected `exe`, title and
|
||||
`launch_args` (also supported in `analysis.extra`). `--folder DIR [--title T]` uses the existing Rift analyzer to pick
|
||||
the exe; ambiguous choices must first be resolved in the library. Optional positional `APP` overrides the generated
|
||||
canonical name. Otherwise use the analysis's `canonical_name`, then a lowercase title slug. Local unverified games
|
||||
require `--force-unverified` **before** any upload. Existing prefix-game installs still work without either option.
|
||||
|
||||
`src/frameport/fexinstall.py` stats the folder and reuses `installer.transfer_link()` and `upload_files()` unchanged:
|
||||
fast link, SFTP `.part` append, tar batches, progress and Reporter cancellation all follow the existing install path.
|
||||
Finished files are skipped by source size/mtime plus remote size; changed sources discard old completed/partial
|
||||
bytes before retry. The source is statted again after transfer. This avoids hashing all source files over NTFS;
|
||||
it cannot detect an edit that deliberately preserves both size and mtime. Do not edit the source during an install.
|
||||
The free-space check reserves 1 GiB in addition to all pending bytes (conservative when `.part` files already exist).
|
||||
|
||||
Skipped, logged and recorded: case-insensitive `*.7z`, `*.7z.0*` split volumes (including backup suffixes), `Data.bin`,
|
||||
`AGRepackInstaller.exe`, `VD.bat`, and `*.AG`. Nothing is extracted or executed on the PC. Every included file's bytes
|
||||
are uploaded as supplied. Existing install-time generic fixes, including the Unreal IME fix, still run on the Frame
|
||||
after registration; they may change the **Frame** executable. The source folder stays unchanged. Symlinks, path
|
||||
escapes, duplicate Windows filenames and collisions with Meta-managed copies are refused.
|
||||
|
||||
App ID precedence:
|
||||
|
||||
1. `[OnlineSubsystemOculus]` in unencrypted pak `Config/DefaultEngine.ini`, then loose `DefaultEngine.ini`,
|
||||
`Oculus.ini` or `OculusConfig.ini`; keys `RiftAppId`, `OculusAppId`, `AppId` in that order. Also checks only the
|
||||
known JSON names `OculusConfig.json` / `oculus-platform-config.json` for `RiftAppId`, `OculusAppId`, `AppId`,
|
||||
`app_id`. IDs must be positive numbers. No binary-string guessing or archive decryption.
|
||||
2. Already cached OculusDB data: `details.store_url` (`fetch_details()` retains the ID in this URL, not an explicit
|
||||
Rift-ID field). No lookup/network request is introduced. A Quest-linked details record can name its Quest store
|
||||
product: check this fallback manually before testing a game with no config ID.
|
||||
3. `--app-id ID`, used only if no earlier source supplies one.
|
||||
|
||||
`app_id_source` records the selected origin. `store_app_id` independently retains the cached store ID when present,
|
||||
so a game ID such as First Contact's `1184903171584429` is not lost to store product `1217155751659625`. Manifest
|
||||
`appId` uses the selected ID; device tests must establish whether Meta instead requires the store product ID for
|
||||
particular builds. The version comes from a version-like folder name, then the exe's PE `.rsrc` FileVersion, else
|
||||
`0.0.0`; versionCode is 1. The shared reader moved to `analysis/unreal_pak.py`; the survey re-exports it via imports
|
||||
so its existing callers and tests keep working. Stream C should keep these imports when merging its survey edits.
|
||||
|
||||
`agent/fexrift_install.py` registers `prepare_local`, `register_local`, `local_info`, `unregister_local` and
|
||||
`runtime_state`. Reservations/metadata live in `fexrift/local-games/<app>.json`, with no login information. They also
|
||||
allow interrupted pre-registration uploads to resume. Registration hashes each file **on the Frame**, writes
|
||||
`<library>/Manifests/<app>.json` and `.json.mini`, and imports the library registry entry through Wine. The extension
|
||||
supplies the library manifest to `manifest_launch` / `game_info`; the existing static checks, IME patch and launcher
|
||||
then run normally. The install check hook marks `local_copy: true`.
|
||||
|
||||
Manifest JSON matches the supplied real First Contact app fixtures: property order (derived properties first),
|
||||
types, CRLF, two-space indentation, backslash `files` keys, forward-slash `launchFile`, and mini containing exe
|
||||
records only. App packageType `APP`, isCore false, launchFile2D null, launchParameters2D empty string,
|
||||
redistributables empty; other defaults come from `MANIFEST_DEFAULTS`. Launch parameters use Windows argv quoting;
|
||||
the launcher preserves the original argv list (a small shared launcher hook avoids splitting local-copy arguments
|
||||
while retaining the existing profiles' shell-fragment parsing).
|
||||
|
||||
Each file has its actual size and whole-file SHA-256, `segmentSize: 10000000`, and one segment per **decimal 10 MB**
|
||||
chunk. Each triple is `[0, chunk_sha256, len(zlib.compress(chunk))]`. The fixture's third number is compressed length,
|
||||
not uncompressed size. We do not know Meta's opaque first value and use 0 as an explicit placeholder; no fabricated
|
||||
CDN identifiers or download URLs are written. Empty files have zero segments. **These manifests describe local
|
||||
bytes, not a working Meta CDN repair recipe. Whether Meta accepts the placeholder without scheduling repair is an
|
||||
open device question.** Do not initiate an update/repair from Meta's app to validate this.
|
||||
|
||||
Internal games: `<PFX>/drive_c/Program Files/Meta Horizon/Software/Software/<app>`, using Meta's existing default
|
||||
library registration. `--dest <mount>` must exactly match an available external mount returned by the existing
|
||||
`drives` command; existing `resolve_dest()` rejects unusable filesystems. No fallback on a missing card.
|
||||
|
||||
External games: `<mount>/FramePort/fexrift-games/<app>`. Their Meta library root is the sibling
|
||||
`<mount>/FramePort/fexrift-library`, containing `Manifests/` and a `Software` symlink to `fexrift-games/`. A stable
|
||||
UUID5 from the mount path identifies the registry library. A prefix `dosdevices/volume{GUID}` links to the mount,
|
||||
and the first unused D:–Y: letter links there too (an existing letter for the same mount is reused). Registry
|
||||
`Path` uses `\\?\Volume{GUID}\FramePort\fexrift-library`, `OriginalPath` uses the letter. The default library stays
|
||||
default. Mount-path changes require uninstall/reinstall; moving these games through the general Move action has
|
||||
not been implemented by this stream. Wine volume discovery and external-library scans need device validation.
|
||||
|
||||
Deployment kind stays `frame_fex`, with `source: local_copy`, `app_id`, `app_id_source`, `store_app_id`, `skipped`,
|
||||
`dest`, and the existing launcher/check fields. Local-copy uninstall removes its files and both manifests. The last
|
||||
game on an external library also removes that library's registry key; the shared default key, shared prefix, saves,
|
||||
login and reusable drive/volume links remain. Imported Meta-managed games still keep their files on uninstall.
|
||||
There is no agent-version bump; integration must bump it so updated agent commands reach existing devices.
|
||||
|
||||
The only other shared-agent helper change sends helper JSON on stdin, avoiding Linux's single-argument size limit
|
||||
for large inventories. The extension files are already included by `artifact_files()` and need no packaging change.
|
||||
|
||||
## Runtime probe
|
||||
|
||||
`--runtime-check` calls the separate bounded `fex_runtime_state` command after install (helper `runtime_state`). It
|
||||
reuses an existing OVRServer or starts it with the shared prefix's Wine environment. It examines only newly appended
|
||||
Service/OVRServer log text, returning `runtime_state: ready`, `install_available` or `timeout` plus `reused_server`.
|
||||
It never returns raw runtime log lines. If it started the server, it kills OVRLibrarian after a fresh invocation
|
||||
completion and stops its Wine server at exit; a reused runtime is left running. The polling budget is 95 seconds,
|
||||
with bounded process start/probe/cleanup, and the agent imposes a 120-second limit. A reused idle runtime may emit no
|
||||
new state line and return `timeout`; that result is inconclusive. The probe does not start the game or prove account
|
||||
ownership. The actual log naming/state-line format remains a device question.
|
||||
|
||||
## Offline validation
|
||||
|
||||
Use a writable UV cache in this sandbox (the default cache is read-only):
|
||||
|
||||
```sh
|
||||
UV_CACHE_DIR=/tmp/frameport-uv-cache uv run --offline --no-sync pytest -q
|
||||
UV_CACHE_DIR=/tmp/frameport-uv-cache uv run --offline --no-sync pytest -q \
|
||||
tests/test_fex_install.py tests/test_fexrift.py tests/test_fexrift_ext.py tests/test_rift_fex_survey.py
|
||||
```
|
||||
|
||||
The full run reached 1,654 passed, 43 skipped; 11 failures + 16 setup errors all tried to create network sockets
|
||||
for existing localhost HTTP/streaming/pairing tests, which this sandbox forbids (`PermissionError: Operation not
|
||||
permitted`). Rerun the whole suite without that sandbox restriction at integration. No socket tests were changed
|
||||
to conceal this limitation. Triage/patch catalog files were not touched, so `patch_docs.py --check` is not required.
|
||||
|
||||
Final follow-up: 1,658 passed, 43 skipped, 27 deselected when excluding precisely those socket-dependent tests.
|
||||
The final install/FEX/survey run (including an additional split-volume suffix case) passed 68 tests, 1 skipped.
|
||||
Ruff passed for the new modules/tests and the survey; `git diff --check` passed.
|
||||
|
||||
## Exact maintainer device test
|
||||
|
||||
Run these only on the maintainer's connected PC. Keep `FRAMEPORT_HOME` pointing to this worktree's `.home`, with
|
||||
its paired Frame target and Rift library entries. Do not point at the Windows FramePort data directory. Ensure the
|
||||
maintainer's integration agent version includes this branch. Wear the headset once after boot; stop any running
|
||||
FEX game before the runtime probe (all use one prefix).
|
||||
|
||||
```sh
|
||||
export FRAMEPORT_HOME="$PWD/.home"
|
||||
export FRAMEPORT_EXPERIMENTAL_FEX=1
|
||||
uv run frameport fex setup
|
||||
uv run frameport fex status
|
||||
uv run frameport list
|
||||
```
|
||||
|
||||
Setup uploads this extension and binaries; it may fetch the signed Meta runtime on the device. Use the login stream
|
||||
to sign in with the owner's account first. `status` should show prefix/meta installed and `login: true` without
|
||||
printing any session content. Do not copy session files, CoreData or sign-in URL queries into a report.
|
||||
|
||||
Pick a library ID the account owns; example (substitute the exact ID from `frameport list`):
|
||||
|
||||
```sh
|
||||
uv run frameport fex install --from-library rift.robo_recall --force-unverified --runtime-check
|
||||
```
|
||||
|
||||
Expect `Skipped repack leftover: ...` entries, `Meta app ID ... (config:...)` or the cached/CLI fallback, upload
|
||||
progress, `Hash and register the game on the Frame`, then generic checks/Steam registration. Final JSON should
|
||||
have `checks.local_copy: true`, with `checks.runtime_state: ready` if Meta emitted the state line. Check
|
||||
`app_id_source` and both IDs in deployment with the filtered command below. `install_available` means registration
|
||||
did not take; stop and report the IDs, canonical name and manifest shape. `timeout` requires inspecting the log
|
||||
format **locally on the device**, keeping any account data private.
|
||||
|
||||
A folder install uses the same flow:
|
||||
|
||||
```sh
|
||||
uv run frameport fex install --folder '/path/to/a/game copy' --title 'My Rift Game' \
|
||||
--app-id 1234567890123456 --force-unverified --runtime-check
|
||||
```
|
||||
|
||||
Replace the example ID with the game's actual ID; the option is a fallback and does not override a config ID.
|
||||
Do not try registering over First Contact already imported by the login tar: the collision refusal is deliberate.
|
||||
|
||||
Inspect deployment fields and launch using the paired connection (edit the package below once):
|
||||
|
||||
```sh
|
||||
uv run python - <<'PY'
|
||||
import json
|
||||
from frameport.cli import _fex_target
|
||||
frame = _fex_target(None).frame
|
||||
pkg = 'rift.robo_recall'
|
||||
try:
|
||||
games = frame.agent('list_installed', package=pkg, details=True)['games']
|
||||
for game in games:
|
||||
print(json.dumps({k: game.get(k) for k in
|
||||
('package', 'kind', 'source', 'app_id', 'app_id_source', 'store_app_id', 'skipped', 'dest')}, indent=1))
|
||||
print(frame.agent('launch', package=pkg))
|
||||
finally:
|
||||
frame.close()
|
||||
PY
|
||||
```
|
||||
|
||||
Expect `kind: frame_fex`, `source: local_copy`, IDs/provenance, and a Steam launch. In the headset check both eyes,
|
||||
controllers, audio and reaching a scene. Look locally for runtime `state: ready`, launcher `FramePort: starting
|
||||
Meta's runtime` / `FramePort: starting <title>`, and the game log. **Missing entitlement** is a valid ownership
|
||||
failure to report, never a reason to modify the copy or checks. Robo Recall may lack the generic IME patch site;
|
||||
report a hang without assuming the registration failed.
|
||||
|
||||
Resume: interrupt the upload with Ctrl-C, then repeat the identical install command. Expect completed files to be
|
||||
skipped and big `.part` files to continue. Compare a few **non-patched** files' SHA-256 locally on source/Frame;
|
||||
manifests should list actual sizes/hashes. No archive, installer, `.AG` file or `VD.bat` should have been uploaded.
|
||||
|
||||
External drive: first uninstall the test copy using the rollback below, then:
|
||||
|
||||
```sh
|
||||
uv run frameport frame drives
|
||||
uv run frameport fex install --from-library rift.robo_recall --force-unverified \
|
||||
--dest /run/media/steamos/REPLACE_WITH_MOUNT --runtime-check
|
||||
```
|
||||
|
||||
Use an actual `usable: true` mount. Expect physical files in `FramePort/fexrift-games/<canonical>`, sibling
|
||||
`fexrift-library/Manifests`, a `Software` symlink, a separate registry library, and `runtime_state: ready`. With the
|
||||
card removed, reinstall/uninstall must fail explicitly rather than writing/deleting internal storage. Reinsert at
|
||||
the same mount path before rollback. Repeat with two games to confirm uninstalling one leaves the second library
|
||||
registered; removing the last should remove its registry key.
|
||||
|
||||
## Undo
|
||||
|
||||
For an installed local copy, end the game in Steam first, then run (substitute its package):
|
||||
|
||||
```sh
|
||||
uv run python - <<'PY'
|
||||
from frameport.cli import _fex_target
|
||||
frame = _fex_target(None).frame
|
||||
try:
|
||||
print(frame.agent('uninstall', package='rift.robo_recall', remove_shortcut=True, keep_data=True))
|
||||
finally:
|
||||
frame.close()
|
||||
PY
|
||||
```
|
||||
|
||||
Expect `removed: true`; game folder, both manifests, deployment and shortcut disappear. Shared prefix, login, saves
|
||||
and unrelated games remain. For a cancelled upload that never produced a deployment, remove only its reservation
|
||||
and uploaded copy through the helper on the Frame (replace canonical/package; stop shared-prefix games first):
|
||||
|
||||
```sh
|
||||
python3 -I ~/.local/share/frameport/fexrift/artifacts/fexrift.py unregister_local \
|
||||
'{"app":"robo-recall","package":"rift.robo_recall"}'
|
||||
```
|
||||
|
||||
Reusable drive links and the empty external library scaffolding can remain for later installs; they contain no
|
||||
game/login data. Do not delete the shared prefix to undo one local installation.
|
||||
@@ -1,178 +0,0 @@
|
||||
# Stream A: Meta sign-in on the Frame
|
||||
|
||||
Implemented on `rift/login`. No Frame or network access was used. The PE helper has **not** been compiled here
|
||||
(llvm-mingw is unavailable); the browser portal and pipe framing remain device acceptance checks.
|
||||
|
||||
## What was built
|
||||
|
||||
`frameport fex login [--start-now] [--timeout 900] [--via-pc]` signs in through Meta's own Client.exe, running in the
|
||||
existing shared FEX prefix. The default creates a temporary, flat Steam entry named **Meta sign-in (FramePort)**
|
||||
using the existing shortcut installer. `--start-now` instead starts a detached user unit without restarting Steam.
|
||||
`fex login --stop` repairs interrupted cleanup. All commands require `FRAMEPORT_EXPERIMENTAL_FEX=1`.
|
||||
|
||||
`agent/fexrift_login.py` registers `login_start`, `login_status`, `login_stop` and private broker/callback commands.
|
||||
`agent/frameport_agent.py` has one separate `fex_login*` block; it passes login messages to the extension over stdin,
|
||||
never JSON argv. No agent version bump or changes to other streams' hook points were needed.
|
||||
|
||||
The launch script imports Steam's display variables, disables gamescope WSI and preloads, starts the existing Meta
|
||||
runtime, waits for OVRLibrarian's completion and kills its stuck process, then starts Client.exe with Proton. Its
|
||||
stdout/stderr and Proton logging are disabled because Electron can print authentication URLs. A lock prevents a
|
||||
second Client.exe launcher. A private broker must be alive before the launcher can run. Cancellation signals the
|
||||
launcher (PID plus kernel start tick, to reject a reused PID), stops Wine, then restores the handler settings. This
|
||||
also prevents an interrupted runtime warm-up from starting Client.exe after cleanup.
|
||||
|
||||
The prefix's WineBrowser `Browsers` setting temporarily points to `login/fp-open-url`. That helper sends the URL to
|
||||
an owner-only Unix socket broker. The broker records only `page_opened_at`, `callback_received_at` and the fixed
|
||||
query-free page URL in `login/state.json`. It opens the real URL with host xdg-open, with output discarded. The
|
||||
temporary host `frameport-oculus.desktop` invokes `fp-oculus-callback`, which sends the callback to the same broker.
|
||||
The broker passes it to `proton run fp_pipesend.exe` **through stdin**. Neither agent forwarding nor Wine invocation
|
||||
puts the callback into argv. The desktop protocol invocation itself necessarily supplies `%u` to its short-lived
|
||||
handler; do not collect process-command-line diagnostics during sign-in.
|
||||
|
||||
`native/fexwine/pipesend.c` sends a NUL-terminated UTF-8 callback to `\\.\pipe\oculus`. It retries a missing or busy pipe
|
||||
for about 20 seconds and prints nothing. Exit codes: 0 written, 2 invalid input, 3 pipe unavailable, 4 write failed.
|
||||
Only Meta's running client receives the link; a second Client.exe is never started to deliver it.
|
||||
|
||||
The extension snapshots the previous WineBrowser value/type, previous host scheme owner, desktop file and mimeapps
|
||||
files before registration. `login/restore.json` contains **registration settings only**. A failed restoration leaves
|
||||
this record for `--stop` to retry. Normal success, timeout, errors and Ctrl-C all clean up; a broker deadline also
|
||||
restores the Frame settings if the PC disconnects (requested timeout plus 180 seconds for Steam shortcut setup).
|
||||
An OS kill/reboot can interrupt this cleanup; rerun `--stop` after reconnecting. The temporary shortcut is removed
|
||||
by the PC's cancel request, asynchronously through the existing shortcut worker. A disconnected PC cannot remove
|
||||
that shortcut until it reconnects, but the launcher refuses to run without its broker.
|
||||
|
||||
## PC browser fallback
|
||||
|
||||
`--via-pc` keeps the full auth URL **only in broker memory**, draining it once through the agent's SSH status reply
|
||||
when the PC explicitly requests `take_url`. Ordinary `fex_login_status` replies never contain the URL. The PC opens
|
||||
the page in its default browser and temporarily borrows the `oculus` scheme. The handler POSTs directly to a loopback
|
||||
server with a random endpoint; it never uses the normal install-link `links/*.link` inbox. HTTP logging is disabled.
|
||||
WSL registers a Windows handler which invokes the WSL Python callback, and opens the sign-in page in Windows.
|
||||
|
||||
Windows/WSL restoration exports and restores the **entire** HKCU `Software\Classes\oculus` tree, including arbitrary
|
||||
subkeys and value types, or removes it if absent before login. The non-secret export is deleted after restoration;
|
||||
if importing it fails, a recovery copy is retained as `<FRAMEPORT_HOME>/oculus-handler-restore.reg` and the error
|
||||
names it. Linux restores the exact previous desktop/mimeapps bytes, including an absent default. macOS fallback
|
||||
is explicitly unsupported. These temporary registrations are separate from the permanent FramePort install-link
|
||||
settings. The PC context restores registration even when sign-in, SSH forwarding or Frame cleanup raises.
|
||||
|
||||
Saved login state in `sessions/` and CoreData stays in the prefix. Status only checks whether a non-empty session
|
||||
file exists; it does not read its contents, prove the session is unexpired, or imply entitlement to any game. This
|
||||
change does not replace Platform SDK DLLs, alter entitlement checks, or access repack archives. A game reporting
|
||||
**Missing entitlement** must remain an entitlement failure.
|
||||
|
||||
## Offline verification
|
||||
|
||||
Run from this worktree, keeping app data isolated in its existing `FRAMEPORT_HOME`:
|
||||
|
||||
```sh
|
||||
UV_CACHE_DIR=/tmp/frameport-uv-cache uv run --offline pytest -q tests/test_fex_login.py tests/test_fexrift.py tests/test_fexrift_ext.py
|
||||
UV_CACHE_DIR=/tmp/frameport-uv-cache uv run --offline ruff check agent/fexrift_login.py src/frameport/fexlogin.py src/frameport/urlhandler.py src/frameport/cli.py tests/test_fex_login.py
|
||||
UV_CACHE_DIR=/tmp/frameport-uv-cache uv run --offline pytest -q
|
||||
```
|
||||
|
||||
The focused suite passed (56 passed, 1 skipped); lint passed. The full suite rerun completed: **1,651 passed, 43 skipped,
|
||||
11 failed, 16 errors**. Every failure/error was `PermissionError: [Errno 1] Operation not permitted` from existing
|
||||
local HTTP/pairing/live-stream socket tests: this sandbox prohibits socket creation/binding. No other stream's files
|
||||
or tests were changed to hide those failures. Run the whole suite on a host permitting loopback sockets before
|
||||
integration. No triage/patch definitions were changed, so patch-doc regeneration is not required.
|
||||
|
||||
## Exact maintainer device acceptance steps
|
||||
|
||||
Use the maintainer's paired Frame configuration; do not copy credentials or configuration into this branch.
|
||||
Close any Rift game first: sign-in uses the same Wine prefix and stopping it ends that prefix's processes.
|
||||
|
||||
1. Build the PE with llvm-mingw on an x86_64 host (or run the existing full `build_pe_dlls.sh` build):
|
||||
|
||||
```sh
|
||||
x86_64-w64-mingw32-clang -O2 -s -Wl,--no-insert-timestamp -o artifacts/fexrift/fp_pipesend.exe native/fexwine/pipesend.c
|
||||
uv run python - <<'PY'
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
root = Path("artifacts/fexrift")
|
||||
sums = root / "SHA256SUMS"
|
||||
lines = [s for s in sums.read_text().splitlines() if s.strip() and s.split()[-1].lstrip("*") != "fp_pipesend.exe"]
|
||||
lines.append(hashlib.sha256((root / "fp_pipesend.exe").read_bytes()).hexdigest() + " fp_pipesend.exe")
|
||||
sums.write_text("\n".join(lines) + "\n")
|
||||
PY
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run frameport fex setup
|
||||
```
|
||||
|
||||
Setup uploads the extension automatically alongside the existing FEX artifacts. Without the exe, login must fail
|
||||
with `fp_pipesend.exe missing: build native/fexwine/pipesend.c and upload it with fex setup`.
|
||||
|
||||
2. Record the Frame's current scheme owner (substitute its paired SSH destination for `FRAME`):
|
||||
|
||||
```sh
|
||||
ssh steamos@FRAME 'xdg-mime query default x-scheme-handler/oculus'
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run frameport fex login --timeout 900
|
||||
```
|
||||
|
||||
Expect `Put on the headset: open 'Meta sign-in (FramePort)' in your Steam library.` Open that entry. Allow about
|
||||
two minutes for the runtime warm-up. Confirm Meta's app is a flat window, not another VR game.
|
||||
|
||||
3. Choose Meta's sign-in action. Confirm Chromium opens **on the Frame**. The PC should print `Sign-in page opened
|
||||
on the Frame.` Complete the real Meta sign-in. When Chromium asks to open an external app, accept it. This is the
|
||||
unproven Flatpak boundary: OpenURI through xdg-desktop-portal must resolve the host's temporary `oculus` handler.
|
||||
Expect `Sign-in response reached Meta's app on the Frame.` followed by `Signed in.` The CLI closes the app/runtime,
|
||||
restores handlers and requests removal of the Steam entry. Check the Steam entry disappears after the shortcut
|
||||
worker finishes; Steam may restart. The saved login must remain visible as `"login": true` in:
|
||||
|
||||
```sh
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run frameport fex status
|
||||
ssh steamos@FRAME 'xdg-mime query default x-scheme-handler/oculus'
|
||||
```
|
||||
|
||||
The second command must return the original owner (including no owner, if originally unset). Do not dump Meta
|
||||
logs, sessions, CoreData, process argv or the full browser URL for debugging.
|
||||
|
||||
4. On an unsigned-in test prefix, or before completing the first sign-in, test the alternate launch route:
|
||||
|
||||
```sh
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run frameport fex login --start-now --timeout 900
|
||||
```
|
||||
|
||||
Expect `Meta's app is starting on the Frame.` There should be no Steam restart and no new shortcut. After a
|
||||
successful login already exists, this command can immediately report `Signed in.` and clean up; that does not
|
||||
exercise a fresh browser exchange. Do not delete the owner's session files just to force this test.
|
||||
|
||||
5. If the portal never delivers the callback, cancel with Ctrl-C and use:
|
||||
|
||||
```sh
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run frameport fex login --start-now --via-pc --timeout 900
|
||||
```
|
||||
|
||||
Click sign-in in the Frame's Meta window; the page must open in the PC browser. Expect `Sign-in page opened in
|
||||
the PC browser.`, then the response/success messages above. On Windows/WSL, verify Meta's previous
|
||||
`HKCU\Software\Classes\oculus` registration is restored; on Linux verify the original xdg default. This also
|
||||
needs a real Windows default-protocol check: OS UserChoice policy can override a Classes registration.
|
||||
|
||||
6. To inspect progress without exposing URLs, use the ordinary agent status command from a second terminal:
|
||||
|
||||
```sh
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run python - <<'PY'
|
||||
import json
|
||||
from frameport.cli import _fex_target
|
||||
frame = _fex_target(None).frame
|
||||
print(json.dumps(frame.agent("fex_login_status"), indent=2))
|
||||
PY
|
||||
```
|
||||
|
||||
Expected keys: `runtime_running`, `client_running`, `page_opened_at`, `callback_received_at`, `signed_in`. Runtime
|
||||
should become true before client; timestamps should appear in that order; signed_in should become true only
|
||||
after a non-empty session file exists. If callback_received_at appears but signed_in never becomes true on a
|
||||
fresh sign-in, the **pipe framing remains unverified**: this implementation uses UTF-8 plus a terminating NUL.
|
||||
Compare against the maintainer's successful spike sender's bytes without printing the URL. Do not retry by
|
||||
starting a second Client.exe, altering entitlement checks, or exposing tokens in diagnostics.
|
||||
|
||||
7. Test cancellation during runtime warm-up, timeout with `--timeout 10`, and repeatable repair/undo:
|
||||
|
||||
```sh
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run frameport fex login --stop
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run frameport fex login --stop
|
||||
```
|
||||
|
||||
Expect `Meta sign-in stopped. Your saved login stays on the Frame.` both times. No delayed Client.exe should
|
||||
appear after waiting two minutes. Status should show client/runtime false, the previous protocol owner should
|
||||
be restored, and the session files should remain. If SSH was lost, the broker cleans up the Frame by its deadline;
|
||||
reconnect and run `--stop` to remove a leftover shortcut or retry an interrupted restoration.
|
||||
@@ -1,276 +0,0 @@
|
||||
# Rift on Frame: performance stream
|
||||
|
||||
Implemented offline on `rift/perf`, from `98786ab`. No Frame measurements were made. Existing research measurements
|
||||
are in `native/fexwine/README.md`; the commands below are for the maintainer to run on the paired Frame.
|
||||
|
||||
## What changed
|
||||
|
||||
`agent/fexrift_perf.py` registers `settings_get`, `settings_set`, `perf_prepare` and `bench`. Setup's existing `revive`
|
||||
step also builds three Revive folders. The PC's existing artifact discovery uploads the extension automatically.
|
||||
The only launcher template changes select the core list, Revive folder and motion-smoothing JSON from variables.
|
||||
The agent and CLI have separate settings/bench blocks; the agent version is unchanged.
|
||||
|
||||
Settings live in `<ROOT>/settings/<Meta-app>.json`, separately from the game catalog. Defaults come from the game's
|
||||
`perf` object, with the existing `env.FP_MEM` / `env.FP_BIGCACHE_MB` as backward-compatible defaults. Overrides take
|
||||
precedence. `null` removes an override. Preparation runs on every launch, writes `<app>.dxvk.conf`, and exports
|
||||
validated values before Wine starts. Updating a setting requires no reinstall after the new launcher is installed.
|
||||
|
||||
| Setting | Default | Accepted values / effect |
|
||||
| --- | --- | --- |
|
||||
| `dxvk_disable_msaa` | `false` | Boolean; `d3d11.disableMsaa` |
|
||||
| `dxgi_sync_interval` | `-1` | Integer -1..4; `dxgi.syncInterval` |
|
||||
| `dxgi_max_frame_latency` | `0` | Integer 0..16; `dxgi.maxFrameLatency` |
|
||||
| `eye_scale` | `1.5` | Divisor 1.0 / 1.25 / 1.5 (100 / 80 / 67 percent nominal eye size) |
|
||||
| `motion_smoothing` | `2` | 0 = default, 1 = force on, 2 = force off; SteamVR `motionSmoothingOverride` |
|
||||
| `cores` | `4-7` | taskset CPU list within 0..7; e.g. `4-7` or `0-3,7` |
|
||||
| `ntsync` | `true` | Off sets `PROTON_NO_NTSYNC=1`; on unsets it |
|
||||
| `fp_mem` | per-game opt-in | Boolean; guest preload enabled only when true |
|
||||
| `bigcache_mb` | `256` | Cache minimum block size, 1..6144 MiB (not the total cache budget) |
|
||||
| `fp_mem_zero` | `false` | Boolean; `FP_MEM_ZERO=1` opts into lazy zero pages |
|
||||
| `wine_large_address_aware` | `true` | Boolean; `WINE_LARGE_ADDRESS_AWARE=1/0` |
|
||||
| `fex` | `{}` | JSON map of whitelisted keys with 0/1 values |
|
||||
|
||||
FEX keys: `FEX_TSOENABLED`, `FEX_VECTORTSOENABLED`, `FEX_MEMCPYSETTSOENABLED`, `FEX_MULTIBLOCK`,
|
||||
`FEX_X87REDUCEDPRECISION`. The first three retain the launcher's default 0, MULTIBLOCK defaults to 1, X87 to 0.
|
||||
Unknown keys, arbitrary environment variables, shell fragments and invalid paths are refused. The commands do not
|
||||
inspect login files, print process command lines, or change Platform SDK/entitlement checks.
|
||||
|
||||
### Revive variants
|
||||
|
||||
The checked-in PC `REVIVE_PATCHES` contains the six visibility patches but **does not contain the research eye-size
|
||||
redirect described in the brief**. Verified locally against the installed DLL's pinned upstream hash:
|
||||
|
||||
- Stock `LibRevive64.dll`: SHA-256 `ef9ffd5342bb4afad9b1c0f4fee1ca3bf32f9d3159d49dd841cc9e22f02663e4`.
|
||||
- After the six PC patches: `eda2a449d230832c95734a03e8c83eeb531668ce676693f636e19c315765af4e`.
|
||||
- RVA `0x12eab` (file `0x122ab`) is `movss xmm4,[rip+0x4bb15]`, loading a shared 1.0 constant.
|
||||
- The research redirect loads the existing 1.5 at **RVA** `0x4eaa4` (file `0x4d4a4`). It is not file offset `0x4eaa4`.
|
||||
|
||||
The extension accepts the pinned six-patch upload, optionally with exactly that research redirect. It redirects
|
||||
this one load into verified alignment padding at RVA `0x12e9c`, after the preceding function's `ret`. Each copy
|
||||
gets its own divisor there. It leaves shared constants untouched. `revive-scales/{1,1.25,1.5}/` hard-links all other
|
||||
files to `revive/`; the DLL is unlinked before writing, so variants and the base never modify one another. The
|
||||
injector is launched from the selected folder, which is where it finds `LibRevive64.dll`.
|
||||
Unexpected hashes, instructions or occupied padding fail explicitly. No Oculus binaries are patched here.
|
||||
|
||||
### fp_mem v3
|
||||
|
||||
`FP_MEM_ZERO=1` calls `madvise(MADV_DONTNEED)` on exactly the reused part before returning it: the requested prefix
|
||||
for short/equal reuse, the cached prefix for a longer allocation. The fresh tail needs no advice. Failed advice
|
||||
evicts the cache entry and falls through to real mmap, rather than returning stale contents. Without ZERO, v2's
|
||||
unzeroed behavior remains. Debug output counts advice calls, discarded bytes and failures.
|
||||
|
||||
The fake-syscall host harness in `tests/test_fex_perf.py` checks equal, short and long reuse, advice failure,
|
||||
unzeroed compatibility, and `FP_MEM_ZERO=0`. It skips only the C test when no host C compiler is installed.
|
||||
The guest artifact has **not** been rebuilt in this stream. On an x86_64 glibc 2.34-compatible build host, from the
|
||||
repository root, the maintainer must run:
|
||||
|
||||
```sh
|
||||
gcc -O2 -shared -fPIC -o artifacts/fexrift/fp_mem.so native/fexwine/fp_mem.c -ldl -lpthread
|
||||
python3 - <<'PY'
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
root = Path('artifacts/fexrift')
|
||||
manifest = root / 'SHA256SUMS'
|
||||
lines = manifest.read_text().splitlines()
|
||||
hash_ = hashlib.sha256((root / 'fp_mem.so').read_bytes()).hexdigest()
|
||||
lines = [f'{hash_} ./fp_mem.so' if line.endswith(' ./fp_mem.so') else line for line in lines]
|
||||
manifest.write_text('\n'.join(lines) + '\n')
|
||||
PY
|
||||
```
|
||||
|
||||
This is an **x86_64 guest** shared library, even though wineserver is native aarch64. Commit the rebuilt artifact
|
||||
and checksum at integration. ZERO experiments with the old artifact would silently measure v2 instead.
|
||||
|
||||
## Benchmark behavior and limits
|
||||
|
||||
`frameport fex bench APP --seconds N` and `scripts/rift_fex_bench.py` each request exactly one run. N is an integer
|
||||
1..120; it includes launcher/runtime startup, not just game sampling. The Frame enforces the deadline independently
|
||||
of the PC connection. A bench lock excludes another bench. The agent first rejects active Lepton/FramePort games;
|
||||
the helper rejects Wine/Proton/FEX processes, other Steam reapers, and Lepton container supervisors. It excludes
|
||||
SteamVR's own AppId 250820 reaper. An open Meta login session also blocks the benchmark.
|
||||
|
||||
The helper requires `IVRSystem_022::GetTrackedDeviceActivityLevel(HMD)` to report **Standby (3)**. All other values,
|
||||
including unknown, idle, recently interacting and interacting, are refused. A write to Steam's `logs/controller.txt`
|
||||
within the last 60 seconds also blocks it (conservative: reconnection is enough). The same owner-activity checks run
|
||||
while sampling; interaction stops the run. This does not claim to detect every desktop keyboard event; do not use
|
||||
this headless tool while someone is using the Frame. A native non-Steam app outside Steam's reaper may also evade
|
||||
the process guard. Do not run simultaneous Play/login/install operations against the shared prefix.
|
||||
|
||||
Before launch, read a positive finite `power/turnOffScreensTimeout`, set `power/pauseCompositorOnStandby=false`
|
||||
through the existing `vrsetting` helper, and temporarily set the screen timeout to N+10. Start `launch.sh` in its
|
||||
own process group. Finally, stop the shared-prefix wineserver (5-second limit), terminate/kill the launcher group,
|
||||
set `pauseCompositorOnStandby=true`, and restore the original screen timeout. Each restoration is attempted even
|
||||
when a preceding cleanup fails. SIGTERM and ordinary errors run this cleanup; SIGKILL, power loss or a dead SteamVR
|
||||
service cannot execute Python's finally. The temporary screen timeout is finite even in those cases.
|
||||
|
||||
Every 0.5 seconds, read up to 128 compositor records via pinned `IVRCompositor_022::GetFrameTimings`. Deduplicate by
|
||||
frame index, retain updated present counts, exclude the initial ring, and count only while scene focus belongs to
|
||||
the game's PID. A hitch is one unique frame presented at least three times. GPU activity uses the actual game
|
||||
process's DRM fdinfo engine-nanosecond deltas, deduplicating shared DRM client IDs; no debugfs permissions needed.
|
||||
Thread wait states are histogrammed from the game's `/proc/<pid>/task/*/wchan` files. No shell commands or logs
|
||||
containing account data enter the summary.
|
||||
|
||||
Successful stdout is one JSON line: effective settings, elapsed time, unique frames, `valid`, frame span, fps,
|
||||
hitches (also per 20 seconds), average render GPU time and client frame interval, game GPU busy percent, and sampled
|
||||
thread wait states. GPU busy is the sum of available engines and may exceed 100 percent on devices with independent
|
||||
engines. `null` means unavailable, not zero. Client frame interval is **not CPU ApplicationTime**. `valid=false`,
|
||||
zero frames or a short sample span means the launch produced insufficient data; do not treat it as a fast run.
|
||||
Use equivalent spans and a stable scene when comparing results. A launcher exiting early is an error.
|
||||
|
||||
## Exact device checks
|
||||
|
||||
The maintainer runs these commands; this stream did not access the network or the Frame. Use the account owner's
|
||||
normal sign-in workflow from the login stream. A missing entitlement is a failed run to report, never bypass.
|
||||
|
||||
1. Build/checksum the guest artifact above. From this worktree on the PC:
|
||||
|
||||
```sh
|
||||
export FRAMEPORT_HOME="$PWD/.home"
|
||||
export FRAMEPORT_EXPERIMENTAL_FEX=1
|
||||
uv run frameport fex setup
|
||||
uv run frameport fex install oculus-first-contact
|
||||
uv run frameport fex settings oculus-first-contact
|
||||
```
|
||||
|
||||
Setup should complete the `revive` step and create all three scale folders. Install once to get the new launcher.
|
||||
Settings should show fp_mem on, threshold 256, eye divisor 1.5, motion smoothing 2, cores 4-7 and ntsync on.
|
||||
Run the Frame helper with `python3 -I` as usual; no additional pip packages are needed there.
|
||||
|
||||
2. Wear the headset once after boot, then remove it and let it reach standby. End every game and the Meta client;
|
||||
wait at least 60 seconds after controller input. A worn headset or a running game must make bench refuse **before
|
||||
changing display settings**. Do not work around a refusal by disabling the checks.
|
||||
|
||||
3. Establish a baseline, then run exactly one bounded experiment per command pair below. Each line is independent;
|
||||
restore the previous changed key with `null` before moving to the next experiment.
|
||||
|
||||
```sh
|
||||
uv run frameport fex settings oculus-first-contact dxvk_disable_msaa=false dxgi_sync_interval=-1 dxgi_max_frame_latency=0 eye_scale=1.5 motion_smoothing=2 cores=4-7 ntsync=true fp_mem=true bigcache_mb=256 fp_mem_zero=false 'fex={}' wine_large_address_aware=true
|
||||
uv run frameport fex bench oculus-first-contact --seconds 120
|
||||
```
|
||||
|
||||
Expected: one summary JSON, `valid=true`, nonzero frames, finite fps/GPU timings; DRM busy may be null if fdinfo
|
||||
is unavailable. After it returns, the game/runtime should be gone and the headset should sleep normally.
|
||||
`launch.log` in the game's anchor should contain `FramePort: starting Meta's runtime` and
|
||||
`FramePort: starting Oculus First Contact`. No rendering with `r.MobileMSAA=2` was proven; do not use that knob.
|
||||
|
||||
4. Run these candidate experiments separately, returning to the baseline between them:
|
||||
|
||||
```sh
|
||||
uv run frameport fex settings oculus-first-contact dxvk_disable_msaa=true
|
||||
uv run frameport fex bench oculus-first-contact --seconds 120
|
||||
uv run frameport fex settings oculus-first-contact dxvk_disable_msaa=null
|
||||
|
||||
uv run frameport fex settings oculus-first-contact dxgi_max_frame_latency=1
|
||||
uv run frameport fex bench oculus-first-contact --seconds 120
|
||||
uv run frameport fex settings oculus-first-contact dxgi_max_frame_latency=null
|
||||
|
||||
uv run frameport fex settings oculus-first-contact eye_scale=1.25
|
||||
uv run frameport fex bench oculus-first-contact --seconds 120
|
||||
uv run frameport fex settings oculus-first-contact eye_scale=null
|
||||
|
||||
uv run frameport fex settings oculus-first-contact 'fex={"FEX_X87REDUCEDPRECISION":1}'
|
||||
uv run frameport fex bench oculus-first-contact --seconds 120
|
||||
uv run frameport fex settings oculus-first-contact fex=null
|
||||
|
||||
uv run frameport fex settings oculus-first-contact wine_large_address_aware=false
|
||||
uv run frameport fex bench oculus-first-contact --seconds 120
|
||||
uv run frameport fex settings oculus-first-contact wine_large_address_aware=null
|
||||
|
||||
uv run frameport fex settings oculus-first-contact ntsync=false
|
||||
uv run frameport fex bench oculus-first-contact --seconds 120
|
||||
uv run frameport fex settings oculus-first-contact ntsync=null
|
||||
|
||||
uv run frameport fex settings oculus-first-contact fp_mem_zero=true
|
||||
uv run frameport fex bench oculus-first-contact --seconds 120
|
||||
uv run frameport fex settings oculus-first-contact fp_mem_zero=null
|
||||
```
|
||||
|
||||
The baseline covers eye divisor 1.5, ntsync on, and ZERO off. MSAA off previously improved GPU load but reduced
|
||||
edge quality. Compare hitches, frame span, GPU time and thread wait distributions; this stream makes no claim
|
||||
that X87 or large-address-awareness helps. GE may ignore the latter for a 64-bit game. Test image quality later
|
||||
in an ordinary worn-headset Play session; the headless bench intentionally refuses that session.
|
||||
|
||||
Script alternative for a single run (do not run this in addition unless another run is intended):
|
||||
|
||||
```sh
|
||||
uv run python scripts/rift_fex_bench.py oculus-first-contact --seconds 120
|
||||
```
|
||||
|
||||
5. Interrupt a run by sending SIGTERM to the `python3 -I .../fexrift.py bench` helper **on the Frame**, after checking
|
||||
its PID with `pgrep -af '[f]exrift.py bench'`. Use `kill -TERM <that-pid>` (never a broad pkill). Expected: interrupted
|
||||
result, game stopped, compositor standby enabled and original screen timeout restored. Also check refusal while
|
||||
the headset is worn and a deliberately invalid setting, e.g. `eye_scale=1.4`, without launching anything.
|
||||
On the Frame, this reads the two settings without printing any account data:
|
||||
|
||||
```sh
|
||||
python3 -I - <<'PY'
|
||||
import ctypes as C, importlib.util, os
|
||||
path = os.path.expanduser('~/.local/share/frameport/fexrift/artifacts/fexrift.py')
|
||||
spec = importlib.util.spec_from_file_location('fx', path)
|
||||
fx = importlib.util.module_from_spec(spec); spec.loader.exec_module(fx)
|
||||
core, method, settings = fx.vr_settings()
|
||||
try:
|
||||
for key, idx, typ in [('pauseCompositorOnStandby', 5, C.c_bool), ('turnOffScreensTimeout', 7, C.c_float)]:
|
||||
err = C.c_int()
|
||||
value = method(settings, idx, typ, C.c_char_p, C.c_char_p, C.POINTER(C.c_int))(
|
||||
b'power', key.encode(), C.byref(err))
|
||||
print(key, value, 'error', err.value)
|
||||
finally:
|
||||
method(core, 1, None)()
|
||||
PY
|
||||
```
|
||||
|
||||
Record these values before the test. Afterwards expect `pauseCompositorOnStandby True error 0` and the original
|
||||
positive timeout with error 0. Confirm the physical display sleeps. With guest `FP_MEM_DEBUG=1`, ZERO reuse logs
|
||||
should show `fp_mem: zero calls ..., bytes ..., failures 0`. This debug variable can be set manually in a test
|
||||
launcher for one run; remove that line afterwards (it is deliberately outside the user environment whitelist).
|
||||
|
||||
## Undo and remaining device questions
|
||||
|
||||
Reset every override (no login or game files removed):
|
||||
|
||||
```sh
|
||||
uv run frameport fex settings oculus-first-contact dxvk_disable_msaa=null dxgi_sync_interval=null dxgi_max_frame_latency=null eye_scale=null motion_smoothing=null cores=null ntsync=null fp_mem=null bigcache_mb=null fp_mem_zero=null fex=null wine_large_address_aware=null
|
||||
```
|
||||
|
||||
To remove this experiment entirely, restore the integration's previous launcher/module and guest artifact, then
|
||||
remove only `settings/<app>.json`, `settings/<app>.dxvk.conf` and `revive-scales/` after stopping all FEX games.
|
||||
Do not remove `compat/pfx`; it holds the owner's login and installed games. For emergency display restoration on the
|
||||
Frame (replace 300 with the previously recorded timeout):
|
||||
|
||||
```sh
|
||||
R="$HOME/.local/share/frameport/fexrift"
|
||||
python3 -I "$R/artifacts/fexrift.py" vrsetting '{"section":"power","key":"pauseCompositorOnStandby","kind":"b","value":true}'
|
||||
python3 -I "$R/artifacts/fexrift.py" vrsetting '{"section":"power","key":"turnOffScreensTimeout","kind":"f","value":300}'
|
||||
```
|
||||
|
||||
Unverified: SteamVR's availability of the pinned historical compositor/system interfaces; whether scene focus PID
|
||||
matches the FEX game's host PID; whether the HMD reports Standby while compositor rendering is forced; how many
|
||||
usable game frames remain after runtime startup within 120 seconds; fdinfo visibility on this OS; the cost of ZERO
|
||||
on the device; all visual/entitlement behavior for other games. Failures must stay visible, not be replaced by fake
|
||||
performance numbers. If startup consumes the window, report `valid=false` and adjust startup in the owning stream
|
||||
before drawing conclusions; do not silently extend the bounded run.
|
||||
|
||||
## Offline validation and integration
|
||||
|
||||
Run `uv run pytest -q` in a normal environment and `uv run python scripts/patch_docs.py --check`. In this sandbox,
|
||||
use a writable UV cache and the already installed environment:
|
||||
|
||||
```sh
|
||||
UV_CACHE_DIR=/tmp/frameport-perf-uv uv run --offline --no-sync pytest -q
|
||||
UV_CACHE_DIR=/tmp/frameport-perf-uv uv run --offline --no-sync pytest -q tests/test_fex_perf.py tests/test_fexrift.py tests/test_fexrift_ext.py
|
||||
```
|
||||
|
||||
The original worktree's Git common directory is mounted read-only. Commits are therefore in the local, writable
|
||||
repository `/tmp/frameport-rift-perf.git` (same base and `rift/perf` ref) and exported as
|
||||
`/tmp/frameport-rift-perf.bundle`. The worktree contains the complete edits. A maintainer with writable original
|
||||
Git metadata can fetch that bundle and fast-forward/cherry-pick the commits; do not discard the worktree edits
|
||||
until integration is confirmed. Local-only instruction files and `.home` are excluded from every commit.
|
||||
|
||||
Validation result in this environment: 80 focused tests passed, 1 skipped; Ruff passed for the three new Python
|
||||
files; `scripts/patch_docs.py --check` passed; `git diff --check` passed. The full suite ran: **1676 passed,
|
||||
43 skipped, 11 failed, 16 errors**. Every remaining failure/error raised `PermissionError: [Errno 1] Operation not
|
||||
permitted` when existing pairing/download/live-stream tests attempted to create sockets. This sandbox forbids
|
||||
sockets, including loopback; these tests were not disabled or modified. A clean full-suite pass remains an
|
||||
integration check in a normal environment. The complete short traceback log is
|
||||
`/tmp/frameport-rift-perf-pytest.log`.
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by session_fixes.c: Meta's detached controllers (XR_META_detached_controllers: a controller put down while
|
||||
// hand tracking runs) on a runtime without that extension. Uses profile_remap, runtime_has_detached_controllers,
|
||||
// active_instance, lookup and LOG.
|
||||
//
|
||||
// Meta's Unity OpenXR features add "Detached ... Controller" devices for /user/detached_controller_meta/left|right.
|
||||
// Unity merges same-named actions of every enabled device into one action, so the first action with such a subaction
|
||||
// path (e.g. "thumbstick") fails with XR_ERROR_PATH_UNSUPPORTED on the Frame's runtime, Unity attaches no action set
|
||||
// at all and neither Unity nor OVRPlugin get controller input: hands lying at the play-space origin on the floor, no
|
||||
// buttons (Spatial Zombies, GitHub #328). With profile_remap on and the runtime lacking the extension, these paths are
|
||||
// left out of the game's actions and suggested bindings, and xrGetCurrentInteractionProfile answers "no profile"
|
||||
// for them (a detached controller that never shows up) instead of XR_ERROR_PATH_UNSUPPORTED.
|
||||
|
||||
#define DETACHED_PREFIX "/user/detached_controller_meta/"
|
||||
|
||||
static int detached_filter_on(void) { return profile_remap && !runtime_has_detached_controllers && active_instance; }
|
||||
|
||||
// 1 if `path` is a detached controller's top-level path or one of its input/output paths
|
||||
static int detached_path(XrPath path) {
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
char name[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
return path && to_string && XR_SUCCEEDED(to_string(active_instance, path, sizeof(name), &size, name)) &&
|
||||
!strncmp(name, DETACHED_PREFIX, strlen(DETACHED_PREFIX));
|
||||
}
|
||||
|
||||
// xrCreateAction: `info` without the detached subaction paths, in *copy (paths in `kept`, room for all of them).
|
||||
// Returns `info` itself when there is nothing to leave out.
|
||||
static const XrActionCreateInfo *detached_strip_action(const XrActionCreateInfo *info, XrActionCreateInfo *copy,
|
||||
XrPath *kept) {
|
||||
if (!info || !info->countSubactionPaths || !info->subactionPaths || !kept || !detached_filter_on()) return info;
|
||||
uint32_t n = 0;
|
||||
for (uint32_t i = 0; i < info->countSubactionPaths; ++i)
|
||||
if (!detached_path(info->subactionPaths[i])) kept[n++] = info->subactionPaths[i];
|
||||
if (n == info->countSubactionPaths) return info;
|
||||
*copy = *info;
|
||||
copy->countSubactionPaths = n;
|
||||
copy->subactionPaths = kept;
|
||||
static int logged;
|
||||
if (logged++ < 3)
|
||||
LOG("profile_remap: action %s without %u detached-controller path(s) (the runtime has no "
|
||||
"XR_META_detached_controllers)", info->actionName, info->countSubactionPaths - n);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// xrSuggestInteractionProfileBindings: `suggested` without bindings of detached controllers, in *copy (bindings in
|
||||
// `kept`, room for all of them). Returns `suggested` itself when there is nothing to leave out.
|
||||
static const XrInteractionProfileSuggestedBinding *detached_strip_bindings(
|
||||
const XrInteractionProfileSuggestedBinding *suggested, XrInteractionProfileSuggestedBinding *copy,
|
||||
XrActionSuggestedBinding *kept) {
|
||||
if (!suggested || !suggested->countSuggestedBindings || !suggested->suggestedBindings || !kept ||
|
||||
!detached_filter_on())
|
||||
return suggested;
|
||||
uint32_t n = 0;
|
||||
for (uint32_t i = 0; i < suggested->countSuggestedBindings; ++i)
|
||||
if (!detached_path(suggested->suggestedBindings[i].binding)) kept[n++] = suggested->suggestedBindings[i];
|
||||
if (n == suggested->countSuggestedBindings) return suggested;
|
||||
*copy = *suggested;
|
||||
copy->countSuggestedBindings = n;
|
||||
copy->suggestedBindings = kept;
|
||||
static int logged;
|
||||
if (logged++ < 8) {
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
char profile[XR_MAX_PATH_LENGTH] = "?";
|
||||
uint32_t size = 0;
|
||||
if (to_string) to_string(active_instance, suggested->interactionProfile, sizeof(profile), &size, profile);
|
||||
LOG("profile_remap: %s bindings without %u detached-controller path(s)", profile,
|
||||
suggested->countSuggestedBindings - n);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
// xrGetCurrentInteractionProfile: a detached controller's path the runtime doesn't know has no profile.
|
||||
static XrResult detached_current_profile(XrPath user, XrInteractionProfileState *state, XrResult result) {
|
||||
if (result != XR_ERROR_PATH_UNSUPPORTED || !state || !detached_filter_on() || !detached_path(user)) return result;
|
||||
state->interactionProfile = XR_NULL_PATH;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
@@ -147,6 +147,7 @@ static int sync_guard; // xrSyncActions one at a time with xrPollEvent, pa
|
||||
static int profile_remap = 1; // Meta's newer controller profiles (rejected by the Frame) -> oculus/touch_controller
|
||||
static int proximity_emul; // finger proximity from capacitive touch: 1 thumb, 2 thumb + index (session_fixes.c)
|
||||
static int runtime_has_proximity; // the runtime has XR_FB_touch_controller_proximity itself
|
||||
static int runtime_has_detached_controllers; // the runtime has XR_META_detached_controllers (detached_controllers.c)
|
||||
static int layer_debug; // diagnostics: layers, swapchains, session states, spaces, aim/grip, refresh rates
|
||||
static int input_diag; // diagnostics: controller profiles, bindings, missing functions, failing input calls
|
||||
static int stable_local; // keep every LOCAL space the app creates on the session-start origin
|
||||
@@ -422,6 +423,8 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
for (uint32_t j = 0; available && j < available_count; ++j)
|
||||
if (!strcmp(available[j].extensionName, "XR_FB_composition_layer_image_layout")) runtime_has_image_layout = 1;
|
||||
else if (!strcmp(available[j].extensionName, "XR_FB_touch_controller_proximity")) runtime_has_proximity = 1;
|
||||
else if (!strcmp(available[j].extensionName, "XR_META_detached_controllers"))
|
||||
runtime_has_detached_controllers = 1;
|
||||
// Layer types are only valid if their extension ends up enabled; some apps submit them regardless.
|
||||
int has_equirect = 0, has_equirect2 = 0, has_cylinder = 0, has_cube = 0;
|
||||
for (uint32_t i = 0; i < kept; ++i) {
|
||||
@@ -2004,7 +2007,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(XrSession session,
|
||||
PFN_xrGetCurrentInteractionProfile fn =
|
||||
(PFN_xrGetCurrentInteractionProfile)lookup(active_instance, "xrGetCurrentInteractionProfile");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult result = fn(session, user, state);
|
||||
XrResult result = detached_current_profile(user, state, fn(session, user, state));
|
||||
if (input_diag && XR_SUCCEEDED(result) && state) input_diag_current_profile(user, state->interactionProfile);
|
||||
if (layer_debug && active_instance) { // which device each hand has, as the runtime reports it
|
||||
PFN_xrPathToString str = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
@@ -2018,16 +2021,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(XrSession session,
|
||||
static int logged;
|
||||
if (logged++ < 40) LOG("layer_debug: interaction profile of %s: %s (result %d)", who, what, result);
|
||||
}
|
||||
if (!controller_fix || XR_FAILED(result) || !state || !state->interactionProfile || !active_instance)
|
||||
return result;
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(active_instance, "xrStringToPath");
|
||||
char name[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
if (to_string && to_path &&
|
||||
XR_SUCCEEDED(to_string(active_instance, state->interactionProfile, sizeof(name), &size, name)) &&
|
||||
(strstr(name, "/valve/") || strstr(name, "/khr/generic_controller")))
|
||||
to_path(active_instance, "/interaction_profiles/oculus/touch_controller", &state->interactionProfile);
|
||||
if (XR_SUCCEEDED(result) && state) current_profile_fix(user, state);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2199,7 +2193,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
if (layer_debug) HOOK_AS(xrGetActionStateBoolean, hook_xrGetActionStateBoolean)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag || proximity_emul)
|
||||
HOOK_AS(xrSuggestInteractionProfileBindings, hook_xrSuggestInteractionProfileBindings)
|
||||
if (proximity_emul) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (proximity_emul || profile_remap) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (layer_debug || aim_correction_on()) {
|
||||
HOOK_AS(xrCreateActionSpace, hook_xrCreateActionSpace)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by session_fixes.c (after TOUCH_PROFILE and app_suggested_touch): what xrGetCurrentInteractionProfile
|
||||
// tells the game about each hand. Uses controller_fix, profile_remap, active_instance, lookup and LOG.
|
||||
// * controller_fix: SteamVR's own profiles (valve/..., khr/generic_controller) are reported as oculus/touch_controller,
|
||||
// the profile Quest games have bindings for.
|
||||
// * profile_remap: when the game suggested bindings only for one of Meta's newer profiles (Touch Plus/Pro) and
|
||||
// hook_xrSuggestInteractionProfileBindings suggested them again for Touch, the runtime reports
|
||||
// oculus/touch_controller, a profile the game never suggested. Unity's OpenXR plugin creates its input devices by
|
||||
// the reported profile, finds none for Touch and leaves the controllers unconnected: hands lying at the play-space
|
||||
// origin on the floor, no buttons (e.g. Hotel Infinity, GitHub #327). Such a hand is reported with the game's own
|
||||
// profile instead (the last one remapped: the Touch bindings the runtime uses are that profile's).
|
||||
|
||||
static char remapped_profile[XR_MAX_PATH_LENGTH]; // set by hook_xrSuggestInteractionProfileBindings
|
||||
|
||||
static void current_profile_fix(XrPath user, XrInteractionProfileState *state) {
|
||||
if ((!controller_fix && !profile_remap) || !state->interactionProfile || !active_instance) return;
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(active_instance, "xrStringToPath");
|
||||
char name[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
if (!to_string || !to_path ||
|
||||
XR_FAILED(to_string(active_instance, state->interactionProfile, sizeof(name), &size, name)))
|
||||
return;
|
||||
int touch = !strcmp(name, TOUCH_PROFILE);
|
||||
if (controller_fix && (strstr(name, "/valve/") || strstr(name, "/khr/generic_controller")) &&
|
||||
XR_SUCCEEDED(to_path(active_instance, TOUCH_PROFILE, &state->interactionProfile)))
|
||||
touch = 1;
|
||||
if (!touch || !profile_remap || !*remapped_profile || app_suggested_touch ||
|
||||
XR_FAILED(to_path(active_instance, remapped_profile, &state->interactionProfile)))
|
||||
return;
|
||||
static int logged;
|
||||
if (logged++ < 4) {
|
||||
char who[XR_MAX_PATH_LENGTH] = "?";
|
||||
to_string(active_instance, user, sizeof(who), &size, who);
|
||||
LOG("profile_remap: %s reported as %s (the app's profile) instead of oculus/touch_controller", who,
|
||||
remapped_profile + strlen("/interaction_profiles/"));
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,8 @@ static const char *const TOUCH_LEFT[] = {"input/x/click", "input/x/touch", "inpu
|
||||
static const char *const TOUCH_RIGHT[] = {"input/a/click", "input/a/touch", "input/b/click", "input/b/touch",
|
||||
"input/system/click"};
|
||||
static int app_suggested_touch;
|
||||
#include "profile_report.c" // remapped_profile, current_profile_fix (xrGetCurrentInteractionProfile)
|
||||
#include "detached_controllers.c" // detached controllers' paths on a runtime without them
|
||||
|
||||
static int in_list(const char *s, const char *const *list, size_t n) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
@@ -332,8 +334,13 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrCreateAction(XrActionSet set, const
|
||||
XrAction *action) {
|
||||
PFN_xrCreateAction fn = (PFN_xrCreateAction)lookup(active_instance, "xrCreateAction");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult r = fn(set, info, action);
|
||||
if (XR_SUCCEEDED(r) && info && action) { // OVRPlugin's action names
|
||||
XrActionCreateInfo copy;
|
||||
XrPath *kept = info && info->countSubactionPaths ? calloc(info->countSubactionPaths, sizeof(*kept)) : NULL;
|
||||
XrResult r = fn(set, detached_strip_action(info, ©, kept), action);
|
||||
free(kept);
|
||||
if (XR_FAILED(r) && input_diag) // this hook replaces input_diag's wrapper of xrCreateAction
|
||||
diag_call_failed("xrCreateAction", r, diag_name_detail(info ? info->actionName : NULL));
|
||||
if (proximity_emul && XR_SUCCEEDED(r) && info && action) { // OVRPlugin's action names
|
||||
if (!strcmp(info->actionName, "hand_thumb_proximity")) thumb_proximity_action = *action;
|
||||
else if (!strcmp(info->actionName, "hand_trigger_proximity")) trigger_proximity_action = *action;
|
||||
else return r;
|
||||
@@ -429,11 +436,26 @@ static XrResult remap_to_touch(XrInstance instance, PFN_xrSuggestInteractionProf
|
||||
return r;
|
||||
}
|
||||
|
||||
static XrResult suggest_bindings(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
const XrInteractionProfileSuggestedBinding *suggested);
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(XrInstance instance,
|
||||
const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
PFN_xrSuggestInteractionProfileBindings fn =
|
||||
(PFN_xrSuggestInteractionProfileBindings)lookup(instance, "xrSuggestInteractionProfileBindings");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrInteractionProfileSuggestedBinding copy;
|
||||
XrActionSuggestedBinding *kept = suggested && suggested->countSuggestedBindings
|
||||
? calloc(suggested->countSuggestedBindings, sizeof(*kept)) : NULL;
|
||||
const XrInteractionProfileSuggestedBinding *use = detached_strip_bindings(suggested, ©, kept);
|
||||
// a profile with only detached-controller bindings: nothing the runtime could bind
|
||||
XrResult r = use != suggested && !use->countSuggestedBindings ? XR_SUCCESS : suggest_bindings(instance, fn, use);
|
||||
free(kept);
|
||||
return r;
|
||||
}
|
||||
|
||||
static XrResult suggest_bindings(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(instance, "xrPathToString");
|
||||
if (suggested && to_string && (layer_debug || aim_correction_on()))
|
||||
for (uint32_t i = 0; i < suggested->countSuggestedBindings; ++i) {
|
||||
@@ -456,6 +478,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(X
|
||||
}
|
||||
if (r != XR_ERROR_PATH_UNSUPPORTED || app_suggested_touch || !IN(profile, NEWER_TOUCH)) return r;
|
||||
XrResult remapped = remap_to_touch(instance, fn, to_string, suggested, profile);
|
||||
if (XR_SUCCEEDED(remapped)) snprintf(remapped_profile, sizeof(remapped_profile), "%s", profile);
|
||||
return XR_SUCCEEDED(remapped) ? XR_SUCCESS : r;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# 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 |
|
||||
|---|---|
|
||||
| `pipesend.c` | `fp_pipesend.exe`, built by `build_pe_dlls.sh` with llvm-mingw. Sends a browser's `oculus://link/frl_login` callback into the running Meta app's `\\.\pipe\oculus`; reads UTF-8 from stdin so SSH forwarding never puts credentials in argv. Retries a busy/missing pipe for 20 seconds. Does not print the link. Add the rebuilt exe to `artifacts/fexrift/SHA256SUMS`. Pipe framing still needs the device check in `docs/rift-fex/login.md`. |
|
||||
| `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_MEM_ZERO=1` (v3) lazily zeroes only the reused range with `MADV_DONTNEED` before returning it; unset keeps v2 behavior. Debug counters report advice calls/bytes/failures. `FP_THP=1` sets `MADV_HUGEPAGE` on large writable regions. From the repo root on the build host, build with `gcc -O2 -shared -fPIC -o artifacts/fexrift/fp_mem.so native/fexwine/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.
|
||||
|
||||
|
||||
## Launch-time experiments and bounded measurements
|
||||
|
||||
The First Contact numbers above are research measurements, not new results from the perf extension. Subsequent
|
||||
owner tests found the remaining bottleneck to be GPU work: about 55 percent game GPU busy at 36 fps, about
|
||||
20 percent for the compositor, and 5–10 ms CPU ApplicationTime. With per-app motion smoothing off, it reached
|
||||
72 fps with occasional misses. DXVK `d3d11.disableMsaa=True` reduced measured game GPU busy from 91 to 69 percent;
|
||||
the owner reported much better performance and rougher edges. `r.MobileMSAA=2` prevented rendering.
|
||||
|
||||
`agent/fexrift_perf.py` adds validated per-game settings read at every launch, including MSAA, frame latency,
|
||||
Revive eye-size divisors, core affinity, ntsync, guest cache options and a small FEX environment whitelist.
|
||||
`FP_MEM_ZERO=1` restores zero-page semantics for cache reuse, with the cost of page faults on later touches.
|
||||
When advice fails the region is evicted and mapped fresh. ZERO is opt-in; the default cache remains unzeroed.
|
||||
The host test uses fake mmap/munmap/madvise to check full, short and long reuse and advice-failure fallback.
|
||||
The real x86_64 guest artifact and its checksum must be rebuilt by the maintainer before ZERO experiments.
|
||||
|
||||
`frameport fex bench APP --seconds 120` requests one headless run with a total launch/sampling window of at most
|
||||
120 seconds, then stops Wine and the launcher and restores compositor standby and the screen timeout. It refuses
|
||||
an active game/login or a headset outside standby. It reports deduplicated compositor frame timings, hitches
|
||||
(frames presented at least three times), game DRM fdinfo GPU deltas and sampled thread wait states. These new
|
||||
paths have only offline tests; the pinned OpenVR interfaces and process attribution need device validation.
|
||||
See [the perf stream notes](../../docs/rift-fex/perf.md) for exact build, baseline, experiment and undo commands.
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/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
|
||||
# Meta's single-instance pipe, used by browser sign-in (no entitlement or Platform SDK changes).
|
||||
x86_64-w64-mingw32-clang -O2 -s -Wl,--no-insert-timestamp -o "$OUT/fp_pipesend.exe" "$HERE/pipesend.c"
|
||||
ls -la "$OUT"
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/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"
|
||||
# -ffile-prefix-map: the artifact is committed to a public repo, keep the build folders out of its assert paths
|
||||
CF="-c -O2 -g -pipe -fno-strict-aliasing -D__WINESRC__ -D_GNU_SOURCE -DFP_EMULATED_X86_64 -ffile-prefix-map=$SRC=wine -ffile-prefix-map=$B=build -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}'))"
|
||||
@@ -1,209 +0,0 @@
|
||||
/* 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_MEM_ZERO=1: MADV_DONTNEED the reused part before returning it (lazy kernel zero-fill). Unlike the default
|
||||
* unzeroed cache this preserves anonymous zero-page semantics; it trades the saved page faults for correctness.
|
||||
* 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 <string.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, zero, 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, zero_calls, zero_failures;
|
||||
static size_t zero_bytes;
|
||||
|
||||
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;
|
||||
zero = (e = getenv("FP_MEM_ZERO")) && !strcmp(e, "1");
|
||||
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; "
|
||||
"zero calls %lu, bytes %zu, failures %lu)\n",
|
||||
addr, len, via_munmap ? "munmap" : "release", kept_total >> 20, stores, hits, partial, drops,
|
||||
zero_calls, zero_bytes, zero_failures);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* lock held: discard only the part about to be reused, including a short request's kept prefix. On failure the
|
||||
* caller falls through to overlap eviction + a real mmap: ZERO must never hand stale contents to the guest. */
|
||||
static int zero_reused(void *addr, size_t len)
|
||||
{
|
||||
if (!zero) return 0;
|
||||
zero_calls++;
|
||||
int r = madvise(addr, len, MADV_DONTNEED);
|
||||
if (r) zero_failures++;
|
||||
else zero_bytes += len;
|
||||
if (debug) fprintf(stderr, "fp_mem: zero calls %lu, bytes %zu, failures %lu\n",
|
||||
zero_calls, zero_bytes, zero_failures);
|
||||
return r;
|
||||
}
|
||||
|
||||
/* 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 (zero_reused(addr, len)) 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)) {
|
||||
int protect_failed = prot != c->prot && real_mprotect(c->addr, c->len, prot);
|
||||
if ((zero && protect_failed) || zero_reused(c->addr, c->len)) {
|
||||
real_munmap(t, len - c->len);
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
@@ -1,70 +0,0 @@
|
||||
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 );
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/* Deliver a protocol callback to the RUNNING Meta client; never start a second Client.exe.
|
||||
* UTF-8 stdin is preferred (no credentials in process argv). argv[1] is useful for non-secret device probes.
|
||||
* Uses a NUL-terminated UTF-8 link. Device verification of the Oculus pipe's framing is still required.
|
||||
*/
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
char link[65537];
|
||||
size_t len;
|
||||
HANDLE pipe = INVALID_HANDLE_VALUE;
|
||||
DWORD written;
|
||||
if (argc == 2) {
|
||||
len = strlen(argv[1]);
|
||||
if (len >= sizeof(link)) return 2;
|
||||
memcpy(link, argv[1], len);
|
||||
} else if (argc == 1) {
|
||||
len = fread(link, 1, sizeof(link), stdin);
|
||||
if (ferror(stdin) || len >= sizeof(link)) return 2;
|
||||
} else return 2;
|
||||
if (len < 24 || strncmp(link, "oculus://link/frl_login?", 24) || memchr(link, 0, len)) return 2;
|
||||
link[len] = 0;
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
pipe = CreateFileW(L"\\\\.\\pipe\\oculus", GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);
|
||||
if (pipe != INVALID_HANDLE_VALUE) break;
|
||||
DWORD err = GetLastError();
|
||||
if (err != ERROR_PIPE_BUSY && err != ERROR_FILE_NOT_FOUND) return 3;
|
||||
if (err == ERROR_PIPE_BUSY) WaitNamedPipeW(L"\\\\.\\pipe\\oculus", 200);
|
||||
else Sleep(200);
|
||||
}
|
||||
if (pipe == INVALID_HANDLE_VALUE) return 3;
|
||||
BOOL ok = WriteFile(pipe, link, (DWORD)len + 1, &written, NULL);
|
||||
CloseHandle(pipe);
|
||||
SecureZeroMemory(link, sizeof(link));
|
||||
return ok && written == len + 1 ? 0 : 4;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,79 +0,0 @@
|
||||
/* 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; }
|
||||
@@ -1,4 +0,0 @@
|
||||
LIBRARY windows.devices.wifi.dll
|
||||
EXPORTS
|
||||
DllGetActivationFactory
|
||||
DllCanUnloadNow PRIVATE
|
||||
+477
-38
@@ -1,69 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score the patch heuristics against the verified catalog: for every game dump that has a catalog recipe, suggest a
|
||||
recipe with the catalog switched off and compare the recipe-relevant choices.
|
||||
"""Evaluate static patch suggestions against catalog-only ground truth (no catalog-assisted suggestions).
|
||||
|
||||
python scripts/eval_heuristics.py "<folder with game dumps>" [--verbose]
|
||||
eval_heuristics.py <dumps folder> [--verbose]
|
||||
eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
|
||||
eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
|
||||
|
||||
Snapshots can be in progress: each read copies to a temporary file and retries incomplete JSON. The split is
|
||||
stratified by analysis engine and stable for a fixed set of packages. Converted inputs are included and labelled.
|
||||
Patch scores are micro-averaged across games, excluding enabled automatic defaults. Expected defaults are detected
|
||||
per analysis before applying explicit catalog removals; inapplicable defaults create no removal decisions. A wrong
|
||||
adapter value is both an FP and an FN. Removal decisions and alternate-build patches have their own identities
|
||||
("remove:<id>", "alt:<id>").
|
||||
Unsupported catalog entries and runtime-only choices are reported separately from the headline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from frameport.analysis.detect import analyze # noqa: E402
|
||||
from frameport.core.library import analysis_from_dict # noqa: E402
|
||||
from frameport.core.models import Recipe # noqa: E402
|
||||
from frameport.patches import base # noqa: E402
|
||||
from frameport.recommend import catalog, engine # noqa: E402
|
||||
from frameport.sources import quest_dump # noqa: E402
|
||||
|
||||
RUNTIME_ONLY = [
|
||||
"use_alt", "adapter.vk_shader_fix", "adapter.focus_hold", "adapter.focus_hold_ms", "adapter.sync_guard",
|
||||
"adapter.scale", "adapter.layer_debug", "adapter.refresh_rate", "adapter.pose_consistency",
|
||||
"lepton_env", "config_sync",
|
||||
]
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
DEVICE_FIELDS = {"device.files", "device.lepton_env", "device.config_sync", "device.foveation"}
|
||||
|
||||
def signature(recipe) -> dict:
|
||||
"""The parts of a recipe that differ between games (defaults/automatic fixes are excluded)."""
|
||||
|
||||
def expected_defaults(analysis) -> set[str]:
|
||||
"""Defaults recommended by their own detection, before recipe normalization/removals.
|
||||
|
||||
As in suggest's initial pass, default_on patches bypass the opt-in applies gate; their own detect() decides.
|
||||
This masks e.g. LTW defaults in games without LTW, without importing any heuristic opt-in/adapter choices.
|
||||
"""
|
||||
return {p.id for p in base.all_patches() if p.default_on and base.for_game(p, analysis)
|
||||
and (s := p.detect(analysis)) and s.recommended}
|
||||
|
||||
|
||||
def signature(recipe: Recipe, defaults: set[str] | None = None) -> dict:
|
||||
"""Recipe decisions, excluding enabled defaults but retaining explicit removal of defaults."""
|
||||
pats = recipe.patches
|
||||
return {
|
||||
"overport": sorted(p for p in pats if base.get(p).category == "overport" and not base.get(p).default_on),
|
||||
"overport_removed": sorted(p for p in (x.id for x in base.all_patches()
|
||||
if x.category == "overport" and x.default_on)
|
||||
if p not in pats),
|
||||
"frame": sorted(p for p in pats if base.get(p).category == "frame" and not base.get(p).default_on),
|
||||
rift = recipe.package.startswith("rift.")
|
||||
patches = [p for p in base.all_patches() if (p.category == "pcvr") == rift]
|
||||
out = {}
|
||||
for category in ("overport", "frame", "pcvr"):
|
||||
out[category] = sorted(p.id for p in patches if p.category == category and not p.default_on and p.id in pats)
|
||||
out[category + "_removed"] = sorted(p.id for p in patches
|
||||
if p.category == category and p.default_on and p.id not in pats
|
||||
and (defaults is None or p.id in defaults))
|
||||
out.update({
|
||||
"adapter": {p.split(".", 1)[1]: v.get("value") for p, v in sorted(pats.items()) if p.startswith("adapter.")},
|
||||
"device": sorted(p for p in pats if p.startswith("device.") and p not in DEVICE_FIELDS
|
||||
and not base.get(p).default_on),
|
||||
"device_files": sorted((pats.get("device.files") or {}).get("files", {})),
|
||||
"foveation": dict(pats.get("device.foveation") or {}),
|
||||
"lepton_env": dict((pats.get("device.lepton_env") or {}).get("env", {})),
|
||||
"config_sync": dict((pats.get("device.config_sync") or {}).get("files", {})),
|
||||
"alt": sorted(recipe.alt_patches),
|
||||
"use_alt": recipe.use_alt,
|
||||
"status_unsupported": recipe.status == "unsupported",
|
||||
}
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def catalog_recipe(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> Recipe:
|
||||
"""Materialize catalog choices plus expected defaults; never import heuristic opt-in choices. With the game's
|
||||
analysis, the engine's structural transforms apply as in suggest(): a 32-bit game goes through the bridge (its
|
||||
entry relies on that instead of listing what the bridge replaces), a 2D app keeps only its non-VR patches."""
|
||||
r = Recipe(entry.package, status=entry.status, as_is=entry.as_is,
|
||||
alt_patches=list(entry.alt_overport), use_alt=entry.use_alt)
|
||||
rift = entry.kind == "rift" or entry.package.startswith("rift.")
|
||||
r.patches = {p.id: {} for p in base.all_patches() if p.default_on and (p.category == "pcvr") == rift
|
||||
and (defaults is None or p.id in defaults)}
|
||||
for pid in (*entry.overport_extra, *entry.frame, *entry.device, *entry.pcvr):
|
||||
r.patches[pid] = {}
|
||||
for pid in (*entry.overport_remove, *entry.frame_remove, *entry.pcvr_remove):
|
||||
r.patches.pop(pid, None)
|
||||
for key, value in entry.adapter.items():
|
||||
r.patches[f"adapter.{key}"] = {"value": value}
|
||||
for pid, param, values in (("device.files", "files", entry.device_files),
|
||||
("device.lepton_env", "env", entry.lepton_env),
|
||||
("device.config_sync", "files", entry.config_sync)):
|
||||
if values:
|
||||
r.patches[pid] = {param: copy.deepcopy(values)}
|
||||
if entry.foveation:
|
||||
r.patches["device.foveation"] = {"mode": entry.foveation}
|
||||
if entry.foveation_radius:
|
||||
r.patches["device.foveation"]["radius"] = entry.foveation_radius
|
||||
if entry.foveation_test:
|
||||
r.patches["device.foveation"]["test"] = True
|
||||
if analysis is not None:
|
||||
if analysis.only_32bit and "frame.a32_bridge" not in entry.frame_remove:
|
||||
r.patches.setdefault("frame.a32_bridge", {})
|
||||
engine.a32_recipe(analysis, r, newly=False)
|
||||
elif analysis.vr_kind == "none":
|
||||
needed = {pid for pid in r.patches if not base.get(pid).needs_vr and pid != "frame.launcher"}
|
||||
needed |= {"frame.launcher"} if analysis.has_info_category else set()
|
||||
r.patches = {pid: v for pid, v in r.patches.items() if pid in needed}
|
||||
r.alt_patches = []
|
||||
return r
|
||||
|
||||
|
||||
def catalog_signature(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> dict:
|
||||
return signature(catalog_recipe(entry, defaults, analysis), defaults)
|
||||
|
||||
|
||||
def engine_group(name: str) -> str:
|
||||
return name if name in ("Unity", "Unreal") else "Other"
|
||||
|
||||
|
||||
def split_packages(engines: dict[str, str]) -> dict[str, str]:
|
||||
"""package -> dev/holdout; nearest 20% per engine (half rounded up), lowest sha256 first.
|
||||
|
||||
Only pass the intersection of catalog and analyses. This is stable under input reordering; adding packages can
|
||||
change membership at a stratum's cutoff. Tiny strata may have no holdout. Unsupported entries participate too.
|
||||
"""
|
||||
groups = defaultdict(list)
|
||||
for pkg, name in engines.items():
|
||||
groups[engine_group(name)].append(pkg)
|
||||
result = {}
|
||||
for packages in groups.values():
|
||||
ordered = sorted(packages, key=lambda p: (hashlib.sha256(p.encode()).digest(), p))
|
||||
n = (len(ordered) + 2) // 5
|
||||
result.update((p, "holdout" if i < n else "dev") for i, p in enumerate(ordered))
|
||||
return dict(sorted(result.items()))
|
||||
|
||||
|
||||
def _parts(sig: dict) -> tuple[dict, dict]:
|
||||
headline, runtime = {}, {}
|
||||
for key, value in sig.items():
|
||||
if key == "adapter":
|
||||
headline[key] = {k: v for k, v in value.items() if f"adapter.{k}" not in RUNTIME_ONLY}
|
||||
runtime[key] = {k: v for k, v in value.items() if f"adapter.{k}" in RUNTIME_ONLY}
|
||||
else:
|
||||
(runtime if key in RUNTIME_ONLY else headline)[key] = value
|
||||
return headline, runtime
|
||||
|
||||
|
||||
def _decisions(sig: dict) -> dict:
|
||||
"""Patch/action identities -> values. Status is a field metric, not a patch."""
|
||||
out = {}
|
||||
for field, value in sig.items():
|
||||
if field == "status_unsupported":
|
||||
continue
|
||||
if field in ("adapter", "lepton_env", "config_sync"):
|
||||
prefix = "adapter" if field == "adapter" else f"device.{field}"
|
||||
out.update((f"{prefix}.{k}", v) for k, v in value.items())
|
||||
elif field == "foveation":
|
||||
if value:
|
||||
out["device.foveation"] = value
|
||||
elif field == "use_alt":
|
||||
if value:
|
||||
out["use_alt"] = value
|
||||
else:
|
||||
prefix = ("remove:" if field.endswith("_removed") else "alt:" if field == "alt"
|
||||
else "device.files:" if field == "device_files" else "")
|
||||
out.update((prefix + p, True) for p in value)
|
||||
return out
|
||||
|
||||
|
||||
def _rates(tp: int, fp: int, fn: int) -> dict:
|
||||
# No positive decisions yields 0, rather than inflating scores for empty recipes/strata.
|
||||
return {"tp": tp, "fp": fp, "fn": fn, "precision": tp / (tp + fp) if tp + fp else 0.0,
|
||||
"recall": tp / (tp + fn) if tp + fn else 0.0,
|
||||
"f1": 2 * tp / (2 * tp + fp + fn) if 2 * tp + fp + fn else 0.0}
|
||||
|
||||
|
||||
def score_signatures(want: dict, got: dict, gaps: frozenset | set = frozenset()) -> dict:
|
||||
"""Compare one pair of signatures; value mismatches count as both missing and extra. `gaps` = decisions the
|
||||
catalog entry couldn't have made (the patch didn't exist when it was verified): left out of both sides."""
|
||||
result = {}
|
||||
for scope, w, g in zip(("headline", "runtime"), _parts(want), _parts(got), strict=True):
|
||||
wanted, actual = _decisions(w), _decisions(g)
|
||||
gap = {k: actual.pop(k) for k in list(actual) if k in gaps and k not in wanted}
|
||||
missing = {k: v for k, v in wanted.items() if k not in actual or actual[k] != v}
|
||||
extra = {k: v for k, v in actual.items() if k not in wanted or wanted[k] != v}
|
||||
per_patch = {k: _rates(int(k in wanted and k in actual and wanted[k] == actual[k]),
|
||||
int(k in extra), int(k in missing)) for k in sorted(wanted.keys() | actual.keys())}
|
||||
fields = {k: w.get(k) == g.get(k) for k in sorted(w.keys() | g.keys())}
|
||||
exact = all(fields.values()) if not gap else not missing and not extra
|
||||
result[scope] = {"exact": exact, "fields": fields, "missing": missing, "extra": extra,
|
||||
"per_patch": per_patch, "gap": gap}
|
||||
return result
|
||||
|
||||
|
||||
def _aggregate(rows: list[dict], scope: str, score_key: str = "score") -> dict:
|
||||
fields = defaultdict(lambda: {"matched": 0, "total": 0})
|
||||
counts = defaultdict(Counter)
|
||||
gaps = Counter()
|
||||
for row in rows:
|
||||
s = row[score_key][scope]
|
||||
gaps.update(s.get("gap") or {})
|
||||
for key, match in s["fields"].items():
|
||||
fields[key]["matched"] += int(match)
|
||||
fields[key]["total"] += 1
|
||||
for key, stats in s["per_patch"].items():
|
||||
counts[key].update({k: stats[k] for k in ("tp", "fp", "fn")})
|
||||
per_patch = {k: _rates(**v) for k, v in sorted(counts.items())}
|
||||
total = Counter()
|
||||
for stats in counts.values():
|
||||
total.update(stats)
|
||||
exact = sum(row[score_key][scope]["exact"] for row in rows)
|
||||
return {"games": len(rows), "exact": exact, "exact_ratio": exact / len(rows) if rows else 0.0,
|
||||
"fields": dict(sorted(fields.items())), "per_patch": per_patch, "gaps": dict(sorted(gaps.items())),
|
||||
"patches": _rates(*(total[k] for k in ("tp", "fp", "fn")))}
|
||||
|
||||
|
||||
def score_records(records: list[dict]) -> dict:
|
||||
"""Score {package, engine, status, converted, want, got, ...} records; no filesystem needed."""
|
||||
rows = [{**r, "score": score_signatures(r["want"], r["got"]),
|
||||
"score_dated": score_signatures(r["want"], r["got"], set(r.get("gaps") or ()))}
|
||||
for r in sorted(records, key=lambda r: r["package"])]
|
||||
|
||||
def metrics(group):
|
||||
supported = [r for r in group if r["status"] != "unsupported"]
|
||||
unsupported = [r for r in group if r["status"] == "unsupported"]
|
||||
return {"total_games": len(group), "converted_games": sum(bool(r.get("converted")) for r in group),
|
||||
"excluded_unsupported": len(unsupported),
|
||||
"headline": _aggregate(supported, "headline"), "runtime": _aggregate(supported, "runtime"),
|
||||
"headline_dated": _aggregate(supported, "headline", "score_dated"),
|
||||
"unsupported": {scope: _aggregate(unsupported, scope) for scope in ("headline", "runtime")}}
|
||||
|
||||
return {**metrics(rows), "by_engine": {name: metrics([r for r in rows if engine_group(r["engine"]) == name])
|
||||
for name in ("Unity", "Unreal", "Other")}, "games": rows}
|
||||
|
||||
|
||||
def read_snapshot(path: Path, attempts: int = 8) -> dict:
|
||||
"""Copy before parsing, even on retries; never write to the source or read APK/data paths in it."""
|
||||
with tempfile.TemporaryDirectory(prefix="frameport-eval-") as tmp:
|
||||
dest = Path(tmp) / "snapshot.json"
|
||||
for attempt in range(attempts):
|
||||
shutil.copyfile(path, dest)
|
||||
try:
|
||||
return json.loads(dest.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
time.sleep(0.25 * (attempt + 1))
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def read_dumps(path: Path) -> dict:
|
||||
records = {}
|
||||
for g in quest_dump.scan(path):
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
row = {"analysis": a.to_dict(), "converted": a.is_overport_output, "name": g.name}
|
||||
if a.package not in records or records[a.package]["converted"] and not row["converted"]:
|
||||
records[a.package] = row
|
||||
return records
|
||||
|
||||
|
||||
def gap_decisions(entry, intro_dates: dict | None) -> set[str]:
|
||||
"""Decision keys for patches introduced after the entry was verified: the catalog can't be wrong about them."""
|
||||
verified = str((entry.verified or {}).get("date") or "")
|
||||
if not intro_dates or not verified:
|
||||
return set()
|
||||
return {(f"alt:{pid}" if pid.startswith("patch_remove_unreal") else pid)
|
||||
for pid, date in intro_dates.items() if date and date > verified}
|
||||
|
||||
|
||||
def inherited_decisions() -> set[str]:
|
||||
"""Heuristic picks a catalog recipe keeps even when the entry doesn't name them (engine.suggest: adapter
|
||||
settings and device patches other than catalog.TOGGLED_DEVICE): the verified build had them, so an "extra" one
|
||||
isn't a detection error. A catalog value that differs still counts (the key is in both sides then)."""
|
||||
return {p.id for p in base.all_patches()
|
||||
if p.category == "adapter" or (p.category == "device" and p.id not in catalog.TOGGLED_DEVICE)}
|
||||
|
||||
|
||||
def evaluate(snapshot: dict, entries: dict, split: str = "dev", intro_dates: dict | None = None) -> dict:
|
||||
analyses = {pkg: analysis_from_dict(v["analysis"]) for pkg, v in snapshot.items() if pkg in entries}
|
||||
assignments = split_packages({pkg: a.engine for pkg, a in analyses.items()})
|
||||
records = []
|
||||
for pkg, a in sorted(analyses.items()):
|
||||
if split != "all" and assignments[pkg] != split:
|
||||
continue
|
||||
entry = entries[pkg]
|
||||
defaults = expected_defaults(a)
|
||||
records.append({"package": pkg, "title": entry.title, "engine": a.engine, "status": entry.status,
|
||||
"converted": bool(snapshot[pkg].get("converted", a.is_overport_output)),
|
||||
"analysis_version": snapshot[pkg].get("analysis_version"), "split": assignments[pkg],
|
||||
"want": catalog_signature(entry, defaults, a),
|
||||
"got": signature(engine.suggest(a, use_catalog=False), defaults),
|
||||
"gaps": sorted(gap_decisions(entry, intro_dates) | inherited_decisions())})
|
||||
return {"schema_version": 1, "split": split, "runtime_only": RUNTIME_ONLY, "snapshot_packages": len(snapshot),
|
||||
"default_policy": "per-analysis default detection, then explicit catalog removals",
|
||||
"catalog_packages": len(entries), "matched_packages": len(analyses),
|
||||
"split_assignments": assignments, **score_records(records)}
|
||||
|
||||
|
||||
def fit_candidates(analysis, entry: catalog.CatalogEntry) -> list[dict]:
|
||||
"""Use suggest's final heuristic pass, including platform filtering, non-VR and a32 normalization.
|
||||
|
||||
Requiring a recommended detect() as well excludes dependencies and synthetic recipe additions. No engine
|
||||
refactor is needed: suggest() remains untouched, and catalog data is used only for the candidate exclusion and
|
||||
the hypothetical warning recipe.
|
||||
"""
|
||||
suggested = engine.suggest(analysis, use_catalog=False)
|
||||
chosen = catalog_recipe(entry, expected_defaults(analysis), analysis)
|
||||
listed = set(chosen.patches) | set(entry.alt_overport)
|
||||
removed = set(entry.overport_remove) | set(entry.frame_remove) | set(entry.pcvr_remove)
|
||||
result = []
|
||||
for pid, params in sorted(suggested.patches.items()):
|
||||
p = base.get(pid)
|
||||
if (p.default_on or pid in listed or pid in removed or not base.for_game(p, analysis)
|
||||
or not p.applies(analysis)):
|
||||
continue
|
||||
s = p.detect(analysis)
|
||||
if not s or not s.recommended:
|
||||
continue
|
||||
trial = copy.deepcopy(chosen)
|
||||
trial.patches[pid] = dict(params)
|
||||
result.append({"patch": pid, "category": p.category, "experimental": p.experimental,
|
||||
"strict": bool(getattr(p, "strict", False)), "reason": s.reason,
|
||||
"warnings": engine.warnings(trial, analysis)})
|
||||
return result
|
||||
|
||||
|
||||
def patch_intro_dates(cache_path: Path) -> dict:
|
||||
"""One local git pickaxe per uncached patch (including missing-history results); never use the network."""
|
||||
try:
|
||||
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
cached = {}
|
||||
changed = False
|
||||
for p in base.all_patches():
|
||||
if p.id in cached:
|
||||
continue
|
||||
needle = p.id.split(".", 1)[1] if p.category == "adapter" else p.id
|
||||
paths = ["src/frameport/patches/settings.py"] if p.category == "adapter" else ["src/frameport"]
|
||||
log = subprocess.run(["git", "log", f'-S"{needle}"', "--reverse", "--format=%ad", "--date=short", "--",
|
||||
*paths], cwd=REPO, text=True, capture_output=True, check=True)
|
||||
dates = log.stdout.splitlines()
|
||||
cached[p.id] = dates[0] if dates else None
|
||||
changed = True
|
||||
if changed:
|
||||
cache_path.write_text(json.dumps(cached, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return cached
|
||||
|
||||
|
||||
def entry_date(entry) -> str:
|
||||
"""When the recipe was verified; `updated` only when there is no verified date (it also moves for unrelated
|
||||
edits, e.g. a note, so it says nothing about which patches the verifier could pick)."""
|
||||
return str(entry.verified.get("date") or "") or str(entry.updated or "")
|
||||
|
||||
|
||||
def fit_report(snapshot: dict, entries: dict, intro_dates: dict) -> dict:
|
||||
games, metadata = [], []
|
||||
totals = Counter()
|
||||
for pkg, entry in sorted(entries.items()):
|
||||
common = {"package": pkg, "title": entry.title, "engine": entry.engine, "xr": entry.xr,
|
||||
"status": entry.status, "verified_date": str(entry.verified.get("date") or ""),
|
||||
"updated": str(entry.updated or ""), "entry_date": entry_date(entry)}
|
||||
if pkg not in snapshot:
|
||||
metadata.append(common)
|
||||
continue
|
||||
candidates = fit_candidates(analysis_from_dict(snapshot[pkg]["analysis"]), entry)
|
||||
for c in candidates:
|
||||
intro = intro_dates.get(c["patch"])
|
||||
c.update(intro_date=intro,
|
||||
newer_patch=bool(intro and common["entry_date"] and intro > common["entry_date"]))
|
||||
totals[c["patch"]] += 1
|
||||
games.append({**common, "converted": bool(snapshot[pkg].get("converted")), "candidates": candidates})
|
||||
return {"games": games, "metadata_only": metadata, "totals_by_patch": dict(sorted(totals.items())),
|
||||
"candidate_count": sum(totals.values()), "games_with_candidates": sum(bool(g["candidates"]) for g in games)}
|
||||
|
||||
|
||||
def _md(value) -> str:
|
||||
return str(value if value is not None else "unknown").replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render_fit_report(report: dict) -> str:
|
||||
lines = ["# New patch fit", "", f"{len(report['games'])} catalog games with analyses; "
|
||||
f"{report['games_with_candidates']} with candidates; {report['candidate_count']} candidate additions. "
|
||||
f"{len(report['metadata_only'])} metadata-only entries.", "",
|
||||
"Candidates are opt-in recommended patches absent from the entry, after the heuristic engine's platform "
|
||||
"and a32 rules. A listed adapter key is excluded even if its value differs. Warnings are for the full "
|
||||
"catalog recipe plus that one patch; they can include pre-existing conflicts. Unknown history/date "
|
||||
"does not imply a newer patch. Default-on patches are not candidates. All splits and statuses included.",
|
||||
"", "## Totals by patch", "", "| Patch | Games |", "| --- | ---: |"]
|
||||
lines.extend(f"| {pid} | {n} |" for pid, n in
|
||||
sorted(report["totals_by_patch"].items(), key=lambda kv: (-kv[1], kv[0])))
|
||||
for game in report["games"]:
|
||||
lines += ["", f"## {_md(game['title'])} (`{game['package']}`)", "",
|
||||
f"{game['engine']} / {game['xr']}; status: {game['status']}; "
|
||||
f"entry date: {game['entry_date'] or 'unknown'}; converted: {game['converted']}.", ""]
|
||||
if not game["candidates"]:
|
||||
lines += ["No candidates."]
|
||||
continue
|
||||
lines += ["| Patch | Category | Experimental | Strict | Reason | Introduced | Newer patch | Warnings |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for c in game["candidates"]:
|
||||
cells = [c["patch"], c["category"], c["experimental"], c["strict"], c["reason"], c["intro_date"],
|
||||
c["newer_patch"], "; ".join(c["warnings"]) or "none"]
|
||||
lines.append("| " + " | ".join(_md(v) for v in cells) + " |")
|
||||
lines += ["", "## Catalog entries without analyses (metadata only)", "",
|
||||
"| Package | Title | Engine | XR | Status | Verified | Updated | Entry date |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for g in report["metadata_only"]:
|
||||
lines.append("| " + " | ".join(_md(g[k]) for k in
|
||||
("package", "title", "engine", "xr", "status", "verified_date", "updated", "entry_date")) + " |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def print_metrics(report: dict, verbose: bool = False) -> None:
|
||||
print(f"Split: {report['split']}; catalog/analysis intersection: {report['matched_packages']}; "
|
||||
f"converted: {report['converted_games']}; unsupported excluded: {report['excluded_unsupported']}")
|
||||
print(f"{'Engine':10} {'Games':>5} {'Exact':>9} {'Fields':>11} {'Prec':>7} {'Recall':>7} {'F1':>7} {'Runtime':>9}")
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
h, r = metrics["headline"], metrics["runtime"]
|
||||
fields = h["fields"].values()
|
||||
f = f"{sum(x['matched'] for x in fields)}/{sum(x['total'] for x in h['fields'].values())}"
|
||||
p = h["patches"]
|
||||
print(f"{name:10} {h['games']:5} {h['exact']:4}/{h['games']:<4} {f:>11} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%} {r['exact']:4}/{r['games']:<4}")
|
||||
print("\nDated (extras the entry couldn't have listed - patch newer than its verified date - or that catalog "
|
||||
"recipes inherit anyway - adapter/device picks - are not errors):")
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
d = metrics["headline_dated"]
|
||||
p = d["patches"]
|
||||
print(f"{name:10} {d['games']:5} {d['exact']:4}/{d['games']:<4} {'':>11} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}")
|
||||
gaps = report["headline_dated"]["gaps"]
|
||||
if gaps:
|
||||
print("Catalog gaps by patch: " + ", ".join(f"{k}={v}" for k, v in sorted(gaps.items(), key=lambda kv: -kv[1])))
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
print(f"\n{name}: field matches (headline / runtime)")
|
||||
for scope in ("headline", "runtime"):
|
||||
matches = metrics[scope]["fields"].items()
|
||||
print(scope + ": " + ", ".join(f"{k}={v['matched']}/{v['total']}" for k, v in matches))
|
||||
print(f"{'Scope / patch':55} {'TP':>5} {'FP':>5} {'FN':>5} {'Prec':>7} {'Recall':>7} {'F1':>7}")
|
||||
for scope in ("headline", "runtime"):
|
||||
for pid, p in metrics[scope]["per_patch"].items():
|
||||
print(f"{scope + ' / ' + pid:55} {p['tp']:5} {p['fp']:5} {p['fn']:5} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}")
|
||||
u = report["unsupported"]["headline"]
|
||||
print(f"\nUnsupported (excluded): {u['exact']}/{u['games']} exact; patch F1={u['patches']['f1']:.1%}")
|
||||
for game in report["games"]:
|
||||
s = game["score"]
|
||||
if verbose or not s["headline"]["exact"] or not s["runtime"]["exact"] or game["status"] == "unsupported":
|
||||
print(f"\n{game['package']} [{game['engine']}, {game['status']}, {game['split']}"
|
||||
+ (", converted" if game["converted"] else "") + "]")
|
||||
for scope in ("headline", "runtime"):
|
||||
for diff in ("missing", "extra"):
|
||||
if s[scope][diff]:
|
||||
print(f" {scope} {diff}: {json.dumps(s[scope][diff], sort_keys=True)}")
|
||||
if not s[scope]["fields"].get("status_unsupported", True):
|
||||
print(f" {scope} status_unsupported: catalog={game['want']['status_unsupported']} "
|
||||
f"heuristics={game['got']['status_unsupported']}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("dumps", type=Path)
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("dumps", type=Path, nargs="?")
|
||||
ap.add_argument("--snapshot", type=Path)
|
||||
ap.add_argument("--split", choices=("dev", "holdout", "all"), default="dev")
|
||||
ap.add_argument("--json", type=Path, dest="json_out")
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
ap.add_argument("--fit-report", action="store_true")
|
||||
ap.add_argument("--out", type=Path, help="Markdown destination for --fit-report (otherwise stdout)")
|
||||
args = ap.parse_args()
|
||||
games = quest_dump.scan(args.dumps)
|
||||
total = matched_fields = games_ok = 0
|
||||
misses = []
|
||||
for g in games:
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
if not catalog.lookup(a.package):
|
||||
continue
|
||||
want = signature(engine.suggest(a, use_catalog=True))
|
||||
want["status_unsupported"] = (catalog.lookup(a.package).status == "unsupported"
|
||||
and "32-bit" in catalog.lookup(a.package).notes)
|
||||
got = signature(engine.suggest(a, use_catalog=False))
|
||||
diffs = {k: (want[k], got[k]) for k in want if want[k] != got[k]}
|
||||
total += len(want)
|
||||
matched_fields += len(want) - len(diffs)
|
||||
games_ok += not diffs
|
||||
if diffs:
|
||||
misses.append((a.package, diffs))
|
||||
if args.verbose or diffs:
|
||||
print(f"{'OK ' if not diffs else 'DIFF'} {a.package}")
|
||||
for k, (w, h) in diffs.items():
|
||||
print(f" {k}: catalog={w} heuristics={h}")
|
||||
n = games_ok + len(misses)
|
||||
print(f"\n{games_ok}/{n} games reproduced exactly; {matched_fields}/{total} recipe fields match")
|
||||
return 0 if not misses else 1
|
||||
if not args.snapshot and not args.dumps:
|
||||
ap.error("provide a dumps folder or --snapshot")
|
||||
if args.out and not args.fit_report:
|
||||
ap.error("--out requires --fit-report")
|
||||
snapshot = read_snapshot(args.snapshot) if args.snapshot else read_dumps(args.dumps)
|
||||
# Explicitly bundled entries: remote caches and personal recipes must not affect this benchmark.
|
||||
entries = catalog._load_dir(REPO / "catalog/games", "bundled")
|
||||
if args.fit_report:
|
||||
cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json"
|
||||
report = fit_report(snapshot, entries, patch_intro_dates(cache))
|
||||
markdown = render_fit_report(report)
|
||||
if args.out:
|
||||
args.out.write_text(markdown, encoding="utf-8")
|
||||
print(f"{report['candidate_count']} candidates in {report['games_with_candidates']} games -> {args.out}")
|
||||
else:
|
||||
print(markdown, end="")
|
||||
else:
|
||||
cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json"
|
||||
# dated scoring reads the cache --fit-report writes; a plain run never shells out for dates
|
||||
dates = json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None
|
||||
report = evaluate(snapshot, entries, args.split, dates)
|
||||
print_metrics(report, args.verbose)
|
||||
if args.json_out:
|
||||
args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
|
||||
# Retain the legacy evaluator's nonzero exit for recipe differences; JSON is still written.
|
||||
return 0 if args.fit_report or report["headline"]["games"] == report["headline"]["exact"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyse every Quest dump below the given folders once and save the analyses as JSON, so heuristics evaluations
|
||||
(`eval_heuristics.py --snapshot`) don't re-read gigabytes of APKs each run.
|
||||
|
||||
python scripts/heuristics_snapshot.py <out.json> <dumps folder> [<dumps folder> ...]
|
||||
|
||||
An original APK beats an already converted one (OVRPort output, e.g. PATCHED/ copies): converted inputs change what
|
||||
the heuristics suggest, so they are only kept when no original exists and are marked `converted`. The output holds
|
||||
local paths: keep it out of git.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from frameport.analysis.detect import ANALYSIS_VERSION, analyze # noqa: E402
|
||||
from frameport.sources import quest_dump # noqa: E402
|
||||
|
||||
|
||||
def _skip(game, roots: list[Path]) -> bool:
|
||||
"""Folders starting with "_" (known-good backups, test builds, keys) are not dumps."""
|
||||
for root in roots:
|
||||
try:
|
||||
rel = game.apk.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
return any(part.startswith("_") for part in rel.parts[:-1])
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("out", type=Path)
|
||||
ap.add_argument("roots", type=Path, nargs="+")
|
||||
args = ap.parse_args()
|
||||
roots = [r.resolve() for r in args.roots]
|
||||
old = json.loads(args.out.read_text()) if args.out.is_file() else {}
|
||||
snap: dict[str, dict] = {}
|
||||
for root in roots:
|
||||
for g in quest_dump.scan(root):
|
||||
if _skip(g, roots):
|
||||
continue
|
||||
key = str(g.apk)
|
||||
cached = next((v for v in old.values() if v.get("apk") == key
|
||||
and v.get("analysis_version") == ANALYSIS_VERSION
|
||||
and v.get("apk_size") == g.apk.stat().st_size), None)
|
||||
t = time.time()
|
||||
if cached:
|
||||
entry = cached
|
||||
else:
|
||||
try:
|
||||
data_bytes = g.data_bytes()
|
||||
a = analyze(g.apk, data_bytes=data_bytes)
|
||||
except Exception as e: # noqa: BLE001 - one unreadable dump mustn't stop the snapshot
|
||||
print(f"FAIL {g.apk}: {e}", file=sys.stderr, flush=True)
|
||||
continue
|
||||
entry = {"analysis": dataclasses.asdict(a), "data_bytes": data_bytes, "apk": key,
|
||||
"apk_size": g.apk.stat().st_size, "name": g.name, "root": str(root),
|
||||
"converted": bool(a.is_overport_output), "analysis_version": ANALYSIS_VERSION}
|
||||
pkg = entry["analysis"]["package"]
|
||||
have = snap.get(pkg)
|
||||
# originals beat converted copies; otherwise the first root given wins
|
||||
if have is None or (have["converted"] and not entry["converted"]):
|
||||
snap[pkg] = entry
|
||||
print(f"{'cache' if cached else f'{time.time() - t:5.1f}s'} {pkg}"
|
||||
f"{' (converted)' if entry['converted'] else ''}", flush=True)
|
||||
args.out.write_text(json.dumps(snap, indent=1, default=str)) # partial results survive a crash
|
||||
print(f"{len(snap)} packages ({sum(v['converted'] for v in snap.values())} converted only) -> {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -42,8 +42,6 @@ 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")
|
||||
for helper in (ROOT / "agent").glob("fexrift*.py"): # experimental Rift-on-Frame helper + its extensions
|
||||
shutil.copy2(helper, DATA / "agent" / f"{helper.name}.txt")
|
||||
print(f"staged data in {DATA}")
|
||||
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""One bounded Rift-on-Frame run. Uses the paired Frame and prints exactly one JSON summary on success.
|
||||
|
||||
FRAMEPORT_EXPERIMENTAL_FEX=1 uv run python scripts/rift_fex_bench.py oculus-first-contact --seconds 120
|
||||
|
||||
Settings are changed separately with `frameport fex settings APP key=value`; this script never starts a sweep.
|
||||
The Frame owns the timeout/cleanup, so closing this PC process does not leave an unbounded game running.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("app", nargs="?", default="oculus-first-contact")
|
||||
p.add_argument("--seconds", type=int, default=120, choices=range(1, 121), metavar="1..120")
|
||||
p.add_argument("--frame", help="paired Frame as user@host")
|
||||
args = p.parse_args(argv)
|
||||
# Reuse the CLI's feature gate, paired-host verification and JSON output. No second SSH implementation.
|
||||
from frameport.cli import fex_bench
|
||||
fex_bench(args.app, args.seconds, args.frame)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,378 +0,0 @@
|
||||
"""Static compatibility survey of Rift games for the experimental Rift-on-Frame mode (x86_64 GE-Proton under FEX +
|
||||
Meta's runtime + Revive, see native/fexwine/README.md). Read-only: looks at the game folders' PE files and the Unreal
|
||||
config inside paks; never runs anything, never touches a repack's own Oculus Platform files (only notes them).
|
||||
|
||||
Engine, VR APIs and the Platform SDK come from FramePort's own Rift analysis (analysis/rift.py); on top of that:
|
||||
the Unreal version (branch string, else the pak format's range), the RHI the game's config selects, the generic
|
||||
Unreal IME fix site (agent/fexrift.py find_ime_patch), anti-tamper sections and other runtime needs.
|
||||
|
||||
usage: uv run python scripts/rift_fex_survey.py [--folder DIR ...] [--out DIR]
|
||||
default input: the library's rift.* games; output: <user data>/fexrift/survey.{md,json} (holds local paths and folder
|
||||
names: keep it out of the repo)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from frameport.analysis import rift # noqa: E402
|
||||
from frameport.core import library # noqa: E402
|
||||
from frameport.core.paths import user_data_dir # noqa: E402
|
||||
|
||||
|
||||
def load_fexrift():
|
||||
spec = importlib.util.spec_from_file_location("fexrift_helper", ROOT / "agent" / "fexrift.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
mod.load_extensions()
|
||||
return mod
|
||||
|
||||
|
||||
FX = load_fexrift()
|
||||
MAX_PE = 400 << 20
|
||||
|
||||
DRM_SECTIONS = {".arch": "Denuvo/Arxan-style section", ".vmp0": "VMProtect", ".vmp1": "VMProtect", ".bind": "Steam DRM",
|
||||
".themida": "Themida", ".enigma1": "Enigma"}
|
||||
ANTICHEAT_FILES = ("easyanticheat", "battleye", "beclient", "eac_launcher")
|
||||
|
||||
# Shared pak/ini reader (also used by the local-copy installer).
|
||||
from frameport.analysis.unreal_pak import ( # noqa: E402, F401
|
||||
CONFIG_KEYS,
|
||||
PAK_MAGIC,
|
||||
PAK_UE,
|
||||
Reader,
|
||||
encoded_entry,
|
||||
entry_header_size,
|
||||
legacy_entry,
|
||||
pak_entries,
|
||||
pak_read,
|
||||
parse_engine_ini,
|
||||
unreal_config,
|
||||
)
|
||||
|
||||
RHI_NAMES = {"DefaultGraphicsRHI_DX11": "d3d11", "DefaultGraphicsRHI_DX12": "d3d12",
|
||||
"DefaultGraphicsRHI_Vulkan": "vulkan"}
|
||||
|
||||
|
||||
def unreal_rhi(cfg: dict) -> tuple[str, str]:
|
||||
"""(RHI the Windows build starts with, where that came from). Unreal 4 defaults to D3D11."""
|
||||
if cfg.get("DefaultGraphicsRHI") in RHI_NAMES:
|
||||
return RHI_NAMES[cfg["DefaultGraphicsRHI"]], "config"
|
||||
rhis = cfg.get("TargetedRHIs") or []
|
||||
if rhis and not any(r.startswith("PCD3D") for r in rhis):
|
||||
if any("VULKAN" in r for r in rhis):
|
||||
return "vulkan", "config (no D3D shaders)"
|
||||
if any(r.startswith("GLSL") for r in rhis):
|
||||
return "opengl", "config (no D3D shaders)"
|
||||
if rhis and all(r == "PCD3D_SM6" for r in rhis if r.startswith("PCD3D")):
|
||||
return "d3d12", "config (SM6 shaders only)"
|
||||
return "d3d11", "config" if rhis else "Unreal default"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PE checks
|
||||
def pe_files(game_dir: Path, limit: int = 600):
|
||||
out = []
|
||||
for p in game_dir.rglob("*"):
|
||||
if p.suffix.lower() in (".exe", ".dll") and p.is_file():
|
||||
out.append(p)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
_BRANCH = re.compile(r"\+\+[A-Za-z0-9_+.\-]{3,60}")
|
||||
_VER = re.compile(r"(?<![\d.])([45])\.(\d{1,2})(?![\d])")
|
||||
|
||||
|
||||
def unreal_version(data: bytes) -> str | None:
|
||||
""""4.13" etc. from Unreal's branch name (++UE4+Release-4.13, ++unreal+4.10+UE4-Studio, ++UE4+Studio-4.27; ASCII
|
||||
or UTF-16). Branches without a number (++depot+Game) give None."""
|
||||
texts = [m.group(0).decode() for m in re.finditer(_BRANCH.pattern.encode(), data)]
|
||||
key = "++".encode("utf-16-le")
|
||||
i = data.find(key)
|
||||
while i >= 0 and len(texts) < 2000:
|
||||
m = _BRANCH.match(data[i:i + 128].decode("utf-16-le", "replace"))
|
||||
if m:
|
||||
texts.append(m.group(0))
|
||||
i = data.find(key, i + 4)
|
||||
for t in texts:
|
||||
letters = sum(c.isalpha() for c in t)
|
||||
if letters < 4 or not re.search(r"ue[45]|unreal|release", t, re.I):
|
||||
continue
|
||||
m = _VER.search(t)
|
||||
if m:
|
||||
return f"{m.group(1)}.{m.group(2)}"
|
||||
return None
|
||||
|
||||
|
||||
def sections(data: bytes) -> list[str]:
|
||||
try:
|
||||
return [s[0] for s in FX.pe_sections(data)]
|
||||
except struct.error:
|
||||
return []
|
||||
|
||||
|
||||
def imports_of(p: Path) -> set[str]:
|
||||
try:
|
||||
info = rift.read_pe(p, limit=80 << 20)[0]
|
||||
return set(info.imports) | set(info.delay_imports)
|
||||
except (rift.PEError, OSError, struct.error, ValueError):
|
||||
return set()
|
||||
|
||||
|
||||
GRAPHICS = {"d3d11.dll": "d3d11", "d3d12.dll": "d3d12", "opengl32.dll": "opengl", "vulkan-1.dll": "vulkan",
|
||||
"d3d9.dll": "d3d9"}
|
||||
|
||||
|
||||
def survey_game(title: str, game_dir: Path, exe_rel: str | None) -> dict:
|
||||
res = {"title": title, "folder": str(game_dir)}
|
||||
if not game_dir.is_dir():
|
||||
res["verdict"] = ("missing", "folder not found")
|
||||
return res
|
||||
exe = game_dir / exe_rel if exe_rel else None
|
||||
if not exe or not exe.is_file():
|
||||
res["verdict"] = ("unknown", "main exe not known")
|
||||
return res
|
||||
a = rift.analyze(game_dir, exe_rel)
|
||||
files = pe_files(game_dir)
|
||||
names = {p.name.lower(): p for p in files}
|
||||
info, data = rift.read_pe(exe, limit=MAX_PE)
|
||||
data = bytes(data)
|
||||
res["exe"] = exe_rel
|
||||
res["arch"] = info.machine
|
||||
res["exe_32bit"] = info.machine == "x86"
|
||||
res["exe_size"] = exe.stat().st_size
|
||||
res["vr"] = [x for x in a.xr.split("+") if x != "?"] or ["?"]
|
||||
res["platform_sdk"] = bool(a.extra.get("platform_sdk"))
|
||||
res["own_platform_dll"] = any(n.startswith("libovrplatform") for n in names) # noted only, never used
|
||||
imports = set(info.imports) | set(info.delay_imports)
|
||||
# engine
|
||||
if a.engine == "Unreal":
|
||||
cores = [p for n, p in names.items() if re.search(r"-core(-win64-shipping)?\.dll$", n)]
|
||||
ue = unreal_version(data) or next((v for v in map(lambda p: unreal_version(p.read_bytes()), cores) if v), None)
|
||||
cfg = unreal_config(game_dir)
|
||||
res.update(cfg)
|
||||
pak_v = int(cfg["pak"][1:]) if re.fullmatch(r"v\d+", cfg.get("pak", "")) else None
|
||||
res["ue_version"] = ue or (PAK_UE.get(pak_v) and f"{PAK_UE[pak_v]} (pak v{pak_v})") or "?"
|
||||
res["engine"] = f"Unreal {res['ue_version']}"
|
||||
rhi, src = unreal_rhi(cfg.get("config") or {})
|
||||
res["graphics"] = [rhi]
|
||||
res["graphics_from"] = src
|
||||
# Unreal IME deadlock fix: exe (monolithic), else modular ApplicationCore/Slate (Core before that split).
|
||||
patch, why = FX.find_ime_patch(data)
|
||||
if not patch:
|
||||
for n, p in sorted(names.items()):
|
||||
if "applicationcore" in n or re.search(r"-(slate|core)(-win64-shipping)?\.dll$", n):
|
||||
p2, why2 = FX.find_ime_patch(p.read_bytes())
|
||||
if p2:
|
||||
patch, why = dict(p2, module=n), None
|
||||
break
|
||||
if not why2.startswith("no Slate"):
|
||||
why = why2
|
||||
res["ime_patch"] = {"rva": hex(patch["rva"]), "module": patch.get("module", "exe")} if patch else why
|
||||
lo = (res["ue_version"].split(" ")[0].split("-")[0]).split(".")
|
||||
try:
|
||||
major_minor = (int(lo[0]), int(lo[1]))
|
||||
res["large_block_alloc"] = "FMallocBinned2 (UE>=4.18): fp_mem candidate" if major_minor >= (4, 18) \
|
||||
else "FMallocBinned (older): fp_mem likely unneeded"
|
||||
except (ValueError, IndexError):
|
||||
res["large_block_alloc"] = "?"
|
||||
else:
|
||||
for p in files: # graphics imports of the game's own DLLs (Unity players, custom engines)
|
||||
if p.parent == exe.parent and p.stat().st_size <= 80 << 20:
|
||||
imports |= imports_of(p)
|
||||
res["graphics"] = sorted({v for k, v in GRAPHICS.items() if k in imports}) or ["?"]
|
||||
res["graphics_from"] = "imports"
|
||||
if a.engine == "Unity":
|
||||
src = names.get("unityplayer.dll")
|
||||
blob = rift.read_pe(src, limit=MAX_PE)[1] if src else data
|
||||
uv = re.search(rb"(20\d\d\.\d+\.\d+[fp]\d+|5\.\d\.\d+[fp]\d+)", blob)
|
||||
res["engine"] = f"Unity {uv.group(1).decode() if uv else '?'} " + \
|
||||
("IL2CPP" if "gameassembly.dll" in names else "Mono")
|
||||
elif any(n.startswith("crysystem") for n in names) or b"CryEngine" in data or b"CRYENGINE" in data:
|
||||
res["engine"] = "CryEngine"
|
||||
else:
|
||||
res["engine"] = "custom"
|
||||
# anti-tamper / anti-cheat
|
||||
drm = sorted({DRM_SECTIONS[s] for s in sections(data) if s in DRM_SECTIONS})
|
||||
if any(any(x in n for x in ANTICHEAT_FILES) for n in names):
|
||||
drm.append("anti-cheat files")
|
||||
res["drm"] = drm
|
||||
# other runtime needs
|
||||
# Imports in modular engine/audio/video DLLs matter too. Reuse this for each candidate exe below; companion
|
||||
# middleware is a folder-level hint, not proof that a particular movie or audio route will be exercised.
|
||||
runtime_imports = set(imports)
|
||||
for p in files:
|
||||
if p.suffix.lower() == ".dll" and p.parent == exe.parent and p.stat().st_size <= 80 << 20:
|
||||
runtime_imports |= imports_of(p)
|
||||
res["runtime"] = runtime_facts(runtime_imports, names)
|
||||
res["executables"] = []
|
||||
for candidate in a.extra.get("exe_candidates", []):
|
||||
rel = candidate["path"]
|
||||
p = game_dir / rel
|
||||
try:
|
||||
pi = info if p == exe else rift.read_pe(p, limit=80 << 20)[0]
|
||||
facts = runtime_facts(set(pi.imports) | set(pi.delay_imports), names)
|
||||
g = sorted({GRAPHICS[i] for i in set(pi.imports) | set(pi.delay_imports) if i in GRAPHICS})
|
||||
res["executables"].append(dict(exe=rel, arch=pi.machine, exe_32bit=pi.machine == "x86",
|
||||
graphics_imports=g, **facts))
|
||||
except (OSError, rift.PEError, struct.error, ValueError):
|
||||
res["executables"].append({"exe": rel, "error": "PE unreadable"})
|
||||
other = []
|
||||
if "mscoree.dll" in imports:
|
||||
other.append(".NET")
|
||||
if res["runtime"]["xaudio2"]:
|
||||
other.append("XAudio2")
|
||||
if res["runtime"]["media_foundation"]:
|
||||
other.append("Media Foundation: video playback is a Proton compatibility risk")
|
||||
if res["runtime"]["bink"]:
|
||||
other.append("Bink video")
|
||||
winrt_re = rb"W\x00i\x00n\x00d\x00o\x00w\x00s\x00\.\x00(?:[A-Z]\x00[a-zA-Z\x00\.]{4,80})"
|
||||
winrt = sorted({w.decode("utf-16-le") for w in re.findall(winrt_re, data)})[:5]
|
||||
if winrt:
|
||||
other.append("WinRT: " + ", ".join(winrt))
|
||||
res["other"] = other
|
||||
res["size_gib"] = round(sum(p.stat().st_size for p in game_dir.rglob("*") if p.is_file()) / 2**30, 1)
|
||||
res["verdict"] = verdict(res)
|
||||
res["suggested_graphics"] = graphics_route(res)
|
||||
res["profile"] = suggested_profile(res)
|
||||
return res
|
||||
|
||||
|
||||
def runtime_facts(imports: set[str], names) -> dict:
|
||||
imports = {i.lower() for i in imports}
|
||||
names = {n.lower() for n in names}
|
||||
return {"xaudio2": any(i.startswith("xaudio2") for i in imports),
|
||||
"media_foundation": bool(imports & {"mf.dll", "mfplat.dll", "mfreadwrite.dll", "evr.dll"}),
|
||||
"bink": any(n.startswith("bink") for n in names | imports),
|
||||
"anti_cheat": any(any(s in n for s in ANTICHEAT_FILES) for n in names | imports)}
|
||||
|
||||
|
||||
def graphics_route(r: dict) -> str:
|
||||
g = r.get("graphics", [])
|
||||
# Unreal's config already reduced its imports to the selected RHI. For custom multi-API engines prefer a
|
||||
# DXVK route over GL; imports prove availability, not that the game will select that renderer at runtime.
|
||||
return next((api for api in ("d3d11", "d3d12", "d3d9", "opengl", "vulkan") if api in g), "d3d11")
|
||||
|
||||
|
||||
def suggested_profile(r: dict) -> dict:
|
||||
status = {"likely OK": "likely", "blocked": "blocked"}.get(r["verdict"][0], "untested")
|
||||
profile = {"title": r["title"],
|
||||
"match": {"executables": [{"name": Path(r["exe"]).name, "size": r["exe_size"]}]},
|
||||
"status": status, "graphics": graphics_route(r), "env": {}, "ime_patch": None,
|
||||
"fp_mem": "candidate" if "candidate" in r.get("large_block_alloc", "") else "off",
|
||||
"bigcache_mb": 256, "dxvk": [],
|
||||
"notes": r["verdict"][1] + ("; Media Foundation videos need a playback test" if
|
||||
r.get("runtime", {}).get("media_foundation") else "")}
|
||||
if r.get("vr") == ["OpenVR"]:
|
||||
profile["revive"] = False
|
||||
if r.get("folder"):
|
||||
info = {"exe": r["exe"], "game_dir": r["folder"]}
|
||||
for hook in FX.GAME_INFO_HOOKS:
|
||||
hook("", info)
|
||||
# Keep an existing build-matched direct-OpenVR choice (e.g. Unity that also contains inert LibOVR strings)
|
||||
# when generating pasteable data. Do not propagate guesses from the folder/title or a bundled crack.
|
||||
if info.get("revive") is False:
|
||||
profile["revive"] = False
|
||||
return profile
|
||||
|
||||
|
||||
def verdict(r: dict) -> tuple[str, str]:
|
||||
"""(likely OK | needs work | blocked, why). "likely OK" = the profile First Contact has on the Frame: x64,
|
||||
D3D11, Unreal with the IME fix site found (or an engine without that deadlock), a VR API Revive/SteamVR serve."""
|
||||
blockers, work = [], []
|
||||
if r.get("arch") not in ("x86_64", "amd64", "AMD64"):
|
||||
work.append(f"{r.get('arch')} exe: needs GE's 32-bit (wow64) path under FEX (untested)")
|
||||
g = r.get("graphics", [])
|
||||
if "d3d11" not in g:
|
||||
if "d3d12" in g:
|
||||
work.append("D3D12: needs vkd3d-proton on Turnip under FEX (untested)")
|
||||
elif "d3d9" in g:
|
||||
work.append("D3D9: DXVK under FEX (untested)")
|
||||
elif "opengl" in g:
|
||||
work.append("OpenGL: no FEX GL thunk configured; rootfs GL renderer unverified")
|
||||
elif "vulkan" in g:
|
||||
work.append("Vulkan: Turnip through FEX's Vulkan thunk (untested with a game)")
|
||||
else:
|
||||
work.append(f"graphics API unclear ({', '.join(g)})")
|
||||
if "anti-cheat files" in r.get("drm", []) or r.get("runtime", {}).get("anti_cheat"):
|
||||
blockers.append("anti-cheat")
|
||||
elif r.get("drm"):
|
||||
work.append("anti-tamper section (" + ", ".join(r["drm"]) + "): heavier under FEX, may break")
|
||||
if r.get("vr") == ["?"]:
|
||||
work.append("VR API not detected")
|
||||
if r.get("engine", "").startswith("Unreal") and isinstance(r.get("ime_patch"), str):
|
||||
work.append(f"IME fix site not found ({r['ime_patch']})")
|
||||
if r.get("engine") in ("custom", "CryEngine"):
|
||||
work.append(f"{r['engine']} engine: no Unreal/Unity fixes apply; startup/allocator behaviour unknown")
|
||||
if blockers:
|
||||
return "blocked", "; ".join(blockers + work)
|
||||
if work:
|
||||
return "needs work", "; ".join(work)
|
||||
return "likely OK", "same profile as First Contact (x64, D3D11, " + "/".join(r.get("vr", [])) + ")"
|
||||
|
||||
|
||||
def library_games():
|
||||
for g in library.games():
|
||||
if not str(g.get("package", "")).startswith("rift."):
|
||||
continue
|
||||
extra = (g.get("analysis") or {}).get("extra") or {}
|
||||
gd = g.get("game_dir") or extra.get("folder") or ""
|
||||
yield g.get("title") or g["package"], Path(gd), g.get("exe") or extra.get("exe")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--folder", action="append", default=[])
|
||||
ap.add_argument("--out", default=str(user_data_dir() / "fexrift"))
|
||||
args = ap.parse_args()
|
||||
if args.folder:
|
||||
games = []
|
||||
for f in args.folder:
|
||||
a = rift.analyze(Path(f))
|
||||
games.append((a.label, Path(f), a.extra.get("exe")))
|
||||
else:
|
||||
games = list(library_games())
|
||||
results = []
|
||||
for title, d, exe in sorted(games, key=lambda t: t[0].lower()):
|
||||
try:
|
||||
r = survey_game(title, d, exe)
|
||||
except Exception as e: # noqa: BLE001 - one broken folder shouldn't stop the survey
|
||||
r = {"title": title, "folder": str(d), "verdict": ("error", f"{type(e).__name__}: {e}")}
|
||||
results.append(r)
|
||||
print(f"{r['verdict'][0]:11s} {title}", flush=True)
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / "survey.json").write_text(json.dumps(results, indent=1))
|
||||
profiles = {re.sub(r"[^a-z0-9]+", "-", r["title"].lower()).strip("-"): r["profile"]
|
||||
for r in results if r.get("profile")}
|
||||
(out / "profiles.json").write_text(json.dumps(profiles, indent=2) + "\n")
|
||||
lines = ["# Rift-on-Frame static survey", "",
|
||||
"| Game | Verdict | Engine | Graphics | VR | Platform SDK | IME fix | AA config | Notes |",
|
||||
"|---|---|---|---|---|---|---|---|---|"]
|
||||
for r in results:
|
||||
cfg = r.get("config") or {}
|
||||
aa = ", ".join(f"{k}={v}" for k, v in cfg.items() if "MSAA" in k or "AntiAliasing" in k) or r.get("pak", "")
|
||||
ime = r.get("ime_patch")
|
||||
if isinstance(ime, dict):
|
||||
ime = ime["rva"] + ("" if ime.get("module") == "exe" else f" ({ime['module']})")
|
||||
else:
|
||||
ime = str(ime)[:40] if r.get("engine", "").startswith("Unreal") else "n/a"
|
||||
graphics = ", ".join(r.get("graphics", [])) + (f" ({r['graphics_from']})" if r.get("graphics_from") else "")
|
||||
lines.append(f"| {r['title']} | {r['verdict'][0]} | {r.get('engine', '')} | {graphics} | "
|
||||
f"{', '.join(r.get('vr', []))} | {'yes' if r.get('platform_sdk') else 'no'} | {ime} | {aa} | "
|
||||
f"{r['verdict'][1]} |")
|
||||
(out / "survey.md").write_text("\n".join(lines) + "\n")
|
||||
print(f"report: {out / 'survey.md'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Read unencrypted Unreal paks and DefaultEngine.ini without extracting game files (stdlib only)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Unreal paks
|
||||
PAK_MAGIC = 0x5A6F12E1
|
||||
# pak format version -> the Unreal versions that write it (when the exe doesn't name its version)
|
||||
PAK_UE = {1: "4.0-4.2", 2: "4.0-4.2", 3: "4.3-4.15", 4: "4.16-4.19", 5: "4.20", 6: "4.20", 7: "4.21",
|
||||
8: "4.22-4.24", 9: "4.25", 10: "4.26", 11: "4.26-4.27"}
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, buf: bytes, pos: int = 0):
|
||||
self.buf, self.p = buf, pos
|
||||
|
||||
def take(self, fmt: str):
|
||||
v = struct.unpack_from(fmt, self.buf, self.p)
|
||||
self.p += struct.calcsize(fmt)
|
||||
return v if len(v) > 1 else v[0]
|
||||
|
||||
def fstring(self) -> str:
|
||||
n = self.take("<i")
|
||||
if n < 0:
|
||||
s = self.buf[self.p:self.p - 2 * n].decode("utf-16-le", "replace")
|
||||
self.p += -2 * n
|
||||
else:
|
||||
s = self.buf[self.p:self.p + n].decode("latin-1")
|
||||
self.p += n
|
||||
return s.rstrip("\0")
|
||||
|
||||
|
||||
def entry_header_size(ver: int, compressed: bool, nblocks: int) -> int:
|
||||
"""Size of the FPakEntry copy in front of a file's data (offset, sizes, method, hash, blocks, flags, block size)."""
|
||||
return 8 + 8 + 8 + 4 + 20 + (4 + 16 * nblocks if compressed else 0) + (1 + 4 if ver >= 3 else 0)
|
||||
|
||||
|
||||
def legacy_entry(r: Reader, ver: int):
|
||||
"""A full FPakEntry: (offset, compressed size, size, method, blocks, encrypted)."""
|
||||
off, csize, usize = r.take("<qqq")
|
||||
method = r.take("<i")
|
||||
r.p += 20
|
||||
blocks, enc = [], 0
|
||||
if ver >= 3:
|
||||
if method:
|
||||
nb = r.take("<i")
|
||||
blocks = [r.take("<qq") for _ in range(nb)]
|
||||
enc = r.buf[r.p]
|
||||
r.p += 1 + 4
|
||||
return off, csize, usize, method, blocks, enc
|
||||
|
||||
|
||||
def encoded_entry(buf: bytes, pos: int, ver: int):
|
||||
"""A v10+ bit-packed entry (FPakFile::DecodePakEntry); blocks relative to the entry like v7+ full entries."""
|
||||
r = Reader(buf, pos)
|
||||
v = r.take("<I")
|
||||
if v & 0x3f == 0x3f:
|
||||
r.take("<I") # compression block size (not needed to read)
|
||||
method = (v >> 23) & 0x3f
|
||||
off = r.take("<I" if v & (1 << 31) else "<Q")
|
||||
usize = r.take("<I" if v & (1 << 30) else "<Q")
|
||||
csize = r.take("<I" if v & (1 << 29) else "<Q") if method else usize
|
||||
enc = bool(v & (1 << 22))
|
||||
nb = (v >> 6) & 0xffff
|
||||
base = entry_header_size(ver, bool(method), nb)
|
||||
blocks = []
|
||||
if nb == 1 and not enc:
|
||||
blocks = [(base, base + csize)]
|
||||
elif nb:
|
||||
o = base
|
||||
for _ in range(nb):
|
||||
s = r.take("<I")
|
||||
blocks.append((o, o + s))
|
||||
o += (s + 15) & ~15 if enc else s
|
||||
return off, csize, usize, method, blocks, enc
|
||||
|
||||
|
||||
def pak_entries(path: Path):
|
||||
"""(version, mount point, {name: entry}, compression names) of an unencrypted UE4 pak, v1-v11; ValueError if the
|
||||
index can't be read (encrypted, unknown footer)."""
|
||||
with open(path, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
f.seek(max(0, size - 512))
|
||||
tail = f.read()
|
||||
i = tail.rfind(struct.pack("<I", PAK_MAGIC), 0, len(tail) - 40)
|
||||
while i >= 0 and len(tail) - i not in (44, 44 + 4 * 32, 44 + 5 * 32): # magic..end, 0/4/5 method names
|
||||
i = tail.rfind(struct.pack("<I", PAK_MAGIC), 0, i)
|
||||
if i < 0:
|
||||
raise ValueError("no pak footer")
|
||||
_, ver, ioff, isize = struct.unpack_from("<IIqq", tail, i)
|
||||
if ver >= 4 and i >= 1 and tail[i - 1] == 1:
|
||||
raise ValueError(f"pak v{ver}: index encrypted")
|
||||
names = [tail[j:j + 32].split(b"\0")[0].decode("latin-1") for j in range(i + 44, len(tail) - 31, 32)]
|
||||
if not 0 <= ioff < size or not 0 < isize <= size - ioff:
|
||||
raise ValueError(f"pak v{ver}: index outside the file")
|
||||
f.seek(ioff)
|
||||
r = Reader(f.read(isize))
|
||||
mount = r.fstring()
|
||||
count = r.take("<i")
|
||||
entries = {}
|
||||
if ver < 10:
|
||||
for _ in range(count):
|
||||
name = r.fstring()
|
||||
entries[name] = legacy_entry(r, ver)
|
||||
return ver, mount, entries, names
|
||||
r.take("<Q") # path hash seed
|
||||
if r.take("<i"):
|
||||
r.take("<qq")
|
||||
r.p += 20 # path hash index: hashes only, no names
|
||||
if not r.take("<i"):
|
||||
raise ValueError(f"pak v{ver}: no full directory index (names hashed only)")
|
||||
fdi_off, fdi_size = r.take("<qq")
|
||||
r.p += 20
|
||||
enc_size = r.take("<i")
|
||||
encoded = r.buf[r.p:r.p + enc_size]
|
||||
r.p += enc_size
|
||||
files = [legacy_entry(r, ver) for _ in range(r.take("<i"))]
|
||||
f.seek(fdi_off)
|
||||
d = Reader(f.read(fdi_size))
|
||||
for _ in range(d.take("<i")):
|
||||
folder = d.fstring()
|
||||
for _ in range(d.take("<i")):
|
||||
name, loc = d.fstring(), d.take("<i")
|
||||
entries[(folder + name).lstrip("/")] = encoded_entry(encoded, loc, ver) if loc >= 0 else files[-loc - 1]
|
||||
return ver, mount, entries, names
|
||||
|
||||
|
||||
def pak_read(path: Path, ver: int, entry, names: list[str] | None = None) -> bytes:
|
||||
off, csize, usize, method, blocks, enc = entry
|
||||
if enc:
|
||||
raise ValueError("entry encrypted")
|
||||
with open(path, "rb") as f:
|
||||
if not method:
|
||||
f.seek(off + entry_header_size(ver, False, 0))
|
||||
return f.read(usize)
|
||||
kind = (names[method - 1] if ver >= 8 and names and 0 < method <= len(names) else "zlib").lower()
|
||||
if kind not in ("zlib", "gzip"):
|
||||
raise ValueError(f"{kind}-compressed")
|
||||
out = b""
|
||||
for s, e in blocks:
|
||||
if ver >= 7:
|
||||
s, e = s + off, e + off
|
||||
f.seek(s)
|
||||
out += zlib.decompress(f.read(e - s), 31 if kind == "gzip" else 15)
|
||||
return out
|
||||
|
||||
|
||||
CONFIG_KEYS = ("r.MobileMSAA", "r.MSAACount", "r.DefaultFeature.AntiAliasing", "r.ForwardShading",
|
||||
"r.useclusteredforward", "vr.InstancedStereo", "r.MobileHDR", "DefaultGraphicsRHI", "TargetedRHIs",
|
||||
"bUseVulkan")
|
||||
|
||||
|
||||
def parse_engine_ini(text: str) -> dict:
|
||||
"""The keys survey cares about; repeated keys (+TargetedRHIs=…) become lists."""
|
||||
want = {k.lower(): k for k in CONFIG_KEYS}
|
||||
vals: dict = {}
|
||||
for line in text.splitlines():
|
||||
k, _, v = line.strip().partition("=")
|
||||
key = want.get(k.lstrip("+-.!").lower())
|
||||
if not key:
|
||||
continue
|
||||
if key == "TargetedRHIs":
|
||||
vals.setdefault(key, [])
|
||||
if k.startswith("-"):
|
||||
vals[key] = [x for x in vals[key] if x != v]
|
||||
elif v not in vals[key]:
|
||||
vals[key].append(v)
|
||||
else:
|
||||
vals[key] = v
|
||||
return vals
|
||||
|
||||
|
||||
def unreal_config(game_dir: Path) -> dict:
|
||||
"""Pak format + rendering/AA settings from the project's DefaultEngine.ini (paks, else a loose file)."""
|
||||
paks = sorted(game_dir.rglob("*.pak"), key=lambda p: -p.stat().st_size)
|
||||
notes, ver_seen = [], None
|
||||
for pak in paks[:6]:
|
||||
try:
|
||||
ver, _, entries, names = pak_entries(pak)
|
||||
except (ValueError, OSError, struct.error, IndexError) as e:
|
||||
notes.append(f"{pak.name}: {e}")
|
||||
continue
|
||||
ver_seen = ver_seen or ver
|
||||
name = next((n for n in entries if n.endswith("Config/DefaultEngine.ini")), None)
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
text = pak_read(pak, ver, entries[name], names).decode("utf-8-sig", "replace")
|
||||
except (ValueError, zlib.error, OSError) as e:
|
||||
notes.append(f"{pak.name}: DefaultEngine.ini {e}")
|
||||
continue
|
||||
return {"pak": f"v{ver}", "config": parse_engine_ini(text), "pak_notes": notes}
|
||||
loose = next((p for p in game_dir.rglob("DefaultEngine.ini") if p.parent.name == "Config"), None)
|
||||
if loose:
|
||||
text = loose.read_text("utf-8", "replace")
|
||||
return {"pak": f"v{ver_seen}" if ver_seen else "", "config": parse_engine_ini(text),
|
||||
"pak_notes": notes + ["config from a loose DefaultEngine.ini"]}
|
||||
return {"pak": f"v{ver_seen}" if ver_seen else "unreadable", "pak_notes": notes or ["no DefaultEngine.ini found"]}
|
||||
|
||||
@@ -54,9 +54,6 @@ 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):
|
||||
@@ -970,134 +967,5 @@ 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) from None
|
||||
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)"),
|
||||
app: Optional[list[str]] = typer.Option(None, "--app", help="Meta's name of a game to copy "
|
||||
"(repeatable; default: the verified games)"),
|
||||
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), apps=app or None)
|
||||
typer.echo(json.dumps(res.get("imported"), indent=1))
|
||||
|
||||
|
||||
@fex_app.command("login")
|
||||
def fex_login(start_now: bool = typer.Option(False, "--start-now", help="start Meta's app now instead of using Steam"),
|
||||
timeout: int = typer.Option(900, min=1, max=86400, help="seconds to wait for sign-in"),
|
||||
via_pc: bool = typer.Option(False, "--via-pc", help="use the PC browser and a temporary Oculus handler"),
|
||||
stop: bool = typer.Option(False, "--stop", help="end sign-in and restore link handlers; keep the login"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Sign in with your Meta account in the Frame's shared Wine prefix."""
|
||||
from . import fexlogin
|
||||
|
||||
t = _fex_target(frame)
|
||||
fexlogin.login(t.frame, start_now=start_now, timeout=timeout, via_pc=via_pc, stop=stop, emit=typer.echo)
|
||||
|
||||
|
||||
@fex_app.command("install")
|
||||
def fex_install(app_name: Optional[str] = typer.Argument(None, metavar="APP",
|
||||
help="Meta's name for the game (verified: oculus-first-contact)"),
|
||||
from_library: Optional[str] = typer.Option(None, "--from-library", help="Rift library ID: rift.<slug>"),
|
||||
folder: Optional[Path] = typer.Option(None, "--folder", help="upload an existing game folder unchanged"),
|
||||
title: Optional[str] = typer.Option(None, "--title", help="title for a local game copy"),
|
||||
app_id: Optional[str] = typer.Option(None, "--app-id", help="Meta app ID if no config/cached ID exists"),
|
||||
dest: Optional[str] = typer.Option(None, "--dest", help="external drive mount point on the Frame"),
|
||||
runtime_check: bool = typer.Option(False, "--runtime-check", help="probe Meta's library state (<=120 s)"),
|
||||
msaa: Optional[int] = typer.Option(None, help="MSAA samples for a verified game that allows it (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"),
|
||||
force_unverified: bool = typer.Option(False, "--force-unverified",
|
||||
help="install a game that isn't verified with this mode "
|
||||
"(only the generic fixes; may not start)"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Install a prefix game or upload a local Rift copy (fixes, launcher, shortcut). Without
|
||||
--force-unverified an unverified game is refused with what the Frame's static checks found."""
|
||||
from . import fexrift
|
||||
|
||||
t = _fex_target(frame)
|
||||
if from_library or folder:
|
||||
from . import fexinstall
|
||||
res = fexinstall.install(t.frame, printing_reporter(False), from_library=from_library, folder=folder,
|
||||
title=title, app=app_name, app_id=app_id, dest=dest, runtime_check=runtime_check,
|
||||
msaa=msaa, add_to_steam=not no_steam, force_unverified=force_unverified)
|
||||
else:
|
||||
if title or app_id or dest:
|
||||
raise typer.BadParameter("--title, --app-id and --dest require --from-library or --folder")
|
||||
res = fexrift.install(t.frame, printing_reporter(False), app_name or "oculus-first-contact", msaa=msaa,
|
||||
add_to_steam=not no_steam, force_unverified=force_unverified)
|
||||
if runtime_check:
|
||||
res["checks"] = dict(res.get("checks") or {}, **t.frame.agent(
|
||||
"fex_runtime_state", app=app_name or "oculus-first-contact", timeout=125))
|
||||
typer.echo(json.dumps(res, indent=1))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- FEX launch-time settings / bounded bench
|
||||
@fex_app.command("settings")
|
||||
def fex_settings(app_name: str = typer.Argument(..., metavar="APP"),
|
||||
values: Optional[list[str]] = typer.Argument(None, metavar="KEY=VALUE"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""Read per-game performance settings, or override them for the next launch (JSON values; null resets a key)."""
|
||||
updates = {}
|
||||
for item in values or []:
|
||||
key, sep, raw = item.partition("=")
|
||||
if not sep or not key:
|
||||
raise typer.BadParameter("use key=value (true/false, numbers, null, or JSON for fex)")
|
||||
try:
|
||||
updates[key] = raw if key == "cores" and raw != "null" else json.loads(raw)
|
||||
except ValueError:
|
||||
updates[key] = raw # taskset's CPU list, e.g. cores=4-7
|
||||
t = _fex_target(frame)
|
||||
args = {"app": app_name}
|
||||
if values:
|
||||
args["values"] = updates
|
||||
typer.echo(json.dumps(t.frame.agent("fex_settings", **args), indent=1))
|
||||
|
||||
|
||||
@fex_app.command("bench")
|
||||
def fex_bench(app_name: str = typer.Argument("oculus-first-contact", metavar="APP"),
|
||||
seconds: int = typer.Option(120, min=1, max=120, help="total launch and sampling window (seconds)"),
|
||||
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
|
||||
"""One bounded headless performance run on an idle Frame; stops the game and restores display power settings."""
|
||||
t = _fex_target(frame)
|
||||
typer.echo(json.dumps(t.frame.agent("fex_bench", app=app_name, seconds=seconds, timeout=seconds + 250),
|
||||
separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -289,40 +289,6 @@ def oculus_platform_dir() -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
def meta_libraries() -> list[Path]:
|
||||
"""This PC's Meta Horizon / Oculus app libraries (folders holding Software/ and Manifests/): the default one under
|
||||
Program Files and the extra ones the app registered (HKCU\\Software\\Oculus VR, LLC\\Oculus\\Libraries\\{id}
|
||||
OriginalPath)."""
|
||||
if not available():
|
||||
return []
|
||||
out: list[Path] = []
|
||||
roots = [env_path("ProgramFiles"), env_path("ProgramW6432")] + ([Path("/mnt/c/Program Files")] if is_wsl() else [])
|
||||
for root in [r for r in roots if r]:
|
||||
for sub in ("Meta Horizon/Software", "Oculus/Software"):
|
||||
out.append(root / sub)
|
||||
key = r"HKCU\Software\Oculus VR, LLC\Oculus\Libraries"
|
||||
try:
|
||||
if is_windows():
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key.partition("\\")[2]) as k:
|
||||
for i in range(winreg.QueryInfoKey(k)[0]):
|
||||
with winreg.OpenKey(k, winreg.EnumKey(k, i)) as sub:
|
||||
out.append(Path(str(winreg.QueryValueEx(sub, "OriginalPath")[0])))
|
||||
else:
|
||||
r = run_win([system32("reg.exe"), "query", key, "/s", "/v", "OriginalPath"])
|
||||
for m in re.finditer(r"^\s*OriginalPath\s+REG_\w+\s+(.+?)\s*$", r.stdout, re.M):
|
||||
out.append(to_local(m[1]))
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
pass
|
||||
seen, result = set(), []
|
||||
for p in out:
|
||||
if p not in seen and (p / "Manifests").is_dir():
|
||||
seen.add(p)
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ firewall (pairing)
|
||||
WSL_VM_CREATOR = "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" # Hyper-V firewall id of WSL's VM
|
||||
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
"""Upload an existing Rift copy and register it with Meta's real runtime; no extraction or licence changes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
from . import fexrift
|
||||
from .analysis import rift, unreal_pak
|
||||
from .core.events import Reporter
|
||||
|
||||
|
||||
def skipped_file(name: str) -> bool:
|
||||
name = name.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
return (name.endswith((".7z", ".ag")) or bool(re.search(r"\.7z\.0", name))
|
||||
or name in ("data.bin", "agrepackinstaller.exe", "vd.bat"))
|
||||
|
||||
|
||||
def relative_file(name: str) -> str:
|
||||
name = name.replace("\\", "/")
|
||||
if (not name or name.startswith("/") or any(p in ("", ".", "..") for p in name.split("/"))
|
||||
or any(c in name for c in ':\x00\r\n')):
|
||||
raise ValueError(f"path must stay inside the game folder: {name!r}")
|
||||
return name
|
||||
|
||||
|
||||
def inventory(folder: Path) -> tuple[dict, list[str]]:
|
||||
"""Stat only: hashing happens on the Frame, avoiding a second full read from slow source drives."""
|
||||
files, skipped, seen = {}, [], set()
|
||||
for root, dirs, names in os.walk(folder):
|
||||
for name in dirs:
|
||||
if (Path(root) / name).is_symlink():
|
||||
raise ValueError("game folders must not contain directory symlinks")
|
||||
for name in sorted(names):
|
||||
path = Path(root) / name
|
||||
rel = relative_file(path.relative_to(folder).as_posix())
|
||||
if skipped_file(rel):
|
||||
skipped.append(rel)
|
||||
continue
|
||||
if path.is_symlink():
|
||||
raise ValueError(f"symlink in game folder: {rel}")
|
||||
if not path.is_file():
|
||||
raise ValueError(f"not a regular game file: {rel}")
|
||||
if rel.casefold() in seen:
|
||||
raise ValueError(f"duplicate Windows filename: {rel}")
|
||||
seen.add(rel.casefold())
|
||||
st = path.stat()
|
||||
files[rel] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
|
||||
return files, sorted(skipped)
|
||||
|
||||
|
||||
def ini_app_id(text: str) -> str | None:
|
||||
section = ""
|
||||
values = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1].lower()
|
||||
elif section == "onlinesubsystemoculus" and not line.startswith((";", "#")):
|
||||
key, _, value = line.partition("=")
|
||||
value = value.split(";", 1)[0].strip().strip('"\'')
|
||||
if value.isdecimal() and int(value) > 0:
|
||||
values[key.strip().lower()] = value
|
||||
return next((values[k] for k in ("riftappid", "oculusappid", "appid") if k in values), None)
|
||||
|
||||
|
||||
def config_app_id(folder: Path, files: dict) -> tuple[str | None, str | None]:
|
||||
# Only unencrypted config entries are read. No archive extraction, guessing from binary strings or traversal
|
||||
# into repack installers. Loose configs also cover Unity/custom engines using Oculus' common config names.
|
||||
for rel in sorted(files):
|
||||
if not rel.lower().endswith(".pak"):
|
||||
continue
|
||||
try:
|
||||
ver, _, entries, names = unreal_pak.pak_entries(folder / rel)
|
||||
for name, ent in entries.items():
|
||||
if not name.lower().endswith("config/defaultengine.ini") or ent[2] > 4 << 20:
|
||||
continue
|
||||
value = ini_app_id(unreal_pak.pak_read(folder / rel, ver, ent, names).decode("utf-8-sig", "replace"))
|
||||
if value:
|
||||
return value, f"config:{rel}:{name}"
|
||||
except (ValueError, OSError, struct.error, IndexError, zlib.error):
|
||||
continue
|
||||
for rel in sorted(files):
|
||||
path = folder / rel
|
||||
name = path.name.lower()
|
||||
if files[rel]["size"] > 4 << 20:
|
||||
continue
|
||||
if name in ("defaultengine.ini", "oculus.ini", "oculusconfig.ini"):
|
||||
value = ini_app_id(path.read_text("utf-8-sig", errors="replace"))
|
||||
elif name in ("oculusconfig.json", "oculus-platform-config.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text("utf-8-sig"))
|
||||
value = next((str(data[k]) for k in ("RiftAppId", "OculusAppId", "AppId", "app_id")
|
||||
if str(data.get(k, "")).isdecimal() and int(data[k]) > 0), None)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
value = None
|
||||
else:
|
||||
continue
|
||||
if value:
|
||||
return value, f"config:{rel}"
|
||||
return None, None
|
||||
|
||||
|
||||
def exe_version(path: Path) -> str | None:
|
||||
"""VS_FIXEDFILEINFO from the PE resource section, without reading a large executable a second time."""
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
head = f.read(4096)
|
||||
pe = struct.unpack_from("<I", head, 0x3c)[0]
|
||||
if head[:2] != b"MZ" or head[pe:pe + 4] != b"PE\0\0":
|
||||
return None
|
||||
count = struct.unpack_from("<H", head, pe + 6)[0]
|
||||
opt_size = struct.unpack_from("<H", head, pe + 20)[0]
|
||||
f.seek(pe + 24 + opt_size)
|
||||
sections = f.read(min(count, 96) * 40)
|
||||
for off in range(0, len(sections), 40):
|
||||
if sections[off:off + 8].rstrip(b"\0") != b".rsrc":
|
||||
continue
|
||||
size, raw = struct.unpack_from("<II", sections, off + 16)
|
||||
f.seek(raw)
|
||||
data = f.read(min(size, 32 << 20))
|
||||
pos = data.find(struct.pack("<II", 0xfeef04bd, 0x10000))
|
||||
if pos >= 0:
|
||||
ms, ls = struct.unpack_from("<II", data, pos + 8)
|
||||
return ".".join(str(v) for v in (ms >> 16, ms & 0xffff, ls >> 16, ls & 0xffff))
|
||||
except (OSError, ValueError, struct.error, IndexError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def meta_manifest(folder: Path) -> dict | None:
|
||||
"""Meta's own manifest when the folder is in a Meta app library (<library>/Software/<name> next to
|
||||
<library>/Manifests/<name>.json, e.g. C:\\Program Files\\Meta Horizon\\Software): its exe, launch arguments
|
||||
(Unreal games need -vr), app ID and version beat anything guessed from the files."""
|
||||
path = folder.parent.parent / "Manifests" / f"{folder.name}.json"
|
||||
if folder.parent.name.lower() != "software" or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
m = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if m.get("canonicalName") != folder.name or not m.get("launchFile") or not str(m.get("appId", "")).isdigit():
|
||||
return None
|
||||
return m
|
||||
|
||||
|
||||
def library_manifest(app_id: str | None, canonical: str | None, exe: str) -> dict | None:
|
||||
"""The same game's manifest in one of this PC's Meta app libraries (installed with the user's own account), for its
|
||||
launch arguments: only when Meta's exe has the same file name as the one being installed (same game build)."""
|
||||
from .core import winhost
|
||||
|
||||
for lib in winhost.meta_libraries():
|
||||
for path in sorted((lib / "Manifests").glob("*.json")):
|
||||
try:
|
||||
m = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not (str(m.get("appId")) == str(app_id) or (canonical and m.get("canonicalName") == canonical)):
|
||||
continue
|
||||
if Path(str(m.get("launchFile", "")).replace("\\", "/")).name.lower() == Path(exe).name.lower():
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def source_info(folder: Path, entry: dict | None = None, title: str | None = None,
|
||||
app: str | None = None, app_id: str | None = None) -> dict:
|
||||
folder = folder.expanduser().resolve()
|
||||
if not folder.is_dir():
|
||||
raise ValueError(f"game folder not found: {folder}")
|
||||
files, skipped = inventory(folder)
|
||||
entry = entry or {}
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
meta = None if entry else meta_manifest(folder)
|
||||
exe = entry.get("exe") or extra.get("exe") or (meta and meta["launchFile"].replace("\\", "/"))
|
||||
if not exe:
|
||||
analysis = rift.analyze(folder)
|
||||
if not analysis.extra.get("exe_confirmed"):
|
||||
raise ValueError("several game programs found; choose one in the FramePort library first")
|
||||
extra = analysis.extra
|
||||
exe = extra["exe"]
|
||||
exe = relative_file(str(exe))
|
||||
if exe not in files:
|
||||
raise ValueError("the selected game program is missing or is a skipped repack file")
|
||||
title = title or entry.get("title") or folder.name
|
||||
canonical = (app or extra.get("canonical_name") or (meta and meta["canonicalName"])
|
||||
or re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-"))
|
||||
package = fexrift.package_for(canonical) # validate before any remote mutation
|
||||
store_url = (entry.get("details") or {}).get("store_url") or ""
|
||||
match = re.fullmatch(r"https://(?:www\.)?meta\.com/experiences/(\d+)/?", store_url)
|
||||
store_id = match[1] if match else None
|
||||
found, source = (str(meta["appId"]), "meta-manifest") if meta else config_app_id(folder, files)
|
||||
if not found and store_id:
|
||||
found, source = store_id, "oculusdb:details.store_url"
|
||||
if not found and app_id:
|
||||
if not re.fullmatch(r"[1-9][0-9]*", app_id):
|
||||
raise ValueError("--app-id must be a positive numeric Meta app ID")
|
||||
found, source = app_id, "cli:--app-id"
|
||||
if not found:
|
||||
raise ValueError("no Meta app ID in the game's config or cached store details; supply --app-id")
|
||||
args = entry.get("launch_args", extra.get("launch_args", [])) or (shlex.split(meta.get("launchParameters") or "")
|
||||
if meta else [])
|
||||
if isinstance(args, str):
|
||||
args = shlex.split(args)
|
||||
if not args and not meta:
|
||||
# Meta launches many Rift games with arguments (Unreal: -vr); a copy installed elsewhere has none of them
|
||||
lib = library_manifest(found, canonical, exe)
|
||||
if lib:
|
||||
args = shlex.split(lib.get("launchParameters") or "")
|
||||
version = re.search(r"(?i)(?<![a-z0-9])(?:v|version[ _-]*)?(\d+(?:\.\d+)+)", folder.name)
|
||||
return {"folder": folder, "app": canonical, "package": entry.get("package") or package, "title": title,
|
||||
"exe": exe, "args": args, "app_id": found, "app_id_source": source, "store_app_id": store_id,
|
||||
"version": (meta and meta.get("version")) or (version[1] if version else exe_version(folder / exe))
|
||||
or "0.0.0",
|
||||
"files": files, "skipped": skipped}
|
||||
|
||||
|
||||
def install(frame, reporter: Reporter, *, from_library: str | None = None, folder: Path | None = None,
|
||||
title: str | None = None, app: str | None = None, app_id: str | None = None, dest: str | None = None,
|
||||
msaa: int | None = None, add_to_steam: bool = True, force_unverified: bool = False,
|
||||
runtime_check: bool = False) -> dict:
|
||||
from .core import library
|
||||
from .install import installer
|
||||
|
||||
fexrift.require_enabled()
|
||||
if bool(from_library) == bool(folder):
|
||||
raise ValueError("choose exactly one of --from-library or --folder")
|
||||
entry = None
|
||||
if from_library:
|
||||
entry = library.load().get("games", {}).get(from_library)
|
||||
if not entry or entry.get("kind") != "rift":
|
||||
raise ValueError("--from-library needs a Rift library entry")
|
||||
folder = Path(entry["game_dir"])
|
||||
reporter.stage("Inspect local game files")
|
||||
info = source_info(folder, entry, title, app, app_id)
|
||||
if info["app"] not in fexrift.GAMES and not force_unverified:
|
||||
raise ValueError("this game is unverified; use --force-unverified to upload and install it")
|
||||
for rel in info["skipped"]:
|
||||
reporter.log(f"Skipped repack leftover: {rel}")
|
||||
reporter.check("Meta app ID", True, f"{info['app_id']} ({info['app_id_source']})")
|
||||
reporter.check_cancel()
|
||||
fexrift.sync_helpers(frame, reporter)
|
||||
prep = frame.agent("fex_prepare_local", app=info["app"], package=info["package"], dest=dest,
|
||||
files=info["files"])
|
||||
items = [(info["folder"] / rel, rel, st["size"]) for rel, st in info["files"].items()
|
||||
if prep["existing"].get(rel) != st["size"]]
|
||||
need = sum(size for _, _, size in items)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError("not enough space for the game + 1 GiB headroom")
|
||||
reporter.stage(f"Upload {info['title']} ({need / 2**30:.1f} GiB remaining)")
|
||||
with installer.transfer_link(frame, reporter, need) as xfer:
|
||||
if items:
|
||||
installer.upload_files(xfer, items, prep["game_dir"], reporter, max(need, 1))
|
||||
reporter.check_cancel()
|
||||
if inventory(info["folder"])[0] != info["files"]:
|
||||
raise RuntimeError("source files changed during upload; retry with a stable game folder")
|
||||
reporter.stage("Hash and register the game on the Frame")
|
||||
metadata = {k: v for k, v in info.items() if k not in ("folder", "files")}
|
||||
frame.agent("fex_register_local", timeout=7200, **metadata)
|
||||
reporter.stage("Install launcher and generic fixes")
|
||||
result = frame.agent("fex_install", package=info["package"], app=info["app"], title=info["title"],
|
||||
source="local_copy", msaa=msaa, force_unverified=force_unverified)
|
||||
if add_to_steam:
|
||||
installer.add_to_steam(frame, [info["package"]], reporter)
|
||||
if runtime_check:
|
||||
result["checks"] = dict(result.get("checks") or {},
|
||||
**frame.agent("fex_runtime_state", app=info["app"], timeout=125))
|
||||
reporter.check("Meta library state", result["checks"].get("runtime_state") in ("ready", "installed"),
|
||||
result["checks"].get("runtime_state", "unknown"))
|
||||
return result
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Interactive Meta sign-in on the Frame. Credential URLs travel in memory, over SSH stdin / a private callback.
|
||||
|
||||
There is intentionally no Reporter or app log in this module: external programs' exception strings and Meta's
|
||||
Electron output can contain login URLs. Only fixed, friendly progress text reaches the CLI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from . import fexrift, urlhandler
|
||||
from .core import winhost
|
||||
from .core.paths import user_data_dir
|
||||
|
||||
TITLE = "Meta sign-in (FramePort)"
|
||||
|
||||
|
||||
def valid_callback(url):
|
||||
if not isinstance(url, str) or len(url) > 65536 or any(ord(c) < 32 for c in url):
|
||||
return False
|
||||
try:
|
||||
u = urllib.parse.urlsplit(url)
|
||||
return (u.scheme, u.netloc, u.path) == ("oculus", "link", "/frl_login")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def open_page(url):
|
||||
try:
|
||||
u = urllib.parse.urlsplit(url)
|
||||
if (u.scheme, u.netloc, u.path) != ("https", "auth.meta.com", "/native_sso/confirm"):
|
||||
raise ValueError
|
||||
if winhost.is_wsl():
|
||||
# Start-Process on Windows, without interpolating the sign-in URL into PowerShell code or argv.
|
||||
subprocess.run([winhost.system32("WindowsPowerShell/v1.0/powershell.exe"), "-NoProfile",
|
||||
"-NonInteractive", "-Command", "Start-Process ([Console]::In.ReadToEnd())"],
|
||||
input=url, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=15, check=True)
|
||||
elif not webbrowser.open(url):
|
||||
raise ValueError
|
||||
except Exception:
|
||||
raise RuntimeError("couldn't open Meta's sign-in page in the PC browser") from None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pc_callback():
|
||||
"""The temporary scheme script POSTs straight into this loopback server, never to links/*.link.
|
||||
|
||||
A random endpoint and no CORS prevent arbitrary websites from posting callbacks. Handler errors and HTTP
|
||||
request lines are deliberately never logged. Both the script and registration are removed in finally blocks.
|
||||
"""
|
||||
inbox = queue.Queue(maxsize=4)
|
||||
endpoint = "/" + secrets.token_hex(24)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
n = int(self.headers.get("Content-Length", "0"))
|
||||
if self.path != endpoint or not 0 < n <= 65536:
|
||||
self.send_error(400)
|
||||
return
|
||||
url = self.rfile.read(n).decode("utf-8")
|
||||
if not valid_callback(url):
|
||||
self.send_error(400)
|
||||
return
|
||||
inbox.put_nowait(url)
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
except (ValueError, OSError, queue.Full):
|
||||
self.send_error(400)
|
||||
|
||||
class Server(ThreadingHTTPServer):
|
||||
def handle_error(self, *args):
|
||||
pass
|
||||
|
||||
server = Server(("127.0.0.1", 0), Handler)
|
||||
server.daemon_threads = True
|
||||
worker = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
address = f"http://127.0.0.1:{server.server_address[1]}{endpoint}"
|
||||
user_data_dir().mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="fex-login-", dir=user_data_dir()) as tmp:
|
||||
kind = urlhandler.platform_kind()
|
||||
if kind == "windows":
|
||||
script = Path(tmp) / "callback.ps1"
|
||||
script.write_text("param([string]$Link)\n$ErrorActionPreference='SilentlyContinue'\n"
|
||||
f"try {{ Invoke-WebRequest -UseBasicParsing -Uri '{address}' -Method POST "
|
||||
"-Body ([Text.Encoding]::UTF8.GetBytes($Link)) | Out-Null } catch {}\n")
|
||||
ps = (Path(os.environ.get("SystemRoot", r"C:\Windows")) /
|
||||
"System32/WindowsPowerShell/v1.0/powershell.exe")
|
||||
command = f'"{ps}" -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{script}" "%1"'
|
||||
else:
|
||||
script = Path(tmp) / "callback.py"
|
||||
script.write_text("import sys,urllib.request\ntry:\n"
|
||||
f" r=urllib.request.Request({address!r},data=sys.argv[1].encode(),method='POST')\n"
|
||||
" urllib.request.urlopen(r,timeout=10).close()\nexcept Exception:\n pass\n")
|
||||
if kind == "wsl":
|
||||
distro = os.environ.get("WSL_DISTRO_NAME", "")
|
||||
if not distro:
|
||||
raise RuntimeError("WSL_DISTRO_NAME is needed for --via-pc")
|
||||
command = (f'"C:\\Windows\\System32\\wsl.exe" -d "{distro}" -e '
|
||||
f'python3 -I "{script}" "%1"')
|
||||
else:
|
||||
# Desktop Exec syntax uses double quotes, with literal percent signs escaped.
|
||||
def dq(s):
|
||||
s = str(s).replace("%", "%%")
|
||||
for c in ("\\", '"', "`", "$"):
|
||||
s = s.replace(c, "\\" + c)
|
||||
return '"' + s.replace("\\", "\\\\") + '"' # Desktop string-value layer
|
||||
command = f"{dq(sys.executable)} -I {dq(script)} %u"
|
||||
with urlhandler.temporary_oculus_handler(command):
|
||||
yield inbox
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join(timeout=2)
|
||||
|
||||
|
||||
def login(frame, *, start_now=False, timeout=900, via_pc=False, stop=False, emit=print):
|
||||
fexrift.require_enabled()
|
||||
if stop:
|
||||
frame.agent("fex_login_cancel")
|
||||
emit("Meta sign-in stopped. Your saved login stays on the Frame.")
|
||||
return {"stopped": True}
|
||||
if not 1 <= timeout <= 86400:
|
||||
raise ValueError("sign-in timeout must be between 1 and 86400 seconds")
|
||||
with pc_callback() if via_pc else nullcontext(None) as inbox:
|
||||
try:
|
||||
started = frame.agent("fex_login", start_now=start_now, via_pc=via_pc, login_timeout=timeout)
|
||||
if started.get("package"):
|
||||
from .core.events import Reporter
|
||||
from .install import installer
|
||||
|
||||
result = installer.add_to_steam(frame, [started["package"]], Reporter())
|
||||
if result.get("state") != "done" or result.get("errors"):
|
||||
raise RuntimeError("Steam hasn't confirmed Meta sign-in; return to Gaming Mode and retry")
|
||||
emit("Meta's app is starting on the Frame." if start_now else
|
||||
f"Put on the headset: open '{TITLE}' in your Steam library.")
|
||||
if via_pc:
|
||||
emit("Complete the Meta sign-in page in this PC's browser.")
|
||||
deadline = time.monotonic() + timeout
|
||||
page_seen = callback_seen = False
|
||||
while time.monotonic() < deadline:
|
||||
st = frame.agent("fex_login_status", take_url=via_pc)
|
||||
# Pop immediately, so even the local status variable never retains a login URL after opening it.
|
||||
url = st.pop("open_url", None)
|
||||
if url:
|
||||
open_page(url)
|
||||
url = None
|
||||
if st.get("page_opened_at") and not page_seen:
|
||||
emit("Sign-in page opened in the PC browser." if via_pc else "Sign-in page opened on the Frame.")
|
||||
page_seen = True
|
||||
if inbox is not None:
|
||||
try:
|
||||
link = inbox.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
reply = frame.agent("fex_login_callback", url=link)
|
||||
if not reply.get("delivered"):
|
||||
raise RuntimeError("Meta's running app didn't accept the callback; retry sign-in")
|
||||
finally:
|
||||
link = None
|
||||
if st.get("callback_received_at") and not callback_seen:
|
||||
emit("Sign-in response reached Meta's app on the Frame.")
|
||||
callback_seen = True
|
||||
if st.get("signed_in"):
|
||||
emit("Signed in.")
|
||||
return {"signed_in": True}
|
||||
time.sleep(3)
|
||||
raise RuntimeError("Meta sign-in timed out; if the callback didn't reach the Frame, retry with --via-pc")
|
||||
finally:
|
||||
# Runs on success, Ctrl-C, timeout and partial setup errors; the broker also expires if SSH is lost.
|
||||
frame.agent("fex_login_cancel")
|
||||
@@ -1,246 +0,0 @@
|
||||
"""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 re
|
||||
import shutil
|
||||
import tarfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .core.events import Reporter
|
||||
from .core.paths import agent_dir, artifacts_dir, user_data_dir
|
||||
|
||||
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()]
|
||||
# fexrift.py, its extension modules (fexrift_<area>.py) and per-game data; release bundles carry .txt copies of
|
||||
# the .py files (their .py files are compiled away)
|
||||
names = {p.name.removesuffix(".txt") for p in agent_dir().glob("fexrift*.py*") if p.suffix in (".py", ".txt")}
|
||||
names |= {p.name for p in agent_dir().glob("fexrift*.json")}
|
||||
for name in sorted(names):
|
||||
src = agent_dir() / name
|
||||
out.append((src if src.exists() else agent_dir() / f"{name}.txt", name))
|
||||
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 sync_helpers(frame, reporter: Reporter) -> None:
|
||||
"""Re-upload fexrift.py, its extension modules and per-game data (small) before work that runs them: only setup
|
||||
uploaded them, so a fixed helper on the PC never reached a Frame set up earlier."""
|
||||
from .install import installer
|
||||
|
||||
items = [(p, rel, p.stat().st_size) for p, rel in artifact_files() if rel.startswith("fexrift")]
|
||||
installer.upload_files(frame, items, frame.agent("fex_prepare")["artifacts"], reporter,
|
||||
max(sum(i[2] for i in items), 1))
|
||||
|
||||
|
||||
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 []:
|
||||
sessions = u / "AppData" / "Roaming" / "Oculus" / "sessions"
|
||||
# signed in = sessions/<numeric Meta user id>-<n>/ (Meta's app creates sessions/_oaf without a sign-in)
|
||||
if sessions.is_dir() and any(p.is_dir() and re.fullmatch(r"\d+(-\d+)*", p.name) for p in sessions.iterdir()):
|
||||
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)
|
||||
for app in apps:
|
||||
package_for(app) # Meta's names only: they are globbed and become tar paths
|
||||
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 package_for(app: str) -> str:
|
||||
"""FramePort package of a Meta app name (verified games keep theirs)."""
|
||||
if app in GAMES:
|
||||
return GAMES[app]["package"]
|
||||
if not re.fullmatch(r"[a-z0-9][a-z0-9_-]+", app):
|
||||
raise ValueError(f"bad Meta app name {app!r}")
|
||||
return "rift." + app.replace("-", "_")
|
||||
|
||||
|
||||
def install(frame, reporter: Reporter, app: str = "oculus-first-contact", msaa: int | None = None,
|
||||
add_to_steam: bool = True, force_unverified: bool = False) -> dict:
|
||||
"""A game from the Frame's prefix as a Steam game. Games that aren't verified (GAMES) need force_unverified:
|
||||
they get only the generic fixes the Frame finds in their binaries (the agent refuses otherwise and reports
|
||||
its static checks)."""
|
||||
from .install import installer
|
||||
|
||||
require_enabled()
|
||||
pkg = package_for(app)
|
||||
title = GAMES.get(app, {}).get("title")
|
||||
reporter.stage(f"Install {title or app}")
|
||||
sync_helpers(frame, reporter)
|
||||
res = frame.agent("fex_install", package=pkg, app=app, title=title, msaa=msaa,
|
||||
force_unverified=force_unverified)
|
||||
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))
|
||||
checks = res.get("checks") or {}
|
||||
if not res.get("verified"):
|
||||
reporter.check("Verified on the Frame", None, f"not yet: {app} gets only the generic fixes")
|
||||
if checks.get("engine") == "unreal" and isinstance(checks.get("ime"), str):
|
||||
reporter.check("Unreal IME fix", None, checks["ime"])
|
||||
apis = checks.get("graphics_imports") or []
|
||||
if apis and not {"d3d11", "vulkan"} & set(apis):
|
||||
reporter.check("Graphics API", None, f"imports only {', '.join(apis)}: untested with this mode")
|
||||
if add_to_steam:
|
||||
installer.add_to_steam(frame, [pkg], reporter)
|
||||
return res
|
||||
@@ -173,7 +173,7 @@
|
||||
"Appearance": "",
|
||||
"AppImage": "",
|
||||
"Apply with Update on Frame.": "",
|
||||
"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.": "",
|
||||
"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), and the runtime's answer for each hand is reported to them as their own profile (Unity's OpenXR input only connects controllers of a profile it knows). Meta's detached-controller paths (XR_META_detached_controllers, which the Frame lacks) are left out of the app's actions and bindings: one such path made Unity attach no controller input at all. Without it such apps get no controller input.": "",
|
||||
"Artwork for {get}": "",
|
||||
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
|
||||
"Artwork updated for {get}": "",
|
||||
|
||||
@@ -112,6 +112,7 @@ class UnityOculusCheck(Patch):
|
||||
return a.engine == "Unity" and "libOVRPlugin.so" in a.libs and bool((a.extra or {}).get("unity_oculus_check"))
|
||||
|
||||
def detect(self, a):
|
||||
# not narrowed by libOculusXRPlugin.so: a verified game ships it and still needs this fix
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "Unity with built-in Oculus support: it checks for Meta's system apps before "
|
||||
"starting VR, else it runs as a 2D app (for example Accounting+, BattleSisters).")
|
||||
|
||||
@@ -12,7 +12,7 @@ frame.unity_text_input).
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import Suggestion, register
|
||||
from .unity_text_input import RET_FALSE, Il2cppReturnPatch
|
||||
from .unity_text_input import RET_FALSE, Il2cppReturnPatch, can_rewrite_il2cpp
|
||||
|
||||
RET_ZERO = RET_FALSE # mov w0, #0 ; ret
|
||||
|
||||
@@ -40,7 +40,8 @@ class UnityRuntimeMsaa(Il2cppReturnPatch):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and not a.only_32bit
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a) and (a.extra or {}).get("ovr_runtime_msaa") and "GLES" in a.graphics:
|
||||
if (self.applies(a) and can_rewrite_il2cpp(a)
|
||||
and (a.extra or {}).get("ovr_runtime_msaa") and "GLES" in a.graphics):
|
||||
return Suggestion(True, "GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled "
|
||||
"render-to-texture can hang the Frame's GPU.")
|
||||
return None
|
||||
|
||||
@@ -85,6 +85,19 @@ def patch_field_loads(lib: bytes, method: tuple[int, int], field_offset: int, va
|
||||
ALL_TARGETS: dict[str, list[str]] = {} # every IL2CPP patch's methods: one Cpp2IL run (and cache entry) serves all
|
||||
|
||||
|
||||
def can_rewrite_il2cpp(a) -> bool:
|
||||
"""Automatic return patches need AArch64 code and metadata Cpp2IL can read.
|
||||
|
||||
Unity 2017's metadata is unreadable by our Cpp2IL workflow (AGENTS.md, Accounting+). Unknown versions keep the
|
||||
existing fallback: apply() reads the version from the APK. This does not restrict manually selected patches.
|
||||
"""
|
||||
try:
|
||||
major = int(str((a.extra or {}).get("unity_version") or "0").split(".")[0])
|
||||
except ValueError:
|
||||
major = 0
|
||||
return "arm64-v8a" in a.abis and not 0 < major < 2018
|
||||
|
||||
|
||||
class Il2cppReturnPatch(Patch):
|
||||
"""Make IL2CPP methods return a constant (targets), found per game with Cpp2IL (analysis/il2cpp.py)."""
|
||||
targets: dict[str, dict[str, bytes]] = {}
|
||||
@@ -164,7 +177,7 @@ class UnityTextInput(Il2cppReturnPatch):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and bool((a.extra or {}).get("text_fields"))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
if self.applies(a) and can_rewrite_il2cpp(a):
|
||||
kinds = " and ".join((a.extra or {}).get("text_fields"))
|
||||
return Suggestion(True, f"Unity app with text fields ({kinds}): they close at once on the Frame because "
|
||||
"it has no system keyboard.")
|
||||
|
||||
@@ -40,6 +40,13 @@ class UnityTlsKeyPool(Patch):
|
||||
def detect(self, a):
|
||||
heavy = heavy_native_libs(a)
|
||||
if self.applies(a) and _unity_major(a) >= 6000 and heavy:
|
||||
# Firebase's Firestore and Gecko both consume keys alongside Unity 6 and the Frame's graphics/XR
|
||||
# runtime. This is the documented exhaustion case (AGENTS.md, #198), rather than just any Firebase
|
||||
# library: keep a lone SDK as an optional diagnostic fix below.
|
||||
if "libFirebaseCppFirestore.so" in heavy and "libxul.so" in heavy:
|
||||
return Suggestion(True, "Unity 6 with Firebase Firestore and Gecko: these native runtimes can "
|
||||
"exhaust Android's 128 thread keys alongside the Frame's graphics and XR "
|
||||
"libraries. Pool Unity's keys to leave room for them.")
|
||||
return Suggestion(False, f"Unity 6 with {', '.join(heavy)}: turn on if the game aborts at start with "
|
||||
"\"cannot create thread specific key\" (for example Wander).")
|
||||
return None
|
||||
|
||||
@@ -106,7 +106,10 @@ SETTINGS = [
|
||||
("profile_remap", "int", 1, "Newer Touch profiles as Touch",
|
||||
"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."),
|
||||
"(components Touch lacks are dropped), and the runtime's answer for each hand is reported to them as their own "
|
||||
"profile (Unity's OpenXR input only connects controllers of a profile it knows). Meta's detached-controller paths "
|
||||
"(XR_META_detached_controllers, which the Frame lacks) are left out of the app's actions and bindings: one such "
|
||||
"path made Unity attach no controller input at all. Without it such apps get no controller input."),
|
||||
("haptic_scale", "float", 1.0, "Vibration strength",
|
||||
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and "
|
||||
"PCM buffers. For games whose vibrations feel much stronger than on a Quest."),
|
||||
@@ -486,6 +489,11 @@ class AdapterSetting(Patch):
|
||||
"Tale, BattleSisters): vibrations are converted before they reach it.",
|
||||
{"value": 1})
|
||||
if (self.key == "pose_time_fix" and a.engine == "Unity" and "libOVRPlugin.so" in a.libs
|
||||
# Absence of the Oculus XR Plugin isn't proof of Unity's built-in VR: Unity OpenXR games can
|
||||
# bundle OVRPlugin too. The system-app check in libunity is positive evidence of built-in Oculus
|
||||
# (analysis/detect.py, frame/unity_oculus_check.py). Older analyses lack this signal; converted
|
||||
# inputs may have had the check rewritten to "android", so keep the legacy fallback for those.
|
||||
and (a.extra.get("unity_oculus_check", True) or a.is_overport_output)
|
||||
and not a.extra.get("oculus_xr_plugin") and "libOculusXRPlugin.so" not in a.libs):
|
||||
return Suggestion(True, "Unity's built-in Oculus support reads controller poses through OVRPlugin at its "
|
||||
"monotonic \"now\", seconds in the past on the Frame: they are located at the "
|
||||
|
||||
@@ -19,7 +19,6 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from .core import applog, winhost
|
||||
@@ -35,72 +34,6 @@ MARK = "frameport-link-handler" # in every command FramePort registers (how it
|
||||
DESKTOP_FILE = "frameport-links.desktop"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def temporary_oculus_handler(command: str):
|
||||
"""Borrow oculus:// for an interactive FEX login only. Never use the install-link disk inbox for credentials.
|
||||
|
||||
Save the entire HKCU tree (including unknown values/subkeys/types), not just its open command: Meta normally
|
||||
owns it. reg.exe export/import works both natively and through WSL. Linux restores the exact mimeapps bytes,
|
||||
including an absent default. The caller supplies a handler which transports links only in memory.
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
kind = platform_kind()
|
||||
if kind not in ("windows", "wsl", "linux"):
|
||||
raise RuntimeError("--via-pc sign-in supports Windows, WSL and Linux")
|
||||
if kind in ("windows", "wsl"):
|
||||
user_data_dir().mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="oculus-handler-", dir=user_data_dir()) as tmp:
|
||||
backup = Path(tmp) / "previous.reg"
|
||||
reg = winhost.system32("reg.exe")
|
||||
key = _reg_key("oculus")
|
||||
existed = winhost.run_win([reg, "query", key]).returncode == 0
|
||||
if existed and winhost.run_win([reg, "export", key, winhost.to_windows(backup), "/y"]).returncode:
|
||||
raise RuntimeError("couldn't save the PC's previous Oculus link handler")
|
||||
try:
|
||||
_reg_set("oculus", command) # explicit --via-pc authorizes a temporary forced registration
|
||||
yield
|
||||
finally:
|
||||
removed = winhost.run_win([reg, "delete", key, "/f"])
|
||||
if existed:
|
||||
restored = winhost.run_win([reg, "import", winhost.to_windows(backup)])
|
||||
if restored.returncode:
|
||||
# Keep the non-secret registry backup for recovery if Windows refuses restoration.
|
||||
recovery = user_data_dir() / "oculus-handler-restore.reg"
|
||||
shutil.copyfile(backup, recovery)
|
||||
raise RuntimeError(f"couldn't restore Oculus link handler; import {recovery}")
|
||||
elif removed.returncode and winhost.run_win([reg, "query", key]).returncode == 0:
|
||||
raise RuntimeError("couldn't remove the PC's temporary Oculus link handler")
|
||||
return
|
||||
if not shutil.which("xdg-mime"):
|
||||
raise RuntimeError("--via-pc needs xdg-mime on this PC")
|
||||
config = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
||||
apps = _desktop_path().parent
|
||||
desktop = apps / "frameport-oculus-login.desktop"
|
||||
paths = {desktop, config / "mimeapps.list", apps / "mimeapps.list", apps / "defaults.list"}
|
||||
for base in (config, apps):
|
||||
paths.update(base.glob("*-mimeapps.list"))
|
||||
for name in os.environ.get("XDG_CURRENT_DESKTOP", "").lower().split(":"):
|
||||
if name and all(c.isalnum() or c in "_-" for c in name):
|
||||
paths.add(base / f"{name}-mimeapps.list")
|
||||
saved = {p: p.read_bytes() if p.exists() else None for p in paths}
|
||||
try:
|
||||
apps.mkdir(parents=True, exist_ok=True)
|
||||
desktop.write_text(desktop_entry(command, ["oculus"]), encoding="utf-8")
|
||||
r = subprocess.run(["xdg-mime", "default", desktop.name, "x-scheme-handler/oculus"],
|
||||
capture_output=True, timeout=10)
|
||||
if r.returncode:
|
||||
raise RuntimeError("couldn't register the PC's temporary Oculus link handler")
|
||||
yield
|
||||
finally:
|
||||
for p, data in saved.items():
|
||||
if data is None:
|
||||
p.unlink(missing_ok=True)
|
||||
else:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_bytes(data)
|
||||
|
||||
|
||||
def links_dir() -> Path:
|
||||
return user_data_dir() / "links"
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"parentCanonicalName": null,
|
||||
"files": {
|
||||
"Engine\\Binaries\\ThirdParty\\PhysX\\PhysX-3.3\\Win64\\VS2015\\nvToolsExt64_1.dll": {
|
||||
"sha256": "81bd0159a73411791fa1606fe2426b763cc055ac1a50c141ce7e890392ece10a",
|
||||
"size": 42496,
|
||||
"segmentSize": 10000000,
|
||||
"segments": [
|
||||
[
|
||||
104219850989527,
|
||||
"81bd0159a73411791fa1606fe2426b763cc055ac1a50c141ce7e890392ece10a",
|
||||
20030
|
||||
]
|
||||
]
|
||||
},
|
||||
"third_party_notices.txt": {
|
||||
"sha256": "5d7413b09526327fba46d138b1570f3d38ba5865bec8dcf7c4b3974749827b53",
|
||||
"size": 227974,
|
||||
"segmentSize": 10000000,
|
||||
"segments": [
|
||||
[
|
||||
68268927150260,
|
||||
"5d7413b09526327fba46d138b1570f3d38ba5865bec8dcf7c4b3974749827b53",
|
||||
50323
|
||||
]
|
||||
]
|
||||
},
|
||||
"TouchNUX_Basics.bat": {
|
||||
"sha256": "3018170ab20e39cc16163292828ef627fb66401344681cd24e25dada4981a4d7",
|
||||
"size": 74,
|
||||
"segmentSize": 10000000,
|
||||
"segments": [
|
||||
[
|
||||
4315401951933,
|
||||
"3018170ab20e39cc16163292828ef627fb66401344681cd24e25dada4981a4d7",
|
||||
73
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"dlcs": {},
|
||||
"assetFiles": {},
|
||||
"packageType": "APP",
|
||||
"isCore": false,
|
||||
"appId": "1217155751659625",
|
||||
"canonicalName": "oculus-first-contact",
|
||||
"launchFile": "TouchNUX/Binaries/Win64/TouchNUX-Win64-Shipping.exe",
|
||||
"launchParameters": "-gamemode=\"experienceonly\"",
|
||||
"launchFile2D": null,
|
||||
"launchParameters2D": "",
|
||||
"version": "1.1.9",
|
||||
"versionCode": 12,
|
||||
"redistributables": [
|
||||
"1183534128364060",
|
||||
"1134814899877089",
|
||||
"822786567843179"
|
||||
],
|
||||
"firewallExceptionsRequired": false,
|
||||
"thirdParty": false,
|
||||
"manifestVersion": 1
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"parentCanonicalName": null,
|
||||
"files": {
|
||||
"TouchNUX.exe": {
|
||||
"sha256": "857df87af986c243cc4e27251085c308b997aa21ed58c22ada5db1802e8b1a0b",
|
||||
"size": 159232,
|
||||
"segmentSize": 10000000,
|
||||
"segments": [
|
||||
[
|
||||
115938579990749,
|
||||
"857df87af986c243cc4e27251085c308b997aa21ed58c22ada5db1802e8b1a0b",
|
||||
85885
|
||||
]
|
||||
]
|
||||
},
|
||||
"TouchNUX\\Binaries\\Win64\\TouchNUX-Win64-Shipping.exe": {
|
||||
"sha256": "746bfb5bea8a57fd2c81b51d99f633711f293b26398d389c1c1f2793bcb9d4b9",
|
||||
"size": 45950464,
|
||||
"segmentSize": 10000000,
|
||||
"segments": [
|
||||
[
|
||||
238054724965948,
|
||||
"df5877bae5bd5d531f34e00a9098e4a38cd4ffc068c3edaf59e36ceaddf5afc7",
|
||||
4156214
|
||||
],
|
||||
[
|
||||
170141929968035,
|
||||
"52cfb7af70ea7bea5c2cad6a19eb30af088f20dc612bec41e83c34fa511540ba",
|
||||
4302663
|
||||
],
|
||||
[
|
||||
180638137026525,
|
||||
"e428e462b7e908c1db9aa4e4cf2c824d14c4502e6dfb30f20e8464fc45915d48",
|
||||
3777541
|
||||
],
|
||||
[
|
||||
62136762428371,
|
||||
"a082def4fcc6f1f29342f02f1384a772d3c0419a233bd1c95226a79417501303",
|
||||
1882840
|
||||
],
|
||||
[
|
||||
32022214875428,
|
||||
"3dce883fa511de5da3ca0c29b340eae48431beb1203360c028e12dcc9d7cf6fb",
|
||||
2576379
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"dlcs": {},
|
||||
"assetFiles": {},
|
||||
"packageType": "APP",
|
||||
"isCore": false,
|
||||
"appId": "1217155751659625",
|
||||
"canonicalName": "oculus-first-contact",
|
||||
"launchFile": "TouchNUX/Binaries/Win64/TouchNUX-Win64-Shipping.exe",
|
||||
"launchParameters": "-gamemode=\"experienceonly\"",
|
||||
"launchFile2D": null,
|
||||
"launchParameters2D": "",
|
||||
"version": "1.1.9",
|
||||
"versionCode": 12,
|
||||
"redistributables": [
|
||||
"1183534128364060",
|
||||
"1134814899877089",
|
||||
"822786567843179"
|
||||
],
|
||||
"firewallExceptionsRequired": false,
|
||||
"thirdParty": false,
|
||||
"manifestVersion": 1
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Synthetic evaluator tests: no APKs, snapshots, git history, or network needed."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.core.library import analysis_from_dict
|
||||
from frameport.patches import base
|
||||
from frameport.recommend import catalog, engine
|
||||
|
||||
script = Path(__file__).parents[1] / "scripts/eval_heuristics.py"
|
||||
spec = importlib.util.spec_from_file_location("eval_heuristics", script)
|
||||
ev = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(ev)
|
||||
|
||||
|
||||
def _analysis(**kwargs):
|
||||
fields = dict(package="com.example.game", engine="Unity", xr="OpenXR", abis=["arm64-v8a"],
|
||||
libs=[], extra={}, graphics="Vulkan")
|
||||
fields.update(kwargs)
|
||||
return analysis_from_dict(fields)
|
||||
|
||||
|
||||
def _entry(**kwargs):
|
||||
return catalog.CatalogEntry(**(dict(package="com.example.game", title="Example", status="works") | kwargs))
|
||||
|
||||
|
||||
def _patch(monkeypatch, pid, *, category="frame", default=False, applies=True, recommended=True,
|
||||
a32=None, requires=(), conflicts=()):
|
||||
base.load_all()
|
||||
p = base.Patch()
|
||||
p.id, p.title, p.category, p.default_on = pid, pid, category, default
|
||||
p.a32, p.requires, p.conflicts = a32, requires, conflicts
|
||||
p.experimental, p.strict = True, True
|
||||
p.applies = lambda _: applies
|
||||
params = {"value": 3} if category == "adapter" else {}
|
||||
p.detect = lambda _: base.Suggestion(recommended, f"reason for {pid}", params)
|
||||
monkeypatch.setitem(base.REGISTRY, pid, p)
|
||||
return p
|
||||
|
||||
|
||||
def test_catalog_signature_is_catalog_only(monkeypatch):
|
||||
def forbidden(*args, **kwargs):
|
||||
pytest.fail("catalog signature must not call suggest")
|
||||
|
||||
monkeypatch.setattr(engine, "suggest", forbidden)
|
||||
a = _analysis()
|
||||
defaults = ev.expected_defaults(a)
|
||||
entry = _entry(overport_extra=["patch_remove_unreal_force_quit"], overport_remove=["patch_copy_libraries"],
|
||||
frame=["frame.unity_text_input"], frame_remove=["frame.adapter"],
|
||||
adapter={"scene_emul": 0, "scale": 1.5}, device=["device.text_input_window"],
|
||||
device_files={"user.cfg": "r_vsync=0"}, alt_overport=["patch_fix_ue4_crash"], use_alt=True,
|
||||
foveation="fixed", foveation_radius=0.5, foveation_test=True,
|
||||
lepton_env={"X": "1"}, config_sync={"user.cfg": {"width": "w"}})
|
||||
want = ev.catalog_signature(entry, defaults)
|
||||
assert want["overport"] == ["patch_remove_unreal_force_quit"]
|
||||
assert want["overport_removed"] == ["patch_copy_libraries"]
|
||||
assert want["frame"] == ["frame.unity_text_input"]
|
||||
assert want["frame_removed"] == ["frame.adapter"]
|
||||
assert want["adapter"] == {"scene_emul": 0, "scale": 1.5}
|
||||
assert want["device"] == ["device.text_input_window"]
|
||||
assert want["device_files"] == ["user.cfg"]
|
||||
assert want["alt"] == ["patch_fix_ue4_crash"]
|
||||
assert want["use_alt"] is True
|
||||
assert want["lepton_env"] == {"X": "1"}
|
||||
assert want["config_sync"] == {"user.cfg": {"width": "w"}}
|
||||
assert want["foveation"] == {"mode": "fixed", "radius": 0.5, "test": True}
|
||||
assert not want["status_unsupported"]
|
||||
|
||||
|
||||
def test_mask_conditional_defaults_and_score_explicit_removals():
|
||||
a = _analysis()
|
||||
defaults = ev.expected_defaults(a)
|
||||
assert "frame.ltw_depth" not in defaults
|
||||
assert "frame.vk_sanitize" not in defaults
|
||||
assert "frame.adapter" in defaults
|
||||
r = ev.catalog_recipe(_entry(), defaults)
|
||||
assert ev.signature(r, defaults)["frame_removed"] == []
|
||||
removed = ev.catalog_signature(_entry(frame_remove=["frame.adapter", "frame.ltw_depth"]), defaults)
|
||||
assert removed["frame_removed"] == ["frame.adapter"]
|
||||
scored = ev.score_signatures(removed, ev.signature(r, defaults))["headline"]
|
||||
assert scored["missing"] == {"remove:frame.adapter": True}
|
||||
assert not scored["exact"]
|
||||
r.patches.pop("frame.adapter")
|
||||
dropped = ev.score_signatures(ev.catalog_signature(_entry(), defaults), ev.signature(r, defaults))["headline"]
|
||||
assert dropped["extra"] == {"remove:frame.adapter": True}
|
||||
|
||||
|
||||
def test_catalog_adapter_does_not_inherit_heuristics():
|
||||
a = _analysis(libs=["libOVRPlugin.so"])
|
||||
got = engine.suggest(a, use_catalog=False)
|
||||
assert "adapter.haptic_fix" in got.patches
|
||||
assert ev.catalog_signature(_entry(), ev.expected_defaults(a))["adapter"] == {}
|
||||
|
||||
|
||||
def test_scorer_values_counts_and_exact():
|
||||
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"], adapter={"scene_emul": 1}))
|
||||
got = ev.catalog_signature(_entry(frame=["frame.unity_text_input", "frame.hw_video_decode"],
|
||||
adapter={"scene_emul": 2}))
|
||||
score = ev.score_signatures(want, got)["headline"]
|
||||
assert not score["exact"]
|
||||
assert score["per_patch"]["frame.unity_text_input"] == ev._rates(1, 0, 0)
|
||||
assert score["per_patch"]["frame.hw_video_decode"] == ev._rates(0, 1, 0)
|
||||
assert score["per_patch"]["adapter.scene_emul"] == ev._rates(0, 1, 1)
|
||||
assert score["missing"] == {"adapter.scene_emul": 1}
|
||||
assert score["extra"] == {"adapter.scene_emul": 2, "frame.hw_video_decode": True}
|
||||
same = ev.score_signatures(want, want)
|
||||
assert same["headline"]["exact"] and same["runtime"]["exact"]
|
||||
assert same["headline"]["per_patch"]["adapter.scene_emul"] == ev._rates(1, 0, 0)
|
||||
|
||||
|
||||
def test_runtime_separate_from_headline():
|
||||
want = ev.catalog_signature(_entry(adapter={"scale": 1.5, "sync_guard": 1}, use_alt=True,
|
||||
lepton_env={"X": "Y"}, config_sync={"foo": {"w": "w"}}))
|
||||
got = ev.catalog_signature(_entry())
|
||||
score = ev.score_signatures(want, got)
|
||||
assert score["headline"]["exact"]
|
||||
assert not score["headline"]["missing"]
|
||||
assert not score["runtime"]["exact"]
|
||||
assert score["runtime"]["per_patch"]["adapter.scale"] == ev._rates(0, 0, 1)
|
||||
assert score["runtime"]["missing"]["use_alt"]
|
||||
|
||||
|
||||
def test_aggregate_excludes_unsupported_and_breaks_down_engines():
|
||||
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"]))
|
||||
empty = ev.catalog_signature(_entry())
|
||||
rows = [dict(package="com.a", status="works", engine="Unity", converted=True, want=want, got=want),
|
||||
dict(package="com.b", status="issues", engine="Unreal", want=want, got=empty),
|
||||
dict(package="com.c", status="unsupported", engine="Other", want=want, got=empty)]
|
||||
result = ev.score_records(rows)
|
||||
assert result["headline"]["games"] == 2
|
||||
assert result["headline"]["exact"] == 1
|
||||
assert result["headline"]["exact_ratio"] == 0.5
|
||||
assert result["headline"]["patches"] == ev._rates(1, 0, 1)
|
||||
assert result["headline"]["fields"]["frame"] == {"matched": 1, "total": 2}
|
||||
assert result["runtime"]["exact"] == 2
|
||||
assert result["excluded_unsupported"] == 1
|
||||
assert result["unsupported"]["headline"]["patches"] == ev._rates(0, 0, 1)
|
||||
assert result["by_engine"]["Unreal"]["headline"]["patches"] == ev._rates(0, 0, 1)
|
||||
assert result["converted_games"] == 1
|
||||
assert len(result["games"]) == 3
|
||||
|
||||
|
||||
def test_split_deterministic_stratified_and_disjoint():
|
||||
engines = {f"com.example.{name}{i}": name for name in ("Unity", "Unreal", "Other") for i in range(10)}
|
||||
assignments = ev.split_packages(engines)
|
||||
assert assignments == ev.split_packages(dict(reversed(list(engines.items()))))
|
||||
for name in ("Unity", "Unreal", "Other"):
|
||||
mine = [pkg for pkg in engines if engines[pkg] == name]
|
||||
ranked = sorted(mine, key=lambda p: ev.hashlib.sha256(p.encode()).digest())
|
||||
assert {p for p in mine if assignments[p] == "holdout"} == set(ranked[:2])
|
||||
dev = {p for p, s in assignments.items() if s == "dev"}
|
||||
holdout = {p for p, s in assignments.items() if s == "holdout"}
|
||||
assert not dev & holdout
|
||||
assert dev | holdout == engines.keys()
|
||||
assert ev.split_packages({}) == {}
|
||||
assert ev.split_packages({"com.one": "CryEngine"}) == {"com.one": "dev"}
|
||||
|
||||
|
||||
def test_fit_selects_only_recommended_opt_ins_not_listed_or_removed(monkeypatch):
|
||||
_patch(monkeypatch, "frame.eval_candidate", conflicts=("frame.adapter",), requires=("frame.eval_unrecommended",))
|
||||
_patch(monkeypatch, "frame.eval_default", default=True)
|
||||
_patch(monkeypatch, "frame.eval_unrecommended", recommended=False)
|
||||
_patch(monkeypatch, "frame.eval_inapplicable", applies=False)
|
||||
_patch(monkeypatch, "frame.eval_listed")
|
||||
_patch(monkeypatch, "frame.eval_removed")
|
||||
_patch(monkeypatch, "frame.eval_alternate")
|
||||
_patch(monkeypatch, "adapter.eval_setting", category="adapter")
|
||||
_patch(monkeypatch, "pcvr.eval_wrong_platform", category="pcvr")
|
||||
entry = _entry(frame=["frame.eval_listed"], frame_remove=["frame.eval_removed"],
|
||||
alt_overport=["frame.eval_alternate"], adapter={"eval_setting": 9})
|
||||
candidates = {r["patch"]: r for r in ev.fit_candidates(_analysis(), entry)}
|
||||
assert "frame.eval_candidate" in candidates
|
||||
assert not set(candidates) & {"frame.eval_default", "frame.eval_unrecommended", "frame.eval_inapplicable",
|
||||
"frame.eval_listed", "frame.eval_removed", "frame.eval_alternate",
|
||||
"adapter.eval_setting", "pcvr.eval_wrong_platform"}
|
||||
c = candidates["frame.eval_candidate"]
|
||||
assert c["reason"] == "reason for frame.eval_candidate"
|
||||
assert c["category"] == "frame" and c["experimental"] and c["strict"]
|
||||
assert any("conflicts" in w for w in c["warnings"])
|
||||
|
||||
|
||||
def test_fit_uses_a32_and_flat_rules(monkeypatch):
|
||||
_patch(monkeypatch, "frame.eval_64_only", a32=False)
|
||||
_patch(monkeypatch, "frame.eval_32_ok", a32=True)
|
||||
candidates = {r["patch"] for r in ev.fit_candidates(_analysis(abis=["armeabi-v7a"]), _entry())}
|
||||
assert "frame.eval_32_ok" in candidates
|
||||
assert "frame.eval_64_only" not in candidates
|
||||
assert "frame.a32_bridge" not in candidates # implied for 32-bit entries, as engine._a32_predates_bridge does
|
||||
flat = {r["patch"] for r in ev.fit_candidates(_analysis(extra={"vr_kind": "none"}), _entry())}
|
||||
assert "frame.eval_32_ok" not in flat # needs_vr defaults True, removed by engine._non_quest
|
||||
|
||||
|
||||
def test_fit_metadata_dates_and_converted(monkeypatch):
|
||||
monkeypatch.setattr(ev, "fit_candidates", lambda a, e: [dict(patch="frame.new", category="frame",
|
||||
reason="x", experimental=False, strict=False,
|
||||
warnings=[])])
|
||||
entries = {"com.a": _entry(package="com.a", verified={"date": "2026-01-01"}, updated="2026-02-01"),
|
||||
"com.b": _entry(package="com.b", status="unsupported", engine="Other", xr="VrApi")}
|
||||
report = ev.fit_report({"com.a": {"analysis": _analysis(package="com.a").to_dict(), "converted": True}}, entries,
|
||||
{"frame.new": "2026-03-01"})
|
||||
assert report["games"][0]["candidates"][0]["newer_patch"] is True
|
||||
assert report["games"][0]["entry_date"] == "2026-01-01" # verified, not updated
|
||||
assert report["games"][0]["converted"]
|
||||
assert report["metadata_only"][0]["package"] == "com.b"
|
||||
assert report["metadata_only"][0]["xr"] == "VrApi"
|
||||
assert report["totals_by_patch"] == {"frame.new": 1}
|
||||
md = ev.render_fit_report(report)
|
||||
assert "metadata only" in md and "com.b" in md and "frame.new" in md
|
||||
|
||||
|
||||
def test_snapshot_copied_before_read_and_retried(tmp_path, monkeypatch):
|
||||
source = tmp_path / "snapshot.json"
|
||||
source.write_text('{"complete": true}')
|
||||
calls = []
|
||||
real_copy = ev.shutil.copyfile
|
||||
|
||||
def copying(src, dest):
|
||||
calls.append((src, dest))
|
||||
real_copy(src, dest)
|
||||
if len(calls) == 1:
|
||||
dest.write_text('{"partial":')
|
||||
|
||||
monkeypatch.setattr(ev.shutil, "copyfile", copying)
|
||||
monkeypatch.setattr(ev.time, "sleep", lambda _: None)
|
||||
assert ev.read_snapshot(source) == {"complete": True}
|
||||
assert len(calls) == 2
|
||||
assert all(src == source and dest != source for src, dest in calls)
|
||||
assert json.loads(source.read_text()) == {"complete": True}
|
||||
|
||||
|
||||
def test_intro_dates_cached_and_adapter_searches_bare_key(tmp_path, monkeypatch):
|
||||
calls = []
|
||||
patch = base.get("adapter.scene_emul")
|
||||
monkeypatch.setattr(base, "all_patches", lambda: [patch])
|
||||
|
||||
def log(args, **kwargs):
|
||||
calls.append(args)
|
||||
return type("Result", (), {"stdout": "2026-01-02\n2026-02-03\n"})()
|
||||
|
||||
monkeypatch.setattr(ev.subprocess, "run", log)
|
||||
cache = tmp_path / "patch-intro-dates.json"
|
||||
assert ev.patch_intro_dates(cache) == {"adapter.scene_emul": "2026-01-02"}
|
||||
assert '-S"scene_emul"' in calls[0]
|
||||
assert calls[0][-1] == "src/frameport/patches/settings.py"
|
||||
assert ev.patch_intro_dates(cache) == {"adapter.scene_emul": "2026-01-02"}
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_legacy_dumps_cli_remains_available(tmp_path, monkeypatch, capsys):
|
||||
a = _analysis()
|
||||
snapshot = {a.package: {"analysis": a.to_dict()}}
|
||||
entry = _entry()
|
||||
calls = []
|
||||
monkeypatch.setattr(ev, "read_dumps", lambda p: calls.append(p) or snapshot)
|
||||
monkeypatch.setattr(catalog, "_load_dir", lambda *args: {a.package: entry})
|
||||
monkeypatch.setattr(ev.sys, "argv", ["eval_heuristics.py", str(tmp_path), "--verbose"])
|
||||
assert ev.main() in (0, 1) # a difference exit is compatible with the original CLI
|
||||
assert calls == [tmp_path]
|
||||
output = capsys.readouterr().out
|
||||
assert "Split: dev" in output and a.package in output
|
||||
|
||||
|
||||
def test_snapshot_fit_cli_writes_markdown_and_json(tmp_path, monkeypatch):
|
||||
a = _analysis()
|
||||
snapshot = tmp_path / "synthetic.json"
|
||||
snapshot.write_text(json.dumps({a.package: {"analysis": a.to_dict(), "converted": True}}))
|
||||
md, js = tmp_path / "fit.md", tmp_path / "fit.json"
|
||||
monkeypatch.setattr(catalog, "_load_dir", lambda *args: {a.package: _entry()})
|
||||
monkeypatch.setattr(ev, "patch_intro_dates", lambda p: {})
|
||||
monkeypatch.setattr(ev.quest_dump, "scan", lambda *a: pytest.fail("must not scan with --snapshot"))
|
||||
monkeypatch.setattr(ev.sys, "argv", ["eval_heuristics.py", "--snapshot", str(snapshot), "--fit-report",
|
||||
"--out", str(md), "--json", str(js)])
|
||||
assert ev.main() == 0
|
||||
assert "# New patch fit" in md.read_text()
|
||||
assert json.loads(js.read_text())["games"][0]["converted"]
|
||||
|
||||
|
||||
def test_evaluate_split_coverage_and_no_snapshot_analysis_mutation():
|
||||
snapshot = {f"com.game.n{i}": {"analysis": _analysis(package=f"com.game.n{i}").to_dict(), "converted": i == 0}
|
||||
for i in range(10)}
|
||||
entries = {p: _entry(package=p) for p in snapshot}
|
||||
before = json.dumps(snapshot, sort_keys=True)
|
||||
reports = {s: ev.evaluate(snapshot, entries, s) for s in ("all", "dev", "holdout")}
|
||||
assert reports["dev"]["total_games"] == 8
|
||||
assert reports["holdout"]["total_games"] == 2
|
||||
assert reports["all"]["total_games"] == 10
|
||||
assert json.dumps(snapshot, sort_keys=True) == before
|
||||
assert reports["all"]["converted_games"] == 1
|
||||
|
||||
|
||||
def test_catalog_truth_applies_the_a32_bridge_transform():
|
||||
"""A 32-bit game's entry relies on the bridge switching OVRPort and the 64-bit fixes off; the heuristics do the
|
||||
same, so neither side may count those as removals."""
|
||||
a = _analysis(abis=["armeabi-v7a"], engine="Unreal", graphics="GLES")
|
||||
defaults = ev.expected_defaults(a)
|
||||
want = ev.catalog_signature(_entry(), defaults, a)
|
||||
got = ev.signature(engine.suggest(a, use_catalog=False), defaults)
|
||||
assert want["overport_removed"] == got["overport_removed"]
|
||||
assert want["frame_removed"] == got["frame_removed"]
|
||||
|
||||
|
||||
def test_dated_score_sets_aside_patches_newer_than_the_entry():
|
||||
want = ev.catalog_signature(_entry(frame=["frame.unity_text_input"]))
|
||||
got = ev.catalog_signature(_entry(frame=["frame.unity_text_input", "frame.hw_video_decode"]))
|
||||
strict = ev.score_signatures(want, got)["headline"]
|
||||
dated = ev.score_signatures(want, got, {"frame.hw_video_decode"})["headline"]
|
||||
assert strict["extra"] and not strict["exact"]
|
||||
assert dated["exact"] and dated["gap"] == {"frame.hw_video_decode": True}
|
||||
assert "frame.hw_video_decode" not in dated["per_patch"]
|
||||
|
||||
|
||||
def test_inherited_picks_are_adapter_and_untoggled_device_patches():
|
||||
inherited = ev.inherited_decisions()
|
||||
assert "adapter.haptic_fix" in inherited
|
||||
assert "device.text_input_window" not in inherited # catalog.TOGGLED_DEVICE: dropped unless the entry names it
|
||||
assert not any(pid.startswith("frame.") for pid in inherited)
|
||||
|
||||
|
||||
def test_gap_decisions_use_the_verified_date_not_updated():
|
||||
e = _entry(verified={"date": "2026-09-28"}, updated="2026-10-14")
|
||||
gaps = ev.gap_decisions(e, {"frame.unity_text_input": "2026-10-03", "frame.adapter": "2026-09-28",
|
||||
"patch_remove_unreal_force_quit": "2026-10-01"})
|
||||
assert gaps == {"frame.unity_text_input", "alt:patch_remove_unreal_force_quit"}
|
||||
assert ev.entry_date(e) == "2026-09-28"
|
||||
@@ -1,470 +0,0 @@
|
||||
"""Local-copy Rift installation: no Frame, source dumps, network or login state required."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import types
|
||||
import zlib
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import fexinstall
|
||||
from frameport.core.events import Cancelled, Reporter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load(path):
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fx(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
artifacts = tmp_path / "artifacts"
|
||||
artifacts.mkdir()
|
||||
for name in ("fexrift.py", "fexrift_install.py", "fexrift_games.json"):
|
||||
shutil.copy2(ROOT / "agent" / name, artifacts / name)
|
||||
core = load(artifacts / "fexrift.py")
|
||||
core.load_extensions()
|
||||
calls = []
|
||||
monkeypatch.setattr(core, "wine", lambda *args, **kw: calls.append((args, kw)))
|
||||
core.reg_calls = calls
|
||||
return core
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game(tmp_path):
|
||||
folder = tmp_path / "Demo v1.2.3"
|
||||
(folder / "Game/Config").mkdir(parents=True)
|
||||
(folder / "Game/Game.exe").write_bytes(b"game bytes")
|
||||
(folder / "Game/Config/DefaultEngine.ini").write_text("[OnlineSubsystemOculus]\nRiftAppId=1184903171584429\n")
|
||||
(folder / "content.bin").write_bytes(b"data\0" * 20)
|
||||
return folder
|
||||
|
||||
|
||||
def entry(game):
|
||||
return {"kind": "rift", "package": "rift.demo", "game_dir": str(game), "exe": "Game/Game.exe",
|
||||
"title": "Demo", "analysis": {"extra": {"launch_args": ["-a", "two words"]}},
|
||||
"details": {"sources": ["oculusdb"], "store_url": "https://www.meta.com/experiences/1217155751659625/"}}
|
||||
|
||||
|
||||
def upload(fx, game, dest=None):
|
||||
info = fexinstall.source_info(game, entry(game))
|
||||
prep = fx.COMMANDS["prepare_local"]({"app": info["app"], "package": info["package"], "files": info["files"],
|
||||
"dest": str(dest) if dest else None})
|
||||
for rel in info["files"]:
|
||||
target = Path(prep["game_dir"]) / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(game / rel, target)
|
||||
return info, prep
|
||||
|
||||
|
||||
def register(fx, info):
|
||||
return fx.COMMANDS["register_local"]({k: v for k, v in info.items() if k not in ("folder", "files")})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["Crack.7z", "crack.7z.001", "CRACK.7Z.01", "Data.bin", "AGRepackInstaller.exe",
|
||||
"crack.7z.001.bak",
|
||||
"VD.bat", "Game.exe.AG", "x/Original.dll.ag"])
|
||||
def test_skipped(name):
|
||||
assert fexinstall.skipped_file(name)
|
||||
ext = load(ROOT / "agent/fexrift_install.py")
|
||||
assert ext.skipped(name)
|
||||
|
||||
|
||||
def test_inventory_does_not_read_or_upload_repack_leftovers(game):
|
||||
for name in ("crack.7z.001", "Data.bin", "Game.exe.AG", "VD.bat", "AGRepackInstaller.exe"):
|
||||
(game / name).write_bytes(b"untouched")
|
||||
info = fexinstall.source_info(game, entry(game))
|
||||
assert len(info["skipped"]) == 5 and len(info["files"]) == 3
|
||||
assert info["version"] == "1.2.3"
|
||||
assert info["app_id"] == "1184903171584429"
|
||||
assert info["store_app_id"] == "1217155751659625" # distinct game and store IDs both survive
|
||||
assert info["app_id_source"].startswith("config:")
|
||||
|
||||
|
||||
def test_app_id_from_pak(game):
|
||||
ini = b"[OnlineSubsystemOculus]\nAppId=333\nOculusAppId=222\nRiftAppId=111\n"
|
||||
def fstr(text):
|
||||
data = text.encode() + b"\0"
|
||||
return struct.pack("<i", len(data)) + data
|
||||
header = struct.pack("<qqqi", 0, len(ini), len(ini), 0) + bytes(20) + bytes(5)
|
||||
body = header + ini
|
||||
index = fstr("../../../") + struct.pack("<i", 1) + fstr("Game/Config/DefaultEngine.ini") + header
|
||||
footer = struct.pack("<IIqq", 0x5A6F12E1, 3, len(body), len(index)) + bytes(20)
|
||||
(game / "content.pak").write_bytes(body + index + footer)
|
||||
info = fexinstall.source_info(game, entry(game), app_id="999")
|
||||
assert info["app_id"] == "111" and info["app_id_source"] == "config:content.pak:Game/Config/DefaultEngine.ini"
|
||||
|
||||
|
||||
def test_id_fallbacks_and_other_engine_config(game):
|
||||
(game / "Game/Config/DefaultEngine.ini").unlink()
|
||||
info = fexinstall.source_info(game, entry(game), app_id="999")
|
||||
assert info["app_id_source"] == "oculusdb:details.store_url"
|
||||
e = entry(game)
|
||||
e.pop("details")
|
||||
assert fexinstall.source_info(game, e, app_id="999")["app_id_source"] == "cli:--app-id"
|
||||
with pytest.raises(ValueError, match="supply --app-id"):
|
||||
fexinstall.source_info(game, e)
|
||||
(game / "OculusConfig.json").write_text('{"RiftAppId":"1234"}')
|
||||
assert fexinstall.source_info(game, e)["app_id"] == "1234"
|
||||
assert fexinstall.ini_app_id("[Other]\nAppId=123\n[OnlineSubsystemOculus]\n;AppId=456") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../escape", "/tmp/x", "C:/x", "bad/name", "xx\n", "xx\\yy", "."])
|
||||
def test_canonical_rejected(fx, bad):
|
||||
with pytest.raises(ValueError):
|
||||
fx.COMMANDS["prepare_local"]({"app": bad, "package": "rift.x", "files": {}})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../Game.exe", "/Game.exe", "C:\\Game.exe", "a/../../Game.exe", "a//Game.exe"])
|
||||
def test_exe_escape_rejected(fx, game, bad):
|
||||
info, _ = upload(fx, game)
|
||||
info["exe"] = bad
|
||||
with pytest.raises(ValueError):
|
||||
register(fx, info)
|
||||
with pytest.raises(ValueError):
|
||||
fexinstall.relative_file(bad)
|
||||
|
||||
|
||||
def test_manifest_fixture_shape_and_local_registration(fx, game):
|
||||
info, prep = upload(fx, game)
|
||||
before = {r: (game / r).read_bytes() for r in info["files"]}
|
||||
out = register(fx, info)
|
||||
mdir = Path(prep["library"]) / "Manifests"
|
||||
m = json.loads((mdir / "demo.json").read_text())
|
||||
mini = json.loads((mdir / "demo.json.mini").read_text())
|
||||
for name, manifest in (("oculus-first-contact.json", m), ("oculus-first-contact.json.mini", mini)):
|
||||
fixture = json.loads((ROOT / "tests/fixtures/fexrift" / name).read_text())
|
||||
assert list(manifest) == list(fixture)
|
||||
assert {k: type(v) for k, v in manifest.items()} == {k: type(v) for k, v in fixture.items()}
|
||||
sample = next(iter(fixture["files"].values()))
|
||||
for f in manifest["files"].values():
|
||||
assert list(f) == list(sample)
|
||||
assert {k: type(v) for k, v in f.items()} == {k: type(v) for k, v in sample.items()}
|
||||
assert m["appId"] == info["app_id"] and m["launchFile"] == "Game/Game.exe"
|
||||
assert m["redistributables"] == [] and m["versionCode"] == 1
|
||||
assert set(mini["files"]) == {"Game\\Game.exe"}
|
||||
for rel, original in before.items():
|
||||
assert (Path(prep["game_dir"]) / rel).read_bytes() == original
|
||||
fm = m["files"][rel.replace("/", "\\")]
|
||||
assert fm["sha256"] == hashlib.sha256(original).hexdigest()
|
||||
assert fm["segments"] == [[0, fm["sha256"], len(zlib.compress(original))]]
|
||||
raw = (mdir / "demo.json").read_bytes()
|
||||
assert b"\r\n" in raw and b"\r\r\n" not in raw
|
||||
assert out["source"] == "local_copy" and out["store_app_id"] != out["app_id"]
|
||||
inf, verified = fx.game_info("demo")
|
||||
assert not verified and inf["game_dir"] == prep["game_dir"]
|
||||
assert inf["args"] == ["-a", "two words"]
|
||||
fx.write_launcher("demo", inf, str(game))
|
||||
assert prep["game_dir"] + "/Game" in (game / "launch.sh").read_text()
|
||||
assert "-a 'two words'" in (game / "launch.sh").read_text()
|
||||
assert fx.reg_calls[0][0][:2] == ("regedit", "/S")
|
||||
assert "DefaultLibrary" in (Path(fx.ROOT) / "local-library.reg").read_text()
|
||||
(mdir / "demo.json").unlink()
|
||||
with pytest.raises(ValueError, match="manifest is missing"):
|
||||
fx.game_info("demo")
|
||||
|
||||
|
||||
def test_chunk_hashes(fx, tmp_path):
|
||||
ext = load(ROOT / "agent/fexrift_install.py")
|
||||
path = tmp_path / "large"
|
||||
data = b"x" * (ext.SEGMENT_SIZE + 3)
|
||||
path.write_bytes(data)
|
||||
m = ext.hash_file(path)
|
||||
assert len(m["segments"]) == 2
|
||||
assert m["segments"][1] == [0, hashlib.sha256(b"xxx").hexdigest(), len(zlib.compress(b"xxx"))]
|
||||
assert m["sha256"] == hashlib.sha256(data).hexdigest()
|
||||
path.write_bytes(b"")
|
||||
assert ext.hash_file(path)["segments"] == []
|
||||
|
||||
|
||||
def test_incomplete_upload_and_symlinks(fx, game, tmp_path):
|
||||
info, prep = upload(fx, game)
|
||||
target = Path(prep["game_dir"]) / "content.bin"
|
||||
target.unlink()
|
||||
with pytest.raises(ValueError, match="incomplete"):
|
||||
register(fx, info)
|
||||
outside = tmp_path / "outside"
|
||||
outside.write_bytes(b"anything")
|
||||
target.symlink_to(outside)
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
register(fx, info)
|
||||
with pytest.raises(ValueError, match="symlink"):
|
||||
fexinstall.inventory(Path(prep["game_dir"]))
|
||||
|
||||
|
||||
def test_resume_changed_source_and_foreign_collision(fx, game):
|
||||
info, prep = upload(fx, game)
|
||||
args = {"app": info["app"], "package": info["package"], "files": info["files"]}
|
||||
resumed = fx.COMMANDS["prepare_local"](args)
|
||||
assert len(resumed["existing"]) == 3
|
||||
part = Path(prep["game_dir"]) / "content.bin.part"
|
||||
part.write_bytes(b"old partial")
|
||||
(game / "content.bin").write_bytes(b"new!\0" * 20) # same size; a cancelled retry must not retain the old bytes
|
||||
new_time = info["files"]["content.bin"]["mtime_ns"] + 1000000000
|
||||
os.utime(game / "content.bin", ns=(new_time, new_time))
|
||||
args["files"] = fexinstall.inventory(game)[0]
|
||||
resumed = fx.COMMANDS["prepare_local"](args)
|
||||
assert "content.bin" not in resumed["existing"] and not part.exists()
|
||||
resumed = fx.COMMANDS["prepare_local"](args)
|
||||
assert "content.bin" not in resumed["existing"]
|
||||
with pytest.raises(ValueError, match="another package"):
|
||||
fx.COMMANDS["prepare_local"](dict(args, package="rift.other"))
|
||||
del args["files"]["content.bin"]
|
||||
with pytest.raises(ValueError, match="removed files"):
|
||||
fx.COMMANDS["prepare_local"](args)
|
||||
other = Path(prep["game_dir"]).with_name("foreign")
|
||||
other.mkdir()
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
fx.COMMANDS["prepare_local"](dict(args, app="foreign"))
|
||||
|
||||
|
||||
def test_uninstall_default_and_external_library(fx, game, tmp_path):
|
||||
info, prep = upload(fx, game)
|
||||
register(fx, info)
|
||||
fx.COMMANDS["unregister_local"]({"app": info["app"], "package": info["package"]})
|
||||
assert not Path(prep["game_dir"]).exists()
|
||||
assert not (Path(prep["library"]) / "Manifests/demo.json").exists()
|
||||
assert len(fx.reg_calls) == 1 # default library is shared and remains registered
|
||||
assert Path(fx.PFX).is_dir()
|
||||
drive = tmp_path / "mount"
|
||||
drive.mkdir()
|
||||
info, prep = upload(fx, game, drive)
|
||||
register(fx, info)
|
||||
assert Path(prep["game_dir"]) == drive / "FramePort/fexrift-games/demo"
|
||||
inf, _ = fx.game_info("demo")
|
||||
assert inf["game_dir"] == prep["game_dir"]
|
||||
assert (Path(prep["library"]) / "Software/demo/Game/Game.exe").is_file()
|
||||
volume = Path(fx.PFX) / "dosdevices" / ("volume{" + prep["library_guid"] + "}")
|
||||
assert volume.resolve() == drive
|
||||
reg = (Path(fx.ROOT) / "local-library.reg").read_text()
|
||||
assert "OriginalPath" in reg and "Volume{" in reg and "DefaultLibrary" not in reg
|
||||
fx.COMMANDS["unregister_local"]({"app": "demo", "package": "rift.demo"})
|
||||
assert not Path(prep["game_dir"]).exists()
|
||||
assert "[-HKEY_CURRENT_USER" in (Path(fx.ROOT) / "local-library.reg").read_text()
|
||||
|
||||
|
||||
def test_agent_deployment_and_uninstall(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
agent = load(ROOT / "agent/frameport_agent.py")
|
||||
calls = []
|
||||
folder = tmp_path / "game"
|
||||
folder.mkdir()
|
||||
local = {"source": "local_copy", "registered": True, "package": "rift.demo", "app_id": "123",
|
||||
"app_id_source": "config:DefaultEngine.ini", "skipped": ["Data.bin"], "dest": None}
|
||||
def helper(cmd, args, **kwargs):
|
||||
calls.append(cmd)
|
||||
if cmd == "local_info":
|
||||
return local
|
||||
if cmd == "unregister_local":
|
||||
shutil.rmtree(folder)
|
||||
return {"removed": True}
|
||||
Path(args["anchor"]).mkdir(parents=True)
|
||||
return {"game_dir": str(folder), "exe": str(folder / "Game.exe"), "title": "Demo",
|
||||
"launcher": str(Path(args["anchor"]) / "launch.sh")}
|
||||
monkeypatch.setattr(agent, "fex_helper", helper)
|
||||
monkeypatch.setattr(agent, "pcvr_pids", lambda _: [])
|
||||
agent.cmd_fex_install({"app": "demo", "package": "rift.demo", "source": "local_copy"})
|
||||
dep = agent.deployment("rift.demo")
|
||||
assert dep["source"] == "local_copy" and dep["app_id"] == "123" and dep["skipped"] == ["Data.bin"]
|
||||
assert agent.cmd_uninstall({"package": "rift.demo"})["removed"]
|
||||
assert "unregister_local" in calls and not folder.exists()
|
||||
monkeypatch.setattr(agent, "cmd_drives", lambda _: {"drives": []})
|
||||
with pytest.raises(agent.AgentError, match="available external drive"):
|
||||
agent.cmd_fex_prepare_local({"app": "demo", "package": "rift.demo", "dest": "/missing", "files": {}})
|
||||
|
||||
|
||||
def test_cli_and_real_orchestration_with_fake_transfer(monkeypatch, game):
|
||||
from frameport import cli
|
||||
from frameport.core import library
|
||||
from frameport.install import installer
|
||||
monkeypatch.setenv("FRAMEPORT_EXPERIMENTAL_FEX", "1")
|
||||
monkeypatch.setattr(library, "load", lambda: {"games": {"rift.demo": entry(game)}})
|
||||
calls, transfers = [], []
|
||||
class Frame:
|
||||
def agent(self, cmd, **kwargs):
|
||||
calls.append((cmd, kwargs))
|
||||
if cmd == "fex_prepare":
|
||||
return {"artifacts": "/frame/artifacts"}
|
||||
if cmd == "fex_prepare_local":
|
||||
return {"game_dir": "/prefix/game", "existing": {"content.bin": 100}, "free_bytes": 20 << 30}
|
||||
if cmd == "fex_install":
|
||||
return {"verified": False}
|
||||
if cmd == "fex_runtime_state":
|
||||
return {"runtime_state": "ready"}
|
||||
return {}
|
||||
frame = Frame()
|
||||
monkeypatch.setattr(cli, "_fex_target", lambda _: types.SimpleNamespace(frame=frame))
|
||||
@contextmanager
|
||||
def link(fr, reporter, need):
|
||||
assert fr is frame
|
||||
yield "fast link"
|
||||
monkeypatch.setattr(installer, "transfer_link", link)
|
||||
monkeypatch.setattr(installer, "upload_files", lambda *args: transfers.append(args))
|
||||
result = CliRunner().invoke(cli.app, ["fex", "install", "--from-library", "rift.demo", "--force-unverified",
|
||||
"--no-steam", "--runtime-check"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert [c[0] for c in calls] == ["fex_prepare", "fex_prepare_local", "fex_register_local", "fex_install",
|
||||
"fex_runtime_state"]
|
||||
# first upload: the helper scripts (a fixed helper must reach a Frame set up earlier), then the game
|
||||
assert transfers[0][2] == "/frame/artifacts" and {r for _, r, _ in transfers[0][1]} >= {"fexrift.py"}
|
||||
assert transfers[1][0] == "fast link"
|
||||
assert "content.bin" not in [r for _, r, _ in transfers[1][1]]
|
||||
assert calls[2][1]["app_id"] == "1184903171584429"
|
||||
calls.clear()
|
||||
monkeypatch.setattr(fexinstall.rift, "analyze", lambda _: types.SimpleNamespace(
|
||||
extra={"exe": "Game/Game.exe", "exe_confirmed": True}))
|
||||
result = CliRunner().invoke(cli.app, ["fex", "install", "--folder", str(game), "--title", "Local demo",
|
||||
"--force-unverified", "--no-steam", "--dest", "/mnt/card"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert calls[1][1]["dest"] == "/mnt/card" and calls[1][1]["app"] == "local-demo"
|
||||
result = CliRunner().invoke(cli.app, ["fex", "install", "--folder", str(game), "--from-library", "rift.demo"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_no_space_cancel_and_unverified_before_upload(monkeypatch, game):
|
||||
from frameport.core import library
|
||||
monkeypatch.setenv("FRAMEPORT_EXPERIMENTAL_FEX", "1")
|
||||
monkeypatch.setattr(library, "load", lambda: {"games": {"rift.demo": entry(game)}})
|
||||
from frameport.install import installer
|
||||
monkeypatch.setattr(installer, "upload_files", lambda *args: None)
|
||||
frame = types.SimpleNamespace(agent=lambda *a, **kw: {"game_dir": "/game", "existing": {}, "free_bytes": 0,
|
||||
"artifacts": "/frame/artifacts"})
|
||||
with pytest.raises(ValueError, match="unverified"):
|
||||
fexinstall.install(frame, Reporter(), from_library="rift.demo")
|
||||
with pytest.raises(RuntimeError, match="not enough space"):
|
||||
fexinstall.install(frame, Reporter(), from_library="rift.demo", force_unverified=True)
|
||||
reporter = Reporter()
|
||||
reporter.cancelled.set()
|
||||
with pytest.raises(Cancelled):
|
||||
fexinstall.install(frame, reporter, from_library="rift.demo", force_unverified=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["ready", "install_available"])
|
||||
def test_runtime_state_uses_only_new_lines_and_reuses_server(fx, monkeypatch, state):
|
||||
probe = fx.COMMANDS["runtime_state"]
|
||||
sub = probe.__globals__["subprocess"]
|
||||
log = Path(fx.USER_DIR) / "AppData/Local/Oculus/Service_123.txt"
|
||||
log.parent.mkdir(parents=True)
|
||||
log.write_text("demo state: ready\n") # old state must not be used
|
||||
calls = []
|
||||
def run(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
with log.open("a") as f:
|
||||
f.write(f"demo state: {state}\n")
|
||||
return types.SimpleNamespace(returncode=0)
|
||||
monkeypatch.setattr(sub, "run", run)
|
||||
result = probe({"app": "demo"})
|
||||
assert result == {"runtime_state": state, "reused_server": True}
|
||||
assert len(calls) == 1 and calls[0][0] == "pgrep" # never stop a runtime we didn't start
|
||||
|
||||
|
||||
def test_runtime_state_starts_and_stops_own_server(fx, monkeypatch):
|
||||
probe = fx.COMMANDS["runtime_state"]
|
||||
sub = probe.__globals__["subprocess"]
|
||||
log = Path(fx.USER_DIR) / "AppData/Local/Oculus/OVRServer.log"
|
||||
log.parent.mkdir(parents=True)
|
||||
calls = []
|
||||
def run(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
return types.SimpleNamespace(returncode=1)
|
||||
def start(argv, **kwargs):
|
||||
calls.append(argv)
|
||||
assert kwargs["stdout"] == sub.DEVNULL and kwargs["stderr"] == sub.DEVNULL
|
||||
log.write_text("demo state: ready\n")
|
||||
return types.SimpleNamespace(poll=lambda: None, wait=lambda **kw: None)
|
||||
monkeypatch.setattr(sub, "run", run)
|
||||
monkeypatch.setattr(sub, "Popen", start)
|
||||
monkeypatch.setattr(fx, "wine_env", lambda: {})
|
||||
result = probe({"app": "demo"})
|
||||
assert result == {"runtime_state": "ready", "reused_server": False}
|
||||
assert calls[-1][-1] == "-k"
|
||||
|
||||
|
||||
def test_large_inventory_sent_to_helper_on_stdin(monkeypatch, tmp_path):
|
||||
agent = load(ROOT / "agent/frameport_agent.py")
|
||||
helper = tmp_path / "fexrift.py"
|
||||
helper.touch()
|
||||
monkeypatch.setattr(agent, "FEX_HELPER", str(helper))
|
||||
received = []
|
||||
def run(argv, **kwargs):
|
||||
received.append((argv, kwargs))
|
||||
return types.SimpleNamespace(stdout='{"ok":true,"result":{}}')
|
||||
monkeypatch.setattr(agent, "run", run)
|
||||
agent.fex_helper("prepare_local", {"files": {"x": "x" * 200000}})
|
||||
assert received[0][0][-1] == "prepare_local"
|
||||
assert len(received[0][1]["input"]) > 200000
|
||||
|
||||
|
||||
def test_runtime_state_installed_from_package_manager_line(fx, monkeypatch):
|
||||
"""The PackageManager start-up listing (seen on the Frame) counts as registered when no state line comes."""
|
||||
probe = fx.COMMANDS["runtime_state"]
|
||||
sub = probe.__globals__["subprocess"]
|
||||
log = Path(fx.USER_DIR) / "AppData/Local/Oculus/Service_123.txt"
|
||||
log.parent.mkdir(parents=True)
|
||||
log.write_text("")
|
||||
|
||||
def run(argv, **kwargs):
|
||||
with log.open("a") as f:
|
||||
f.write("15/10 20:37:03.294 [ 404] {INFO} [PackageManager] Currently installed package: demo-other, version code: 1\n"
|
||||
"15/10 20:37:03.294 [ 404] {INFO} [PackageManager] Currently installed package: demo, version code: 1\n")
|
||||
return types.SimpleNamespace(returncode=0)
|
||||
monkeypatch.setattr(sub, "run", run)
|
||||
assert probe({"app": "demo"})["runtime_state"] == "installed"
|
||||
|
||||
|
||||
def test_folder_in_a_meta_library_uses_metas_manifest(tmp_path):
|
||||
"""C:\\Program Files\\Meta Horizon\\Software layout: Meta's manifest gives exe, -vr arguments, ID and version."""
|
||||
lib = tmp_path / "Meta Horizon" / "Software"
|
||||
game = lib / "Software" / "oculus-dreamdeck"
|
||||
exe = game / "WindowsNoEditor/Dreamdeck/Binaries/Win64/Dreamdeck-Win64-Shipping.exe"
|
||||
exe.parent.mkdir(parents=True)
|
||||
exe.write_bytes(b"MZ" + b"\0" * 64)
|
||||
(lib / "Manifests").mkdir()
|
||||
(lib / "Manifests" / "oculus-dreamdeck.json").write_text(json.dumps({
|
||||
"canonicalName": "oculus-dreamdeck", "appId": "941682542593981", "version": "1.3",
|
||||
"launchFile": "WindowsNoEditor\\Dreamdeck\\Binaries\\Win64\\Dreamdeck-Win64-Shipping.exe",
|
||||
"launchParameters": "-fullscreen -vr"}), encoding="utf-8")
|
||||
info = fexinstall.source_info(game, title="Oculus Dreamdeck")
|
||||
assert info["exe"] == "WindowsNoEditor/Dreamdeck/Binaries/Win64/Dreamdeck-Win64-Shipping.exe"
|
||||
assert info["args"] == ["-fullscreen", "-vr"] and info["version"] == "1.3"
|
||||
assert (info["app"], info["app_id"], info["app_id_source"]) == ("oculus-dreamdeck", "941682542593981",
|
||||
"meta-manifest")
|
||||
# a manifest for another name is not trusted
|
||||
(lib / "Manifests" / "oculus-dreamdeck.json").write_text(json.dumps({"canonicalName": "other", "appId": "1",
|
||||
"launchFile": "x.exe"}))
|
||||
assert fexinstall.meta_manifest(game) is None
|
||||
|
||||
|
||||
def test_launch_args_from_the_pcs_meta_library(tmp_path, monkeypatch):
|
||||
"""A copy outside Meta's library gets the launch arguments of the same build in the user's Meta app library."""
|
||||
from frameport.core import winhost
|
||||
lib = tmp_path / "Meta Horizon" / "Software"
|
||||
(lib / "Manifests").mkdir(parents=True)
|
||||
(lib / "Manifests" / "oculus-dreamdeck.json").write_text(json.dumps({
|
||||
"canonicalName": "oculus-dreamdeck", "appId": "941682542593981", "launchParameters": "-fullscreen -vr",
|
||||
"launchFile": "WindowsNoEditor\\Dreamdeck\\Binaries\\Win64\\Dreamdeck-Win64-Shipping.exe"}))
|
||||
monkeypatch.setattr(winhost, "meta_libraries", lambda: [lib])
|
||||
m = fexinstall.library_manifest("941682542593981", None, "x/Dreamdeck-Win64-Shipping.exe")
|
||||
assert m and m["launchParameters"] == "-fullscreen -vr"
|
||||
assert fexinstall.library_manifest("941682542593981", None, "x/Other-Win64-Shipping.exe") is None # other build
|
||||
assert fexinstall.library_manifest("1", "other", "x/Dreamdeck-Win64-Shipping.exe") is None
|
||||
|
||||
|
||||
def test_meta_libraries_on_this_pc():
|
||||
"""Real lookup on a PC with Meta's app (skipped elsewhere): every result holds a Manifests folder."""
|
||||
from frameport.core import winhost
|
||||
libs = winhost.meta_libraries()
|
||||
if not libs:
|
||||
pytest.skip("no Meta app library on this PC")
|
||||
assert all((p / "Manifests").is_dir() for p in libs)
|
||||
@@ -1,483 +0,0 @@
|
||||
"""Offline sign-in tests. Fake credentials must never reach files, logs, or Wine/SSH argv."""
|
||||
import copy
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli, fexlogin, urlhandler
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PAGE = "https://auth.meta.com/native_sso/confirm?native_sso_etoken=FAKE_SECRET_DO_NOT_PERSIST"
|
||||
CALLBACK = "oculus://link/frl_login?native_sso_etoken=FAKE_SECRET_DO_NOT_PERSIST"
|
||||
|
||||
|
||||
def load(path, name):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
|
||||
monkeypatch.setattr("os.getuid", lambda: 1000, raising=False) # Frame-only API; tests also run on Windows.
|
||||
artifacts = tmp_path / "artifacts"
|
||||
artifacts.mkdir()
|
||||
for name in ("fexrift.py", "fexrift_games.json", "fexrift_login.py"):
|
||||
shutil.copyfile(ROOT / "agent" / name, artifacts / name)
|
||||
core = load(artifacts / "fexrift.py", "login_core")
|
||||
core.ROOT = str(tmp_path / "r") # Unix socket paths are limited to 108 bytes on Linux.
|
||||
module = load(artifacts / "fexrift_login.py", "login_extension")
|
||||
module.register(core)
|
||||
monkeypatch.setattr(core, "wine", lambda *a, **kw: SimpleNamespace(stdout="", returncode=0))
|
||||
monkeypatch.setattr(core, "wineserver_kill", lambda: None)
|
||||
monkeypatch.setattr(core, "steam_display_env", lambda: {"DISPLAY": ":1"})
|
||||
module.folder().mkdir(parents=True)
|
||||
return module
|
||||
|
||||
|
||||
def test_extension_discovery(ext):
|
||||
ext.CORE.load_extensions()
|
||||
assert "login" in ext.CORE.LOADED_EXTENSIONS
|
||||
assert {"login_start", "login_status", "login_stop"} <= ext.CORE.COMMANDS.keys()
|
||||
|
||||
|
||||
def assert_no_credentials(path):
|
||||
for p in path.rglob("*"):
|
||||
if p.is_file():
|
||||
assert b"FAKE_SECRET_DO_NOT_PERSIST" not in p.read_bytes(), p
|
||||
|
||||
|
||||
def test_open_url_helper_and_state_never_persist_query(ext, monkeypatch):
|
||||
broker = ext.Broker(False)
|
||||
seen = []
|
||||
monkeypatch.setattr(ext, "quiet", lambda argv, **kw: seen.append((argv, kw)))
|
||||
# Execute the generated helper with an in-memory transport (the build sandbox disallows socket.bind).
|
||||
class Socket:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def settimeout(self, timeout):
|
||||
pass
|
||||
|
||||
def connect(self, path):
|
||||
assert path == str(ext.folder() / "broker.sock")
|
||||
|
||||
def sendall(self, msg):
|
||||
self.reply = broker.handle(json.loads(msg))
|
||||
|
||||
def shutdown(self, how):
|
||||
pass
|
||||
|
||||
def recv(self, size):
|
||||
return json.dumps(self.reply).encode()
|
||||
|
||||
monkeypatch.setattr(ext.socket, "socket", lambda *a: Socket())
|
||||
monkeypatch.setattr(sys, "argv", ["fp-open-url", PAGE])
|
||||
helper = ext.folder() / "fp-open-url"
|
||||
helper.write_text(ext.helper_script("page"))
|
||||
exec(compile(helper.read_text(), str(helper), "exec"), {})
|
||||
state = json.loads((ext.folder() / "state.json").read_text())
|
||||
assert state["page_url"] == PAGE.split("?")[0] and state["page_opened_at"] > 0
|
||||
assert seen[0][0] == ["xdg-open", PAGE]
|
||||
assert_no_credentials(ext.folder())
|
||||
|
||||
|
||||
def test_memory_only_fallback_and_status_transitions(ext, monkeypatch):
|
||||
broker = ext.Broker(True)
|
||||
monkeypatch.setattr(ext, "running", lambda name: True)
|
||||
monkeypatch.setattr(ext, "request", broker.handle)
|
||||
assert ext.status({}) == {"runtime_running": True, "client_running": True, "page_opened_at": None,
|
||||
"callback_received_at": None, "signed_in": False}
|
||||
assert broker.handle({"op": "page", "url": PAGE})["accepted"]
|
||||
assert "open_url" not in ext.status({})
|
||||
assert ext.status({"take_url": True})["open_url"] == PAGE
|
||||
assert "open_url" not in ext.status({"take_url": True})
|
||||
monkeypatch.setattr(ext, "deliver", lambda url: {"delivered": url == CALLBACK})
|
||||
assert broker.handle({"op": "callback", "url": CALLBACK}) == {"delivered": True}
|
||||
sessions = Path(ext.CORE.PFX) / "drive_c/users/steamuser/AppData/Roaming/Oculus/sessions"
|
||||
sessions.mkdir(parents=True)
|
||||
# real layout: Meta's app creates sessions/_oaf when the runtime starts, a sign-in adds sessions/<user id>/
|
||||
(sessions / "_oaf").mkdir()
|
||||
(sessions / "_oaf" / "data.sqlite").write_text("framework")
|
||||
(sessions / "empty").touch()
|
||||
assert not ext.status({})["signed_in"]
|
||||
(sessions / "1234567890-1275611841").mkdir()
|
||||
(sessions / "1234567890-1275611841" / "data.sqlite").write_text("test session")
|
||||
st = ext.status({})
|
||||
assert st["signed_in"] and st["page_opened_at"] and st["callback_received_at"]
|
||||
assert_no_credentials(ext.folder())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", ["https://evil.test/?secret=x", "oculus://evil/frl_login?x",
|
||||
"https://auth.meta.com@evil.test/native_sso/confirm?x", CALLBACK + "\n"])
|
||||
def test_reject_other_links_without_echoing(ext, url):
|
||||
assert not ext.Broker(True).handle({"op": "page", "url": url})["accepted"]
|
||||
assert not ext.deliver(url)["delivered"]
|
||||
|
||||
|
||||
def test_callback_goes_to_pe_stdin(ext, monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(ext, "quiet", lambda argv, **kw: calls.append((argv, kw)) or SimpleNamespace(returncode=0))
|
||||
assert ext.deliver(CALLBACK) == {"delivered": True}
|
||||
argv, kw = calls[0]
|
||||
assert CALLBACK not in repr(argv) and kw["input"] == CALLBACK.encode()
|
||||
assert "LD_PRELOAD" not in kw["env"] and kw["env"]["ENABLE_GAMESCOPE_WSI"] == "0"
|
||||
|
||||
|
||||
def test_start_generates_helpers_and_stop_restores_exactly(ext, monkeypatch, tmp_path):
|
||||
(Path(ext.CORE.HERE) / "fp_pipesend.exe").touch()
|
||||
client = Path(ext.CORE.META_DIR) / "Support/oculus-client/Client.exe"
|
||||
client.parent.mkdir(parents=True)
|
||||
client.touch()
|
||||
apps, _ = ext.desktop_paths()
|
||||
apps.mkdir(parents=True)
|
||||
desktop = apps / ext.DESKTOP
|
||||
desktop.write_bytes(b"previous desktop\r\n")
|
||||
mime = tmp_path / "config/mimeapps.list"
|
||||
mime.parent.mkdir(parents=True)
|
||||
before = b"[Default Applications]\r\nx-scheme-handler/oculus=meta.desktop;\r\n"
|
||||
mime.write_bytes(before)
|
||||
calls, active = [], []
|
||||
|
||||
def wine(*args, **kw):
|
||||
calls.append(args)
|
||||
return SimpleNamespace(stdout=" Browsers REG_EXPAND_SZ /previous browser --flag\n", returncode=0)
|
||||
|
||||
def quiet(argv, **kw):
|
||||
if argv[:2] == ["xdg-mime", "default"]:
|
||||
mime.write_text("temporary handler")
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
def request(msg):
|
||||
if active and msg["op"] == "ping":
|
||||
return {"ready": True}
|
||||
raise OSError("no broker")
|
||||
|
||||
monkeypatch.setattr(ext.CORE, "wine", wine)
|
||||
monkeypatch.setattr(ext, "running", lambda name: False)
|
||||
monkeypatch.setattr(ext, "request", request)
|
||||
monkeypatch.setattr(ext, "quiet", quiet)
|
||||
monkeypatch.setattr(ext.subprocess, "Popen", lambda *a, **kw: active.append(True))
|
||||
monkeypatch.setattr(ext.subprocess, "run", lambda *a, **kw: SimpleNamespace(stdout="meta.desktop\n"))
|
||||
result = ext.start({"timeout": 900})
|
||||
assert not result["started"]
|
||||
entry = desktop.read_text()
|
||||
assert 'Exec="' in entry and "fp-oculus-callback\" %u" in entry
|
||||
assert "NoDisplay=true" in entry and "MimeType=x-scheme-handler/oculus;" in entry
|
||||
script = (ext.folder() / "launch.sh").read_text()
|
||||
assert "OVRServer_x64.exe" in script and "[O]VRLibrarian.exe" in script and "run ./Client.exe" in script
|
||||
assert "exec >/dev/null 2>&1" in script and "unset LD_PRELOAD" in script
|
||||
assert ext.stop({})["stopped"] and ext.stop({})["stopped"]
|
||||
assert mime.read_bytes() == before and desktop.read_bytes() == b"previous desktop\r\n"
|
||||
assert any("/previous browser --flag" in a and "REG_EXPAND_SZ" in a for a in calls)
|
||||
assert_no_credentials(ext.folder())
|
||||
|
||||
|
||||
def test_missing_pe_has_actionable_error(ext):
|
||||
with pytest.raises(ext.CORE.FexError, match="fp_pipesend.exe missing"):
|
||||
ext.start({})
|
||||
|
||||
|
||||
def test_desktop_handler_exec_uses_the_existing_desktop_quoting_rules(ext):
|
||||
agent = load(ROOT / "agent/frameport_agent.py", "login_quoting_agent")
|
||||
helper = '/tmp/login $d "quoted" \\50%/fp-oculus-callback'
|
||||
entry = ext.desktop_entry(helper)
|
||||
assert f"Exec={agent.desktop_exec_arg(helper)} %u\n" in entry
|
||||
|
||||
|
||||
def test_cleanup_keeps_sessions_and_retries_after_failure(ext, monkeypatch):
|
||||
path = ext.folder() / "restore.json"
|
||||
path.write_text(json.dumps({"browser": None, "files": {}}))
|
||||
sessions = Path(ext.CORE.PFX) / "drive_c/users/steamuser/AppData/Roaming/Oculus/sessions"
|
||||
sessions.mkdir(parents=True)
|
||||
(sessions / "1234567890-1275611841").mkdir()
|
||||
(sessions / "1234567890-1275611841" / "fake").write_bytes(b"test session")
|
||||
monkeypatch.setattr(ext, "request", lambda msg: (_ for _ in ()).throw(OSError()))
|
||||
monkeypatch.setattr(ext.CORE, "wineserver_kill", lambda: (_ for _ in ()).throw(OSError()))
|
||||
with pytest.raises(OSError):
|
||||
ext.stop({})
|
||||
assert path.exists()
|
||||
monkeypatch.setattr(ext.CORE, "wineserver_kill", lambda: None)
|
||||
assert ext.stop({})["signed_in"] and not path.exists()
|
||||
assert ext.stop({})["signed_in"] and (sessions / "1234567890-1275611841" / "fake").read_bytes() == b"test session"
|
||||
|
||||
|
||||
def test_stop_signals_launcher_before_wine_and_registry(ext, monkeypatch):
|
||||
(ext.folder() / "launch.pid").write_text("12345 98765")
|
||||
(ext.folder() / "restore.json").write_text(json.dumps({"browser": None, "files": {}}))
|
||||
events = []
|
||||
ticks = iter(["98765", None])
|
||||
monkeypatch.setattr(ext, "process_tick", lambda pid: next(ticks))
|
||||
monkeypatch.setattr(ext.os, "kill", lambda pid, sig: events.append("launcher"))
|
||||
monkeypatch.setattr(ext.CORE, "wineserver_kill", lambda: events.append("wine"))
|
||||
monkeypatch.setattr(ext.CORE, "wine", lambda *a, **kw: events.append("registry"))
|
||||
ext.restore()
|
||||
assert events == ["launcher", "wine", "registry"]
|
||||
assert not (ext.folder() / "launch.pid").exists()
|
||||
|
||||
|
||||
def test_stop_does_not_signal_a_reused_pid(ext, monkeypatch):
|
||||
(ext.folder() / "launch.pid").write_text("12345 98765")
|
||||
monkeypatch.setattr(ext, "process_tick", lambda pid: "other start time")
|
||||
monkeypatch.setattr(ext.os, "kill", lambda *a: pytest.fail("must not signal an unrelated process"))
|
||||
assert ext.stop_launcher() is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32" or not shutil.which("bash"), reason="needs bash")
|
||||
def test_generated_launcher_shell_syntax(ext):
|
||||
script = ext.folder() / "launch.sh"
|
||||
script.write_text(ext.launcher())
|
||||
result = ext.subprocess.run(["bash", "-n", str(script)], capture_output=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_broker_expiry_restores_registration(ext, monkeypatch):
|
||||
events = []
|
||||
|
||||
class Socket:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def bind(self, path):
|
||||
Path(path).touch()
|
||||
|
||||
def listen(self, count):
|
||||
pass
|
||||
|
||||
def settimeout(self, timeout):
|
||||
pass
|
||||
|
||||
ticks = iter([0, 2])
|
||||
monkeypatch.setattr(ext.time, "monotonic", lambda: next(ticks))
|
||||
monkeypatch.setattr(ext.socket, "socket", lambda *a: Socket())
|
||||
monkeypatch.setattr(ext, "restore", lambda: events.append("restored"))
|
||||
assert ext.broker({"timeout": 1, "via_pc": True})["stopped"]
|
||||
assert events == ["restored"] and not (ext.folder() / "broker.sock").exists()
|
||||
|
||||
|
||||
def test_pc_protocol_callback_posts_into_memory_and_restores(monkeypatch, tmp_path):
|
||||
events, commands, handlers = [], [], []
|
||||
|
||||
class Server:
|
||||
def __init__(self, address, handler):
|
||||
self.server_address = ("127.0.0.1", 12345)
|
||||
handlers.append(handler)
|
||||
|
||||
def serve_forever(self):
|
||||
pass
|
||||
|
||||
def shutdown(self):
|
||||
events.append("shutdown")
|
||||
|
||||
def server_close(self):
|
||||
events.append("closed")
|
||||
|
||||
@contextmanager
|
||||
def registration(command):
|
||||
commands.append(command)
|
||||
events.append("registered")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append("restored")
|
||||
|
||||
def urlopen(req, **kw):
|
||||
handler = handlers[0].__new__(handlers[0])
|
||||
handler.path = req.selector
|
||||
handler.headers = {"Content-Length": str(len(req.data))}
|
||||
handler.rfile = io.BytesIO(req.data)
|
||||
handler.send_response = lambda code: events.append(code)
|
||||
handler.end_headers = lambda: None
|
||||
handler.do_POST()
|
||||
return SimpleNamespace(close=lambda: None)
|
||||
|
||||
monkeypatch.setattr(fexlogin, "ThreadingHTTPServer", Server)
|
||||
monkeypatch.setattr(urlhandler, "platform_kind", lambda: "linux")
|
||||
monkeypatch.setattr(urlhandler, "temporary_oculus_handler", registration)
|
||||
monkeypatch.setattr(fexlogin, "user_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(fexlogin.urllib.request, "urlopen", urlopen)
|
||||
with fexlogin.pc_callback() as inbox:
|
||||
# Run the generated Python protocol helper, retaining its real HTTP request construction.
|
||||
script = next(tmp_path.rglob("callback.py"))
|
||||
monkeypatch.setattr(sys, "argv", [str(script), CALLBACK])
|
||||
exec(compile(script.read_text(), str(script), "exec"), {})
|
||||
assert inbox.get_nowait() == CALLBACK and commands[0].endswith(" %u")
|
||||
assert_no_credentials(tmp_path)
|
||||
assert events == ["registered", 204, "restored", "shutdown", "closed"]
|
||||
assert not list(tmp_path.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exists", [True, False])
|
||||
@pytest.mark.parametrize("kind", ["windows", "wsl"])
|
||||
def test_temporary_windows_handler_exact_tree_restore(tmp_path, monkeypatch, exists, kind):
|
||||
monkeypatch.setattr(urlhandler, "platform_kind", lambda: kind)
|
||||
monkeypatch.setattr(urlhandler, "user_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(urlhandler.winhost, "to_windows", str)
|
||||
# Unknown binary values and subkeys must be restored too, not just shell/open/command.
|
||||
old = {"values": {"": [1, "Meta"], "URL Protocol": [1, ""], "binary": [3, [0, 255]]},
|
||||
"shell": {"open": {"command": [2, '"%MetaHome%/Client.exe" "%1"']}}, "other": {"x": [4, 5]}}
|
||||
tree = copy.deepcopy(old) if exists else None
|
||||
|
||||
def reg(argv, **kw):
|
||||
nonlocal tree
|
||||
op = argv[1]
|
||||
if op == "query":
|
||||
return SimpleNamespace(returncode=0 if tree is not None else 1)
|
||||
if op == "export":
|
||||
Path(argv[3]).write_text(json.dumps(tree))
|
||||
elif op == "delete":
|
||||
tree = None
|
||||
elif op == "import":
|
||||
tree = json.loads(Path(argv[2]).read_text())
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
def register(scheme, command):
|
||||
nonlocal tree
|
||||
assert scheme == "oculus"
|
||||
tree = {"temporary": command}
|
||||
|
||||
monkeypatch.setattr(urlhandler.winhost, "run_win", reg)
|
||||
monkeypatch.setattr(urlhandler, "_reg_set", register)
|
||||
with pytest.raises(RuntimeError, match="simulate interruption"):
|
||||
with urlhandler.temporary_oculus_handler("memory-only callback"):
|
||||
assert tree == {"temporary": "memory-only callback"}
|
||||
raise RuntimeError("simulate interruption")
|
||||
# only the handler's own files (another test's background thread may create folders in the data dir meanwhile)
|
||||
assert tree == (old if exists else None) and not list(tmp_path.glob("oculus-handler*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exists", [True, False])
|
||||
def test_temporary_linux_handler_restores_default_and_file(tmp_path, monkeypatch, exists):
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
|
||||
monkeypatch.setattr(urlhandler, "platform_kind", lambda: "linux")
|
||||
monkeypatch.setattr(urlhandler.shutil, "which", lambda cmd: "/bin/" + cmd)
|
||||
mime = tmp_path / "config/mimeapps.list"
|
||||
mime.parent.mkdir()
|
||||
before = b"[Default Applications]\r\nx-scheme-handler/oculus=meta.desktop;\r\n"
|
||||
if exists:
|
||||
mime.write_bytes(before)
|
||||
|
||||
def run(argv, **kw):
|
||||
mime.write_text("temporary mime default")
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
monkeypatch.setattr(urlhandler.subprocess, "run", run)
|
||||
with urlhandler.temporary_oculus_handler('"python" -I "/tmp/callback.py" %u'):
|
||||
assert "oculus;" in (urlhandler._desktop_path().parent / "frameport-oculus-login.desktop").read_text()
|
||||
assert mime.read_bytes() == before if exists else not mime.exists()
|
||||
assert not (urlhandler._desktop_path().parent / "frameport-oculus-login.desktop").exists()
|
||||
|
||||
|
||||
class FakeFrame:
|
||||
def __init__(self, states=None, error=None):
|
||||
self.calls = []
|
||||
self.states = iter(states or [{"signed_in": True}])
|
||||
self.error = error
|
||||
|
||||
def agent(self, cmd, **args):
|
||||
self.calls.append((cmd, args))
|
||||
if cmd == "fex_login":
|
||||
return {"started": args.get("start_now"), "package": None if args.get("start_now") else "linux.signin"}
|
||||
if cmd == "fex_login_status":
|
||||
if self.error:
|
||||
raise self.error
|
||||
return next(self.states)
|
||||
if cmd == "fex_login_callback":
|
||||
return {"delivered": True}
|
||||
return {"stopped": True}
|
||||
|
||||
|
||||
def test_cli_success_with_fake_agent(monkeypatch):
|
||||
from frameport.install import installer
|
||||
|
||||
monkeypatch.setenv("FRAMEPORT_EXPERIMENTAL_FEX", "1")
|
||||
frame = FakeFrame([{"page_opened_at": 1}, {"signed_in": True, "callback_received_at": 2}])
|
||||
monkeypatch.setattr(cli, "_fex_target", lambda f: SimpleNamespace(frame=frame))
|
||||
monkeypatch.setattr(fexlogin.time, "sleep", lambda n: None)
|
||||
added = []
|
||||
monkeypatch.setattr(installer, "add_to_steam", lambda f, pkgs, rep: added.extend(pkgs) or {"state": "done"})
|
||||
res = CliRunner().invoke(cli.app, ["fex", "login", "--timeout", "25"])
|
||||
assert res.exit_code == 0, res.output
|
||||
assert "Put on the headset" in res.output and "Signed in." in res.output
|
||||
assert added == ["linux.signin"]
|
||||
assert frame.calls[0][1]["login_timeout"] == 25 and frame.calls[-1][0] == "fex_login_cancel"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error", [RuntimeError("SSH lost"), KeyboardInterrupt()])
|
||||
def test_cli_flow_cleanup_on_exception(monkeypatch, error):
|
||||
monkeypatch.setenv("FRAMEPORT_EXPERIMENTAL_FEX", "1")
|
||||
frame = FakeFrame(error=error)
|
||||
with pytest.raises(type(error)):
|
||||
fexlogin.login(frame, start_now=True, emit=lambda s: None)
|
||||
assert frame.calls[-1][0] == "fex_login_cancel"
|
||||
|
||||
|
||||
def test_timeout_and_stop(monkeypatch):
|
||||
monkeypatch.setenv("FRAMEPORT_EXPERIMENTAL_FEX", "1")
|
||||
ticks = iter([0, 1, 3])
|
||||
monkeypatch.setattr(fexlogin.time, "monotonic", lambda: next(ticks))
|
||||
monkeypatch.setattr(fexlogin.time, "sleep", lambda n: None)
|
||||
frame = FakeFrame([{}])
|
||||
with pytest.raises(RuntimeError, match="timed out"):
|
||||
fexlogin.login(frame, start_now=True, timeout=2, emit=lambda s: None)
|
||||
assert frame.calls[-1][0] == "fex_login_cancel"
|
||||
frame = FakeFrame()
|
||||
fexlogin.login(frame, stop=True, emit=lambda s: None)
|
||||
assert frame.calls == [("fex_login_cancel", {})]
|
||||
|
||||
|
||||
def test_via_pc_forwards_callback_in_agent_stdin_and_cleans_up(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("FRAMEPORT_EXPERIMENTAL_FEX", "1")
|
||||
events, opened, messages = [], [], []
|
||||
|
||||
@contextmanager
|
||||
def callback():
|
||||
events.append("registered")
|
||||
inbox = queue.Queue()
|
||||
inbox.put(CALLBACK)
|
||||
try:
|
||||
yield inbox
|
||||
finally:
|
||||
events.append("restored")
|
||||
|
||||
monkeypatch.setattr(fexlogin, "pc_callback", callback)
|
||||
monkeypatch.setattr(fexlogin, "open_page", opened.append)
|
||||
monkeypatch.setattr(fexlogin.time, "sleep", lambda n: None)
|
||||
frame = FakeFrame([{"page_opened_at": 1, "open_url": PAGE}, {"signed_in": True}])
|
||||
fexlogin.login(frame, start_now=True, via_pc=True, emit=messages.append)
|
||||
assert events == ["registered", "restored"] and opened == [PAGE]
|
||||
assert ("fex_login_callback", {"url": CALLBACK}) in frame.calls
|
||||
assert CALLBACK not in "\n".join(messages) and PAGE not in "\n".join(messages)
|
||||
assert_no_credentials(tmp_path)
|
||||
|
||||
|
||||
def test_agent_callback_helper_uses_stdin(monkeypatch, tmp_path):
|
||||
agent = load(ROOT / "agent/frameport_agent.py", "login_agent")
|
||||
helper = tmp_path / "fexrift.py"
|
||||
helper.touch()
|
||||
monkeypatch.setattr(agent, "FEX_HELPER", str(helper))
|
||||
calls = []
|
||||
monkeypatch.setattr(agent.subprocess, "run", lambda argv, **kw: calls.append((argv, kw)) or
|
||||
SimpleNamespace(stdout=json.dumps({"ok": True, "result": {"delivered": True}})))
|
||||
assert agent.cmd_fex_login_callback({"url": CALLBACK})["delivered"]
|
||||
assert CALLBACK not in repr(calls[0][0]) and json.loads(calls[0][1]["input"])["url"] == CALLBACK
|
||||
@@ -1,501 +0,0 @@
|
||||
"""Offline launch settings, bounded bench and fp_mem v3; never loads SteamVR or launches Wine."""
|
||||
import ctypes
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def perf(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
artifacts = tmp_path / "artifacts"
|
||||
artifacts.mkdir()
|
||||
for name in ("fexrift.py", "fexrift_perf.py", "fexrift_games.json"):
|
||||
shutil.copyfile(ROOT / "agent" / name, artifacts / name)
|
||||
spec = importlib.util.spec_from_file_location("fex_perf_core", artifacts / "fexrift.py")
|
||||
core = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(core)
|
||||
core.load_extensions()
|
||||
# A registered function's globals are the isolated extension's namespace (no sys.path imports under -I).
|
||||
mod = SimpleNamespace(**core.COMMANDS["bench"].__globals__)
|
||||
return core, mod
|
||||
|
||||
|
||||
def test_settings_defaults_overrides_and_reset(perf):
|
||||
core, p = perf
|
||||
app = "oculus-first-contact"
|
||||
baseline = p.settings_get({"app": app})
|
||||
assert baseline["settings"]["fp_mem"] is True
|
||||
assert baseline["settings"]["bigcache_mb"] == 256
|
||||
assert p.settings_get({"app": "other"})["settings"]["fp_mem"] is False
|
||||
p.settings_set({"app": app, "eye_scale": 1.25, "fp_mem": False, "cores": "0-3,7"})
|
||||
got = core.COMMANDS["settings_get"]({"app": app})
|
||||
assert got["settings"]["eye_scale"] == 1.25 and got["settings"]["fp_mem"] is False
|
||||
p.settings_set({"app": app, "fp_mem": None})
|
||||
assert p.settings_get({"app": app})["settings"]["fp_mem"] is True
|
||||
path = Path(core.ROOT) / "settings" / (app + ".json")
|
||||
assert json.loads(path.read_text()) == {"eye_scale": 1.25, "cores": "0-3,7"}
|
||||
assert "fp_mem" not in p.settings_get({"app": app})["overrides"]
|
||||
core.GAMES[app]["perf"] = {"motion_smoothing": 1}
|
||||
assert p.settings_get({"app": app})["settings"]["motion_smoothing"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("values", [{"eye_scale": True}, {"eye_scale": 1.4}, {"cores": "7-4"},
|
||||
{"cores": "4-7;id"}, {"cores": "8"}, {"ntsync": "false"},
|
||||
{"dxgi_max_frame_latency": -1}, {"dxgi_sync_interval": True},
|
||||
{"bigcache_mb": 0}, {"motion_smoothing": 3}, {"unknown": 1},
|
||||
{"fex": {"FEX_ENV": "LD_PRELOAD=bad"}},
|
||||
{"fex": {"FEX_X87REDUCEDPRECISION": "1;id"}}])
|
||||
def test_reject_settings_without_partial_write(perf, values):
|
||||
core, p = perf
|
||||
with pytest.raises(ValueError):
|
||||
p.settings_set(dict(values, app="test-app", fp_mem_zero=True))
|
||||
assert not Path(p.settings_path("test-app")).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("app", ["../escape", "a/b", "..", "", "a'bad", "a\n", None])
|
||||
def test_app_path_confined(perf, app):
|
||||
_, p = perf
|
||||
with pytest.raises(ValueError):
|
||||
p.settings_get({"app": app})
|
||||
|
||||
|
||||
def pe_revive():
|
||||
# One synthetic .text section, same RVA->file-offset shift as 3.2.0. Tests don't need a proprietary DLL.
|
||||
d = bytearray(0x13000)
|
||||
d[:2] = b"MZ"
|
||||
struct.pack_into("<I", d, 0x3c, 0x80)
|
||||
d[0x80:0x84] = b"PE\0\0"
|
||||
struct.pack_into("<H", d, 0x86, 1)
|
||||
struct.pack_into("<H", d, 0x94, 0xf0)
|
||||
struct.pack_into("<8sIIII", d, 0x80 + 24 + 0xf0, b".text", 0x13000, 0x1000, 0x13000, 0x400)
|
||||
d[0x12291] = 0xc3
|
||||
d[0x12292:0x122a0] = b"\xcc" * 14
|
||||
d[0x122ab:0x122b3] = bytes.fromhex("f30f102515bb0400")
|
||||
return bytes(d)
|
||||
|
||||
|
||||
def test_eye_scale_changes_only_instruction_and_padding(perf):
|
||||
_, p = perf
|
||||
d = pe_revive()
|
||||
for scale in (1.0, 1.25, 1.5):
|
||||
out = p.eye_variant(d, scale)
|
||||
assert struct.unpack_from("<f", out, 0x1229c)[0] == scale
|
||||
assert out[0x122ab:0x122af] == bytes.fromhex("f30f1025")
|
||||
assert 0x12eb3 + struct.unpack_from("<i", out, 0x122af)[0] == 0x12e9c
|
||||
assert all(a == b for i, (a, b) in enumerate(zip(d, out, strict=True))
|
||||
if not (0x1229c <= i < 0x122a0 or 0x122af <= i < 0x122b3))
|
||||
bad = bytearray(d)
|
||||
bad[0x1229c] = 0x90
|
||||
with pytest.raises(ValueError, match="instruction/padding"):
|
||||
p.eye_variant(bad, 1.25)
|
||||
|
||||
|
||||
def test_prepare_scales_hardlinks_and_private_dll(perf, monkeypatch):
|
||||
core, p = perf
|
||||
src = Path(core.REVIVE)
|
||||
src.mkdir(parents=True)
|
||||
(src / "LibRevive64.dll").write_bytes(pe_revive())
|
||||
(src / "ReviveInjector.exe").write_bytes(b"fake injector")
|
||||
# Exercise the real copying/patching after faking only the outer pinned-upload hash check.
|
||||
monkeypatch.setattr(p.hashlib, "sha256", lambda _: SimpleNamespace(hexdigest=lambda:
|
||||
"eda2a449d230832c95734a03e8c83eeb531668ce676693f636e19c315765af4e"))
|
||||
p.prepare_scales()
|
||||
for scale in (1.0, 1.25, 1.5):
|
||||
dest = Path(p.scale_folder(scale))
|
||||
assert os.stat(dest / "ReviveInjector.exe").st_ino == os.stat(src / "ReviveInjector.exe").st_ino
|
||||
assert os.stat(dest / "LibRevive64.dll").st_ino != os.stat(src / "LibRevive64.dll").st_ino
|
||||
assert struct.unpack_from("<f", (dest / "LibRevive64.dll").read_bytes(), 0x1229c)[0] == scale
|
||||
assert (src / "LibRevive64.dll").read_bytes() == pe_revive()
|
||||
|
||||
|
||||
def test_prepare_reads_new_settings_each_launch(perf, tmp_path):
|
||||
core, p = perf
|
||||
app = "oculus-first-contact"
|
||||
folder = Path(p.scale_folder(1.5))
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "LibRevive64.dll").touch()
|
||||
p.settings_set({"app": app, "fp_mem": False, "ntsync": False, "fp_mem_zero": True,
|
||||
"fex": {"FEX_X87REDUCEDPRECISION": 1}, "motion_smoothing": 0})
|
||||
first = p.perf_prepare({"app": app})
|
||||
config = Path(core.ROOT) / "settings" / (app + ".dxvk.conf")
|
||||
# all DXVK settings at their defaults: no config at all (= the launch verified in the headset)
|
||||
assert not config.exists() and "DXVK_CONFIG_FILE" not in first["shell"]
|
||||
assert f"FP_REVIVE_DIR={core.REVIVE}" in first["shell"] and "WINE_LARGE_ADDRESS_AWARE" not in first["shell"]
|
||||
assert "export FP_MEM=''" in first["shell"] and "PROTON_NO_NTSYNC=1" in first["shell"]
|
||||
assert 'FEX_X87REDUCEDPRECISION=1' in first["shell"] and 'FP_MEM_ZERO=1' in first["shell"]
|
||||
p.settings_set({"app": app, "dxvk_disable_msaa": True, "motion_smoothing": 1})
|
||||
second = p.perf_prepare({"app": app})
|
||||
assert "d3d11.disableMsaa = True" in config.read_text()
|
||||
assert '\"value\": 1' in second["shell"]
|
||||
anchor = tmp_path / "anchor"
|
||||
anchor.mkdir()
|
||||
core.write_launcher(app, core.GAMES[app], str(anchor))
|
||||
text = (anchor / "launch.sh").read_text()
|
||||
assert "perf_prepare" in text and text.index("perf_prepare") < text.index("OVRServer_x64.exe")
|
||||
assert 'taskset -c "$FP_CORES"' in text and '"$FP_REVIVE_DIR/ReviveInjector.exe"' in text
|
||||
assert 'vrsetting "$FP_MOTION_SMOOTHING"' in text
|
||||
subprocess.run(["bash", "-n", str(anchor / "launch.sh")], check=True)
|
||||
|
||||
|
||||
def test_drm_parsing_and_counter_reset(perf, tmp_path):
|
||||
_, p = perf
|
||||
text = "drm-client-id: 72\ndrm-pdev: platform:gpu\ndrm-engine-gpu: 1234 ns\ndrm-engine-copy: 44 ns\n"
|
||||
assert p.drm_engines(text) == {"gpu": 1234, "copy": 44}
|
||||
assert p.drm_engines("drm-engine-gpu: nan ns\ndrm-engine-gpu: 10 ms") == {}
|
||||
base = tmp_path / "32"
|
||||
(base / "fdinfo").mkdir(parents=True)
|
||||
for fd in ("1", "2"):
|
||||
(base / "fdinfo" / fd).write_text(text)
|
||||
(base / "task/33").mkdir(parents=True)
|
||||
(base / "task/33/wchan").write_text("futex_wait_queue")
|
||||
engines, waits = p.process_sample([32], str(tmp_path))
|
||||
assert sum(engines.values()) == 1278 and waits == {"futex_wait_queue": 1}
|
||||
assert p.gpu_delta({"a": 99, "b": 10}, {"a": 120, "b": 1, "new": 999}) == 21
|
||||
assert ctypes.sizeof(p.FrameTiming) == 184
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row,busy", [(("steam", "/usr/bin/steam"), False),
|
||||
(("vrserver", "/opt/steamvr/vrserver"), False),
|
||||
(("reaper", "reaper SteamLaunch AppId=250820 -- vrstartup"), False),
|
||||
(("reaper", "reaper SteamLaunch AppId=123 -- native-game"), True),
|
||||
(("wine", "game.exe -foo"), True),
|
||||
(("bash", "bash /path/proton run game"), True),
|
||||
(("wineserver", "wineserver -p"), True),
|
||||
(("conmon", "conmon --name lepton-steamlaunch-123"), True)])
|
||||
def test_busy_guard(perf, row, busy):
|
||||
_, p = perf
|
||||
assert bool(p.busy_processes({10: row})) == busy
|
||||
|
||||
|
||||
class FakeVR:
|
||||
def activity(self):
|
||||
return 3
|
||||
|
||||
def screen_timeout(self):
|
||||
return 300.0
|
||||
|
||||
def timings(self):
|
||||
return []
|
||||
|
||||
def scene_pid(self):
|
||||
return 42
|
||||
|
||||
|
||||
def fake_bench(perf, monkeypatch):
|
||||
core, p = perf
|
||||
# Patch the actual function globals rather than the SimpleNamespace's copies.
|
||||
g = core.COMMANDS["bench"].__globals__
|
||||
events = []
|
||||
monkeypatch.setitem(g, "processes", lambda: {})
|
||||
monkeypatch.setitem(g, "recent_steam_input", lambda _: False)
|
||||
monkeypatch.setitem(g, "vr_set", lambda *args: events.append(args))
|
||||
monkeypatch.setitem(g, "stop_launcher", lambda child: events.append(("stop", child)))
|
||||
monkeypatch.setattr(p.signal, "signal", lambda *args: 0)
|
||||
return p, g, events
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["first_setting", "second_setting", "launch", "sample", "stop", "restore"])
|
||||
def test_bench_restores_even_on_failure(perf, monkeypatch, failure):
|
||||
p, g, events = fake_bench(perf, monkeypatch)
|
||||
vr = FakeVR()
|
||||
def setting(section, key, kind, value):
|
||||
events.append((section, key, kind, value))
|
||||
if ((failure == "first_setting" and value is False) or
|
||||
(failure == "second_setting" and key == "turnOffScreensTimeout" and value != 300.0) or
|
||||
(failure == "restore" and value is True)):
|
||||
raise RuntimeError(failure)
|
||||
monkeypatch.setitem(g, "vr_set", setting)
|
||||
child = SimpleNamespace(poll=lambda: None)
|
||||
def launch(*args, **kwargs):
|
||||
if failure == "launch":
|
||||
raise RuntimeError("launch")
|
||||
return child
|
||||
monkeypatch.setattr(p.subprocess, "Popen", launch)
|
||||
if failure == "sample":
|
||||
vr.timings = lambda: (_ for _ in ()).throw(RuntimeError("sample"))
|
||||
if failure == "stop":
|
||||
def stop(child):
|
||||
events.append(("stop", child))
|
||||
raise RuntimeError("stop")
|
||||
monkeypatch.setitem(g, "stop_launcher", stop)
|
||||
clock = [0.0]
|
||||
monkeypatch.setattr(p.time, "monotonic", lambda: clock[0])
|
||||
monkeypatch.setattr(p.time, "sleep", lambda s: clock.__setitem__(0, clock[0] + s))
|
||||
with pytest.raises(RuntimeError, match=failure):
|
||||
p.bench_run({"app": "test", "seconds": 1, "exe": "game.exe"}, "/fake/launch.sh", vr)
|
||||
assert ("power", "pauseCompositorOnStandby", "b", True) in events
|
||||
assert events[-1] == ("power", "turnOffScreensTimeout", "f", 300.0)
|
||||
assert any(e[0] == "stop" for e in events)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("activity,seconds", [(0, 1), (1, 1), (2, 1), (4, 1), (-1, 1), (3, 0), (3, 121), (3, True)])
|
||||
def test_bench_guard_no_side_effects(perf, monkeypatch, activity, seconds):
|
||||
p, _, events = fake_bench(perf, monkeypatch)
|
||||
vr = FakeVR()
|
||||
vr.activity = lambda: activity
|
||||
with pytest.raises(ValueError):
|
||||
p.bench_run({"app": "test", "seconds": seconds}, "/fake/launch.sh", vr)
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_bench_summary_deduplicates_ring_frames(perf, monkeypatch):
|
||||
p, g, events = fake_bench(perf, monkeypatch)
|
||||
count, clock = [0], [0.0]
|
||||
def rows():
|
||||
count[0] += 1
|
||||
return {} if count[0] == 1 else {42: ("game", "game.exe")}
|
||||
monkeypatch.setitem(g, "processes", rows)
|
||||
monkeypatch.setitem(g, "process_sample", lambda _: ({"gpu": int(clock[0] * 1e9)}, {"0": 1}))
|
||||
monkeypatch.setattr(p.time, "monotonic", lambda: clock[0])
|
||||
monkeypatch.setattr(p.time, "sleep", lambda s: clock.__setitem__(0, clock[0] + s))
|
||||
monkeypatch.setattr(p.subprocess, "Popen", lambda *a, **kw: SimpleNamespace(poll=lambda: None))
|
||||
vr = FakeVR()
|
||||
record = {"index": 3, "presents": 3, "gpu_ms": 8, "cpu_ms": 10, "system_time": 1.0}
|
||||
vr.timings = lambda: [record] if count[0] > 1 else []
|
||||
summary = p.bench_run({"app": "test", "seconds": 2, "exe": "game.exe"}, "/fake/launch.sh", vr)
|
||||
assert summary["frames"] == summary["hitches"] == 1
|
||||
assert summary["valid"] is False and summary["fps"] is None
|
||||
assert summary["game_gpu_busy_pct"] == 100.0
|
||||
assert summary["render_gpu_ms"] == 8
|
||||
assert events[-1] == ("power", "turnOffScreensTimeout", "f", 300.0)
|
||||
|
||||
|
||||
C_HARNESS = r'''
|
||||
#define _GNU_SOURCE
|
||||
#include <assert.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
/* Disable only constructors and the unused mmap64 alias; test the production functions with fake syscalls. */
|
||||
#define __attribute__(x)
|
||||
#define mmap fp_mmap
|
||||
#define mmap64 fp_mmap64
|
||||
#define munmap fp_munmap
|
||||
#define mprotect fp_mprotect
|
||||
#define madvise fp_madvise
|
||||
int fp_madvise(void *, size_t, int);
|
||||
#include "fp_mem.c"
|
||||
static char region[16384] __attribute__((aligned(4096)));
|
||||
static size_t advised_len, unmapped_len;
|
||||
static int advice_fail, map_calls;
|
||||
static void *fake_map(void *a, size_t n, int p, int f, int fd, off_t off) { map_calls++; return a; }
|
||||
static int fake_unmap(void *a, size_t n) { unmapped_len += n; return 0; }
|
||||
static int fake_protect(void *a, size_t n, int p) { return 0; }
|
||||
int fp_madvise(void *a, size_t n, int advice) {
|
||||
assert(advice == MADV_DONTNEED);
|
||||
advised_len += n;
|
||||
if (advice_fail) return -1;
|
||||
memset(a, 0, n);
|
||||
return 0;
|
||||
}
|
||||
static void reset(void) {
|
||||
memset(cache, 0, sizeof(cache)); memset(region, 0x5a, sizeof(region));
|
||||
kept_total = advised_len = unmapped_len = map_calls = advice_fail = 0;
|
||||
cache_min = 4096; thp = 0;
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
fp_mem_init();
|
||||
real_mmap = fake_map; real_munmap = fake_unmap; real_mprotect = fake_protect;
|
||||
int expected_zero = atoi(argv[1]);
|
||||
assert(zero == expected_zero);
|
||||
for (int mode = 0; mode < 3; mode++) {
|
||||
reset();
|
||||
size_t old = 8192, want = mode == 0 ? old : mode == 1 ? 4096 : 12288;
|
||||
assert(!fp_munmap(region, old)); assert(kept_total == old);
|
||||
assert(fp_mmap(region, want, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0) == region);
|
||||
assert(!cache[0].used && kept_total == 0 && hits == (unsigned long)mode+1);
|
||||
assert(advised_len == (expected_zero ? (want < old ? want : old) : 0));
|
||||
assert(region[0] == (expected_zero ? 0 : 0x5a));
|
||||
assert(unmapped_len == (want < old ? old-want : 0));
|
||||
if (want > old) assert(region[old] == 0x5a); /* fake fresh tail was not advised */
|
||||
}
|
||||
if (expected_zero) {
|
||||
assert(zero_calls == 3 && zero_bytes == 20480 && zero_failures == 0);
|
||||
reset(); store(region, 8192, 1, KEPT_PROT); advice_fail = 1;
|
||||
assert(fp_mmap(region, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0) == region);
|
||||
assert(zero_failures == 1 && map_calls == 1 && drops == 1 && kept_total == 0);
|
||||
reset(); store(region, 8192, 1, KEPT_PROT); advice_fail = 1;
|
||||
fp_mmap(region, 12288, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0);
|
||||
assert(zero_failures == 2 && map_calls == 2 && kept_total == 0 && unmapped_len == 12288);
|
||||
} else assert(zero_calls == 0 && zero_bytes == 0 && zero_failures == 0);
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,zero", [(None, 0), ("0", 0), ("1", 1)])
|
||||
def test_fp_mem_fake_syscalls(tmp_path, value, zero):
|
||||
cc = shutil.which("cc")
|
||||
if not cc:
|
||||
pytest.skip("host C compiler unavailable")
|
||||
harness = tmp_path / "harness.c"
|
||||
harness.write_text(C_HARNESS)
|
||||
binary = tmp_path / "harness"
|
||||
subprocess.run([cc, "-O0", "-I", str(ROOT / "native/fexwine"), str(harness), "-ldl", "-lpthread", "-o",
|
||||
str(binary)], check=True, capture_output=True, text=True)
|
||||
env = dict(os.environ)
|
||||
env.pop("FP_MEM_ZERO", None)
|
||||
if value is not None:
|
||||
env["FP_MEM_ZERO"] = value
|
||||
subprocess.run([str(binary), str(zero)], check=True, capture_output=True, text=True, env=env)
|
||||
|
||||
|
||||
def test_cli_settings_and_bench_routing(monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
calls = []
|
||||
target = SimpleNamespace(frame=SimpleNamespace(agent=lambda name, **args: calls.append((name, args)) or {}))
|
||||
monkeypatch.setattr(cli, "_fex_target", lambda frame: target)
|
||||
monkeypatch.setattr(cli, "_update_hint", lambda: None)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli.app, ["fex", "settings", "test-app", "cores=7", "ntsync=false", "eye_scale=1.25",
|
||||
'fex={"FEX_X87REDUCEDPRECISION":1}', "fp_mem_zero=null"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert calls[-1] == ("fex_settings", {"app": "test-app", "values": {
|
||||
"cores": "7", "ntsync": False, "eye_scale": 1.25, "fex": {"FEX_X87REDUCEDPRECISION": 1},
|
||||
"fp_mem_zero": None}})
|
||||
assert runner.invoke(cli.app, ["fex", "settings", "test-app"]).exit_code == 0
|
||||
assert calls[-1] == ("fex_settings", {"app": "test-app"})
|
||||
assert runner.invoke(cli.app, ["fex", "settings", "test-app", "cores=null"]).exit_code == 0
|
||||
assert calls[-1] == ("fex_settings", {"app": "test-app", "values": {"cores": None}})
|
||||
result = runner.invoke(cli.app, ["fex", "bench", "test-app", "--seconds", "3"])
|
||||
assert result.exit_code == 0 and result.output.count("\n") == 1
|
||||
assert calls[-1] == ("fex_bench", {"app": "test-app", "seconds": 3, "timeout": 253})
|
||||
count = len(calls)
|
||||
assert runner.invoke(cli.app, ["fex", "bench", "test-app", "--seconds", "121"]).exit_code == 2
|
||||
assert runner.invoke(cli.app, ["fex", "settings", "test-app", "broken"]).exit_code == 2
|
||||
assert len(calls) == count
|
||||
|
||||
|
||||
def test_agent_perf_commands_use_installed_launcher(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
spec = importlib.util.spec_from_file_location("perf_agent", ROOT / "agent/frameport_agent.py")
|
||||
agent = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(agent)
|
||||
anchor = Path(agent.ANCHORS) / "rift.test"
|
||||
anchor.mkdir(parents=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"kind": "frame_fex", "app": "test-app"}))
|
||||
calls = []
|
||||
monkeypatch.setattr(agent, "fex_helper", lambda command, args, timeout: calls.append((command, args, timeout)))
|
||||
monkeypatch.setattr(agent, "game_running", lambda: False)
|
||||
agent.cmd_fex_settings({"app": "test-app", "values": {"ntsync": False}})
|
||||
assert calls[-1] == ("settings_set", {"app": "test-app", "ntsync": False}, 60)
|
||||
agent.cmd_fex_settings({"app": "test-app"})
|
||||
assert calls[-1] == ("settings_get", {"app": "test-app"}, 60)
|
||||
agent.cmd_fex_bench({"app": "test-app", "seconds": 5})
|
||||
assert calls[-1] == ("bench", {"app": "test-app", "seconds": 5, "launcher": str(anchor / "launch.sh")}, 245)
|
||||
count = len(calls)
|
||||
with pytest.raises(agent.AgentError):
|
||||
agent.cmd_fex_bench({"app": "test-app", "seconds": 121})
|
||||
with pytest.raises(agent.AgentError):
|
||||
agent.cmd_fex_settings({"app": "test-app", "values": {"app": "other"}})
|
||||
monkeypatch.setattr(agent, "game_running", lambda: True)
|
||||
with pytest.raises(agent.AgentError, match="running"):
|
||||
agent.cmd_fex_bench({"app": "test-app"})
|
||||
assert len(calls) == count
|
||||
|
||||
|
||||
def test_profile_dxvk_lines_share_the_one_config(perf, tmp_path):
|
||||
"""The compat profile's dxvk.conf lines and these settings end up in one per-game config; settings win."""
|
||||
core, p = perf
|
||||
app = "oculus-first-contact"
|
||||
folder = Path(p.scale_folder(1.5))
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "LibRevive64.dll").touch()
|
||||
p.settings_set({"app": app, "dxvk_disable_msaa": True})
|
||||
p.perf_prepare({"app": app, "dxvk": ["dxgi.maxFrameRate = 72", "d3d11.disableMsaa = False", "bad\nline"]})
|
||||
text = (Path(core.ROOT) / "settings" / (app + ".dxvk.conf")).read_text()
|
||||
assert "dxgi.maxFrameRate = 72" in text and "bad" not in text
|
||||
assert text.count("d3d11.disableMsaa") == 1 and "d3d11.disableMsaa = True" in text # the setting wins
|
||||
anchor = tmp_path / "anchor"
|
||||
anchor.mkdir()
|
||||
core.write_launcher(app, dict(core.GAMES[app], dxvk=["dxgi.maxFrameRate = 72"]), str(anchor))
|
||||
assert "dxgi.maxFrameRate = 72" in (anchor / "launch.sh").read_text()
|
||||
|
||||
|
||||
def test_prepare_scales_accepts_the_real_setup_upload(perf, tmp_path):
|
||||
"""Against the real Revive 3.2.0 DLL patched the way `fex setup` uploads it (skipped without Revive)."""
|
||||
from frameport import fexrift as pc
|
||||
from frameport.tools import revive as revive_tool
|
||||
|
||||
try:
|
||||
stock = revive_tool.revive_dir()
|
||||
except Exception:
|
||||
pytest.skip("no Revive install")
|
||||
if not stock or not (Path(stock) / "LibRevive64.dll").is_file():
|
||||
pytest.skip("no Revive install")
|
||||
core, p = perf
|
||||
pc.patch_revive(Path(stock), Path(core.REVIVE))
|
||||
p.prepare_scales()
|
||||
for scale in (1.0, 1.25, 1.5):
|
||||
dll = (Path(p.scale_folder(scale)) / "LibRevive64.dll").read_bytes()
|
||||
assert struct.unpack_from("<f", dll, 0x1229c)[0] == scale
|
||||
|
||||
|
||||
@pytest.mark.parametrize("how", ["crash", "result", "error"])
|
||||
def test_bench_guarded_restores_display_when_the_measurement_dies(perf, monkeypatch, tmp_path, how):
|
||||
"""A native crash in the ctypes SteamVR calls skips bench_run's finally (seen on the Frame): the parent must
|
||||
still restore the display settings and stop the game from the guard file."""
|
||||
core, p = perf
|
||||
g = core.COMMANDS["bench"].__globals__
|
||||
Path(core.ROOT).mkdir(parents=True, exist_ok=True)
|
||||
log = tmp_path / "events.txt"
|
||||
|
||||
def run(args, launcher, vr):
|
||||
g["guard_write"](screen=300.0, pgid=999999)
|
||||
if how == "crash":
|
||||
os.kill(os.getpid(), 9) # what a segfault in vrclient.so looks like to Python
|
||||
if how == "error":
|
||||
raise ValueError("refused")
|
||||
return {"frames": 3}
|
||||
monkeypatch.setitem(g, "VR", lambda: SimpleNamespace(close=lambda: None))
|
||||
monkeypatch.setitem(g, "bench_run", run)
|
||||
monkeypatch.setitem(g, "vrsetting_subprocess",
|
||||
lambda *a: log.open("a").write(json.dumps(a) + "\n"))
|
||||
def run_cmd(argv, **k):
|
||||
if str(argv[0]).endswith("wineserver"):
|
||||
log.open("a").write('["wineserver -k"]\n')
|
||||
return SimpleNamespace(stdout="", stderr="", returncode=0)
|
||||
monkeypatch.setattr(p.subprocess, "run", run_cmd)
|
||||
if how == "crash":
|
||||
with pytest.raises(ValueError, match="died .*display settings restored"):
|
||||
g["bench_guarded"]({"app": "x"}, "/fake/launch.sh")
|
||||
lines = [json.loads(l) for l in log.read_text().splitlines()]
|
||||
assert ["power", "pauseCompositorOnStandby", "b", True] in lines
|
||||
assert ["power", "turnOffScreensTimeout", "f", 300.0] in lines and ["wineserver -k"] in lines
|
||||
elif how == "error":
|
||||
with pytest.raises(ValueError, match="refused"):
|
||||
g["bench_guarded"]({"app": "x"}, "/fake/launch.sh")
|
||||
else:
|
||||
assert g["bench_guarded"]({"app": "x"}, "/fake/launch.sh") == {"frames": 3}
|
||||
assert not Path(g["guard_path"]()).exists()
|
||||
|
||||
|
||||
def test_vr_set_runs_each_write_in_its_own_process(perf, monkeypatch):
|
||||
core, p = perf
|
||||
g = core.COMMANDS["bench"].__globals__
|
||||
calls = []
|
||||
|
||||
def run(argv, **k):
|
||||
calls.append(argv)
|
||||
ok = json.loads(argv[-1])["value"] is not None
|
||||
return SimpleNamespace(returncode=0, stdout=json.dumps({"ok": True, "result": {"error": 0 if ok else 1}}))
|
||||
monkeypatch.setattr(p.subprocess, "run", run)
|
||||
monkeypatch.setitem(g, "CORE", SimpleNamespace(HERE="/a", cmd_vrsetting=lambda a: pytest.fail("in-process")))
|
||||
g["vr_set"]("power", "pauseCompositorOnStandby", "b", False)
|
||||
g["vr_set"]("power", "turnOffScreensTimeout", "f", 30.0)
|
||||
assert len(calls) == 2 and all(c[-2] == "vrsetting" for c in calls)
|
||||
with pytest.raises(ValueError, match="refused"):
|
||||
g["vr_set"]("power", "x", "b", None)
|
||||
@@ -1,371 +0,0 @@
|
||||
"""Experimental Rift-on-Frame mode (fexrift): the Frame helper, the agent commands and the PC side, without a Frame."""
|
||||
import base64
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load(monkeypatch, tmp_path, name):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
spec = importlib.util.spec_from_file_location(name.replace(".py", ""), ROOT / "agent" / name)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fx(monkeypatch, tmp_path):
|
||||
return load(monkeypatch, tmp_path, "fexrift.py")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Meta's signatures
|
||||
def _sign(payload: bytes, n: int, d: int) -> str:
|
||||
k = (n.bit_length() + 7) // 8
|
||||
m = b"\x00\x01" + b"\xff" * (k - 3 - len(payload)) + b"\x00" + payload
|
||||
return base64.b64encode(pow(int.from_bytes(m, "big"), d, n).to_bytes(k, "big")).decode()
|
||||
|
||||
|
||||
def test_rsa_public_decrypt_type1(fx):
|
||||
p, q, e = 2 ** 127 - 1, 2 ** 89 - 1, 65537 # Mersenne primes: a small test key
|
||||
n, d = p * q, pow(e, -1, (p - 1) * (q - 1))
|
||||
sig = _sign(b"abc123", n, d)
|
||||
assert fx.rsa_public_decrypt(sig, keys=[(n, e)]) == "abc123"
|
||||
other = (2 ** 61 - 1) * (2 ** 107 - 1) # wrong key -> refused
|
||||
with pytest.raises(fx.FexError):
|
||||
fx.rsa_public_decrypt(sig, keys=[(other, e)])
|
||||
|
||||
|
||||
def test_meta_keys_parse(fx):
|
||||
keys = [fx.rsa_key(k) for k in fx.META_KEYS_B64]
|
||||
assert [n.bit_length() for n, _ in keys] == [4096, 4096] and all(e == 65537 for _, e in keys)
|
||||
|
||||
|
||||
def test_dawn_manifest_order_and_keys(fx):
|
||||
m = fx.dawn_manifest({"files": {"a/b.dll": {"size": 1}}, "version": "1.2", "canonicalName": "x", "extra": 5})
|
||||
keys = list(m)
|
||||
assert keys[0] == "canonicalName" and keys.index("launchFile2D") < keys.index("version") < keys.index("files")
|
||||
assert "a\\b.dll" in m["files"]
|
||||
assert m["dlcs"] == {} and m["extra"] == 5 and list(m)[-1] == "extra"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ game fixes
|
||||
def _pe(code_at: int, code: bytes) -> bytearray:
|
||||
"""Minimal PE: one section mapping RVA 0x1000.. to file offset 0x200.."""
|
||||
data = bytearray(0x1200)
|
||||
data[:2] = b"MZ"
|
||||
struct.pack_into("<I", data, 0x3c, 0x80)
|
||||
data[0x80:0x84] = b"PE\0\0"
|
||||
struct.pack_into("<H", data, 0x86, 1) # one section
|
||||
struct.pack_into("<H", data, 0x94, 0xf0) # optional header size
|
||||
sec = 0x80 + 24 + 0xf0
|
||||
struct.pack_into("<8sIIII", data, sec, b".text", 0x1000, 0x1000, 0x1000, 0x200)
|
||||
data[0x200 + code_at - 0x1000:0x200 + code_at - 0x1000 + len(code)] = code
|
||||
return data
|
||||
|
||||
|
||||
def test_byte_patch_states(fx, tmp_path):
|
||||
patch = {"rva": 0x1010, "old": "c7862001000001000000", "new": "c7862001000000000000"}
|
||||
exe = tmp_path / "game.exe"
|
||||
exe.write_bytes(_pe(0x1010, bytes.fromhex(patch["old"])))
|
||||
assert fx.apply_byte_patch(str(exe), patch) == "patched"
|
||||
assert exe.read_bytes()[0x210:0x21a] == bytes.fromhex(patch["new"])
|
||||
assert (tmp_path / "game.exe.orig").read_bytes()[0x210:0x21a] == bytes.fromhex(patch["old"])
|
||||
assert fx.apply_byte_patch(str(exe), patch) == "already"
|
||||
other = tmp_path / "other.exe"
|
||||
other.write_bytes(_pe(0x1010, b"\x90" * 10))
|
||||
assert fx.apply_byte_patch(str(other), patch) == "unknown build"
|
||||
assert not (tmp_path / "other.exe.orig").exists()
|
||||
|
||||
|
||||
def test_first_contact_patch_matches_the_real_instruction(fx):
|
||||
p = fx.GAMES["oculus-first-contact"]["ime_patch"]
|
||||
old, new = bytes.fromhex(p["old"]), bytes.fromhex(p["new"])
|
||||
# mov dword ptr [rsi+0x120], imm32: only the immediate changes (1 -> 0)
|
||||
assert old[:6] == new[:6] == bytes.fromhex("c78620010000") and old[6:] == (1).to_bytes(4, "little")
|
||||
assert new[6:] == bytes(4)
|
||||
|
||||
|
||||
CVAR_AT, VAR_AT = 0x1800, 0x1900
|
||||
|
||||
|
||||
def _ime_pe(form="member", value=1):
|
||||
"""Synthetic Unreal-like code: the CVar's UTF-16 name, its registration (lea r8 <variable>; lea rdx <name>) and
|
||||
for the member form the constructor's `mov dword ptr [rsi+0x120], value` before it."""
|
||||
code = bytearray(0x1000)
|
||||
|
||||
def put(rva, b):
|
||||
code[rva - 0x1000:rva - 0x1000 + len(b)] = b
|
||||
put(CVAR_AT, ("Slate.DeferWindowsMessageProcessing" + "\0").encode("utf-16-le"))
|
||||
if form == "member":
|
||||
put(0x1100, bytes.fromhex("c78620010000") + value.to_bytes(4, "little"))
|
||||
put(0x1200, bytes.fromhex("4c8d8620010000")) # lea r8, [rsi+0x120]
|
||||
elif form == "global":
|
||||
put(VAR_AT, value.to_bytes(4, "little"))
|
||||
put(0x1200, bytes.fromhex("4c8d05") + struct.pack("<i", VAR_AT - 0x1207)) # lea r8, [rip+var]
|
||||
if form != "unreferenced":
|
||||
put(0x1207, bytes.fromhex("488d15") + struct.pack("<i", CVAR_AT - 0x120e)) # lea rdx, [rip+name]
|
||||
return _pe(0x1000, bytes(code))
|
||||
|
||||
|
||||
def test_find_ime_patch_forms(fx, tmp_path):
|
||||
patch, why = fx.find_ime_patch(bytes(_ime_pe("member")))
|
||||
assert why is None and patch == {"rva": 0x1100, "old": "c7862001000001000000", "new": "c7862001000000000000"}
|
||||
patch, _ = fx.find_ime_patch(bytes(_ime_pe("global")))
|
||||
assert patch == {"rva": VAR_AT, "old": "01000000", "new": "00000000"}
|
||||
patch, _ = fx.find_ime_patch(bytes(_ime_pe("member", value=0))) # already patched: same site
|
||||
assert patch["rva"] == 0x1100
|
||||
exe = tmp_path / "g.exe"
|
||||
exe.write_bytes(_ime_pe("member", value=0))
|
||||
assert fx.apply_byte_patch(str(exe), patch) == "already"
|
||||
assert fx.find_ime_patch(bytes(_ime_pe("unreferenced")))[0] is None
|
||||
assert "no Slate" in fx.find_ime_patch(bytes(_pe(0x1000, b"\x90" * 16)))[1]
|
||||
|
||||
|
||||
def test_find_ime_patch_first_contact(fx):
|
||||
"""The real exe (FRAMEPORT_FIRST_CONTACT_EXE, original or patched): the finder lands on the known site."""
|
||||
import os
|
||||
|
||||
path = os.environ.get("FRAMEPORT_FIRST_CONTACT_EXE")
|
||||
if not path or not os.path.isfile(path):
|
||||
pytest.skip("set FRAMEPORT_FIRST_CONTACT_EXE to First Contact's TouchNUX-Win64-Shipping.exe")
|
||||
patch, why = fx.find_ime_patch(open(path, "rb").read())
|
||||
assert why is None and patch["rva"] == fx.GAMES["oculus-first-contact"]["ime_patch"]["rva"]
|
||||
|
||||
|
||||
def _meta_game(fx, app, exe_rel, manifest=True, params='-mode="vr" -x'):
|
||||
game = Path(fx.META_DIR) / "Software" / "Software" / app
|
||||
exe = game / exe_rel
|
||||
exe.parent.mkdir(parents=True)
|
||||
exe.write_bytes(_ime_pe("member"))
|
||||
if manifest:
|
||||
man = Path(fx.META_DIR) / "Software" / "Manifests"
|
||||
man.mkdir(parents=True, exist_ok=True)
|
||||
(man / f"{app}.json").write_text(json.dumps({"canonicalName": app, "launchFile": exe_rel.replace("/", "\\"),
|
||||
"launchParameters": params}))
|
||||
return exe
|
||||
|
||||
|
||||
def test_manifest_launch(fx):
|
||||
_meta_game(fx, "some-game", "Proj/Binaries/Win64/Proj-Win64-Shipping.exe")
|
||||
m = fx.manifest_launch("some-game")
|
||||
assert m == {"exe": "Proj/Binaries/Win64/Proj-Win64-Shipping.exe", "args": ["-mode=vr", "-x"],
|
||||
"title": "some-game"}
|
||||
assert fx.manifest_launch("missing") is None
|
||||
(Path(fx.META_DIR) / "Software" / "Manifests" / "bad.json").write_text(json.dumps({"launchFile": "..\\x.exe"}))
|
||||
with pytest.raises(fx.FexError):
|
||||
fx.manifest_launch("bad")
|
||||
assert fx.unreal_project("Proj/Binaries/Win64/P.exe") == "Proj" and fx.unreal_project("Game.exe") is None
|
||||
|
||||
|
||||
def test_install_unverified_needs_force(fx, tmp_path):
|
||||
exe = _meta_game(fx, "some-game", "Proj/Binaries/Win64/Proj-Win64-Shipping.exe")
|
||||
anchor = str(tmp_path / "anchor")
|
||||
with pytest.raises(fx.FexError, match="force_unverified"):
|
||||
fx.cmd_install({"app": "some-game", "anchor": anchor})
|
||||
assert not (exe.parent / (exe.name + ".orig")).exists() # refused before anything changed
|
||||
res = fx.cmd_install({"app": "some-game", "anchor": anchor, "force_unverified": True, "msaa": 2})
|
||||
assert res["verified"] is False and res["ime_patch"] == "patched" and "msaa" not in res # msaa: per game only
|
||||
assert res["checks"]["engine"] == "unreal" and res["checks"]["ime"]["rva"] == 0x1100
|
||||
text = (Path(anchor) / "launch.sh").read_text()
|
||||
assert "Proj-Win64-Shipping.exe' -mode=vr -x" in text and "FP_MEM" not in text # fp_mem: per game only
|
||||
assert fx.cmd_install({"app": "some-game", "anchor": anchor, "force_unverified": True})["ime_patch"] == "already"
|
||||
|
||||
|
||||
def test_install_verified_uses_the_finder(fx, tmp_path):
|
||||
_meta_game(fx, "oculus-first-contact", fx.GAMES["oculus-first-contact"]["exe"], manifest=False)
|
||||
res = fx.cmd_install({"app": "oculus-first-contact", "anchor": str(tmp_path / "a"), "msaa": 2})
|
||||
assert res["verified"] and res["ime_patch"] == "patched" and res["msaa"] == 2 # the finder's site, not 0x22a605
|
||||
ini = Path(fx.USER_DIR) / "AppData/Local/TouchNUX/Saved/Config/WindowsNoEditor/Engine.ini"
|
||||
assert "r.MobileMSAA=2" in ini.read_text()
|
||||
with pytest.raises(fx.FexError, match="no manifest"):
|
||||
fx.cmd_install({"app": "not-here", "anchor": str(tmp_path / "b"), "force_unverified": True})
|
||||
|
||||
|
||||
def test_ini_values_keep_crlf_and_replace(fx, tmp_path):
|
||||
ini = tmp_path / "Engine.ini"
|
||||
ini.write_bytes(b"[Core.System]\r\nPaths=x\r\n\r\n[SystemSettings]\r\nr.MobileMSAA=4\r\nr.Other=1\r\n")
|
||||
fx.set_ini_values(str(ini), "SystemSettings", {"r.MobileMSAA": "2"})
|
||||
fx.set_ini_values(str(ini), "/Script/Engine.RendererSettings", {"r.MobileMSAA": "2"})
|
||||
text = ini.read_bytes().decode()
|
||||
assert "\n" not in text.replace("\r\n", "")
|
||||
assert text.count("r.MobileMSAA=2") == 2 and "r.MobileMSAA=4" not in text and "r.Other=1" in text
|
||||
assert "[/Script/Engine.RendererSettings]\r\nr.MobileMSAA=2" in text
|
||||
|
||||
|
||||
def test_launcher_renders(fx, tmp_path):
|
||||
anchor = tmp_path / "anchor"
|
||||
anchor.mkdir()
|
||||
exe = fx.write_launcher("oculus-first-contact", fx.GAMES["oculus-first-contact"], str(anchor))
|
||||
text = (anchor / "launch.sh").read_text()
|
||||
assert exe.endswith("TouchNUX-Win64-Shipping.exe")
|
||||
for needle in ("flock -n 9", "FP_NATIVE_WSERVER=1", "FP_MEM=1", "FP_BIGCACHE_MB=256", "taskset -c 4-7",
|
||||
"ReviveInjector.exe", "OVRLibrarian", "wineserver\" -k", "motionSmoothingOverride",
|
||||
"-gamemode=experienceonly", "plays.log", "ENABLE_GAMESCOPE_WSI=0", "unset LD_PRELOAD"):
|
||||
assert needle in text, needle
|
||||
assert (anchor / "launch.sh").stat().st_mode & 0o100
|
||||
|
||||
|
||||
def test_login_archive_members_are_checked(fx, tmp_path):
|
||||
def tar_with(name, kind="file"):
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as t:
|
||||
info = tarfile.TarInfo(name)
|
||||
if kind == "sym":
|
||||
info.type, info.linkname = tarfile.SYMTYPE, "/etc/passwd"
|
||||
t.addfile(info, io.BytesIO(b"") if kind == "file" else None)
|
||||
buf.seek(0)
|
||||
return tarfile.open(fileobj=buf)
|
||||
assert [m.name for m in fx.safe_members(tar_with("sessions/a"))] == ["sessions/a"]
|
||||
for bad in ("../x", "/abs", "other/x", "sessions/../../x"):
|
||||
with pytest.raises(fx.FexError):
|
||||
list(fx.safe_members(tar_with(bad)))
|
||||
with pytest.raises(fx.FexError):
|
||||
list(fx.safe_members(tar_with("sessions/link", "sym")))
|
||||
|
||||
|
||||
def test_fixes_reg_has_meta_library_and_root(fx):
|
||||
der = (ROOT / "artifacts" / "fexrift" / "digicert-assured-id-root-ca.der").read_bytes()
|
||||
reg = fx.fixes_reg(der)
|
||||
assert "\r\n" in reg and fx.DIGICERT_ROOT_SHA1 in reg
|
||||
# Meta's default library exactly as Meta's app writes it in user.reg (lab prefix, where imported games run)
|
||||
assert f'"DefaultLibrary"="{fx.LIBRARY_GUID}"' in reg
|
||||
assert '"OriginalPath"="C:\\\\Program Files\\\\Meta Horizon\\\\Software"' in reg
|
||||
assert ('"Path"="\\\\\\\\?\\\\Volume{00000000-0000-0000-0000-000000000043}\\\\Program Files\\\\Meta Horizon'
|
||||
'\\\\Software"') in reg
|
||||
assert "Windows.Devices.WiFi.WiFiAdapter" in reg and "[HKEY_LOCAL_MACHINE\\Software\\Revive]" in reg
|
||||
|
||||
|
||||
def test_status_without_setup(fx):
|
||||
st = fx.cmd_status({})
|
||||
assert st["proton"] is False and st["prefix"] is False and st["login"] is False and st["apps"] == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ agent commands
|
||||
def test_agent_fex_install_and_uninstall(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path, "frameport_agent.py")
|
||||
game_dir = tmp_path / "pfx/drive_c/Program Files/Meta Horizon/Software/Software/oculus-first-contact"
|
||||
calls = []
|
||||
|
||||
def fake_helper(cmd, args=None, timeout=900):
|
||||
calls.append((cmd, args))
|
||||
(Path(args["anchor"])).mkdir(parents=True, exist_ok=True)
|
||||
(Path(args["anchor"]) / "launch.sh").write_text("#!/bin/sh\n")
|
||||
return {"exe": str(game_dir / "x.exe"), "game_dir": str(game_dir), "title": "Oculus First Contact",
|
||||
"launcher": args["anchor"] + "/launch.sh", "ime_patch": "patched"}
|
||||
monkeypatch.setattr(a, "fex_helper", fake_helper)
|
||||
game_dir.mkdir(parents=True)
|
||||
res = a.cmd_fex_install({"package": "rift.oculus_first_contact", "app": "oculus-first-contact", "msaa": 2})
|
||||
assert calls[0][0] == "install" and calls[0][1]["msaa"] == 2
|
||||
dep = a.deployment("rift.oculus_first_contact")
|
||||
assert dep["kind"] == "frame_fex" and dep["base"] == str(game_dir) and dep["fixes"]["ime_patch"] == "patched"
|
||||
launcher = f'"{a.ANCHORS}/rift.oculus_first_contact/launch.sh"'
|
||||
assert res["appid"] == a.shortcut_appid(launcher, "Oculus First Contact")
|
||||
assert a.shortcut_args("rift.oculus_first_contact")[4] == "PC VR on Frame"
|
||||
assert calls[0][1]["force_unverified"] is False
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_fex_install({"package": "rift.oculus_first_contact", "app": "../evil"})
|
||||
monkeypatch.setattr(a, "pcvr_pids", lambda base: [])
|
||||
out = a.cmd_uninstall({"package": "rift.oculus_first_contact", "keep_data": False})
|
||||
assert out["removed"] and game_dir.is_dir() # the prefix (and the game in it) stays
|
||||
assert a.deployment("rift.oculus_first_contact") is None
|
||||
|
||||
|
||||
def test_agent_fex_launch_test_refused(monkeypatch, tmp_path):
|
||||
a = load(monkeypatch, tmp_path, "frameport_agent.py")
|
||||
anchor = Path(a.ANCHORS) / "rift.oculus_first_contact"
|
||||
anchor.mkdir(parents=True)
|
||||
(anchor / "deployment.json").write_text(json.dumps({"package": "rift.oculus_first_contact", "kind": "frame_fex",
|
||||
"appid": 1, "base": str(anchor), "title": "X"}))
|
||||
with pytest.raises(a.AgentError, match="launch tests"):
|
||||
a.cmd_launch_test({"package": "rift.oculus_first_contact", "seconds": 5})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PC side
|
||||
def test_gate(monkeypatch):
|
||||
from frameport import fexrift
|
||||
|
||||
monkeypatch.delenv(fexrift.EXPERIMENT_ENV, raising=False)
|
||||
assert not fexrift.enabled()
|
||||
with pytest.raises(RuntimeError, match=fexrift.EXPERIMENT_ENV):
|
||||
fexrift.require_enabled()
|
||||
monkeypatch.setenv(fexrift.EXPERIMENT_ENV, "1")
|
||||
assert fexrift.enabled()
|
||||
|
||||
|
||||
def test_package_for():
|
||||
from frameport import fexrift
|
||||
|
||||
assert fexrift.package_for("oculus-first-contact") == "rift.oculus_first_contact"
|
||||
assert fexrift.package_for("some-game") == "rift.some_game"
|
||||
for bad in ("../x", "a*", "A"):
|
||||
with pytest.raises(ValueError):
|
||||
fexrift.package_for(bad)
|
||||
|
||||
|
||||
def test_patch_revive_refuses_other_builds(tmp_path):
|
||||
from frameport import fexrift
|
||||
|
||||
src = tmp_path / "revive"
|
||||
src.mkdir()
|
||||
(src / "LibRevive64.dll").write_bytes(b"not revive 3.2.0")
|
||||
with pytest.raises(RuntimeError, match=fexrift.REVIVE_VERSION):
|
||||
fexrift.patch_revive(src, tmp_path / "out")
|
||||
|
||||
|
||||
def test_revive_patch_list_is_consistent():
|
||||
from frameport import fexrift
|
||||
|
||||
offs = [o for o, _, _ in fexrift.REVIVE_PATCHES]
|
||||
assert offs == sorted(offs)
|
||||
assert all(len(a) == len(b) for _, a, b in fexrift.REVIVE_PATCHES)
|
||||
|
||||
|
||||
def test_login_archive(tmp_path):
|
||||
from frameport import fexrift
|
||||
|
||||
pfx = tmp_path / "pfx"
|
||||
user = pfx / "drive_c/users/someone/AppData/Roaming/Oculus/sessions"
|
||||
(user / "_oaf").mkdir(parents=True) # Meta's framework folder alone isn't a sign-in
|
||||
with pytest.raises(RuntimeError, match="sign in"):
|
||||
fexrift.login_archive(pfx, ["oculus-first-contact"], tmp_path / "l0.tar")
|
||||
user.mkdir(parents=True, exist_ok=True)
|
||||
(user / "token").write_text("secret")
|
||||
(user / "1234567890-1275611841").mkdir()
|
||||
meta = pfx / "drive_c/Program Files/Meta Horizon"
|
||||
(meta / "CoreData").mkdir(parents=True)
|
||||
(meta / "CoreData" / "x").write_text("1")
|
||||
(meta / "Software/Manifests").mkdir(parents=True)
|
||||
(meta / "Software/Manifests/oculus-first-contact.json").write_text("{}")
|
||||
(meta / "Software/Software/oculus-first-contact/TouchNUX").mkdir(parents=True)
|
||||
(meta / "Software/Software/oculus-first-contact/TouchNUX/a.exe").write_text("MZ")
|
||||
out = tmp_path / "login.tar"
|
||||
info = fexrift.login_archive(pfx, ["oculus-first-contact", "missing-app"], out)
|
||||
assert info["apps"] == ["oculus-first-contact"]
|
||||
assert out.stat().st_mode & 0o077 == 0 # owner-only (holds sign-in tokens)
|
||||
with tarfile.open(out) as t:
|
||||
names = set(t.getnames())
|
||||
assert {"sessions/token", "CoreData/x", "Manifests/oculus-first-contact.json",
|
||||
"Software/oculus-first-contact/TouchNUX/a.exe"} <= names
|
||||
with pytest.raises(RuntimeError, match="sign in"):
|
||||
fexrift.login_archive(tmp_path / "empty", ["x"], tmp_path / "l2.tar")
|
||||
|
||||
|
||||
def test_artifact_files_listed():
|
||||
from frameport import fexrift
|
||||
|
||||
rels = {rel for _, rel in fexrift.artifact_files()}
|
||||
assert {"fexrift.py", "fp_mem.so", "wineserver.arm64", "windows.devices.wifi.dll",
|
||||
"digicert-assured-id-root-ca.der", "dlls/crypt32.dll", "dlls/sechost.dll", "dlls/dnsapi.dll",
|
||||
"dlls/windows.devices.enumeration.dll"} <= rels
|
||||
root = ROOT / "artifacts" / "fexrift"
|
||||
sums = dict(reversed(line.split(None, 1)) for line in (root / "SHA256SUMS").read_text().splitlines())
|
||||
for rel, digest in ((r.lstrip("./"), d) for r, d in sums.items()):
|
||||
assert hashlib.sha256((root / rel).read_bytes()).hexdigest() == digest, rel
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Compatibility profiles/graphics/old Unreal modules, without Wine, FEX, network or the owner's dumps."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from test_fexrift import _ime_pe, _meta_game
|
||||
from test_fexrift_ext import load_copy
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fx(monkeypatch, tmp_path):
|
||||
core = load_copy(monkeypatch, tmp_path)
|
||||
shutil.copy2(ROOT / "agent/fexrift_compat.py", Path(core.HERE) / "fexrift_compat.py")
|
||||
core.load_extensions()
|
||||
return core
|
||||
|
||||
|
||||
def compat():
|
||||
spec = importlib.util.spec_from_file_location("compat", ROOT / "agent/fexrift_compat.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_profile_match_modes_and_ambiguity(tmp_path):
|
||||
path = tmp_path / "Game.exe"
|
||||
path.write_bytes(b"game")
|
||||
mod = compat()
|
||||
for match in ({"canonical_names": ["studio-game"]},
|
||||
{"sha256": [hashlib.sha256(b"game").hexdigest()]},
|
||||
{"executables": [{"name": "game.EXE", "size": 4}]}):
|
||||
profile = {"match": match, "status": "likely"}
|
||||
assert mod.match_profile({"profile": profile}, "studio-game", str(path)) == ("profile", profile)
|
||||
assert mod.match_profile({"p": {"match": {"executables": [{"name": "Game.exe", "size": 5}]}}},
|
||||
"unknown", str(path)) == (None, None)
|
||||
with pytest.raises(ValueError, match="ambiguous"):
|
||||
mod.match_profile({k: {"match": {"canonical_names": ["same"]}} for k in ("a", "b")}, "same", "")
|
||||
|
||||
|
||||
def test_likely_installs_with_warning_and_without_fp_mem(fx, tmp_path):
|
||||
exe = _meta_game(fx, "manifest-name", "Proj/Binaries/Win64/Game.exe")
|
||||
fx.GAMES["survey-name"] = {"match": {"executables": [{"name": exe.name, "size": exe.stat().st_size}]},
|
||||
"status": "likely", "graphics": "d3d11", "fp_mem": "candidate"}
|
||||
info, verified = fx.game_info("manifest-name")
|
||||
assert info["exe"] == "Proj/Binaries/Win64/Game.exe" and not verified
|
||||
result = fx.cmd_install({"app": "manifest-name", "anchor": str(tmp_path / "anchor")})
|
||||
assert result["warnings"] and result["verified"] is False
|
||||
assert result["checks"]["profile_warnings"] == result["warnings"]
|
||||
assert result["checks"]["profile"] == "survey-name" and result["checks"]["fp_mem"] == "candidate"
|
||||
text = Path(result["launcher"]).read_text()
|
||||
assert "export FP_MEM=1" not in text and "graphics=d3d11" in text
|
||||
assert "ReviveInjector.exe" in text
|
||||
|
||||
|
||||
def test_untested_requires_force_and_blocked_cannot_be_forced(fx, tmp_path):
|
||||
exe = _meta_game(fx, "test", "Proj/Binaries/Win64/Game.exe")
|
||||
fx.GAMES["test"] = {"status": "untested"}
|
||||
args = {"app": "test", "anchor": str(tmp_path / "anchor")}
|
||||
with pytest.raises(fx.FexError, match="force_unverified"):
|
||||
fx.cmd_install(args)
|
||||
assert not Path(str(exe) + ".orig").exists()
|
||||
assert fx.cmd_install(dict(args, force_unverified=True))["verified"] is False
|
||||
fx.GAMES["test"]["status"] = "blocked"
|
||||
with pytest.raises(fx.FexError, match="blocked"):
|
||||
fx.cmd_install(dict(args, force_unverified=True))
|
||||
|
||||
|
||||
def test_profile_key_does_not_bypass_its_build_match(fx):
|
||||
_meta_game(fx, "same-name", "Proj/Binaries/Win64/Game.exe")
|
||||
fx.GAMES["same-name"] = {"match": {"executables": [{"name": "Game.exe", "size": 99}]},
|
||||
"status": "likely", "graphics": "d3d12"}
|
||||
info, verified = fx.game_info("same-name")
|
||||
assert not verified and "status" not in info and "graphics" not in info
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["d3d11", "d3d9", "d3d12", "opengl", "vulkan"])
|
||||
def test_launcher_routes_and_config_are_scoped_to_anchor(fx, tmp_path, route):
|
||||
anchor = tmp_path / "anchor"
|
||||
anchor.mkdir()
|
||||
info = {"exe": "game.exe", "title": "Game", "graphics": route, "revive": False,
|
||||
"dxvk": ["dxgi.maxFrameRate = 72", "d3d11.zeroInitWorkgroupMemory = True"]}
|
||||
fx.write_launcher("test", info, str(anchor))
|
||||
text = (anchor / "launch.sh").read_text()
|
||||
assert "unset PROTON_USE_WINED3D PROTON_NO_D3D12 PROTON_NO_D3D11" in text
|
||||
assert ("export PROTON_USE_WINED3D=1" in text) == (route == "opengl")
|
||||
assert 'export DXVK_CONFIG_FILE="$A/dxvk.conf"' in text and ' > "$A/dxvk.conf"' in text
|
||||
assert "ReviveInjector.exe" not in text
|
||||
assert 'taskset -c "$FP_CORES" "$G/proton" run ' in text and "FP_CORES=4-7" in text
|
||||
|
||||
|
||||
def test_32bit_and_vkd3d_layout_report(fx, tmp_path, monkeypatch):
|
||||
exe = tmp_path / "game.exe"
|
||||
data = _ime_pe()
|
||||
struct.pack_into("<H", data, 0x84, 0x14c)
|
||||
exe.write_bytes(data)
|
||||
ge = tmp_path / "ge"
|
||||
dll = ge / "files/lib64/wine/vkd3d-proton/d3d12.dll"
|
||||
dll.parent.mkdir(parents=True)
|
||||
dll.touch()
|
||||
monkeypatch.setattr(fx, "GE", str(ge))
|
||||
facts = fx.INSTALL_CHECK_HOOKS[0]("test", {"graphics": "d3d12"}, {"exe": str(exe)})
|
||||
assert facts["exe_32bit"] and "untested" in facts["wow64"]
|
||||
assert facts["vkd3d_dlls"] == ["files/lib64/wine/vkd3d-proton/d3d12.dll"]
|
||||
|
||||
|
||||
def test_old_modular_unreal_core_has_the_same_ime_cvar(fx, tmp_path):
|
||||
# Old Core contains FWindowsApplication; Slate itself and the exe need not contain the registration at all.
|
||||
exe = tmp_path / "RoboRecall-Win64-Shipping.exe"
|
||||
exe.write_bytes(b"no registration here")
|
||||
core = tmp_path / "RoboRecall-Core-Win64-Shipping.dll"
|
||||
core.write_bytes(_ime_pe())
|
||||
target, patch, why = fx.find_ime_target([str(exe)])
|
||||
assert target == str(core) and patch["rva"] == 0x1100 and why is None
|
||||
assert fx.apply_byte_patch(target, patch) == "patched"
|
||||
assert fx.find_ime_target([str(exe)])[1] == patch # repeated install is idempotent
|
||||
core.write_bytes(_ime_pe("unreferenced"))
|
||||
assert fx.find_ime_target([str(exe)])[1] is None # a CVar string alone is insufficient
|
||||
|
||||
|
||||
def test_profiles_do_not_enable_candidate_caches():
|
||||
games = json.loads((ROOT / "agent/fexrift_games.json").read_text())
|
||||
for key, info in games.items():
|
||||
if key.startswith("_"):
|
||||
continue
|
||||
assert info.get("status", "verified") in compat().STATUSES
|
||||
if info.get("status") != "verified":
|
||||
assert info.get("fp_mem") != "enabled" and info.get("env", {}).get("FP_MEM") != "1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("log", "sig"), [
|
||||
("OVRServer: Missing entitlement", "fex-missing-entitlement"),
|
||||
("OVRServer: app status install_available", "fex-registration-missing"),
|
||||
("Rejecting new committed universe since IMU fallback is active", "fex-imu-fallback"),
|
||||
("OVRLibrarian: Service invocation completed\nFramePort FEX: OVRLibrarian still running", "fex-librarian-stuck"),
|
||||
("err:vkd3d: Failed to create Vulkan device", "fex-vkd3d-init"),
|
||||
("err:wined3d:wined3d_adapter_gl_init Failed to initialize OpenGL", "fex-wined3d-init"),
|
||||
("ReviveInjector: Failed to inject process", "fex-revive-injector"),
|
||||
])
|
||||
def test_fex_triage(log, sig):
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
result = triage(log, "EXITED", "rift.test")
|
||||
finding = next(f for f in result.findings if f.id == sig)
|
||||
assert finding.suggest == []
|
||||
if sig == "fex-missing-entitlement":
|
||||
assert finding.severity == "info" and "not a FramePort bug" in finding.diagnosis
|
||||
|
||||
|
||||
def test_fex_triage_does_not_treat_normal_completion_as_hang():
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
log = "OVRLibrarian: Service invocation completed\nReviveInjector: Succesfully injected!"
|
||||
result = triage(log, "RUNNING", "rift.test")
|
||||
assert not any(f.id in ("fex-librarian-stuck", "fex-revive-injector") for f in result.findings)
|
||||
@@ -1,82 +0,0 @@
|
||||
"""fexrift.py's extension modules (fexrift_<area>.py) and per-game data (fexrift_games.json)."""
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_copy(monkeypatch, tmp_path, ext_source=None):
|
||||
"""fexrift.py copied into its own folder (HERE), optionally with one extension module next to it."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
d = tmp_path / "artifacts"
|
||||
d.mkdir()
|
||||
shutil.copy2(ROOT / "agent" / "fexrift.py", d / "fexrift.py")
|
||||
shutil.copy2(ROOT / "agent" / "fexrift_games.json", d / "fexrift_games.json")
|
||||
if ext_source:
|
||||
(d / "fexrift_perf.py").write_text(ext_source)
|
||||
spec = importlib.util.spec_from_file_location("fexrift", d / "fexrift.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_games_json_loaded():
|
||||
data = json.loads((ROOT / "agent" / "fexrift_games.json").read_text())
|
||||
assert "oculus-first-contact" in data
|
||||
|
||||
|
||||
def test_games_rva_hex(monkeypatch, tmp_path):
|
||||
fx = load_copy(monkeypatch, tmp_path)
|
||||
fc = fx.GAMES["oculus-first-contact"]
|
||||
assert fc["ime_patch"]["rva"] == 0x22a605
|
||||
assert not any(k.startswith("_") for k in fc) and "_comment" not in fx.GAMES
|
||||
|
||||
|
||||
def test_extension_registers_command_and_hooks(monkeypatch, tmp_path):
|
||||
src = (
|
||||
"def register(core):\n"
|
||||
" core.COMMANDS['hello'] = lambda args: {'root': core.ROOT}\n"
|
||||
" core.LAUNCHER_ENV_HOOKS.append(lambda app, info: ['export FP_TEST=1'])\n"
|
||||
)
|
||||
fx = load_copy(monkeypatch, tmp_path, src)
|
||||
fx.load_extensions()
|
||||
assert fx.LOADED_EXTENSIONS == ["perf"]
|
||||
monkeypatch.setattr(fx, "ROOT", "/patched")
|
||||
assert fx.COMMANDS["hello"]({}) == {"root": "/patched"}
|
||||
anchor = tmp_path / "anchor"
|
||||
anchor.mkdir()
|
||||
fx.write_launcher("oculus-first-contact", fx.GAMES["oculus-first-contact"], str(anchor))
|
||||
assert "export FP_TEST=1\n" in (anchor / "launch.sh").read_text()
|
||||
|
||||
|
||||
def test_no_extensions_is_fine(monkeypatch, tmp_path):
|
||||
fx = load_copy(monkeypatch, tmp_path)
|
||||
fx.load_extensions()
|
||||
assert fx.LOADED_EXTENSIONS == []
|
||||
|
||||
|
||||
def test_artifact_files_include_extensions_and_data():
|
||||
from frameport import fexrift
|
||||
|
||||
rels = {rel for _, rel in fexrift.artifact_files()}
|
||||
assert {"fexrift.py", "fexrift_games.json"} <= rels
|
||||
|
||||
|
||||
def test_signed_in_needs_a_user_id_folder(monkeypatch, tmp_path):
|
||||
fx = load_copy(monkeypatch, tmp_path)
|
||||
sessions = tmp_path / "sessions"
|
||||
assert not fx.signed_in(str(sessions))
|
||||
(sessions / "_oaf").mkdir(parents=True)
|
||||
(sessions / "_oaf" / "data.sqlite").write_text("x") # what the runtime alone creates
|
||||
assert not fx.signed_in(str(sessions))
|
||||
(sessions / "x1368").mkdir()
|
||||
assert not fx.signed_in(str(sessions))
|
||||
(sessions / "1368740236322643-1275611841").mkdir() # the real shape: <user id>-<n>
|
||||
assert fx.signed_in(str(sessions))
|
||||
|
||||
|
||||
def test_ime_patch_off_in_profile(monkeypatch, tmp_path):
|
||||
fx = load_copy(monkeypatch, tmp_path)
|
||||
assert fx.GAMES["chronos"]["ime_patch"] == "off"
|
||||
+17
-13
@@ -8,6 +8,12 @@ from frameport.sources import quest_dump
|
||||
|
||||
pytestmark = pytest.mark.games
|
||||
|
||||
# 2026-10-15 full snapshot (80 dumps, 65 supported catalog games), strict score: 5/65 exact, patch F1 73.8%;
|
||||
# rounded down. The dated score (--fit-report's date cache) was 39/65, F1 88.6%. See docs/HEURISTICS_EVAL.md;
|
||||
# runtime-only choices and unsupported catalog entries are excluded.
|
||||
MIN_HEURISTICS_EXACT_RATIO = 0.07
|
||||
MIN_HEURISTICS_PATCH_F1 = 0.73
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dumps():
|
||||
@@ -36,8 +42,7 @@ def test_every_catalog_game_found_and_detected(dumps):
|
||||
|
||||
|
||||
def test_heuristics_reproduce_catalog(dumps):
|
||||
"""Without the catalog, heuristics must reproduce the verified recipes (Phantom's use-the-no-ForceQuit-build is a
|
||||
runtime finding the triage makes, not a static one)."""
|
||||
"""Ratchet catalog-only static accuracy without requiring runtime findings to be detectable from APKs."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
@@ -45,16 +50,15 @@ def test_heuristics_reproduce_catalog(dumps):
|
||||
spec = importlib.util.spec_from_file_location("ev", script)
|
||||
ev = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(ev)
|
||||
exact = total = 0
|
||||
snapshot = {}
|
||||
for g in dumps:
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
entry = catalog.lookup(a.package)
|
||||
if not entry:
|
||||
continue
|
||||
from frameport.recommend import engine
|
||||
|
||||
want, got = ev.signature(engine.suggest(a)), ev.signature(engine.suggest(a, use_catalog=False))
|
||||
want["status_unsupported"] = got["status_unsupported"] = False
|
||||
total += 1
|
||||
exact += want == got
|
||||
assert exact >= total - 1, f"only {exact}/{total} catalog recipes reproduced by heuristics"
|
||||
row = {"analysis": a.to_dict(), "converted": a.is_overport_output}
|
||||
if a.package not in snapshot or snapshot[a.package]["converted"] and not row["converted"]:
|
||||
snapshot[a.package] = row
|
||||
entries = catalog._load_dir(script.parents[1] / "catalog/games", "bundled")
|
||||
result = ev.evaluate(snapshot, entries, split="all")["headline"]
|
||||
assert result["games"], "no supported catalog games found in the dumps"
|
||||
assert result["exact_ratio"] >= MIN_HEURISTICS_EXACT_RATIO, (
|
||||
f"only {result['exact']}/{result['games']} static catalog recipes reproduced exactly")
|
||||
assert result["patches"]["f1"] >= MIN_HEURISTICS_PATCH_F1, result["patches"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Adapter recommendations use the detected runtime path, without a catalog."""
|
||||
import pytest
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.recommend.engine import suggest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("extra", "other_libs", "converted", "expected"), [
|
||||
# Built-in Oculus has the system-app check; modern Unity OpenXR can still ship OVRPlugin.
|
||||
({"unity_oculus_check": True}, [], False, True),
|
||||
({"unity_oculus_check": False}, ["libUnityOpenXR.so"], False, False),
|
||||
({"unity_oculus_check": False}, [], False, False),
|
||||
# Neither the version nor the graphics API is the reason to select this fix.
|
||||
({"unity_oculus_check": False, "unity_version": "2019.4.20f1"}, [], False, False),
|
||||
({"unity_oculus_check": True, "unity_version": "2019.4.20f1"}, [], False, True),
|
||||
# The built-in check can coexist with XR Plugin Management: retain both exclusions.
|
||||
({"unity_oculus_check": True, "oculus_xr_plugin": True}, [], False, False),
|
||||
({"unity_oculus_check": True}, ["libOculusXRPlugin.so"], False, False),
|
||||
# Old analyses have no check signal. Converted builds may have the check patched out.
|
||||
({}, [], False, True),
|
||||
({"unity_oculus_check": False}, [], True, True),
|
||||
({"unity_oculus_check": False}, ["libOculusXRPlugin.so"], True, False),
|
||||
])
|
||||
def test_pose_time_fix_uses_builtin_oculus_evidence(extra, other_libs, converted, expected):
|
||||
a = _analysis(libs=["libunity.so", "libOVRPlugin.so", *other_libs],
|
||||
extra=extra, is_overport_output=converted)
|
||||
patch = base.get("adapter.pose_time_fix")
|
||||
detected = patch.detect(a)
|
||||
assert patch.applies(a) # still available for manual investigation in any OVRPlugin game
|
||||
assert bool(detected and detected.recommended) is expected
|
||||
recipe = suggest(a, use_catalog=False)
|
||||
assert (patch.id in recipe.patches) is expected
|
||||
if expected:
|
||||
assert recipe.patches[patch.id] == {"value": 1}
|
||||
# The new evidence is specific to pose timing: OVRPlugin's haptic bug affects both paths.
|
||||
assert recipe.patches["adapter.haptic_fix"] == {"value": 1}
|
||||
|
||||
|
||||
def test_pose_time_fix_still_requires_ovrplugin():
|
||||
a = _analysis(libs=["libunity.so"], extra={"unity_oculus_check": True}, is_overport_output=False)
|
||||
patch = base.get("adapter.pose_time_fix")
|
||||
assert not patch.applies(a)
|
||||
assert patch.detect(a) is None
|
||||
assert patch.id not in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
def test_unreal_pose_evidence_is_independent_of_unity():
|
||||
a = _analysis(engine="Unreal", libs=["libUE4.so", "libOVRPlugin.so"], is_overport_output=False,
|
||||
extra={"unity_oculus_check": False, "unreal_thumb_touch": True})
|
||||
assert suggest(a, use_catalog=False).patches["adapter.pose_time_fix"] == {"value": 1}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Unity heuristics from executable paths and tool support, without catalog/package matching."""
|
||||
import pytest
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.recommend.engine import suggest
|
||||
|
||||
IL2CPP_FIXES = ("frame.unity_text_input", "frame.unity_runtime_msaa_off")
|
||||
|
||||
|
||||
def _il2cpp(**changes):
|
||||
values = dict(libs=["libunity.so", "libil2cpp.so", "libOVRPlugin.so"], graphics="GLES",
|
||||
extra={"unity_version": "2019.4.10f1", "text_fields": ["TMP_InputField"],
|
||||
"ovr_runtime_msaa": True})
|
||||
values.update(changes)
|
||||
return _analysis(**values)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
|
||||
@pytest.mark.parametrize("version", ["2017.4.23f1", "2017.1.0f1", "5.6.7f1"])
|
||||
def test_unreadable_old_metadata_does_not_recommend_il2cpp_rewrites(pid, version):
|
||||
a = _il2cpp(extra={"unity_version": version, "text_fields": ["TMP_InputField"], "ovr_runtime_msaa": True})
|
||||
p = base.get(pid)
|
||||
assert p.detect(a) is None
|
||||
assert pid not in suggest(a, use_catalog=False).patches
|
||||
assert p.applies(a) # still available for an explicit recipe; only automatic detection changes
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
|
||||
@pytest.mark.parametrize("version", ["2018.4.0f1", "2019.4.1f1", "2022.3.1f1", "6000.5.6f1", None, "unknown"])
|
||||
def test_supported_or_unknown_metadata_keeps_il2cpp_recommendations(pid, version):
|
||||
a = _il2cpp(extra={"unity_version": version, "text_fields": ["InputField"], "ovr_runtime_msaa": True})
|
||||
assert base.get(pid).detect(a).recommended
|
||||
assert pid in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
|
||||
@pytest.mark.parametrize("abis", [["armeabi-v7a"], ["x86_64"], []])
|
||||
def test_aarch64_return_instructions_are_not_recommended_for_other_abis(pid, abis):
|
||||
assert base.get(pid).detect(_il2cpp(abis=abis)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pid", IL2CPP_FIXES)
|
||||
def test_il2cpp_rewrites_still_need_unity_and_il2cpp(pid):
|
||||
assert base.get(pid).detect(_il2cpp(engine="Unreal")) is None
|
||||
assert base.get(pid).detect(_il2cpp(libs=["libunity.so", "libmonobdwgc-2.0.so"])) is None
|
||||
|
||||
|
||||
def test_text_classes_remain_a_signal_for_both_unity_field_implementations():
|
||||
p = base.get("frame.unity_text_input")
|
||||
for fields in (["TMP_InputField"], ["InputField"], ["TMP_InputField", "InputField"]):
|
||||
a = _il2cpp(extra={"text_fields": fields})
|
||||
assert p.detect(a).recommended
|
||||
assert base.get("device.text_input_window").detect(a).recommended
|
||||
assert p.detect(_il2cpp(extra={"text_fields": []})) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("graphics", ["Vulkan (declared in manifest)", "Vulkan (Unity boot.config)"])
|
||||
def test_runtime_msaa_does_not_guess_gles_from_a_vulkan_label(graphics):
|
||||
assert base.get("frame.unity_runtime_msaa_off").detect(_il2cpp(graphics=graphics)) is None
|
||||
|
||||
|
||||
def test_runtime_msaa_needs_the_runtime_msaa_signal():
|
||||
assert base.get("frame.unity_runtime_msaa_off").detect(_il2cpp(extra={}, unity_msaa_levels=4)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["2017.4.23f1", "2019.4.20f1", None])
|
||||
def test_built_in_oculus_path_still_needs_system_app_bypass(version):
|
||||
a = _il2cpp(extra={"unity_version": version, "unity_oculus_check": True})
|
||||
p = base.get("frame.unity_oculus_check")
|
||||
assert p.detect(a).recommended
|
||||
assert p.id in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
def test_oculus_xr_plugin_still_gets_the_builtin_startup_check():
|
||||
"""A verified game (com.coatsink.alone) ships libOculusXRPlugin.so and still needs the bypass."""
|
||||
a = _il2cpp(libs=["libunity.so", "libil2cpp.so", "libOVRPlugin.so", "libOculusXRPlugin.so"],
|
||||
extra={"unity_version": "2019.4.35f1", "unity_oculus_check": True})
|
||||
assert base.get("frame.unity_oculus_check").detect(a).recommended
|
||||
|
||||
|
||||
def test_oculus_metadata_field_alone_does_not_prove_a_native_plugin_path():
|
||||
a = _il2cpp(extra={"unity_oculus_check": True, "oculus_xr_plugin": True})
|
||||
assert base.get("frame.unity_oculus_check").detect(a).recommended
|
||||
|
||||
|
||||
def test_oculus_bypass_needs_both_the_check_and_ovrplugin():
|
||||
p = base.get("frame.unity_oculus_check")
|
||||
assert p.detect(_il2cpp(extra={})) is None
|
||||
assert p.detect(_il2cpp(libs=["libunity.so"], extra={"unity_oculus_check": True})) is None
|
||||
assert p.detect(_il2cpp(engine="Other", extra={"unity_oculus_check": True})) is None
|
||||
|
||||
|
||||
FIRESTORE = "libFirebaseCppFirestore.so"
|
||||
GECKO = "libxul.so"
|
||||
|
||||
|
||||
def _tls(version="6000.5.6f1", heavy=(FIRESTORE, GECKO), **changes):
|
||||
return _analysis(libs=["libunity.so", *heavy], extra={"unity_version": version}, **changes)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["6000.0.1f1", "6000.5.6f1"])
|
||||
def test_unity6_firestore_and_gecko_recommend_thread_key_pool(version):
|
||||
a = _tls(version)
|
||||
p = base.get("frame.unity_tls_key_pool")
|
||||
assert p.detect(a).recommended
|
||||
assert p.id in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
@pytest.mark.parametrize("heavy", [(FIRESTORE,), (GECKO,), ("libFirebaseCppApp-9_6_0.so", GECKO)])
|
||||
def test_less_thread_key_pressure_remains_an_optional_diagnostic(heavy):
|
||||
a = _tls(heavy=heavy)
|
||||
p = base.get("frame.unity_tls_key_pool")
|
||||
assert not p.detect(a).recommended
|
||||
assert p.id not in suggest(a, use_catalog=False).patches
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", ["2019.4.1f1", "2022.3.1f1", None, "unknown"])
|
||||
def test_thread_key_pressure_rule_requires_unity6(version):
|
||||
assert base.get("frame.unity_tls_key_pool").detect(_tls(version)) is None
|
||||
|
||||
|
||||
def test_thread_key_pool_requires_native_unity_for_arm64():
|
||||
p = base.get("frame.unity_tls_key_pool")
|
||||
assert p.detect(_tls(engine="Unreal")) is None
|
||||
assert p.detect(_tls(abis=["armeabi-v7a"])) is None
|
||||
assert p.detect(_analysis(libs=[FIRESTORE, GECKO], extra={"unity_version": "6000.5.6f1"})) is None
|
||||
|
||||
|
||||
def test_rendering_tradeoffs_stay_opt_in_without_the_runtime_symptom():
|
||||
a = _il2cpp(libs=["libunity.so", "libil2cpp.so", "libOculusXRPlugin.so"], unity_msaa_levels=4,
|
||||
extra={"oculus_xr_plugin": True, "ovr_runtime_msaa": True})
|
||||
for pid in ("frame.unity_multipass", "frame.unity_no_overlay_copy"):
|
||||
p = base.get(pid)
|
||||
assert p.applies(a) and p.detect(a) is None
|
||||
assert pid not in suggest(a, use_catalog=False).patches
|
||||
@@ -0,0 +1,223 @@
|
||||
"""What FrameBridge's xrGetCurrentInteractionProfile reports (native/adapter/profile_report.c): SteamVR's profiles as
|
||||
Touch (controller_fix) and, after profile_remap moved a game's Touch Plus/Pro bindings to Touch, the game's own profile
|
||||
(GitHub #327: Unity's OpenXR input found no device for oculus/touch_controller, the hands lay on the floor). Compiled
|
||||
for this host with a small harness standing in for the adapter and the runtime.
|
||||
Opt-in (FRAMEPORT_NATIVE_TESTS=1, -m native): needs the OpenXR headers in native/.cache (from native/build.py)."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INC = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
|
||||
CC = shutil.which("cc") or shutil.which("clang")
|
||||
pytestmark = [pytest.mark.native, pytest.mark.skipif(
|
||||
not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not INC or not CC,
|
||||
reason="set FRAMEPORT_NATIVE_TESTS=1 (needs the OpenXR headers from native/build.py and a host C compiler)")]
|
||||
|
||||
HARNESS = r"""
|
||||
#include <openxr/openxr.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
|
||||
static XrInstance active_instance = (XrInstance)(uintptr_t)1;
|
||||
static int controller_fix = 1, profile_remap = 1, app_suggested_touch;
|
||||
static const char *const TOUCH_PROFILE = "/interaction_profiles/oculus/touch_controller";
|
||||
static char paths[16][XR_MAX_PATH_LENGTH];
|
||||
static int path_count;
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_string_to_path(XrInstance i, const char *s, XrPath *out) {
|
||||
(void)i;
|
||||
for (int k = 0; k < path_count; ++k) if (!strcmp(paths[k], s)) { *out = (XrPath)(k + 1); return XR_SUCCESS; }
|
||||
snprintf(paths[path_count], XR_MAX_PATH_LENGTH, "%s", s);
|
||||
*out = (XrPath)++path_count;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_path_to_string(XrInstance i, XrPath p, uint32_t cap, uint32_t *n,
|
||||
char *out) {
|
||||
(void)i;
|
||||
if (!p || p > (XrPath)path_count) return XR_ERROR_PATH_INVALID;
|
||||
*n = (uint32_t)snprintf(out, cap, "%s", paths[p - 1]) + 1;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance i, const char *name) {
|
||||
(void)i;
|
||||
if (!strcmp(name, "xrPathToString")) return (PFN_xrVoidFunction)fake_path_to_string;
|
||||
if (!strcmp(name, "xrStringToPath")) return (PFN_xrVoidFunction)fake_string_to_path;
|
||||
return NULL;
|
||||
}
|
||||
#include "profile_report.c"
|
||||
static XrPath P(const char *s) { XrPath p; fake_string_to_path(0, s, &p); return p; }
|
||||
static const char *answer(const char *hand, const char *runtime) {
|
||||
XrInteractionProfileState state = {XR_TYPE_INTERACTION_PROFILE_STATE, NULL, runtime ? P(runtime) : XR_NULL_PATH};
|
||||
current_profile_fix(P(hand), &state);
|
||||
return state.interactionProfile ? paths[state.interactionProfile - 1] : "(none)";
|
||||
}
|
||||
int main(void) {
|
||||
const char *touch = "/interaction_profiles/oculus/touch_controller";
|
||||
const char *frame = "/interaction_profiles/valve/frame_controller";
|
||||
const char *plus = "/interaction_profiles/meta/touch_controller_plus";
|
||||
// no remap: SteamVR's profile becomes Touch, Touch stays Touch, none stays none
|
||||
printf("%s\n", answer("/user/hand/left", frame));
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
printf("%s\n", answer("/user/hand/left", NULL));
|
||||
// the game's Touch Plus bindings were moved to Touch: Touch (and SteamVR's profile) are reported as Touch Plus
|
||||
snprintf(remapped_profile, sizeof(remapped_profile), "%s", plus);
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
printf("%s\n", answer("/user/hand/right", frame));
|
||||
printf("%s\n", answer("/user/hand/right", NULL));
|
||||
printf("%s\n", answer("/user/hand/right", "/interaction_profiles/htc/vive_controller"));
|
||||
// controller_fix off: SteamVR's own profile is passed on unchanged, Touch is still reported as the game's
|
||||
controller_fix = 0;
|
||||
printf("%s\n", answer("/user/hand/left", frame));
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
// the game suggested Touch bindings itself: Touch is reported as Touch
|
||||
controller_fix = 1;
|
||||
app_suggested_touch = 1;
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
// profile_remap off: Touch as Touch
|
||||
app_suggested_touch = 0;
|
||||
profile_remap = 0;
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
STDOUT = [
|
||||
"/interaction_profiles/oculus/touch_controller",
|
||||
"/interaction_profiles/oculus/touch_controller",
|
||||
"(none)",
|
||||
"/interaction_profiles/meta/touch_controller_plus",
|
||||
"/interaction_profiles/meta/touch_controller_plus",
|
||||
"(none)",
|
||||
"/interaction_profiles/htc/vive_controller",
|
||||
"/interaction_profiles/valve/frame_controller",
|
||||
"/interaction_profiles/meta/touch_controller_plus",
|
||||
"/interaction_profiles/oculus/touch_controller",
|
||||
"/interaction_profiles/oculus/touch_controller",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def harness(tmp_path_factory):
|
||||
d = tmp_path_factory.mktemp("profile_report")
|
||||
(d / "harness.c").write_text(HARNESS)
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
|
||||
"-Wno-missing-field-initializers", "-I", str(INC), "-I", str(ROOT / "native/adapter"),
|
||||
str(d / "harness.c"), "-o", str(d / "harness")], check=True)
|
||||
return d / "harness"
|
||||
|
||||
|
||||
def test_reports_the_games_profile_after_a_remap(harness):
|
||||
p = subprocess.run([str(harness)], capture_output=True, text=True)
|
||||
assert p.returncode == 0, p.stderr
|
||||
assert p.stdout.splitlines() == STDOUT
|
||||
logged = [line for line in p.stderr.splitlines() if line.startswith("profile_remap:")]
|
||||
assert logged[:2] == [
|
||||
"profile_remap: /user/hand/left reported as meta/touch_controller_plus (the app's profile) instead of "
|
||||
"oculus/touch_controller",
|
||||
"profile_remap: /user/hand/right reported as meta/touch_controller_plus (the app's profile) instead of "
|
||||
"oculus/touch_controller",
|
||||
]
|
||||
assert len(logged) == 3 # once per report (at most 4)
|
||||
|
||||
|
||||
DETACHED = r"""
|
||||
#include <openxr/openxr.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
|
||||
static XrInstance active_instance = (XrInstance)(uintptr_t)1;
|
||||
static int profile_remap = 1, runtime_has_detached_controllers;
|
||||
static char paths[32][XR_MAX_PATH_LENGTH];
|
||||
static int path_count;
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_string_to_path(XrInstance i, const char *s, XrPath *out) {
|
||||
(void)i;
|
||||
for (int k = 0; k < path_count; ++k) if (!strcmp(paths[k], s)) { *out = (XrPath)(k + 1); return XR_SUCCESS; }
|
||||
snprintf(paths[path_count], XR_MAX_PATH_LENGTH, "%s", s);
|
||||
*out = (XrPath)++path_count;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_path_to_string(XrInstance i, XrPath p, uint32_t cap, uint32_t *n,
|
||||
char *out) {
|
||||
(void)i;
|
||||
if (!p || p > (XrPath)path_count) return XR_ERROR_PATH_INVALID;
|
||||
*n = (uint32_t)snprintf(out, cap, "%s", paths[p - 1]) + 1;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance i, const char *name) {
|
||||
(void)i;
|
||||
if (!strcmp(name, "xrPathToString")) return (PFN_xrVoidFunction)fake_path_to_string;
|
||||
return NULL;
|
||||
}
|
||||
#include "detached_controllers.c"
|
||||
static XrPath P(const char *s) { XrPath p; fake_string_to_path(0, s, &p); return p; }
|
||||
static void show_action(const XrActionCreateInfo *info) {
|
||||
printf("%s:", info->actionName);
|
||||
for (uint32_t i = 0; i < info->countSubactionPaths; ++i) printf(" %s", paths[info->subactionPaths[i] - 1]);
|
||||
printf("\n");
|
||||
}
|
||||
int main(void) {
|
||||
XrPath sub[4] = {P("/user/hand/left"), P("/user/hand/right"), P("/user/detached_controller_meta/left"),
|
||||
P("/user/detached_controller_meta/right")}, kept[4];
|
||||
XrActionCreateInfo info = {XR_TYPE_ACTION_CREATE_INFO, NULL, "thumbstick", XR_ACTION_TYPE_VECTOR2F_INPUT, 4, sub,
|
||||
"Thumbstick"}, copy;
|
||||
show_action(detached_strip_action(&info, ©, kept));
|
||||
info.countSubactionPaths = 2; // hands only: passed on as it is
|
||||
printf("%d\n", detached_strip_action(&info, ©, kept) == &info);
|
||||
|
||||
XrActionSuggestedBinding b[3] = {{(XrAction)(uintptr_t)7, P("/user/hand/left/input/thumbstick")},
|
||||
{(XrAction)(uintptr_t)7, P("/user/detached_controller_meta/left/input/thumbstick")},
|
||||
{(XrAction)(uintptr_t)8, P("/user/hand/right/input/a/click")}}, kb[3];
|
||||
XrInteractionProfileSuggestedBinding s = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, NULL,
|
||||
P("/interaction_profiles/oculus/touch_controller"), 3, b}, scopy;
|
||||
const XrInteractionProfileSuggestedBinding *u = detached_strip_bindings(&s, &scopy, kb);
|
||||
for (uint32_t i = 0; i < u->countSuggestedBindings; ++i) printf("%s\n", paths[u->suggestedBindings[i].binding - 1]);
|
||||
|
||||
XrInteractionProfileState st = {XR_TYPE_INTERACTION_PROFILE_STATE, NULL, 5};
|
||||
XrResult r = detached_current_profile(sub[2], &st, XR_ERROR_PATH_UNSUPPORTED);
|
||||
printf("%d %llu\n", r, (unsigned long long)st.interactionProfile);
|
||||
printf("%d\n", detached_current_profile(sub[0], &st, XR_ERROR_PATH_UNSUPPORTED)); // hands: unchanged
|
||||
printf("%d\n", detached_current_profile(sub[2], &st, XR_ERROR_ACTIONSET_NOT_ATTACHED)); // other errors stay
|
||||
|
||||
runtime_has_detached_controllers = 1; // the runtime knows them: nothing changes
|
||||
info.countSubactionPaths = 4;
|
||||
printf("%d %d %d\n", detached_strip_action(&info, ©, kept) == &info,
|
||||
detached_strip_bindings(&s, &scopy, kb) == &s,
|
||||
detached_current_profile(sub[2], &st, XR_ERROR_PATH_UNSUPPORTED));
|
||||
runtime_has_detached_controllers = 0;
|
||||
profile_remap = 0; // off: nothing changes
|
||||
printf("%d %d\n", detached_strip_action(&info, ©, kept) == &info, detached_strip_bindings(&s, &scopy, kb) == &s);
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_detached_controller_paths_left_out(tmp_path):
|
||||
(tmp_path / "detached.c").write_text(DETACHED)
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
|
||||
"-Wno-missing-field-initializers", "-I", str(INC), "-I", str(ROOT / "native/adapter"),
|
||||
str(tmp_path / "detached.c"), "-o", str(tmp_path / "detached")], check=True)
|
||||
p = subprocess.run([str(tmp_path / "detached")], capture_output=True, text=True)
|
||||
assert p.returncode == 0, p.stderr
|
||||
assert p.stdout.splitlines() == [
|
||||
"thumbstick: /user/hand/left /user/hand/right",
|
||||
"1",
|
||||
"/user/hand/left/input/thumbstick",
|
||||
"/user/hand/right/input/a/click",
|
||||
"0 0",
|
||||
str(-22), # XR_ERROR_PATH_UNSUPPORTED
|
||||
str(-46), # XR_ERROR_ACTIONSET_NOT_ATTACHED
|
||||
"1 1 -22",
|
||||
"1 1",
|
||||
]
|
||||
assert p.stderr.splitlines() == [
|
||||
"profile_remap: action thumbstick without 2 detached-controller path(s) (the runtime has no "
|
||||
"XR_META_detached_controllers)",
|
||||
"profile_remap: /interaction_profiles/oculus/touch_controller bindings without 1 detached-controller path(s)",
|
||||
]
|
||||
@@ -1,159 +0,0 @@
|
||||
"""scripts/rift_fex_survey.py: Unreal version strings, pak indexes (v3, v11) on synthetic paks, RHI and verdicts."""
|
||||
import importlib.util
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sv():
|
||||
spec = importlib.util.spec_from_file_location("rift_fex_survey", ROOT / "scripts" / "rift_fex_survey.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def fstr(s: str) -> bytes:
|
||||
b = s.encode("latin-1") + b"\0"
|
||||
return struct.pack("<i", len(b)) + b
|
||||
|
||||
|
||||
INI = b"[/Script/WindowsTargetPlatform.WindowsTargetSettings]\r\n+TargetedRHIs=PCD3D_SM5\r\n" \
|
||||
b"[/Script/Engine.RendererSettings]\r\nr.MobileMSAA=4\r\nr.ForwardShading=True\r\n"
|
||||
|
||||
|
||||
def legacy_entry(off: int, data: bytes) -> bytes:
|
||||
return struct.pack("<qqqi", off, len(data), len(data), 0) + bytes(20) + b"\0" + struct.pack("<I", 0)
|
||||
|
||||
|
||||
def pak_v3(path: Path):
|
||||
body = legacy_entry(0, INI) + INI
|
||||
index = fstr("../../../") + struct.pack("<i", 1) + fstr("Proj/Config/DefaultEngine.ini") + legacy_entry(0, INI)
|
||||
footer = struct.pack("<IIqq", 0x5A6F12E1, 3, len(body), len(index)) + bytes(20)
|
||||
path.write_bytes(body + index + footer)
|
||||
|
||||
|
||||
def pak_v11(path: Path, encrypted_index=False):
|
||||
"""Two files: the ini stored, a text zlib-compressed (one block), both bit-packed entries + full directory index."""
|
||||
other = b"hello " * 50
|
||||
packed = zlib.compress(other)
|
||||
head = 8 + 8 + 8 + 4 + 20 # entry copy in front of the data
|
||||
stored_at, comp_at = 0, head + 1 + 4 + len(INI)
|
||||
body = bytes(head + 1 + 4) + INI + bytes(head + 4 + 16 + 1 + 4) + packed
|
||||
flags32 = (1 << 31) | (1 << 30) | (1 << 29)
|
||||
enc = struct.pack("<III", flags32, stored_at, len(INI))
|
||||
second = len(enc)
|
||||
enc += struct.pack("<IIII", flags32 | (1 << 23) | (1 << 6), comp_at, len(other), len(packed))
|
||||
fdi = struct.pack("<i", 1) + fstr("Proj/Config/") + struct.pack("<i", 2) + \
|
||||
fstr("DefaultEngine.ini") + struct.pack("<i", 0) + fstr("notes.txt") + struct.pack("<i", second)
|
||||
fdi_off = len(body)
|
||||
index = fstr("../../../") + struct.pack("<iQii", 2, 0, 0, 1) + struct.pack("<qq", fdi_off, len(fdi)) + bytes(20) \
|
||||
+ struct.pack("<i", len(enc)) + enc + struct.pack("<i", 0)
|
||||
ioff = fdi_off + len(fdi)
|
||||
names = b"".join(n.ljust(32, b"\0") for n in (b"Zlib", b"", b"", b"", b""))
|
||||
footer = bytes(16) + bytes([1 if encrypted_index else 0]) + \
|
||||
struct.pack("<IIqq", 0x5A6F12E1, 11, ioff, len(index)) + bytes(20) + names
|
||||
path.write_bytes(body + fdi + index + footer)
|
||||
return other
|
||||
|
||||
|
||||
def test_unreal_version(sv):
|
||||
assert sv.unreal_version(b"x\0++UE4+Release-4.19\0") == "4.19"
|
||||
assert sv.unreal_version(b"\0\0" + "++UE4+Studio-4.27-CL-0".encode("utf-16-le") + b"\0\0") == "4.27"
|
||||
assert sv.unreal_version(b"\0++unreal+4.10+UE4-Studio\0") == "4.10"
|
||||
assert sv.unreal_version(b"\0++depot+Game-CL-55578\0") is None # no number in the branch
|
||||
assert sv.unreal_version(b"\0++5.6+5\0++CrossGPUFence-\0") is None
|
||||
|
||||
|
||||
def test_pak_v3(sv, tmp_path):
|
||||
pak = tmp_path / "a.pak"
|
||||
pak_v3(pak)
|
||||
ver, mount, entries, _ = sv.pak_entries(pak)
|
||||
assert ver == 3 and mount == "../../../"
|
||||
assert sv.pak_read(pak, ver, entries["Proj/Config/DefaultEngine.ini"]) == INI
|
||||
|
||||
|
||||
def test_pak_v11(sv, tmp_path):
|
||||
pak = tmp_path / "b.pak"
|
||||
other = pak_v11(pak)
|
||||
ver, _, entries, names = sv.pak_entries(pak)
|
||||
assert ver == 11 and names[0] == "Zlib"
|
||||
assert set(entries) == {"Proj/Config/DefaultEngine.ini", "Proj/Config/notes.txt"}
|
||||
assert sv.pak_read(pak, ver, entries["Proj/Config/DefaultEngine.ini"], names) == INI
|
||||
assert sv.pak_read(pak, ver, entries["Proj/Config/notes.txt"], names) == other
|
||||
cfg = sv.unreal_config(tmp_path)
|
||||
assert cfg["pak"] in ("v3", "v11") and cfg["config"]["TargetedRHIs"] == ["PCD3D_SM5"]
|
||||
enc = tmp_path / "enc" / "c.pak"
|
||||
enc.parent.mkdir()
|
||||
pak_v11(enc, encrypted_index=True)
|
||||
with pytest.raises(ValueError, match="encrypted"):
|
||||
sv.pak_entries(enc)
|
||||
assert "encrypted" in sv.unreal_config(enc.parent)["pak_notes"][0]
|
||||
|
||||
|
||||
def test_engine_ini_and_rhi(sv):
|
||||
cfg = sv.parse_engine_ini(INI.decode())
|
||||
assert cfg == {"TargetedRHIs": ["PCD3D_SM5"], "r.MobileMSAA": "4", "r.ForwardShading": "True"}
|
||||
assert sv.unreal_rhi(cfg) == ("d3d11", "config")
|
||||
assert sv.unreal_rhi({}) == ("d3d11", "Unreal default")
|
||||
assert sv.unreal_rhi({"DefaultGraphicsRHI": "DefaultGraphicsRHI_DX12"})[0] == "d3d12"
|
||||
assert sv.unreal_rhi({"TargetedRHIs": ["SF_VULKAN_SM5"]})[0] == "vulkan"
|
||||
assert sv.unreal_rhi({"TargetedRHIs": ["PCD3D_SM6"]})[0] == "d3d12"
|
||||
removed = sv.parse_engine_ini("+TargetedRHIs=PCD3D_SM5\n+TargetedRHIs=SF_VULKAN_SM5\n-TargetedRHIs=PCD3D_SM5\n")
|
||||
assert removed["TargetedRHIs"] == ["SF_VULKAN_SM5"]
|
||||
|
||||
|
||||
def test_verdict(sv):
|
||||
ok = {"arch": "x86_64", "graphics": ["d3d11"], "vr": ["LibOVR"], "drm": [], "engine": "Unreal 4.19",
|
||||
"ime_patch": {"rva": "0x1", "module": "exe"}}
|
||||
assert sv.verdict(ok)[0] == "likely OK"
|
||||
assert sv.verdict(dict(ok, graphics=["d3d12"]))[0] == "needs work"
|
||||
assert sv.verdict(dict(ok, ime_patch="CVar name found but not its reference"))[0] == "needs work"
|
||||
assert sv.verdict(dict(ok, ime_patch="no Slate.DeferWindowsMessageProcessing (older Unreal or not Unreal)"))[0] \
|
||||
== "needs work" # a missing marker is not proof that an older engine cannot defer IME messages
|
||||
assert sv.verdict(dict(ok, engine="custom", ime_patch=None))[0] == "needs work"
|
||||
assert sv.verdict(dict(ok, drm=["anti-cheat files"]))[0] == "blocked"
|
||||
|
||||
|
||||
def test_runtime_facts_and_routes(sv):
|
||||
facts = sv.runtime_facts({"XAUDIO2_7.dll", "MFReadWrite.dll", "bink2w64.dll"}, {"game.exe"})
|
||||
assert facts == {"xaudio2": True, "media_foundation": True, "bink": True, "anti_cheat": False}
|
||||
assert sv.runtime_facts({"EasyAntiCheat.dll"}, set())["anti_cheat"]
|
||||
assert sv.graphics_route({"graphics": ["opengl", "d3d9"]}) == "d3d9"
|
||||
assert sv.graphics_route({"graphics": ["d3d12"]}) == "d3d12"
|
||||
assert sv.graphics_route({"graphics": ["opengl"]}) == "opengl"
|
||||
|
||||
|
||||
def test_ready_to_paste_profile_and_cache_hint(sv):
|
||||
r = {"title": "Game", "exe": "Proj/Binaries/Win64/Game.exe", "exe_size": 123,
|
||||
"graphics": ["d3d11"], "large_block_alloc": "FMallocBinned2: fp_mem candidate",
|
||||
"runtime": {"media_foundation": True}, "verdict": ("likely OK", "static survey")}
|
||||
p = sv.suggested_profile(r)
|
||||
assert p["match"] == {"executables": [{"name": "Game.exe", "size": 123}]}
|
||||
assert p["status"] == "likely" and p["fp_mem"] == "candidate" and p["env"] == {}
|
||||
assert "args" not in p # preserve Meta's manifest arguments when pasted into the profile catalog
|
||||
assert "Media Foundation" in p["notes"]
|
||||
assert sv.suggested_profile(dict(r, vr=["OpenVR"]))["revive"] is False
|
||||
assert sv.suggested_profile(dict(r, verdict=("blocked", "anti-cheat")))["status"] == "blocked"
|
||||
|
||||
|
||||
def test_survey_per_exe_facts_and_profile(sv, tmp_path, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from frameport.analysis.rift import PEInfo
|
||||
|
||||
exe = tmp_path / "Game.exe"
|
||||
exe.write_bytes(b"not executed")
|
||||
analysis = SimpleNamespace(xr="OpenVR", engine="Unity", extra={"platform_sdk": False,
|
||||
"exe_candidates": [{"path": "Game.exe"}]})
|
||||
monkeypatch.setattr(sv.rift, "analyze", lambda *a: analysis)
|
||||
pi = PEInfo("x86", ["opengl32.dll", "mfplat.dll"])
|
||||
monkeypatch.setattr(sv.rift, "read_pe", lambda *a, **kw: (pi, b"5.4.2f2"))
|
||||
result = sv.survey_game("Game", tmp_path, "Game.exe")
|
||||
assert result["exe_32bit"] and result["executables"][0]["exe_32bit"]
|
||||
assert result["runtime"]["media_foundation"] and result["suggested_graphics"] == "opengl"
|
||||
assert result["profile"]["status"] == "untested"
|
||||
@@ -510,3 +510,32 @@ def test_obb_and_cube_advice_describes_limits():
|
||||
advice = next(f for f in cube.findings if f.id == "cube-swapchain-refused")
|
||||
assert "GLES" in advice.diagnosis
|
||||
assert "For a Vulkan game, the current build cannot stand in" in advice.diagnosis
|
||||
|
||||
|
||||
# Deadly Delivery 1.0.30 (GitHub #322): renders, then its lobby's Meta entitlement check fails four times and it quits
|
||||
LOG_ENTITLEMENT = """
|
||||
10-15 12:45:23.964 212 240 I ActivityManager: Start proc 1214:com.FlatHeadStudio.DeadlyDelivery/u0a14 for \
|
||||
pre-top-activity {com.FlatHeadStudio.DeadlyDelivery/com.unity3d.player.UnityPlayerActivity}
|
||||
10-15 12:45:38.349 1214 1238 E Unity : [LobbyManager]: Entitlement check failed: .
|
||||
10-15 12:45:38.349 1214 1238 E Unity : FlatHeads.<>c__DisplayClass105_0:<CheckEntitlementAndConfigureAsync>b__0(Message)
|
||||
10-15 12:45:38.352 1214 1238 I Unity : [LobbyManager]: Retrying oculus authentication in 2 seconds.
|
||||
10-15 12:45:43.372 1214 1286 I FrameBridge: pacing: 71.6 fps, displayTime vs predicted: avg 13.97 ms, max 83.33 ms
|
||||
10-15 12:45:46.349 1214 1238 I Unity : stopping xr plugin
|
||||
10-15 12:45:47.424 1214 1238 I Unity : [Photon] Disconnected from Photon (None)
|
||||
10-15 12:45:48.857 1214 1214 I Process : Sending signal. PID: 1214 SIG: 9
|
||||
"""
|
||||
|
||||
|
||||
def test_meta_entitlement_quest():
|
||||
r = triage(LOG_ENTITLEMENT, "EXITED", "com.FlatHeadStudio.DeadlyDelivery")
|
||||
f = next(f for f in r.findings if f.id == "meta-entitlement")
|
||||
assert f.severity == "error" and f.suggest == []
|
||||
assert "Entitlement check failed" in f.evidence
|
||||
assert "oculus-entitlement" not in {x.id for x in r.findings} # the Rift signature stays PC VR only
|
||||
assert r.verdict == "fail"
|
||||
assert "meta-entitlement" in {x.id for x in triage("E Unity: ovrPlatformInitialize_NotEntitled", "EXITED").findings}
|
||||
# routine Platform SDK lines of games that work don't match; a game that logs it and keeps running still passes
|
||||
for ok in ("I Unity : CheckEntitlementAndConfigureAsync", "I Unity : Entitlement check passed",
|
||||
"I Unity : ovr_Entitlement_GetIsViewerEntitled", "I Unity : User is entitled"):
|
||||
assert "meta-entitlement" not in {x.id for x in triage(ok + "\n" + LOG_OK, "RUNNING").findings}
|
||||
assert triage("E Unity : Entitlement check failed\n" + LOG_OK, "RUNNING").verdict == "pass"
|
||||
Reference in new issue
Block a user