mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-28 00:01:09 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2017194f3 | ||
|
|
7a11706b3a | ||
|
|
76d4390b19 | ||
|
|
2022d9986a | ||
|
|
9ff7edf93b | ||
|
|
a5722b50a0 | ||
|
|
0a13ca72a2 | ||
|
|
c53d754007 | ||
|
|
a1662da17d | ||
|
|
0141bad259 | ||
|
|
6ed95684dc | ||
|
|
a6ca8385a9 | ||
|
|
210410f125 |
No files matched your search
@@ -40,6 +40,8 @@ jobs:
|
||||
archive: FramePort-macos-arm64.zip
|
||||
- os: ubuntu-22.04 # oldest supported: the bundle needs the build host's GLib/glibc or newer
|
||||
archive: FramePort-linux-x64.tar.gz
|
||||
- os: ubuntu-22.04-arm # Linux ARM64 (same base as x64)
|
||||
archive: FramePort-linux-arm64.tar.gz
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -50,9 +52,9 @@ jobs:
|
||||
- run: uv sync --extra dev
|
||||
- id: flet
|
||||
run: uv run python scripts/package.py
|
||||
continue-on-error: ${{ runner.os == 'macOS' }}
|
||||
- name: macOS fallback (PyInstaller bundle)
|
||||
if: runner.os == 'macOS' && steps.flet.outcome == 'failure'
|
||||
continue-on-error: ${{ runner.os == 'macOS' || runner.arch == 'ARM64' }}
|
||||
- name: macOS / Linux ARM64 fallback (PyInstaller bundle)
|
||||
if: (runner.os == 'macOS' || runner.arch == 'ARM64') && steps.flet.outcome == 'failure'
|
||||
run: uv run --with pyinstaller python scripts/package.py --pyinstaller
|
||||
|
||||
# ---- signing: self-signed certificate on Windows, ad-hoc on macOS ----
|
||||
@@ -95,7 +97,9 @@ jobs:
|
||||
run: ditto -c -k --keepParent "$(find dist -maxdepth 3 -name '*.app' -type d | head -1)" ${{ matrix.archive }}
|
||||
- name: Package (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: tar czf ${{ matrix.archive }} -C dist --transform 's,^linux,FramePort,' linux
|
||||
run: | # flet build -> dist/linux; the PyInstaller fallback -> dist/FramePort (one folder)
|
||||
if [ -d dist/linux ]; then tar czf ${{ matrix.archive }} -C dist --transform 's,^linux,FramePort,' linux
|
||||
else tar czf ${{ matrix.archive }} -C dist FramePort; fi
|
||||
# self-update with this archive: unpack, verify the layout (+ the signer on Windows), run the real swap script
|
||||
- name: Update smoke test
|
||||
run: uv run python scripts/update_smoke.py ${{ matrix.archive }}
|
||||
@@ -137,7 +141,7 @@ jobs:
|
||||
git fetch --force origin "refs/tags/${tag}:refs/tags/${tag}"
|
||||
whatsnew=$(git tag -l --format='%(contents:body)' "$tag")
|
||||
[ -n "$whatsnew" ] || whatsnew=$(git tag -l --format='%(contents:subject)' "$tag")
|
||||
{ echo "FramePort ${tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64)."; echo;
|
||||
{ echo "FramePort ${tag}: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64)."; echo;
|
||||
if [ -n "$whatsnew" ] && [ "$(git cat-file -t "$tag")" = tag ]; then
|
||||
echo "## What's new"; echo; echo "$whatsnew"; echo; fi
|
||||
echo "Already have FramePort? It offers this update itself (Library → **Update now**)."
|
||||
|
||||
@@ -414,6 +414,18 @@ for steamos (Steam Input) → agent v33 `_keyboard` uinput keyboard ("Type on Fr
|
||||
Installing a second APK into a container with `pm install` re-runs Lepton's post-install hook on the **main** app
|
||||
(`lepton.active_app_id`; moves its files to /data/steam_app) and corrupts it on the next start (fix: touch the APK →
|
||||
re-bake); `cmd_real package install` skips the hook.
|
||||
**User reports (2026-10-04, issues #4-#10):** Steam shortcuts go to the signed-in account (`loginusers.vdf`
|
||||
MostRecent) else every account (agent v35 `library_users`; it used to refuse with >1 account → Play gave Steam's "Game
|
||||
configuration unavailable"); `launch` uses the shortcut's own appid and errors `NOT_IN_LIBRARY`, the app then adds it
|
||||
and plays. Unity IL2CPP fixes share one Cpp2IL run (`unity_text_input.Il2cppReturnPatch`, `ALL_TARGETS`, cache keyed by
|
||||
global-metadata.dat): `frame.unity_runtime_msaa_off` (OVRManager raises MSAA to 4x at runtime: "Switching to the
|
||||
recommended level" → Lucky's Tale restarted the headset; `OVRDisplay.get_recommendedMSAALevel` → 0) and
|
||||
`frame.unity_multipass` (Oculus XR Plugin multiview → MultiPass via `OculusSettings.GetStereoRenderingMode`; I Am Cat's
|
||||
right eye grey). Both checked against Toy Master's Cpp2IL output, not yet in a headset. VR4 quits itself (System.exit
|
||||
after the intro movie) → catalog `use_alt`. **Catalog changes reach existing games** without hardcoded migrations:
|
||||
recipes store `catalog_rev` (= `CatalogEntry.rev()`), `library._follow_catalog` re-derives non-user recipes whose entry
|
||||
changed; a maintained entry beats the user's shared copy when its `updated`/verified date is later (`catalog._newer`). Unity `boot.config` "vulkan" substring mislabels GLES games as Vulkan
|
||||
(I Am Cat ran GLES); OVRPlugin's "Unavailable OpenXR extension: XR_FB_scene" is routine (no longer triaged).
|
||||
**Lepton storage (2026-09-30):** each app's /sdcard (= /storage/emulated/0 → `<base>/lepton-data/external`) has `Movies`/`Download`/`Documents` symlinked to the Frame's `~/Videos`/`~/Downloads`/`~/Documents` (liblepton/mounting.sh, only if they exist at start); agent v24 `storage_targets` reads that mapping. Android's MediaProvider canonicalises paths to /home/steamos/... and rejects every file ("doesn't appear under [/system/media...]"), `sm list-volumes` is empty: the media index never works, apps must browse folders. Lepton installs with `adb install -g` (runtime permissions granted, MANAGE_EXTERNAL_STORAGE too). Files: `install/files.py`, `frameport frame send|storage`, GUI Files tab (formerly Frame → Send files).
|
||||
**SteamVR per-app settings (2026-09-30):** editing steamvr.vrsettings while SteamVR runs is lost; the web API (127.0.0.1:27062 /app/setsettings) needs `x-steamvr-secret`. `native/vrsettings` = `fp_vrsettings.exe` (freestanding, OpenVR `FnTable:IVRSettings_003` as a Utility app, loads SteamVR's bin/win64/openvr_api.dll) sets them live and SteamVR persists them: section `steam.app.<shortcut appid>`, keys `preferredRefreshRate` (float) and `motionSmoothingOverride` (0 global, 1 on, 2 off, 3 always). Steam Link (vrlink) lists the Frame's rates 72/80/90/96/108/120/144 in vrserver.txt and follows the per-app preference ("host preferred N Hz"; whether the key is honoured is unverified in-headset yet). Judder metric: vrcompositor.txt session summary dropped + "Timed out. N total" (Stormland: 0 dropped but 313 timeouts in 2 min); fpsVR (`%LOCALAPPDATA%\fpsVR\*.json`, 0.1 ms histograms) gives p99 CPU/GPU ms. `pcvr.steamvr_tuning` (default on, PC only) applies on Play: highest rate whose budget ≥ p99×1.05, at least one step down, smoothing on.
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ uv run ruff check . # lint
|
||||
row and a unit test.
|
||||
- Native binaries in `artifacts/` are rebuilt with `python native/build.py` (downloads the NDK).
|
||||
|
||||
## Adding a tested game
|
||||
|
||||
Tested a game on the Steam Frame? The easiest way is in FramePort: open the game → **…** → **Share working config…**.
|
||||
It opens a prefilled GitHub issue with the exact recipe; after review a workflow turns it into
|
||||
`catalog/games/<package>.yaml`. Without the app: [working config form](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml). Something doesn't run: the game's
|
||||
**Report a problem…** (diagnostics zip, personal data removed) or the [problem report form](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml).
|
||||
After catalog changes, regenerate the games list: `python scripts/compat_list.py`.
|
||||
|
||||
## Translating FramePort
|
||||
The app's texts (screens, help, patch and setting descriptions) can be translated; the command line, logs and
|
||||
diagnostics stay English. No programming needed:
|
||||
|
||||
@@ -26,7 +26,8 @@ Install games that target the Meta Quest, Android, or general PCVR onto your **V
|
||||
- **Per-game recipes:** a tested catalog plus detection rules; every patch explained in plain words.
|
||||
- **FrameBridge:** FramePort's OpenXR adapter emulates what the Frame natively lacks (passthrough, room, controller models,
|
||||
curved and 360° layers); game settings as simple switches.
|
||||
- **Beyond Quest:** Android apps as windows, PC VR via Proton or Revive, a Files tab with drag and drop.
|
||||
- **Beyond Quest:** Android apps as windows, PC VR via Proton or Revive, Windows (non-VR) games via Proton, a Files
|
||||
tab with drag and drop.
|
||||
- **Self-updating** releases, redacted diagnostics, one-click problem reports and working-config sharing.
|
||||
|
||||
## Quick start
|
||||
@@ -36,11 +37,12 @@ Install games that target the Meta Quest, Android, or general PCVR onto your **V
|
||||
2. **Connect the Frame** (once):
|
||||
1. In FramePort click **Steam Frame → Show setup command**. Keep FramePort open; the Frame and your computer must
|
||||
be on the same Wi-Fi.
|
||||
2. On the Frame press the **Steam button → Power → Switch to Desktop**.
|
||||
3. Open the app menu (bottom-left corner), search for **Konsole** and open it.
|
||||
2. On the Frame open the **SteamVR dashboard → Launch a program → Desktop**: the Linux desktop opens on a virtual
|
||||
screen.
|
||||
3. Open the app menu (bottom-left corner of that desktop) → **System → Konsole** (or search for Konsole).
|
||||
4. Type the command FramePort shows exactly as shown (on-screen keyboard or any USB/Bluetooth keyboard) and press
|
||||
**Enter**. This will run the following [bash setup script](bootstrap/bootstrap.sh).
|
||||
5. After a few seconds the desktop closes by itself and the Frame returns to its normal view; that's expected. If
|
||||
5. After a few seconds the desktop closes by itself (Steam restarts once); that's expected. If
|
||||
Steam asks to install **Lepton**, confirm it. FramePort shows the Frame as connected within a minute. No
|
||||
password needed.
|
||||
3. **Add games → Scan a folder** with your game backups (APK + OBB, or PC VR game folders).
|
||||
@@ -66,6 +68,12 @@ fix, so Steam's on-screen keyboard and Type on Frame work in them too.
|
||||
If a game has already been tested with FramePort, it will automatically use the optimal game config. Otherwise, FramePort
|
||||
will attempt to guess key patches. If you find a new config that works for an app you are testing, please consider submitting it to the community!
|
||||
|
||||
**[List of tested games](docs/GAMES.md)**
|
||||
|
||||
**Tested something? Share it.** In FramePort open the game → **…** → **Share working config…** (it fills in the
|
||||
recipe for you) or **Report a problem…** (attaches a diagnostics zip with personal data removed). Without the app:
|
||||
[share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) · [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml). Shared configs become built-in recipes for everyone.
|
||||
|
||||
## Built on
|
||||
|
||||
[OVRPort](https://github.com/Android-XR-Bridge/OVRPort) (Quest → OpenXR, originally
|
||||
@@ -81,6 +89,7 @@ What FramePort adds itself: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
| [INSTALL.md](docs/INSTALL.md) | Install, connect, update, PC VR, files, problem reports |
|
||||
| [FRAME_SETUP.md](docs/FRAME_SETUP.md) | What setup changes on the Frame, networks and firewalls, undoing it |
|
||||
| [COMPATIBILITY.md](docs/COMPATIBILITY.md) | What runs and how well |
|
||||
| [GAMES.md](docs/GAMES.md) | Tested games and how well they run |
|
||||
| [PLAYBOOK.md](docs/PLAYBOOK.md) | Symptoms and fixes per game |
|
||||
| [FRAME_RUNTIME.md](docs/FRAME_RUNTIME.md) | Steam Frame runtime facts |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | How the code is organised |
|
||||
|
||||
+172
-43
@@ -35,7 +35,7 @@ import sys
|
||||
import time
|
||||
import zlib
|
||||
|
||||
AGENT_VERSION = 33
|
||||
AGENT_VERSION = 35
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -564,6 +564,48 @@ def steam_users():
|
||||
if os.path.isdir(os.path.join(STEAM, "userdata")) else []
|
||||
|
||||
|
||||
STEAMID64_BASE = 76561197960265728
|
||||
|
||||
|
||||
def active_steam_user():
|
||||
"""userdata folder (account id) of the most recent Steam login (config/loginusers.vdf), if it has one."""
|
||||
try:
|
||||
text = open(os.path.join(STEAM, "config/loginusers.vdf"), encoding="utf-8", errors="replace").read()
|
||||
except OSError:
|
||||
return None
|
||||
users = steam_users()
|
||||
for sid, body in re.findall(r'"(\d{17})"\s*\{([^}]*)\}', text):
|
||||
if re.search(r'"MostRecent"\s+"1"', body) and str(int(sid) - STEAMID64_BASE) in users:
|
||||
return str(int(sid) - STEAMID64_BASE)
|
||||
return None
|
||||
|
||||
|
||||
def library_users():
|
||||
"""Steam accounts whose library gets FramePort's shortcuts: the signed-in one, else every account on the Frame
|
||||
(several accounts used to stop the shortcut step: "found 2 Steam users", and Play then failed with Steam's "Game
|
||||
configuration unavailable")."""
|
||||
users = steam_users()
|
||||
if not users:
|
||||
raise AgentError("Steam has no signed-in account on this Frame yet; sign in to Steam on the Frame first")
|
||||
active = active_steam_user()
|
||||
return [active] if active else users
|
||||
|
||||
|
||||
def shortcut_appid_for(exe):
|
||||
"""appid of the shortcut whose Exe is `exe` in the signed-in account's library (any account if unknown); None
|
||||
when it isn't in the library."""
|
||||
for u in library_users():
|
||||
vdf = os.path.join(STEAM, "userdata", u, "config/shortcuts.vdf")
|
||||
try:
|
||||
root = vdf_decode(open(vdf, "rb").read()) if os.path.exists(vdf) else {}
|
||||
except (OSError, AgentError):
|
||||
continue
|
||||
for v in (root.get("shortcuts") or {}).values():
|
||||
if isinstance(v, dict) and v.get("Exe") == exe and v.get("appid"):
|
||||
return v["appid"] & 0xFFFFFFFF
|
||||
return None
|
||||
|
||||
|
||||
def container_running(appid):
|
||||
p = run(["podman", "ps", "--format", "{{.Names}}"])
|
||||
return f"lepton-steamlaunch-{appid}" in p.stdout.split()
|
||||
@@ -637,6 +679,58 @@ def battery_state():
|
||||
return battery
|
||||
|
||||
|
||||
BOOT_STATE = os.path.join(HOME, ".cache/frameport-boot.json")
|
||||
# what the last lines of a boot's journal say when it was shut down or rebooted on purpose (a crash, a GPU hang that
|
||||
# reset the Frame or a pulled battery leaves none of these)
|
||||
CLEAN_SHUTDOWN = ("Reached target System Power Off", "Reached target System Reboot", "Reached target Shutdown",
|
||||
"System is powering down", "System is rebooting", "systemd-shutdown", "Power-Off", "Rebooting.")
|
||||
|
||||
|
||||
def boot_state():
|
||||
"""This boot and how the previous one ended: {"boot_id", "boot_time", "prev_clean" (True/False/None = unknown),
|
||||
"last_launch" ({"package", "title", "time"}: the FramePort game started last before this boot)}. Worked out once
|
||||
per boot (cached), so the app can say "your Frame restarted while <game> was running"."""
|
||||
try:
|
||||
boot_id = open("/proc/sys/kernel/random/boot_id").read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
cached = json.load(open(BOOT_STATE))
|
||||
if cached.get("boot_id") == boot_id:
|
||||
return cached
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
boot_time = None
|
||||
try:
|
||||
for line in open("/proc/stat"):
|
||||
if line.startswith("btime "):
|
||||
boot_time = int(line.split()[1])
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
tail = run(["journalctl", "-b", "-1", "-n", "120", "-q", "--no-pager", "-o", "cat"]).stdout
|
||||
except OSError:
|
||||
tail = ""
|
||||
prev_clean = any(m in tail for m in CLEAN_SHUTDOWN) if tail.strip() else None
|
||||
last = None
|
||||
for d in cmd_list_installed({})["games"]:
|
||||
log = os.path.join(d["base"], "launch.log")
|
||||
try:
|
||||
t = os.path.getmtime(log)
|
||||
except OSError:
|
||||
continue
|
||||
if (boot_time is None or t < boot_time) and (last is None or t > last["time"]):
|
||||
last = {"package": d["package"], "title": d.get("title") or d["package"], "time": t}
|
||||
state = {"boot_id": boot_id, "boot_time": boot_time, "prev_clean": prev_clean, "last_launch": last}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(BOOT_STATE), exist_ok=True)
|
||||
with open(BOOT_STATE, "w") as f:
|
||||
json.dump(state, f)
|
||||
except OSError:
|
||||
pass
|
||||
return state
|
||||
|
||||
|
||||
def cmd_battery(args):
|
||||
return {"battery": battery_state()}
|
||||
|
||||
@@ -660,6 +754,7 @@ def cmd_info(args):
|
||||
"steam_running": run(["pgrep", "-x", "steam"]).returncode == 0,
|
||||
"host_fixes": ensure_host_fixes(), "kernel_keys": key_usage(),
|
||||
"proton": cmd_proton_status({}) if args.get("proton", True) else None,
|
||||
"boot": boot_state(),
|
||||
"battery": battery_state(),
|
||||
}
|
||||
|
||||
@@ -735,7 +830,8 @@ def vdf_encode(obj):
|
||||
return bytes(out) + bytes([TYPE_END])
|
||||
|
||||
|
||||
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None):
|
||||
def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Frame", launch_options="", tags=None,
|
||||
openvr=True):
|
||||
"""Add/update a non-Steam shortcut (matched by Exe, so its appid never changes). `tags` (genres, the user's tags)
|
||||
are merged with tags already on the shortcut, so ones set in Steam are kept."""
|
||||
data = open(vdf_path, "rb").read() if os.path.exists(vdf_path) else b""
|
||||
@@ -751,8 +847,8 @@ def upsert_shortcut(vdf_path, exe, title, start_dir, icon="", tag="Quest on Fram
|
||||
merged = list(dict.fromkeys(have + [t for t in [tag] + list(tags) if t]))
|
||||
entry["tags"] = {str(i): t for i, t in enumerate(merged)}
|
||||
entry.update(appname=title, Exe=exe, StartDir=start_dir, icon=icon or entry.get("icon", ""), ShortcutPath="",
|
||||
LaunchOptions=launch_options, IsHidden=0, AllowDesktopConfig=1, AllowOverlay=1, OpenVR=1, Devkit=0,
|
||||
DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
|
||||
LaunchOptions=launch_options, IsHidden=0, AllowDesktopConfig=1, AllowOverlay=1,
|
||||
OpenVR=1 if openvr else 0, Devkit=0, DevkitGameID="", DevkitOverrideAppID=0, FlatpakAppID="")
|
||||
if data:
|
||||
backup_vdf(vdf_path)
|
||||
os.makedirs(os.path.dirname(vdf_path), exist_ok=True)
|
||||
@@ -875,42 +971,15 @@ def shortcuts_worker(payload):
|
||||
args = json.loads(payload)
|
||||
result = {"state": "done", "added": [], "errors": [], "finished": None}
|
||||
try:
|
||||
users = steam_users()
|
||||
if len(users) != 1:
|
||||
raise AgentError(f"found {len(users)} Steam users; not guessing which library to edit")
|
||||
vdf = os.path.join(STEAM, "userdata", users[0], "config/shortcuts.vdf")
|
||||
users = library_users()
|
||||
try:
|
||||
service = stop_steam()
|
||||
except AgentError:
|
||||
raise AgentError("Steam did not close; library not modified") from None
|
||||
grid = os.path.join(os.path.dirname(vdf), "grid")
|
||||
os.makedirs(grid, exist_ok=True)
|
||||
for r in args.get("remove", []): # uninstalled games
|
||||
try:
|
||||
if remove_shortcut(vdf, r["exe"]):
|
||||
result.setdefault("removed", []).append(r["exe"])
|
||||
for art in grid_files(grid, r.get("appid") or ""):
|
||||
os.remove(art)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{r.get('exe')}: {exc}")
|
||||
for pkg in args["packages"]:
|
||||
try:
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
dep = json.load(open(os.path.join(anchor, "deployment.json")))
|
||||
icon = next(iter(glob.glob(os.path.join(anchor, "artwork/icon.*"))), "")
|
||||
tag = "PC VR on Frame" if dep.get("kind") == "pcvr" else "Quest on Frame"
|
||||
got = upsert_shortcut(vdf, f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag,
|
||||
tags=dep.get("tags") or [])
|
||||
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
|
||||
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
|
||||
if not img:
|
||||
continue
|
||||
for old in glob.glob(os.path.join(grid, f"{got}{suffix}.*")):
|
||||
os.remove(old)
|
||||
shutil.copy(img, os.path.join(grid, f"{got}{suffix}{os.path.splitext(img)[1]}"))
|
||||
result["added"].append({"package": pkg, "appid": got, "expected": dep["appid"]})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{pkg}: {exc}")
|
||||
for user in steam_users(): # uninstalled games leave every account's library
|
||||
remove_from_library(user, args.get("remove", []), result)
|
||||
for user in users:
|
||||
update_library(user, args["packages"], result)
|
||||
if service or args.get("restart", True):
|
||||
start_steam(service)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -922,6 +991,48 @@ def shortcuts_worker(payload):
|
||||
json.dump(result, f)
|
||||
|
||||
|
||||
def remove_from_library(user, remove, result):
|
||||
"""Remove uninstalled games' shortcuts + grid art from one Steam account (Steam is closed)."""
|
||||
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
|
||||
grid = os.path.join(os.path.dirname(vdf), "grid")
|
||||
for r in remove:
|
||||
try:
|
||||
if remove_shortcut(vdf, r["exe"]):
|
||||
result.setdefault("removed", []).append(r["exe"])
|
||||
for art in grid_files(grid, r.get("appid") or ""):
|
||||
os.remove(art)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{r.get('exe')}: {exc}")
|
||||
|
||||
|
||||
def update_library(user, packages, result):
|
||||
"""Add/update installed games' shortcuts + grid art in one Steam account (Steam is closed)."""
|
||||
vdf = os.path.join(STEAM, "userdata", user, "config/shortcuts.vdf")
|
||||
grid = os.path.join(os.path.dirname(vdf), "grid")
|
||||
os.makedirs(grid, exist_ok=True)
|
||||
for pkg in packages:
|
||||
try:
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
dep = json.load(open(os.path.join(anchor, "deployment.json")))
|
||||
icon = next(iter(glob.glob(os.path.join(anchor, "artwork/icon.*"))), "")
|
||||
flat = dep.get("vr") is False
|
||||
tag = ("Windows game on Frame" if flat else "PC VR on Frame") if dep.get("kind") == "pcvr" \
|
||||
else "Quest on Frame"
|
||||
got = upsert_shortcut(vdf, f'"{anchor}/launch.sh"', dep["title"], anchor, icon, tag,
|
||||
tags=dep.get("tags") or [], openvr=not flat)
|
||||
for kind, suffix in (("portrait", "p"), ("landscape", ""), ("hero", "_hero"), ("logo", "_logo")):
|
||||
img = next(iter(glob.glob(os.path.join(anchor, f"artwork/{kind}.*"))), None)
|
||||
if not img:
|
||||
continue
|
||||
for old in glob.glob(os.path.join(grid, f"{got}{suffix}.*")):
|
||||
os.remove(old)
|
||||
shutil.copy(img, os.path.join(grid, f"{got}{suffix}{os.path.splitext(img)[1]}"))
|
||||
if not any(a["package"] == pkg for a in result["added"]):
|
||||
result["added"].append({"package": pkg, "appid": got, "expected": dep["appid"]})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"{pkg}: {exc}")
|
||||
|
||||
|
||||
def cmd_shortcut_status(args):
|
||||
try:
|
||||
return json.load(open(STATUS_FILE))
|
||||
@@ -965,6 +1076,9 @@ def steam_gameid(appid):
|
||||
return (int(appid) << 32) | 0x02000000
|
||||
|
||||
|
||||
NOT_IN_LIBRARY = "not in the Frame's Steam library"
|
||||
|
||||
|
||||
def cmd_launch(args):
|
||||
"""Start an installed game the way the headset's library does: ask the running Steam to launch its shortcut, so
|
||||
it gets Steam's VR session, overlay and controller setup (unlike launch_test's direct, headless start)."""
|
||||
@@ -974,7 +1088,10 @@ def cmd_launch(args):
|
||||
raise AgentError(f"{pkg} is not installed")
|
||||
if run(["pgrep", "-x", "steam"]).returncode != 0:
|
||||
raise AgentError("Steam isn't running on the Frame")
|
||||
gid = steam_gameid(dep["appid"])
|
||||
appid = shortcut_appid_for(f'"{os.path.join(ANCHORS, pkg)}/launch.sh"')
|
||||
if appid is None: # Steam would only say "Game configuration unavailable"
|
||||
raise AgentError(f"{NOT_IN_LIBRARY}: {dep.get('title') or pkg}")
|
||||
gid = steam_gameid(appid) # the shortcut's own id (it keeps its first one when the title changes)
|
||||
# systemd-run: the launch request must outlive this SSH session
|
||||
run(["systemd-run", "--user", "--collect", "--quiet", f"--unit=frameport-launch-{int(time.time())}",
|
||||
"steam", "-ifrunning", f"steam://rungameid/{gid}"])
|
||||
@@ -1665,8 +1782,7 @@ set -euo pipefail
|
||||
base={base_q}
|
||||
[[ -d "$base/game" ]] || {{ echo "Game files missing at $base (storage not mounted?)" >&2; exit 1; }}
|
||||
export SteamAppId={appid}
|
||||
export SteamGameId={appid}
|
||||
export STEAM_COMPAT_APP_ID={appid}
|
||||
{steam_game_id}export STEAM_COMPAT_APP_ID={appid}
|
||||
export STEAM_COMPAT_DATA_PATH="$base/compatdata"
|
||||
export STEAM_COMPAT_CLIENT_INSTALL_PATH={steam_q}
|
||||
export STEAM_COMPAT_INSTALL_PATH="$base/game"
|
||||
@@ -1733,7 +1849,7 @@ OCULUS_HMD_HELPER = "fp_oculushmd.exe"
|
||||
|
||||
|
||||
def write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive, env, xr_layer=False, game_args=(),
|
||||
oculus_hmd=False):
|
||||
oculus_hmd=False, vr=True):
|
||||
exe = os.path.join(base, "game", exe_rel)
|
||||
prefix = compat_command(tool["dir"])
|
||||
injector = os.path.join(base, "revive", "ReviveInjector.exe")
|
||||
@@ -1754,6 +1870,8 @@ def write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive
|
||||
text = LAUNCH_PROTON_SH.format(
|
||||
title=title.replace("\n", " "), pkg=pkg, base_q=shlex.quote(base), appid=appid, steam_q=shlex.quote(STEAM),
|
||||
tool=tool["name"], extra_env=extra, workdir=("/" + shlex.quote(workdir)) if workdir else "",
|
||||
# Proton sets up VR (vrclient, wineopenxr) only when SteamGameId is set: a flat Windows game goes without
|
||||
steam_game_id=f"export SteamGameId={appid}\n" if vr else "",
|
||||
xr_layer=XR_LAYER_ENV if xr_layer else "",
|
||||
command=" ".join(shlex.quote(a) for a in argv))
|
||||
path = os.path.join(anchor, "launch.sh")
|
||||
@@ -1863,8 +1981,9 @@ def cmd_finalize_pcvr(args):
|
||||
raise AgentError(f"{OCULUS_HMD_HELPER} missing")
|
||||
set_crash_reporter(base, enabled=not args.get("no_crash_reporter"))
|
||||
set_libovr_redirect(base, exe_rel, enabled=bool(args.get("libovr_redirect")), bundled=not revive)
|
||||
vr = args.get("vr", True) is not False # False: a flat Windows game (no VR at all)
|
||||
write_proton_launcher(anchor, base, pkg, title, appid, tool, exe_rel, revive, args.get("env"), xr_layer,
|
||||
args.get("game_args") or [], oculus_hmd)
|
||||
args.get("game_args") or [], oculus_hmd, vr=vr)
|
||||
art_in = os.path.join(base, "incoming-artwork")
|
||||
if os.path.isdir(art_in):
|
||||
shutil.rmtree(os.path.join(anchor, "artwork"), ignore_errors=True)
|
||||
@@ -1872,7 +1991,7 @@ def cmd_finalize_pcvr(args):
|
||||
dep = {"package": pkg, "kind": "pcvr", "appid": int(appid), "base": base, "title": title, "exe": exe_rel,
|
||||
"tags": args.get("tags") or [],
|
||||
"sha256": args.get("exe_sha256"), "revive": revive, "revive_version": args.get("revive_version"),
|
||||
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd,
|
||||
"proton": tool["name"], "xr_layer": xr_layer, "oculus_hmd": oculus_hmd, "vr": vr,
|
||||
"libovr_redirect": bool(args.get("libovr_redirect")),
|
||||
"recipe": args.get("recipe"),
|
||||
"installed_by": "frameport",
|
||||
@@ -1956,7 +2075,7 @@ def cmd_uninstall(args):
|
||||
remove_tree(base)
|
||||
anchor = os.path.join(ANCHORS, pkg)
|
||||
removed_sc = False
|
||||
if args.get("remove_shortcut") and len(steam_users()) == 1:
|
||||
if args.get("remove_shortcut") and steam_users():
|
||||
# Steam keeps its own copy of shortcuts.vdf and writes it back: change it only with Steam closed (worker)
|
||||
removed_sc = cmd_shortcuts({"remove": [{"exe": f'"{anchor}/launch.sh"', "appid": dep.get("appid")}]})["started"]
|
||||
if not keep_data or base != anchor:
|
||||
@@ -2144,6 +2263,16 @@ def cmd_collect_diag(args):
|
||||
"{{.Names}} {{.Status}}"]).stdout[-20000:]
|
||||
host["steam_running"] = run(["pgrep", "-x", "steam"]).returncode == 0
|
||||
host["uptime"] = _tail("/proc/uptime", 200)
|
||||
host["boot"] = boot_state()
|
||||
# how the previous boot ended: errors and kernel (GPU/msm/kgsl, OOM, panic) warnings before a crash or reset
|
||||
for name, extra in (("previous-boot-errors.txt", ["-p", "err"]),
|
||||
("previous-boot-kernel.txt", ["-k", "-p", "warning"])):
|
||||
try:
|
||||
text = run(["journalctl", "-b", "-1", *extra, "-n", "400", "-q", "--no-pager"]).stdout[-max_bytes:]
|
||||
except OSError:
|
||||
text = ""
|
||||
if text.strip():
|
||||
files[name] = text
|
||||
host["installed"] = [{k: g.get(k) for k in ("package", "title", "kind", "appid", "version", "agent_version")}
|
||||
for g in cmd_list_installed({})["games"]]
|
||||
out = {"agent_version": AGENT_VERSION, "host": host, "files": files}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# FramePort one-time Steam Frame setup. Run in the Frame's Desktop Mode terminal (Konsole):
|
||||
# FramePort one-time Steam Frame setup. Run in the Frame's desktop terminal (SteamVR dashboard -> Launch a program
|
||||
# -> Desktop, then System -> Konsole):
|
||||
# curl -fsS <pc-ip>:<port>/<code> | bash
|
||||
# (the FramePort app shows the exact line and serves this script while its Setup page is open).
|
||||
#
|
||||
@@ -128,6 +129,6 @@ if [[ ! -x "$DEVKIT_HELPER" || ! -f "$STEAM_CONFIG" ]] || \
|
||||
echo "Couldn't turn it on automatically. Turn it on in Settings > System > Developer, then run this command again."
|
||||
exit 1
|
||||
fi
|
||||
echo "Steam restarts to turn it on. That closes Desktop Mode in a few seconds and the Frame returns to its"
|
||||
echo "Steam restarts to turn it on. That closes the desktop in a few seconds and the Frame returns to its"
|
||||
echo "normal view; setup finishes on its own. If Steam asks to install Lepton, confirm it."
|
||||
echo "Then return to FramePort on your PC: the Frame appears as connected within a minute."
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.Armature.VR4
|
||||
title: VR4
|
||||
status: works
|
||||
notes: Installs the no-ForceQuit build (the regular one quits after the opening cutscene).
|
||||
details: The regular build quits itself when the first level loads after the opening cutscene (System.exit, GitHub
|
||||
issue 10), so the no-ForceQuit build is installed.
|
||||
tested_version: '2.3'
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
verified:
|
||||
date: '2026-10-02'
|
||||
known_good_sha256: 40ff74e37264b28ae8826923b69d0731d9620094c76462be34159643d6ae91b3
|
||||
app: 0.3.4
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 26
|
||||
issue: 1
|
||||
updated: '2026-10-04'
|
||||
source_hint: Resident Evil 4
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.NewFolderGames.IAmCat
|
||||
title: I Am Cat
|
||||
status: issues
|
||||
notes: Renders each eye separately; with the default single-pass rendering the right eye showed only effects on grey.
|
||||
details: Unity 2022.3 with the Oculus XR Plugin falls back to GLES (Zink) on the Frame, where its multiview pass drew
|
||||
the right eye's slice wrong (GitHub issue 8). frame.unity_multipass switches the game to multi-pass rendering; not
|
||||
yet confirmed in a headset.
|
||||
tested_version: 1.4.0.0
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.unity_multipass
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
issue: 8
|
||||
updated: '2026-10-04'
|
||||
source_hint: I Am Cat
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.beatgames.beatsaber
|
||||
title: Beat Saber
|
||||
status: works
|
||||
tested_version: 1.40.8_7379
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: ee720e040931f2547f7611e58ec5ad72d86e800dd4cf3135f576019683f2f359
|
||||
app: 0.6.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 6
|
||||
source_hint: Beat Saber
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.lakento.dinosquest
|
||||
title: Dinosaur Island
|
||||
status: works
|
||||
tested_version: 1.7.5
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: 41ecbc728dc533eebcc3980e140fe1764fbd1958735d8a299c63fa9cda13a923
|
||||
app: 0.6.2
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 12
|
||||
source_hint: DINOSAUR ISLAND
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.owlchemylabs.jobsimulator
|
||||
title: Job Simulator
|
||||
status: works
|
||||
tested_version: 1.4.0.6515
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: 64638d7f93bb93cc61f422a43a08bb0d35b9bb4b463e90a42da7002147f03d1b
|
||||
app: 0.6.2
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 11
|
||||
source_hint: Job Simulator
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.playful.LuckysTale
|
||||
title: Lucky's Tale
|
||||
status: issues
|
||||
notes: Keeps MSAA off; the game turned it on by itself and the whole Frame restarted in a menu.
|
||||
details: Meta's OVRManager raises MSAA to 4x at runtime ("Switching to the recommended level"); multisampled
|
||||
render-to-texture on GLES (Zink) hung the Frame's GPU (GitHub issue 9). frame.unity_runtime_msaa_off keeps it at 0;
|
||||
not yet confirmed in a headset.
|
||||
tested_version: 1.0.55
|
||||
engine: Unity
|
||||
xr: VrApi
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
issue: 9
|
||||
updated: '2026-10-04'
|
||||
source_hint: Luckys Tale
|
||||
@@ -0,0 +1,19 @@
|
||||
package: com.resolutiongames.cookinggame
|
||||
title: Cook-Out
|
||||
status: works
|
||||
tested_version: 1.20.0.10625-release
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-03'
|
||||
known_good_sha256: 7217649c503adf76f3a959fc72858c446c2a42a9ce9e209286977b1950397915
|
||||
app: 0.6.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20260930.6234839'
|
||||
agent: 33
|
||||
issue: 3
|
||||
source_hint: com.resolutiongames.cookinggame
|
||||
@@ -1,11 +1,12 @@
|
||||
package: com.stremio.vrone
|
||||
title: Stremio
|
||||
status: issues
|
||||
status: works
|
||||
notes: 'Unity 6 VR media centre (its interface is web pages in Vuplex 3D WebView). Text fields closed at once on the
|
||||
Frame (no system keyboard): frame.unity_text_input keeps them selected and device.text_input_window shows the
|
||||
Android window so Steam''s on-screen keyboard (or Type on Frame from the PC) types into them; confirmed in the
|
||||
headset by the owner (2026-10-03). Known issue: the add-ons page stays blank in VR (the add-on sync itself
|
||||
succeeds). "Play in an external player" can''t reach another app: Lepton sends every link to Steam''s browser.'
|
||||
headset by the owner (2026-10-03), who confirmed it working. Note: the add-ons page can stay blank in VR (the
|
||||
add-on sync itself succeeds). "Play in an external player" can''t reach another app: Lepton sends every link to
|
||||
Steam''s browser.'
|
||||
details: 'Patches: frame.unity_text_input (TMP_InputField.TouchScreenKeyboardShouldBeUsed -> false,
|
||||
isKeyboardUsingEvents -> true; uGUI InputField too), device.text_input_window (lepton-show-flatscreen).'
|
||||
tested_version: 0.7.5
|
||||
|
||||
+9
-2
@@ -44,7 +44,13 @@ signatures:
|
||||
pattern: 'zink.*DEVICE[_ ]LOST|VK_ERROR_DEVICE_LOST'
|
||||
severity: fatal
|
||||
diagnosis: The GPU hung (Zink/Mesa). For GLES Unity games MSAA render-to-texture is the usual cause.
|
||||
suggest: [frame.unity_no_msaa]
|
||||
suggest: [frame.unity_no_msaa, frame.unity_runtime_msaa_off]
|
||||
- id: unity-runtime-msaa
|
||||
pattern: 'recommended MSAA level is [248]\. Switching to the recommended level'
|
||||
severity: info
|
||||
diagnosis: The game's OVRManager turns MSAA on at runtime (QualitySettings can't keep it off). On GLES this can
|
||||
hang the Frame's GPU or restart the headset; if it does, keep it off in the game's code.
|
||||
suggest: [frame.unity_runtime_msaa_off]
|
||||
- id: gl-shader-failed
|
||||
pattern: 'GLShim\s*: SHADER COMPILE FAILED|#extension directive is not allowed in the middle|could not implicitly convert operands'
|
||||
severity: error
|
||||
@@ -79,7 +85,8 @@ signatures:
|
||||
agent's controller_models command).
|
||||
suggest: []
|
||||
- id: scene-missing
|
||||
pattern: 'XR_FB_scene|XR_FB_spatial_entity.*(not supported|UNSUPPORTED)|xrQuerySpacesFB'
|
||||
# not OVRPlugin's routine "Unavailable OpenXR extension: XR_FB_scene" (printed by every OVRPlugin game)
|
||||
pattern: '(?<!Unavailable OpenXR extension: )XR_FB_scene|XR_FB_spatial_entity.*(not supported|UNSUPPORTED)|xrQuerySpacesFB'
|
||||
severity: warning
|
||||
diagnosis: The game uses the Meta scene (room) API; enable the adapter's guardian-based room emulation.
|
||||
suggest: [adapter.scene_emul]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Compatibility
|
||||
|
||||
The built-in catalog has tested settings for 39 games (27 work, 6 work with known issues, 6 can't run on the Frame).
|
||||
The built-in catalog has tested settings for games, each marked as working, working with known issues, or not
|
||||
running on the Frame: see the [list of tested games](GAMES.md) (recipes in [catalog/games](../catalog/games)).
|
||||
Other games get suggested patches from detection rules; each suggestion states its reason, and every patch can be
|
||||
switched on or off under **Customize**: described in plain words, with **Show technical details** for the exact
|
||||
effect of each patch.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Tested games
|
||||
|
||||
Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort suggests patches for them, and a working config can be shared from the app (**Share working config…**).
|
||||
Tested a game? [Share a working config](https://github.com/spoopyghosty0/frameport/issues/new?template=working-config.yml) or [report a problem](https://github.com/spoopyghosty0/frameport/issues/new?template=bug-report.yml) (in the app: the game's **…** menu does both and fills in the details).
|
||||
Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
|
||||
| Game | Platform | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 4XVR Video Player | Quest | ✅ Works | |
|
||||
| Asgard's Wrath 2 | Quest | ✅ Works | |
|
||||
| BAM | Quest | ✅ Works | |
|
||||
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
|
||||
| Batman: Arkham Shadow | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Carve Snowboarding | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Demeter | Quest | ✅ Works | |
|
||||
| Dinosaur Island | Quest | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| H.U.N.T | Quest | ✅ Works | |
|
||||
| In Death: Unchained | Quest | ✅ Works | |
|
||||
| Job Simulator | Quest | ✅ Works | |
|
||||
| LEGO® Bricktales | Quest | ✅ Works | |
|
||||
| Marvel's Deadpool VR | Quest | ✅ Works | |
|
||||
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
|
||||
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
|
||||
| Nano | Quest | ✅ Works | |
|
||||
| NEX Player | Quest | ✅ Works | |
|
||||
| NOPE CHALLENGE | Quest | ✅ Works | |
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| PowerWash Simulator VR | Quest | ✅ Works | |
|
||||
| Rick and Morty: Virtual Rick-ality | PC VR | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Stremio | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| Toy Master | Quest | ✅ Works | |
|
||||
| Under Cover | Quest | ✅ Works | |
|
||||
| VR4 | Quest | ✅ Works | |
|
||||
| Wallace & Gromit in The Grand Getaway | Quest | ✅ Works | |
|
||||
| Arcsmith | Quest | ⚠️ Works with issues | Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out). |
|
||||
| Assassin's Creed Nexus | Quest | ⚠️ Works with issues | Some launch warning text is still upside down; the rest of the UI is fixed by flip emulation. |
|
||||
| I Am Cat | Quest | ⚠️ Works with issues | Renders each eye separately; with the default single-pass rendering the right eye showed only effects on grey. |
|
||||
| Lucky's Tale | Quest | ⚠️ Works with issues | Keeps MSAA off; the game turned it on by itself and the whole Frame restarted in a menu. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
|
||||
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
|
||||
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
|
||||
| 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. |
|
||||
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
| Sniper Elite VR | Quest | ❌ Doesn't run | GPU hang (zink: DEVICE LOST) even with MSAA off. |
|
||||
| Sports Scramble (Santa Cruz) | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
+10
-4
@@ -9,6 +9,7 @@ runtime, the OVRPort CLI and apksigner into its data folder (Settings → Tools
|
||||
| Windows 10/11 (x64) | `FramePort-windows-x64.zip` | `FramePort.exe` |
|
||||
| macOS (Apple Silicon) | `FramePort-macos-arm64.zip` | `FramePort.app` |
|
||||
| Linux (x64, GTK 3; Ubuntu 22.04 or newer) | `FramePort-linux-x64.tar.gz` | `FramePort/FramePort` |
|
||||
| Linux (ARM64, GTK 3; Ubuntu 22.04 or newer) | `FramePort-linux-arm64.tar.gz` | `FramePort/FramePort` |
|
||||
| Command line only (Python 3.11+) | `frameport-<version>-py3-none-any.whl` | `frameport --help` |
|
||||
|
||||
The command-line version installs from the wheel's release link with `uv tool install <link>` (or pipx / pip).
|
||||
@@ -22,7 +23,7 @@ start shows a warning:
|
||||
`FramePort-selfsigned.cer` (attached to each release) into *Trusted Root Certification Authorities* (Current User) to
|
||||
show FramePort as the publisher; the certificate can only sign code. Remove it with `certmgr.msc`.
|
||||
- **macOS:** right-click `FramePort.app` → **Open** → **Open** (once), or `xattr -dr com.apple.quarantine FramePort.app`.
|
||||
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort`.
|
||||
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort` (ARM64: `FramePort-linux-arm64.tar.gz`).
|
||||
|
||||
## Connecting the Steam Frame
|
||||
|
||||
@@ -30,12 +31,12 @@ The Frame and the computer must be on the same network.
|
||||
|
||||
1. In FramePort open **Steam Frame** and click **Show setup command**.
|
||||
2. First time only, on the Frame:
|
||||
1. Press the **Steam button → Power → Switch to Desktop**.
|
||||
2. Open the app menu (bottom-left corner), search for **Konsole** and open it.
|
||||
1. Open the **SteamVR dashboard → Launch a program → Desktop**: the Linux desktop opens on a virtual screen.
|
||||
2. Open the app menu (bottom-left corner of that desktop) → **System → Konsole** (or search for Konsole).
|
||||
3. Type the command FramePort shows exactly as shown (on-screen keyboard or any USB/Bluetooth keyboard) and press
|
||||
**Enter**. It looks like `curl -fsS 192.168.1.20:8765/1a2b3c4d | bash`: your computer's address, then a
|
||||
one-time code.
|
||||
4. After a few seconds the desktop closes by itself and the Frame returns to its normal view; that's expected. If
|
||||
4. After a few seconds the desktop closes by itself (Steam restarts once); that's expected. If
|
||||
Steam asks to install **Lepton** (Valve's Android runtime), confirm it.
|
||||
|
||||
FramePort connects by itself within a minute. No password is needed. The command lets FramePort in and turns on
|
||||
@@ -118,6 +119,11 @@ an installed Revive, or downloads a portable copy.
|
||||
on the game page.
|
||||
- Games that use the Oculus Platform SDK check the licence through the Oculus app, so they run on the PC only.
|
||||
|
||||
**Windows games without VR:** **Add games → Add one game folder…** with the game's folder (pick the program that
|
||||
starts it if asked). FramePort installs it on the Frame and Proton runs it as a window, like Steam's own Windows games;
|
||||
it shows up in the Frame's Steam library tagged "Windows game on Frame". Whether a game runs depends on Proton on ARM
|
||||
(x86 games run through emulation).
|
||||
|
||||
## Files on the Frame (videos, documents, mods, saves)
|
||||
|
||||
The **Files** tab manages files on the Frame over the same connection as installs; no other transfer app is needed.
|
||||
|
||||
@@ -50,6 +50,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Quest app keeps recentering / snapping the view when you turn your head; log shows session state 5→4→3→4→5 within a second | the Frame briefly takes focus; the app recenters on focus changes | unfixed: hiding the dips (debounce) made AC Nexus stay black, so it was removed |
|
||||
| Video player / app finds no local videos; MediaProvider "Requested path /home/steamos/... doesn't appear under ..." | Lepton's /sdcard is a symlink to a host path, so Android's media index rejects every file | upload in the Files tab (Videos = /sdcard/Movies) and browse folders in the app |
|
||||
| Game (Unity, GLES) freezes, `zink: DEVICE LOST` | multisampled render-to-texture hangs the GPU | `frame.unity_no_msaa`; if it persists: unfixable → PC version |
|
||||
| Unity game hangs or the whole Frame restarts (e.g. in a menu); log: `The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended level.` (e.g. 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 grey, the other is fine (e.g. 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 |
|
||||
| Setup/intro loops every launch (Espire 2) | save folders created without write permission | launcher repairs permissions every 2 s (built in) |
|
||||
| VrApi bridge: never enters VR | Frame reaches FOCUSED later than Quest; tracking only when worn | bridge waits 30 s; test in the headset |
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- **Windows:** unzip `FramePort-windows-x64.zip`, run `FramePort.exe` (warning → **More info → Run anyway**).
|
||||
- **macOS:** unzip `FramePort-macos-arm64.zip`, right-click `FramePort.app` → **Open**.
|
||||
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort`.
|
||||
- **Linux:** `tar xzf FramePort-linux-x64.tar.gz && ./FramePort/FramePort` (ARM64: `FramePort-linux-arm64.tar.gz`).
|
||||
|
||||
Full guide: [docs/INSTALL.md](https://github.com/spoopyghosty0/frameport/blob/main/docs/INSTALL.md) · checksums in
|
||||
`SHA256SUMS.txt`.
|
||||
@@ -21,7 +21,10 @@ from frameport.recommend.catalog import CatalogEntry, to_yaml # noqa: E402
|
||||
|
||||
PKG_RE = re.compile(r"^(rift\.[a-z0-9_]{1,80}|[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+){1,8})$")
|
||||
STATUSES = {"works", "issues", "unsupported", "unknown"}
|
||||
LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "frame_remove", "pcvr", "pcvr_remove"}
|
||||
LIST_FIELDS = {"overport_extra", "overport_remove", "alt_overport", "frame", "frame_remove", "pcvr", "pcvr_remove",
|
||||
"device"}
|
||||
# FramePort's own patch ids must exist (OVRPort's are listed by its CLI at runtime, so not checked here)
|
||||
OWN_PATCH_FIELDS = ("frame", "frame_remove", "pcvr", "pcvr_remove", "device")
|
||||
DICT_FIELDS = {"adapter", "device_files", "lepton_env", "proton_env", "verified"}
|
||||
ID_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,80}$")
|
||||
|
||||
@@ -64,6 +67,13 @@ def validate(d: dict) -> CatalogEntry:
|
||||
for k in LIST_FIELDS & set(d):
|
||||
if not isinstance(d[k], list) or not all(isinstance(x, str) and ID_RE.match(x) for x in d[k]):
|
||||
raise Invalid(f"{k} must be a list of patch ids")
|
||||
from frameport.patches import base
|
||||
|
||||
base.load_all()
|
||||
for k in OWN_PATCH_FIELDS:
|
||||
unknown_ids = [x for x in d.get(k) or [] if x not in base.REGISTRY]
|
||||
if unknown_ids:
|
||||
raise Invalid(f"{k}: unknown patch id(s) {', '.join(unknown_ids)}")
|
||||
for k in DICT_FIELDS & set(d):
|
||||
if not isinstance(d[k], dict):
|
||||
raise Invalid(f"{k} must be a mapping")
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Write docs/GAMES.md: the tested games from the bundled catalog as a simple table to share.
|
||||
Run after catalog changes: python scripts/compat_list.py"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from frameport.recommend import catalog # noqa: E402
|
||||
|
||||
STATUS = {"works": "✅ Works", "issues": "⚠️ Works with issues", "unsupported": "❌ Doesn't run"}
|
||||
ORDER = {"works": 0, "issues": 1, "unsupported": 2}
|
||||
ISSUES = "https://github.com/spoopyghosty0/frameport/issues/new?template="
|
||||
SHARE, REPORT = ISSUES + "working-config.yml", ISSUES + "bug-report.yml"
|
||||
|
||||
|
||||
def short(note: str) -> str:
|
||||
"""The note's first sentence, without technical asides."""
|
||||
first = re.split(r"(?<=[.!?])\s", " ".join((note or "").split()), maxsplit=1)[0]
|
||||
return first if len(first) <= 160 else first[:157].rstrip() + "…"
|
||||
|
||||
|
||||
def render() -> str:
|
||||
entries = [e for e in catalog.load().values() if e.origin == "bundled" and e.status in STATUS]
|
||||
entries.sort(key=lambda e: (ORDER[e.status], e.title.lower()))
|
||||
rows = ["| Game | Platform | Status | Notes |", "|---|---|---|---|"]
|
||||
for e in entries:
|
||||
platform = "PC VR" if e.kind == "rift" else "Quest"
|
||||
note = "" if e.status == "works" else short(e.notes).replace("|", "/")
|
||||
rows.append(f"| {e.title} | {platform} | {STATUS[e.status]} | {note} |")
|
||||
return ("# Tested games\n\n"
|
||||
"Games tested on the Steam Frame with FramePort's recipes. Games not listed here may work too: FramePort "
|
||||
"suggests patches for them, and a working config can be shared from the app (**Share working config…**).\n"
|
||||
f"Tested a game? [Share a working config]({SHARE}) or [report a problem]({REPORT}) (in the app: the game's "
|
||||
"**…** menu does both and fills in the details).\n"
|
||||
"Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.\n\n"
|
||||
+ "\n".join(rows) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
out = ROOT / "docs" / "GAMES.md"
|
||||
out.write_text(render(), encoding="utf-8")
|
||||
print(f"wrote {out}")
|
||||
@@ -70,6 +70,8 @@ def main() -> int:
|
||||
# translations (data files PyInstaller doesn't pick up from the imports)
|
||||
"--add-data", f"{ROOT / 'src/frameport/locales'}{sep}frameport/locales",
|
||||
"--distpath", str(ROOT / "dist"), "--yes"]
|
||||
if TARGET == "linux":
|
||||
cmd.append("--onedir") # a folder: the bundle check sees the agent source, the archive the app
|
||||
else:
|
||||
# --yes: install the Flutter SDK etc. without prompting; --no-rich-output: plain logs (CI, Windows consoles)
|
||||
cmd = ["flet", "build", TARGET, str(ROOT), "--project", "FramePort", "--product", "FramePort",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.6.3"
|
||||
@@ -193,6 +193,9 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
"split_apk": bool({"isSplitRequired", "requiredSplitTypes"} & set(manifest_strings)),
|
||||
# Unity (IL2CPP) text fields: they close at once on the Frame (frame.unity_text_input)
|
||||
"text_fields": unity_text_fields(il2cpp_meta) if il2cpp_meta else [],
|
||||
# Meta's OVRManager raises MSAA at runtime (frame.unity_runtime_msaa_off); Oculus XR Plugin (multiview)
|
||||
"ovr_runtime_msaa": bool(il2cpp_meta) and b"\0useRecommendedMSAALevel\0" in il2cpp_meta,
|
||||
"oculus_xr_plugin": bool(il2cpp_meta) and b"\0m_StereoRenderingModeAndroid\0" in il2cpp_meta,
|
||||
"unity_version": unity_version(ggm, lib_bytes.get("libunity.so")) if engine == "Unity" else None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
in its "diffable C#" output with the attribute injector). Offset is the method's position in the libil2cpp.so FILE
|
||||
(not the virtual address: those differ, e.g. by 0x4000 in Stremio VR's library), which is what a byte patch needs.
|
||||
|
||||
Results are cached per libil2cpp.so (sha256), so a rebuild of the same game doesn't run Cpp2IL again."""
|
||||
Results are cached per game build (its global-metadata.dat), so a rebuild of the same game doesn't run Cpp2IL again,
|
||||
and neither does the next patch of the same build (an earlier one has already changed libil2cpp.so's bytes, never its
|
||||
layout)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -39,8 +41,9 @@ def _plain_version(version: str) -> str:
|
||||
return m.group(0) if m else version
|
||||
|
||||
|
||||
def _cache_file(lib: bytes) -> Path:
|
||||
return cache_dir() / "il2cpp" / (hashlib.sha256(lib).hexdigest()[:32] + ".json")
|
||||
def _cache_file(lib: bytes, metadata: bytes) -> Path:
|
||||
key = hashlib.sha256(metadata + len(lib).to_bytes(8, "little")).hexdigest()[:32]
|
||||
return cache_dir() / "il2cpp" / (key + ".json")
|
||||
|
||||
|
||||
def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[str, list[str]],
|
||||
@@ -48,7 +51,7 @@ def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[s
|
||||
"""wanted: {class file (relative to Cpp2IL's DiffableCs folder, e.g. "Unity.TextMeshPro/TMPro/TMP_InputField.cs"):
|
||||
[method names]} -> {class file: {method: (file offset, length)}}; classes or methods not in the game are left
|
||||
out."""
|
||||
cache_file = _cache_file(lib)
|
||||
cache_file = _cache_file(lib, metadata)
|
||||
try:
|
||||
cached = json.loads(cache_file.read_text())
|
||||
if all(cls in cached.get("classes", {}) or cls in cached.get("missing", []) for cls in wanted):
|
||||
|
||||
@@ -299,6 +299,8 @@ def original_platform(entry: dict) -> str:
|
||||
"""What the game was made for (a Steam tag)."""
|
||||
extra = (entry.get("analysis") or {}).get("extra") or {}
|
||||
if entry.get("kind") == "rift":
|
||||
if extra.get("flat"):
|
||||
return "Windows"
|
||||
return "Oculus Rift" if extra.get("needs_revive") else "PC VR"
|
||||
kind = extra.get("vr_kind") or "quest"
|
||||
return {"quest": "Meta Quest", "none": "Android"}.get(kind, "Android VR")
|
||||
@@ -310,7 +312,9 @@ def steam_tags(entry: dict, where: str = "frame") -> list[str]:
|
||||
from ..targets.pc_revive import TAG
|
||||
|
||||
kind = ((entry.get("analysis") or {}).get("extra") or {}).get("vr_kind") or "quest"
|
||||
runs = TAG if where != "frame" else "PC VR on Frame" if entry.get("kind") == "rift" else \
|
||||
flat = bool(((entry.get("analysis") or {}).get("extra") or {}).get("flat"))
|
||||
runs = TAG if where != "frame" else ("Windows game on Frame" if flat else "PC VR on Frame") \
|
||||
if entry.get("kind") == "rift" else \
|
||||
"Quest on Frame" if kind == "quest" else "Android on Frame"
|
||||
base = [runs, original_platform(entry)]
|
||||
genres = ((entry.get("details") or {}).get("genres") or [])[:4]
|
||||
|
||||
@@ -405,7 +405,8 @@ def frame_pair(timeout: float = typer.Option(600, help="seconds to wait for the
|
||||
from .frame.pairing import PairingServer
|
||||
|
||||
srv = PairingServer().start()
|
||||
typer.echo("On the Frame, open Desktop Mode → Konsole and run:\n\n " + srv.one_liner + "\n")
|
||||
typer.echo("On the Frame, open the SteamVR dashboard → Launch a program → Desktop, then System → Konsole, and "
|
||||
"run:\n\n " + srv.one_liner + "\n")
|
||||
end = time.time() + timeout
|
||||
while time.time() < end and not srv.paired:
|
||||
time.sleep(1)
|
||||
|
||||
@@ -28,7 +28,7 @@ def load() -> dict:
|
||||
data = json.loads(_path().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"games": {}, "settings": {}}
|
||||
if _migrate(data):
|
||||
if _migrate(data) | _follow_catalog(data):
|
||||
save(data)
|
||||
return data
|
||||
|
||||
@@ -42,6 +42,27 @@ def edit() -> Iterator[dict]:
|
||||
save(data)
|
||||
|
||||
|
||||
def _follow_catalog(data: dict) -> bool:
|
||||
"""Recipes FramePort derived (not ones the user edited) follow their catalog entry: when an entry is new or
|
||||
changed (e.g. a fix shipped with an update), the game's recipe is derived again; Update on Frame applies it."""
|
||||
from ..recommend import catalog, engine
|
||||
|
||||
changed = False
|
||||
for pkg, g in (data.get("games") or {}).items():
|
||||
r, a = g.get("recipe"), g.get("analysis")
|
||||
if not isinstance(r, dict) or not isinstance(a, dict) or r.get("source") == "user":
|
||||
continue
|
||||
entry = catalog.lookup(pkg)
|
||||
if entry is None or r.get("catalog_rev") == entry.rev():
|
||||
continue
|
||||
try:
|
||||
g["recipe"] = recipe_to_dict(engine.suggest(analysis_from_dict(a)))
|
||||
except Exception: # noqa: BLE001 - a malformed entry keeps its recipe
|
||||
continue
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _migrate(data: dict) -> bool:
|
||||
"""One-time updates of stored recipes when a new default is introduced (each runs once per library)."""
|
||||
done = data.setdefault("settings", {}).setdefault("migrations", [])
|
||||
|
||||
@@ -82,6 +82,7 @@ class Recipe:
|
||||
as_is: bool = False # install the game unchanged (already patched): no overport / Frame fixes / Revive
|
||||
overport: bool = True # False: an ordinary Android app: no VR translation (overport) and no FrameBridge adapter
|
||||
reasons: dict[str, str] = field(default_factory=dict) # patch id -> why it was suggested
|
||||
catalog_rev: str = "" # CatalogEntry.rev() it was derived from: a changed catalog entry re-derives the recipe
|
||||
|
||||
def enabled(self, patch_id: str) -> bool:
|
||||
return patch_id in self.patches
|
||||
|
||||
@@ -46,8 +46,12 @@ def explain(exc: BaseException) -> str:
|
||||
low = msg.lower()
|
||||
if paramiko and isinstance(exc, paramiko.ChannelException):
|
||||
return tr("The connection to the Frame is busy. Try again in a moment.")
|
||||
if "authentication failed" in low:
|
||||
return msg # already says what to do (run the bootstrap / enter the password)
|
||||
if "authentication failed" in low or "no authentication methods available" in low:
|
||||
if "password was refused" in low:
|
||||
return tr("The Frame refused that password. Check it, or run the first-time setup instead: Steam Frame → "
|
||||
"Show setup command.")
|
||||
return tr("This Frame isn't set up for FramePort yet. Run the first-time setup: Steam Frame → Show setup "
|
||||
"command.")
|
||||
if is_connection_error(exc):
|
||||
return tr("Can't reach your Frame. Make sure it's on, awake and on the same network, then try again.")
|
||||
if isinstance(exc, OSError) and exc.errno == errno.ENOSPC:
|
||||
|
||||
@@ -24,6 +24,25 @@ from ..core.paths import agent_file, ssh_dir, user_data_dir, write_atomic
|
||||
REMOTE_AGENT_DIR = ".local/share/frameport/agent"
|
||||
|
||||
|
||||
class FrameNotPaired(ConnectionError):
|
||||
"""SSH works but none of FramePort's ways in was accepted: the Frame hasn't run the first-time setup (or the
|
||||
password was wrong). The message keeps "authentication failed" for older callers."""
|
||||
|
||||
def __init__(self, tried_password: bool, details: list[str]):
|
||||
self.tried_password = tried_password
|
||||
self.details = details
|
||||
reason = ("the password was refused" if tried_password
|
||||
else "this Frame doesn't know FramePort yet")
|
||||
super().__init__(f"SSH authentication failed: {reason}. Run the first-time setup on the Frame (Steam Frame → "
|
||||
"Show setup command)." + (f" ({'; '.join(details)})" if details else ""))
|
||||
|
||||
|
||||
def _no_auth_methods(exc: BaseException) -> bool:
|
||||
"""paramiko ends a key/agent attempt that had nothing to offer (no SSH keys on this PC, no agent) with a plain
|
||||
SSHException, not an AuthenticationException: it's a failed login all the same, not a network problem."""
|
||||
return isinstance(exc, paramiko.SSHException) and "no authentication methods available" in str(exc).lower()
|
||||
|
||||
|
||||
def app_key() -> paramiko.Ed25519Key:
|
||||
path = ssh_dir() / "id_ed25519"
|
||||
if not path.exists():
|
||||
@@ -133,6 +152,9 @@ def bundled_agent_version() -> int | None:
|
||||
RUN_TIMEOUT = 120 # seconds: default bound for one remote command's output
|
||||
|
||||
|
||||
NOT_IN_LIBRARY = "not in the Frame's Steam library" # agent cmd_launch's error when the shortcut is missing
|
||||
|
||||
|
||||
class AgentFailed(RuntimeError):
|
||||
pass
|
||||
|
||||
@@ -283,12 +305,17 @@ class Frame:
|
||||
except paramiko.AuthenticationException as exc:
|
||||
client.close()
|
||||
errors.append(f"{attempt}: {exc}")
|
||||
except paramiko.SSHException as exc:
|
||||
client.close()
|
||||
if not _no_auth_methods(exc):
|
||||
raise
|
||||
# (Reddit, 2026-10-03: this ended the loop before the password was tried)
|
||||
errors.append(f"{attempt}: {exc}")
|
||||
except BaseException:
|
||||
client.close()
|
||||
raise
|
||||
else:
|
||||
raise ConnectionError("SSH authentication failed (" + "; ".join(errors) + "). Run the FramePort "
|
||||
"bootstrap on the Frame or enter the steamos password.")
|
||||
raise FrameNotPaired(bool(self.password), errors)
|
||||
client.save_host_keys(str(known))
|
||||
transport = client.get_transport()
|
||||
transport.set_keepalive(15)
|
||||
|
||||
@@ -210,7 +210,7 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
env=launch_env(plan.recipe),
|
||||
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
|
||||
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version,
|
||||
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source},
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
|
||||
@@ -325,6 +325,14 @@ def _tags(package: str) -> list[str]:
|
||||
return steam_tags(entry) if entry else []
|
||||
|
||||
|
||||
def is_flat_windows(package: str) -> bool:
|
||||
"""A Windows game without VR (added with "Add one game folder…"): Proton runs it as a window, no VR setup."""
|
||||
from ..core import library
|
||||
|
||||
g = library.game(package) or {}
|
||||
return bool(((g.get("analysis") or {}).get("extra") or {}).get("flat"))
|
||||
|
||||
|
||||
def _flatscreen(package: str) -> bool:
|
||||
"""An Android app without VR: Lepton must show it as a flat window (it never draws through OpenXR)."""
|
||||
from ..core import library
|
||||
@@ -384,8 +392,13 @@ def add_to_steam(frame: Frame, packages: list[str], reporter: Reporter, wait: fl
|
||||
continue
|
||||
if status.get("state") in ("done", "failed"):
|
||||
break
|
||||
added = {a["package"] for a in status.get("added", [])}
|
||||
for a in status.get("added", []):
|
||||
reporter.check(f"Steam library: {a['package']}", True, f"shortcut {a['appid']}")
|
||||
for e in status.get("errors", []):
|
||||
reporter.check("Steam library", False, e)
|
||||
if not status.get("errors"):
|
||||
for pkg in packages:
|
||||
if pkg not in added: # no answer in time: say so (Play adds a missing shortcut itself)
|
||||
reporter.check(f"Steam library: {pkg}", False, "the Frame didn't confirm the library entry in time")
|
||||
return status
|
||||
@@ -56,6 +56,7 @@
|
||||
"A new FramePort version is ready to install": "",
|
||||
"A recipe is the list of patches FramePort applies to a game before installing it. Known-good recipes were tested on a Frame; the others are suggested from the game's engine and VR API. Hover a patch for why it's there.": "",
|
||||
"A VR app made for another Android headset. OpenXR apps are translated like Quest games; apps built on a headset maker's own SDK (Pico, HTC Wave) can't run on the Frame.": "",
|
||||
"A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it as a window (like Steam's own Windows games); it's in the Frame's Steam library.": "",
|
||||
"A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).": "",
|
||||
"A zip with FramePort's logs, the game's recipe and analysis, launch-test logs and the Frame's runtime details — enough to debug without the game files. IP addresses, host and user names, home folders and Steam ids are replaced by placeholders. Attach it to a GitHub issue.": "",
|
||||
"ABIs": "",
|
||||
@@ -70,6 +71,7 @@
|
||||
"Add games": "",
|
||||
"Add one game folder…": "",
|
||||
"Add this tag": "",
|
||||
"Add to Steam library: {title}": "",
|
||||
"Add videos": "",
|
||||
"Add videos and files…": "",
|
||||
"Add your games": "",
|
||||
@@ -97,6 +99,7 @@
|
||||
"Allows very large pictures (8K video, theatres) instead of quitting.": "",
|
||||
"Already patched: don't change the game's files": "",
|
||||
"Already patched: install as is (skip patching)": "",
|
||||
"Already set up: on your network": "",
|
||||
"Also in your library: {value} version": "",
|
||||
"Also remove FramePort's games and files from the Frame": "",
|
||||
"An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's Android container.": "",
|
||||
@@ -235,6 +238,7 @@
|
||||
],
|
||||
"Downloading…": "",
|
||||
"Downloads": "",
|
||||
"Draws each eye separately; try it when one eye shows a grey or broken picture.": "",
|
||||
"Drop invalid layers": "",
|
||||
"Drop layers whose swapchain failed or whose extension isn't enabled.": "",
|
||||
"Drop to upload": "",
|
||||
@@ -403,7 +407,7 @@
|
||||
"It has a known blocker on the Steam Frame.": "",
|
||||
"It has issues: share…": "",
|
||||
"it stopped at '{value}'": "",
|
||||
"It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on Developer Mode closes Desktop Mode; the setup finishes on its own and FramePort connects by itself. You only do this once.": "",
|
||||
"It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on Developer Mode closes the desktop; the setup finishes on its own and FramePort connects by itself. You only do this once.": "",
|
||||
"It was removed from the library.": "",
|
||||
"It works: share…": "",
|
||||
"Java runtime": "",
|
||||
@@ -412,6 +416,7 @@
|
||||
"Keep converted copies on this PC after installing (FramePort converts again for every install, so they're only needed for inspecting a build)": "",
|
||||
"Keep game saves on the Frame": "",
|
||||
"Keep menus in place": "",
|
||||
"Keeps a smoothing setting off that the game turns on itself and that can freeze the Frame.": "",
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
@@ -471,6 +476,7 @@
|
||||
"Menus and screens": "",
|
||||
"Meta Quest": "",
|
||||
"Meta XR Audio: skip Quest telemetry class": "",
|
||||
"Meta's OVRManager switches Unity to the headset's recommended MSAA level (4x) while the game runs, whatever its quality settings say (log: \"Switching to the recommended level\"). Multisampled render-to-texture on GLES can hang the Frame's GPU, up to a restart of the whole headset (e.g. Lucky's Tale in a menu). Rewrites OVRDisplay.recommendedMSAALevel -> 0 in libil2cpp.so (found with Cpp2IL), so MSAA stays off.": "",
|
||||
"MiB": "",
|
||||
"Mirrored": "",
|
||||
"Mixed reality": "",
|
||||
@@ -487,13 +493,14 @@
|
||||
"Needs Windows (or WSL on Windows)": "",
|
||||
"Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise).": "",
|
||||
"New folder": "",
|
||||
"New Frame? Run one command on it and FramePort does the rest.": "",
|
||||
"New Frame? Run one command on it and FramePort does the rest. Did this once already? Your Frame appears under step 2.": "",
|
||||
"New name": "",
|
||||
"Next": "",
|
||||
"No activity": "",
|
||||
"no answer from the Frame": "",
|
||||
"No artwork could be downloaded from that {source} result; the current artwork stays. Try another one.": "",
|
||||
"No folders to rescan yet: add games with Scan a folder": "",
|
||||
"No Frames found. New Frame? Use first-time setup below. Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
|
||||
"No Frames found. New Frame? Use first-time setup (step 1). Otherwise make sure Developer Mode is on (Settings → System → Developer) and the Frame is on the same network.": "",
|
||||
"No games found in that folder": "",
|
||||
"No limit": "",
|
||||
"No matching files": "",
|
||||
@@ -523,16 +530,16 @@
|
||||
"Now {scale:.0%}; {new:.0%} after restarting FramePort.": "",
|
||||
"Oculus game — needs Revive, which doesn't run on the Frame. Play it on this PC (SteamVR).": "",
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or grey (e.g. I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
|
||||
"off: {names}": "",
|
||||
"Offline": "",
|
||||
"Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer flip_emul).": "",
|
||||
"On Frame": "",
|
||||
"On the Frame, place Revive's runtime (FramePort's Revive, or the repack's bundled LibRevive) where the game's Oculus SDK looks for LibOVRRT so it can load a VR runtime (pure runtime substitution — what Revive's LoadLibrary hook does on Windows; that hook doesn't work under Proton on the Frame). It does NOT bypass the Oculus runtime signature check: a game that verifies the runtime's signature will still refuse it. Ignored on this PC (Revive's hook handles it there).": "",
|
||||
"On the Frame: Steam button → Power → Switch to Desktop, open Konsole and run:": "",
|
||||
"On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole, and run:": "",
|
||||
"On this PC": "",
|
||||
"On this PC, launch the game through Revive's OpenVR backend, which talks straight to SteamVR — the original, most reliable Revive path. On by default for PC installs. Turn it off to use Revive's newer OpenXR backend instead. Ignored on the Frame (which always uses OpenXR).": "",
|
||||
"On this PC, runs Revive through SteamVR (works with more headsets).": "",
|
||||
"On your network": "",
|
||||
"on: {names}": "",
|
||||
"One PC VR game folder": "",
|
||||
"Only FramePort's entry is removed. Your game files and anything installed stay.": "",
|
||||
@@ -546,7 +553,7 @@
|
||||
"Opens this player's storage on the Frame (Files tab): upload videos into the folder it lists": "",
|
||||
"OpenXR runtime": "",
|
||||
"Or paste text to type it in one go": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up above. Open Settings → Developer → Pair new host on the Frame, click Connect here and approve FramePort. That way needs no connection into this computer.": "",
|
||||
"Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows up under step 2. Open Settings → Developer → Pair new host on the Frame, click Connect there and approve FramePort. That way needs no connection into this computer.": "",
|
||||
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
|
||||
"Override the guardian width (0 = use guardian, min 1.5 m).": "",
|
||||
"OVRPort converts Quest games from Meta's own VR APIs to standard OpenXR. These are its optional patches.": "",
|
||||
@@ -813,6 +820,7 @@
|
||||
"The Frame is charging: continuing the installs.": "",
|
||||
"the Frame isn't connected": "",
|
||||
"The Frame isn't connected": "",
|
||||
"The Frame refused that password. Check it, or run the first-time setup instead: Steam Frame → Show setup command.": "",
|
||||
"The Frame went offline: {exc}": "",
|
||||
"The Frame's battery is at {pct} % and not charging. Plug it in (a strong charger, not a PC port): long installs can drain it, and FramePort pauses them at {pause} %.": "",
|
||||
"The Frame's battery is at {pct} %: installs paused so it doesn't switch off mid-upload. Plug it in; the queue continues by itself.": "",
|
||||
@@ -837,6 +845,7 @@
|
||||
"This computer's keyboard works as a keyboard on your Frame while this window is open. In the headset, select a text field (in a game or app, in Steam or on the desktop), then type here.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder is empty. Upload files with the buttons above.": "",
|
||||
"This Frame isn't set up for FramePort yet. Run the first-time setup: Steam Frame → Show setup command.": "",
|
||||
"This game can't run on the Steam Frame.": "",
|
||||
"This game is marked \"Can't run\" on the Steam Frame:": "",
|
||||
"This game's folder has several programs and FramePort isn't sure which one starts the game. Open the game (or right-click → Change executable) to pick it.": "",
|
||||
@@ -883,6 +892,8 @@
|
||||
"Uninstalled {title}": "",
|
||||
"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.": "",
|
||||
"Unity: disable MSAA": "",
|
||||
"Unity: keep MSAA off at runtime (OVRManager)": "",
|
||||
"Unity: render each eye separately (no multiview)": "",
|
||||
"Unreal builds of Meta XR Audio abort ('ClassNotFoundException com.oculus.os.AnalyticsEvent') on non-Quest devices. Patches that one JNI FindClass call so the library continues (e.g. NOPE Challenge).": "",
|
||||
"Unreal games start CrashReportClient when they crash, which leaves a crash dialog instead of simply closing. This passes -nocrashreports to the game and, on the Frame, renames the game's copy of CrashReportClient.exe so it can't start (your game files on this PC aren't changed).": "",
|
||||
"Untested": "",
|
||||
@@ -946,6 +957,8 @@
|
||||
"Where this game's recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
|
||||
"Which program starts {get}?": "",
|
||||
"Which Proton build runs the game on the Frame (a Steam compat tool name from the Frame's ARM64 compat list, e.g. proton_11-arm64 or proton-experimental-arm64). Empty = newest installed.": "",
|
||||
"Windows": "",
|
||||
"Windows game (Proton)": "",
|
||||
"Windows not detected": "",
|
||||
"Windows treats this network as public and its firewall blocks the Frame. In Windows' network settings set this network to Private (or allow FramePort on public networks when Windows asks).": "",
|
||||
"Windows' firewall for WSL blocks the Frame from reaching FramePort. Open the setup command again and allow the change when Windows asks (admin).": "",
|
||||
@@ -969,6 +982,8 @@
|
||||
"Your custom recipe": "",
|
||||
"your Frame": "",
|
||||
"Your Frame is back: continuing": "",
|
||||
"Your Frame isn't set up for FramePort yet. Run the first-time setup once (step 1).": "",
|
||||
"Your Frame restarted unexpectedly while {title} was running (or soon after). If that game crashed it, please report it.": "",
|
||||
"Your headset, what it's ready for and what's installed": "",
|
||||
"Your own game files aren't touched.": "",
|
||||
"Your own tags (filled) can be anything you like. Outlined tags are added automatically from the engine, VR API and store genres. Filter the library by either.": "",
|
||||
@@ -1031,7 +1046,9 @@
|
||||
],
|
||||
"{on} on your Frame": "",
|
||||
"{remaining} more after this": "",
|
||||
"{title} couldn't be added to the Frame's Steam library: {why}": "",
|
||||
"{title} failed: {error}": "",
|
||||
"{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)": "",
|
||||
"{title}: analyzed again": "",
|
||||
"{title}: details from {value}": "",
|
||||
"{title}: done": "",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Unity XR settings the game's code chooses at runtime, fixed in libil2cpp.so (found per game with Cpp2IL, like
|
||||
frame.unity_text_input).
|
||||
|
||||
- MSAA: Meta's OVRManager (useRecommendedMSAALevel) raises Unity's antiAliasing to the headset's recommended level
|
||||
(4x) at runtime ("The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended
|
||||
level."), whatever QualitySettings say, so frame.unity_no_msaa can't keep it off. Multisampled render-to-texture on
|
||||
GLES through Zink hangs the Frame's GPU (Lucky's Tale: the whole headset restarted in a menu, 2026-10-03).
|
||||
OVRDisplay.recommendedMSAALevel -> 0 keeps it off.
|
||||
- Multiview: Oculus XR Plugin games render both eyes in one pass (Multiview) on Android. I Am Cat (Unity 2022.3, GLES
|
||||
through Zink) drew only effects into the right eye's array slice; OculusSettings.GetStereoRenderingMode -> 0
|
||||
(MultiPass) renders each eye in its own pass."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import Suggestion, register
|
||||
from .unity_text_input import RET_FALSE, Il2cppReturnPatch
|
||||
|
||||
RET_ZERO = RET_FALSE # mov w0, #0 ; ret
|
||||
|
||||
|
||||
class UnityRuntimeMsaa(Il2cppReturnPatch):
|
||||
id = "frame.unity_runtime_msaa_off"
|
||||
title = "Unity: keep MSAA off at runtime (OVRManager)"
|
||||
description = ("Meta's OVRManager switches Unity to the headset's recommended MSAA level (4x) while the game runs, "
|
||||
"whatever its quality settings say (log: \"Switching to the recommended level\"). Multisampled "
|
||||
"render-to-texture on GLES can hang the Frame's GPU, up to a restart of the whole headset (e.g. "
|
||||
"Lucky's Tale in a menu). Rewrites OVRDisplay.recommendedMSAALevel -> 0 in libil2cpp.so (found with "
|
||||
"Cpp2IL), so MSAA stays off.")
|
||||
order = 47
|
||||
targets = {"Oculus.VR/OVRDisplay.cs": {"get_recommendedMSAALevel": RET_ZERO},
|
||||
"Assembly-CSharp/OVRDisplay.cs": {"get_recommendedMSAALevel": RET_ZERO}} # older Oculus Integration
|
||||
check_name = "Unity MSAA"
|
||||
|
||||
def applies(self, a):
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
class UnityMultiPass(Il2cppReturnPatch):
|
||||
id = "frame.unity_multipass"
|
||||
title = "Unity: render each eye separately (no multiview)"
|
||||
description = ("Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only "
|
||||
"effects or grey (e.g. I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. "
|
||||
"Rewrites OculusSettings.GetStereoRenderingMode -> MultiPass in libil2cpp.so (found with Cpp2IL). "
|
||||
"Try it when one eye is wrong.")
|
||||
order = 48
|
||||
targets = {"Unity.XR.Oculus/Unity/XR/Oculus/OculusSettings.cs": {"GetStereoRenderingMode": RET_ZERO}}
|
||||
check_name = "Unity stereo mode"
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and not a.only_32bit \
|
||||
and (a.extra or {}).get("oculus_xr_plugin", True) is not False
|
||||
|
||||
def detect(self, a):
|
||||
return None # only for a game that shows the symptom (catalog recipe or the user's choice)
|
||||
|
||||
|
||||
register(UnityRuntimeMsaa)
|
||||
register(UnityMultiPass)
|
||||
@@ -29,12 +29,14 @@ def _executable(data: bytes) -> list[tuple[int, int]]:
|
||||
if s["p_type"] == "PT_LOAD" and s["p_flags"] & 1]
|
||||
|
||||
|
||||
def patch_methods(lib: bytes, found: dict[str, dict[str, tuple[int, int]]]) -> tuple[bytes | None, list[str]]:
|
||||
"""Write the return values over the methods' first two instructions. found = il2cpp.find_methods' result.
|
||||
def patch_methods(lib: bytes, found: dict[str, dict[str, tuple[int, int]]],
|
||||
targets: dict[str, dict[str, bytes]] | None = None) -> tuple[bytes | None, list[str]]:
|
||||
"""Write the return values over the methods' first two instructions. found = il2cpp.find_methods' result,
|
||||
targets = {class file: {method: RET_*}} (default: the text field methods).
|
||||
Returns (patched library or None when nothing changed, notes)."""
|
||||
out, notes, changed = bytearray(lib), [], False
|
||||
code = _executable(lib)
|
||||
for cls, methods in TARGETS.items():
|
||||
for cls, methods in (targets or TARGETS).items():
|
||||
for method, new in methods.items():
|
||||
off, length = found.get(cls, {}).get(method, (None, 0))
|
||||
if off is None:
|
||||
@@ -47,11 +49,50 @@ def patch_methods(lib: bytes, found: dict[str, dict[str, tuple[int, int]]]) -> t
|
||||
continue
|
||||
out[off:off + len(new)] = new
|
||||
changed = True
|
||||
notes.append(f"{name} -> {'true' if new == RET_TRUE else 'false'} (at {off:#x})")
|
||||
notes.append(f"{name} -> {'true/1' if new == RET_TRUE else 'false/0'} (at {off:#x})")
|
||||
return (bytes(out) if changed else None), notes
|
||||
|
||||
|
||||
class UnityTextInput(Patch):
|
||||
ALL_TARGETS: dict[str, list[str]] = {} # every IL2CPP patch's methods: one Cpp2IL run (and cache entry) serves all
|
||||
|
||||
|
||||
class Il2cppReturnPatch(Patch):
|
||||
"""Make IL2CPP methods return a constant (targets), found per game with Cpp2IL (analysis/il2cpp.py)."""
|
||||
targets: dict[str, dict[str, bytes]] = {}
|
||||
check_name = ""
|
||||
|
||||
def __init_subclass__(cls, **kw):
|
||||
super().__init_subclass__(**kw)
|
||||
for c, methods in cls.targets.items():
|
||||
ALL_TARGETS.setdefault(c, [])
|
||||
ALL_TARGETS[c] += [m for m in methods if m not in ALL_TARGETS[c]]
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
from ...analysis.il2cpp import find_methods
|
||||
|
||||
ws = ctx.ws
|
||||
lib_name = ws.lib("libil2cpp.so")
|
||||
if ws.abi != "arm64-v8a" or not ws.has(lib_name) or not ws.has(METADATA):
|
||||
return False
|
||||
version = (ctx.analysis.extra or {}).get("unity_version")
|
||||
if not version:
|
||||
ctx.reporter.check(self.check_name, False, "not fixed: unknown Unity version (rescan the game)")
|
||||
return False
|
||||
lib = ws.read(lib_name)
|
||||
ctx.reporter.log(f"{self.check_name}: finding the code (Cpp2IL; the first time can take a minute)")
|
||||
try:
|
||||
found = find_methods(lib, ws.read(METADATA), version, ALL_TARGETS)
|
||||
patched, notes = patch_methods(lib, found, self.targets)
|
||||
except Exception as exc: # noqa: BLE001 - optional fix: the game still builds, unchanged
|
||||
ctx.reporter.check(self.check_name, False, f"not fixed: {exc}")
|
||||
return False
|
||||
ctx.notes.extend(notes or [f"{self.check_name}: code not found in this game"])
|
||||
if patched is not None:
|
||||
ws.put(lib_name, patched)
|
||||
return patched is not None
|
||||
|
||||
|
||||
class UnityTextInput(Il2cppReturnPatch):
|
||||
id = "frame.unity_text_input"
|
||||
title = "Make Unity text fields work without a system keyboard"
|
||||
description = ("Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a "
|
||||
@@ -63,6 +104,8 @@ class UnityTextInput(Patch):
|
||||
"Frame from the PC.")
|
||||
order = 46
|
||||
needs_vr = False
|
||||
targets = TARGETS
|
||||
check_name = "Unity text fields"
|
||||
|
||||
def applies(self, a):
|
||||
return a.engine == "Unity" and "libil2cpp.so" in a.libs and bool((a.extra or {}).get("text_fields"))
|
||||
@@ -74,29 +117,5 @@ class UnityTextInput(Patch):
|
||||
"it has no system keyboard.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
from ...analysis.il2cpp import find_methods
|
||||
|
||||
ws = ctx.ws
|
||||
lib_name = ws.lib("libil2cpp.so")
|
||||
if ws.abi != "arm64-v8a" or not ws.has(lib_name) or not ws.has(METADATA):
|
||||
return False
|
||||
version = (ctx.analysis.extra or {}).get("unity_version")
|
||||
if not version:
|
||||
ctx.reporter.check("Unity text fields", False, "not fixed: unknown Unity version (rescan the game)")
|
||||
return False
|
||||
lib = ws.read(lib_name)
|
||||
ctx.reporter.log("finding the text field code (Cpp2IL; the first time can take a minute)")
|
||||
try:
|
||||
found = find_methods(lib, ws.read(METADATA), version, {c: list(m) for c, m in TARGETS.items()})
|
||||
patched, notes = patch_methods(lib, found)
|
||||
except Exception as exc: # noqa: BLE001 - optional fix: the game still builds, its text fields as before
|
||||
ctx.reporter.check("Unity text fields", False, f"not fixed: {exc}")
|
||||
return False
|
||||
ctx.notes.extend(notes or ["no Unity text field code found"])
|
||||
if patched is not None:
|
||||
ws.put(lib_name, patched)
|
||||
return patched is not None
|
||||
|
||||
|
||||
register(UnityTextInput)
|
||||
@@ -43,6 +43,9 @@ SUMMARIES = {
|
||||
"frame.ovrplatformcompat": "Adds a small piece of Meta's platform library that some games need to start.",
|
||||
"frame.ovrstubs": "Provides stand-ins for Meta store functions the game expects, so it doesn't crash at start.",
|
||||
"frame.unity_no_msaa": "Turns off a smoothing setting that crashes some older Unity games on the Frame.",
|
||||
"frame.unity_runtime_msaa_off": "Keeps a smoothing setting off that the game turns on itself and that can freeze "
|
||||
"the Frame.",
|
||||
"frame.unity_multipass": "Draws each eye separately; try it when one eye shows a grey or broken picture.",
|
||||
"frame.swapchain_limit": "Allows very large pictures (8K video, theatres) instead of quitting.",
|
||||
"frame.vrapi_bridge": "Translator for games built on Meta's oldest VR interface.",
|
||||
"frame.gl_shim": "Fixes graphics code that the Frame's drivers reject (black screen with sound).",
|
||||
|
||||
@@ -153,7 +153,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {folder.name}")
|
||||
a = rift.analyze(folder, exe=exe, tree=tree)
|
||||
if not a.extra.get("vr_found") and not force and not old:
|
||||
# no VR runtime in it: added on purpose ("Add one game folder…") = a flat Windows game run by Proton
|
||||
a.extra["flat"] = not a.extra.get("vr_found")
|
||||
if a.extra["flat"] and not force and not old:
|
||||
if reporter:
|
||||
reporter.log(f"skipped {folder.name}: no VR support (OpenXR, SteamVR or Oculus) found in {a.extra['exe']}")
|
||||
return None
|
||||
|
||||
@@ -49,6 +49,7 @@ class CatalogEntry:
|
||||
proton_tool: str = ""
|
||||
as_is: bool = False # Rift: the dump runs unchanged (e.g. a SteamVR build: no Revive)
|
||||
verified: dict = field(default_factory=dict)
|
||||
updated: str = "" # date of the last maintainer change without a new headset test (e.g. a fix from a report)
|
||||
source_hint: str = ""
|
||||
origin: str = "bundled"
|
||||
|
||||
@@ -57,6 +58,14 @@ class CatalogEntry:
|
||||
known = {k: v for k, v in d.items() if k in cls.__dataclass_fields__}
|
||||
return cls(**known, origin=origin)
|
||||
|
||||
def rev(self) -> str:
|
||||
"""Fingerprint of the recipe content: library recipes derived from this entry follow it when it changes."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
d = {k: v for k, v in self.to_dict().items() if k != "origin"}
|
||||
return hashlib.sha256(json.dumps(d, sort_keys=True, default=str).encode()).hexdigest()[:16]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
out = {}
|
||||
for k in self.__dataclass_fields__:
|
||||
@@ -114,11 +123,21 @@ def load(refresh: bool = False) -> dict[str, CatalogEntry]:
|
||||
if _cache is None or refresh:
|
||||
entries = _load_dir(catalog_dir() / "games", "bundled")
|
||||
entries.update(_load_remote())
|
||||
entries.update(_load_dir(user_catalog_dir(), "user"))
|
||||
for pkg, e in _load_dir(user_catalog_dir(), "user").items():
|
||||
if not _newer(entries.get(pkg), e): # the user's own recipe, unless a maintained one was verified later
|
||||
entries[pkg] = e
|
||||
_cache = entries
|
||||
return _cache
|
||||
|
||||
|
||||
def _newer(maintained: CatalogEntry | None, user: CatalogEntry) -> bool:
|
||||
"""True when the bundled/remote entry was verified after the user's (e.g. a fix for a config the user shared)."""
|
||||
if maintained is None:
|
||||
return False
|
||||
when = max(str(maintained.updated or ""), str(maintained.verified.get("date") or ""))
|
||||
return when > str(user.verified.get("date") or "")
|
||||
|
||||
|
||||
def lookup(package: str) -> CatalogEntry | None:
|
||||
return load().get(package)
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ from ..patches import base
|
||||
from . import catalog
|
||||
from .catalog import TOGGLED_DEVICE
|
||||
|
||||
# what a flat (non-VR) Windows game keeps of the PC VR patches
|
||||
FLAT_WINDOWS_PATCHES = ("pcvr.proton_tool", "pcvr.proton_env", "pcvr.proton_log", "pcvr.no_crash_reporter")
|
||||
|
||||
|
||||
def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
entry = catalog.lookup(analysis.package) if use_catalog else None
|
||||
recipe = Recipe(analysis.package, title=analysis.label)
|
||||
recipe = Recipe(analysis.package, title=analysis.label, catalog_rev=entry.rev() if entry else "")
|
||||
# 1. heuristics / defaults from every patch module
|
||||
rift = (analysis.extra or {}).get("kind") == "rift"
|
||||
for patch in base.all_patches():
|
||||
@@ -27,6 +30,10 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
# 2. known-good catalog recipe overrides heuristics
|
||||
if rift:
|
||||
_rift_recipe(analysis, recipe, entry)
|
||||
if (analysis.extra or {}).get("flat"): # a Windows game without VR: Proton only, no VR parts
|
||||
for pid in [p for p in recipe.patches if p not in FLAT_WINDOWS_PATCHES]:
|
||||
recipe.patches.pop(pid)
|
||||
recipe.reasons.pop(pid, None)
|
||||
elif entry:
|
||||
recipe.source = f"catalog ({entry.origin})"
|
||||
recipe.status, recipe.notes, recipe.title = entry.status, entry.notes, entry.title or recipe.title
|
||||
|
||||
+64
-4
@@ -19,6 +19,7 @@ import flet as ft
|
||||
from .. import pipeline
|
||||
from ..core import applog, library
|
||||
from ..errors import explain
|
||||
from ..frame.connection import NOT_IN_LIBRARY, AgentFailed
|
||||
from ..i18n import fmt_size, tr, tr_n
|
||||
from ..recommend import catalog
|
||||
from . import components as C
|
||||
@@ -393,8 +394,13 @@ class FramePortApp:
|
||||
else (lambda e: self.show_activity(True)))
|
||||
elif job.state == "failed":
|
||||
msg = tr("{title} failed: {error}").format(title=job.title, error=job.error)
|
||||
self.toast(msg, error=True, action=tr("Details"),
|
||||
on_action=lambda e: self.show_activity(True))
|
||||
pkg = job.package
|
||||
if job.kind in ("install", "test") and pkg: # one click to the diagnostics + prefilled issue
|
||||
self.toast(msg, error=True, action=tr("Report a problem"),
|
||||
on_action=lambda e: self.report_problem_dialog(pkg))
|
||||
else:
|
||||
self.toast(msg, error=True, action=tr("Details"),
|
||||
on_action=lambda e: self.show_activity(True))
|
||||
self.refresh_view()
|
||||
if job.kind == "scan" and self.exe_queue:
|
||||
self.page.run_thread(self.next_exe_choice)
|
||||
@@ -494,7 +500,8 @@ class FramePortApp:
|
||||
if self.frame_state == "connected" and C.install_state(g, self.frame_info) in ("installed", "outdated"):
|
||||
out.append((tr("Play on Frame"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "frame"), False,
|
||||
tr("Starts the game through the Frame's Steam — put the headset on")))
|
||||
if g.get("kind") == "rift" and pkg in self.pc_installs():
|
||||
if g.get("kind") == "rift" and pkg in self.pc_installs() and \
|
||||
not ((g.get("analysis") or {}).get("extra") or {}).get("flat"):
|
||||
out.append((tr("Play on this PC"), ft.Icons.PLAY_ARROW_ROUNDED, lambda e: self.play(pkg, "pc"), False,
|
||||
tr("Starts the game through Steam on this PC (SteamVR + Revive)")))
|
||||
return out
|
||||
@@ -503,7 +510,13 @@ class FramePortApp:
|
||||
title = self._title(pkg)
|
||||
|
||||
def work():
|
||||
res = self._target_for(to).launch(pkg) or {}
|
||||
try:
|
||||
res = self._target_for(to).launch(pkg) or {}
|
||||
except AgentFailed as exc:
|
||||
if to != "frame" or NOT_IN_LIBRARY not in str(exc):
|
||||
raise
|
||||
self._add_then_play(pkg, title) # e.g. the shortcut step failed during the install
|
||||
return
|
||||
library.upsert_game(pkg, last_played=time.time())
|
||||
if to == "frame":
|
||||
self.toast(tr("Starting {title} on the Frame — put the headset on").format(title=title))
|
||||
@@ -515,6 +528,21 @@ class FramePortApp:
|
||||
self.toast(tr("Starting SteamVR and {title} — put your headset on").format(title=title))
|
||||
self.run_bg(work)
|
||||
|
||||
def _add_then_play(self, pkg: str, title: str) -> None:
|
||||
def run(reporter):
|
||||
target = self._target_for("frame")
|
||||
status = target.add_to_library([pkg], reporter)
|
||||
if not any(a.get("package") == pkg for a in status.get("added", [])):
|
||||
raise RuntimeError(tr("{title} couldn't be added to the Frame's Steam library: {why}").format(
|
||||
title=title, why="; ".join(status.get("errors") or []) or tr("no answer from the Frame")))
|
||||
time.sleep(15) # Steam restarted: give it time to load the library before asking it to launch
|
||||
target.launch(pkg)
|
||||
library.upsert_game(pkg, last_played=time.time())
|
||||
return tr("Starting {title} on the Frame — put the headset on").format(title=title)
|
||||
self.toast(tr("{title} isn't in the Frame's Steam library yet: adding it (Steam restarts once)").format(
|
||||
title=title))
|
||||
self.submit(tr("Add to Steam library: {title}").format(title=title), run, pkg, "tool-frame")
|
||||
|
||||
def quick_action(self, g: dict) -> tuple[str | None, str | None]:
|
||||
"""(tooltip, kind) for a library card's round button; kind: play | install | update | reinstall (None: no
|
||||
button, e.g. while a job runs for the game or before a Frame is connected)."""
|
||||
@@ -1335,6 +1363,30 @@ class FramePortApp:
|
||||
except Exception: # noqa: BLE001
|
||||
traceback.print_exc()
|
||||
|
||||
def _check_frame_restart(self, info: dict | None) -> None:
|
||||
"""The Frame restarted without shutting down (a crash, a GPU reset) soon after a FramePort game started: say
|
||||
so, with Report a problem (Reddit 2026-10-03: "Resident Evil 4 crashes the Frame after the opening cutscene").
|
||||
Uses the last boot seen (kept in settings, so a crash while FramePort was closed counts too)."""
|
||||
from . import restart
|
||||
|
||||
boot = (info or {}).get("boot") or {}
|
||||
if not boot.get("boot_id"):
|
||||
return
|
||||
seen = library.setting("frame.boot_id")
|
||||
if seen == boot["boot_id"]:
|
||||
return
|
||||
library.set_setting("frame.boot_id", boot["boot_id"])
|
||||
game = restart.crashed_game(boot, seen)
|
||||
if game is None:
|
||||
return
|
||||
applog.log.info("Frame restarted unexpectedly after %s was started: %s", game["package"], boot)
|
||||
library.upsert_game(game["package"], frame_restart=boot.get("boot_time")) if library.game(game["package"]) \
|
||||
else None
|
||||
pkg = game["package"]
|
||||
self.toast(tr("Your Frame restarted unexpectedly while {title} was running (or soon after). If that game "
|
||||
"crashed it, please report it.").format(title=game.get("title") or pkg), error=True,
|
||||
action=tr("Report a problem"), on_action=lambda e: self.report_problem_dialog(pkg))
|
||||
|
||||
def _battery_check(self, fetch: bool) -> None:
|
||||
"""Battery level for the sidebar, and the queue on battery power: warn once, pause before the Frame would
|
||||
switch itself off mid-upload, continue when it charges (ui/battery.py)."""
|
||||
@@ -1384,6 +1436,7 @@ class FramePortApp:
|
||||
self.toast(explain(exc), error=True)
|
||||
return False
|
||||
self.frame_info, self.frame_state = info, "connected"
|
||||
self._check_frame_restart(info)
|
||||
self._awake_at = 0.0 # the lock went away with the old connection's Frame session: take it again
|
||||
self.jobs.resume()
|
||||
self.toast(tr("Your Frame is back: continuing"))
|
||||
@@ -1459,6 +1512,7 @@ class FramePortApp:
|
||||
t.label = target.label
|
||||
save_target(target)
|
||||
old, self.target, self.frame_info, self.frame_state = self.target, t, info, "connected"
|
||||
self._check_frame_restart(info)
|
||||
if old is not None and old is not t and not self.jobs.current():
|
||||
try: # the connection it replaces (a running job keeps using its own until it ends)
|
||||
old.close()
|
||||
@@ -1466,9 +1520,14 @@ class FramePortApp:
|
||||
pass
|
||||
if not quiet:
|
||||
self.toast(tr("Connected to {label}").format(label=target.label))
|
||||
self._not_paired = False
|
||||
except Exception as exc: # noqa: BLE001
|
||||
from ..frame.connection import FrameNotPaired
|
||||
|
||||
self.frame_state = "offline"
|
||||
self.frame_info = None
|
||||
# reachable, but it doesn't let FramePort in: the Frame page points at the first-time setup
|
||||
self._not_paired = isinstance(exc, FrameNotPaired)
|
||||
if not quiet:
|
||||
self.toast(tr("Couldn't connect: {exc}").format(exc=explain(exc)), error=True)
|
||||
if self.route[0] in ("frame", "library", "game", "welcome"):
|
||||
@@ -1502,6 +1561,7 @@ class FramePortApp:
|
||||
changed = info.get("installed") != (self.frame_info or {}).get("installed") or \
|
||||
self.frame_state != "connected"
|
||||
self.frame_info, self.frame_state = info, "connected"
|
||||
self._check_frame_restart(info)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
frame = getattr(target, "frame", None)
|
||||
if frame is not None and frame.alive():
|
||||
|
||||
@@ -45,6 +45,8 @@ def platform(g: dict) -> tuple[str, str, str]:
|
||||
"""(short label, long label, help key) for a game's platform: what it was made for, not how it runs here."""
|
||||
if g.get("kind") == "rift":
|
||||
extra = ((g.get("analysis") or {}).get("extra") or {})
|
||||
if extra.get("flat"):
|
||||
return tr("Windows"), tr("Windows game (Proton)"), "platform_windows"
|
||||
oculus = extra.get("needs_revive") or extra.get("libovr")
|
||||
return tr("PC VR"), tr("PC VR · Oculus") if oculus else tr("PC VR"), "platform_pcvr"
|
||||
kind = (((g.get("analysis") or {}).get("extra") or {}).get("vr_kind")) or "quest"
|
||||
|
||||
@@ -28,6 +28,8 @@ HELP: dict[str, str] = _Translated({
|
||||
"game (or right-click → Change executable) to pick it.",
|
||||
"platform_quest": "A Meta Quest game (APK). It's rebuilt for the Frame and runs in Lepton, Valve's Android "
|
||||
"container.",
|
||||
"platform_windows": "A Windows game without VR, added with \"Add one game folder…\". On the Frame, Proton runs it "
|
||||
"as a window (like Steam's own Windows games); it's in the Frame's Steam library.",
|
||||
"platform_pcvr": "A Windows PC VR game (OpenXR, SteamVR or Oculus). It runs on this PC with SteamVR, or on the "
|
||||
"Frame with Proton. Oculus-only games also need Revive (Oculus → OpenXR).",
|
||||
"platform_android": "An ordinary Android app or game (no VR). It's installed unchanged and runs in Lepton, Valve's "
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Did the Frame crash because of a game? (no Flet) From the agent's boot state: a new boot whose predecessor didn't
|
||||
shut down cleanly, with a FramePort game started shortly before it."""
|
||||
from __future__ import annotations
|
||||
|
||||
SOON = 3 * 3600 # a game started up to 3 h before the restart may have caused it
|
||||
|
||||
|
||||
def crashed_game(boot: dict, previous_boot_id: str | None) -> dict | None:
|
||||
"""The game to blame ({"package", "title", "time"}) or None. Only for a boot FramePort hasn't seen before whose
|
||||
previous boot ended without a clean shutdown (a battery shutdown or a normal power-off is clean)."""
|
||||
if not previous_boot_id or boot.get("boot_id") == previous_boot_id or boot.get("prev_clean") is not False:
|
||||
return None
|
||||
last, boot_time = boot.get("last_launch"), boot.get("boot_time")
|
||||
if not last or not boot_time or not 0 <= boot_time - last.get("time", 0) <= SOON:
|
||||
return None
|
||||
return last
|
||||
@@ -173,7 +173,7 @@ class FrameView:
|
||||
devkit=f.source == "devkit")),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S3, T.px(8), T.S2, T.px(8)), bgcolor=T.SURFACE_2,
|
||||
border_radius=T.RADIUS_SM))
|
||||
found.controls = items or [C.body(tr("No Frames found. New Frame? Use first-time setup below. Otherwise "
|
||||
found.controls = items or [C.body(tr("No Frames found. New Frame? Use first-time setup (step 1). Otherwise "
|
||||
"make sure Developer Mode is on (Settings → System → Developer) and "
|
||||
"the Frame is on the same network."))]
|
||||
found.controls.append(C.ghost(tr("Search again"), ft.Icons.REFRESH_ROUNDED, lambda e: app.run_bg(discover)))
|
||||
@@ -215,7 +215,8 @@ class FrameView:
|
||||
discovery), which used to close it."""
|
||||
line = app.pairing.one_liner
|
||||
pair_box.controls = [
|
||||
C.body(tr("On the Frame: Steam button → Power → Switch to Desktop, open Konsole and run:"), T.TEXT),
|
||||
C.body(tr("On the Frame: SteamVR dashboard → Launch a program → Desktop, then app menu → System → "
|
||||
"Konsole, and run:"), T.TEXT),
|
||||
ft.Container(ft.Row([ft.Text(line, font_family="monospace", selectable=True, size=T.px(12),
|
||||
color=T.TEXT, expand=True),
|
||||
C.icon_btn(ft.Icons.CONTENT_COPY_ROUNDED, tr("Copy"), lambda e: app.copy(line))]),
|
||||
@@ -225,7 +226,7 @@ class FrameView:
|
||||
C.meta(tr("Waiting for your Frame… (code {code})").format(code=app.pairing.code))],
|
||||
spacing=T.S2),
|
||||
C.meta(tr("It trusts this app, turns on Developer Mode and installs Lepton if needed. Turning on "
|
||||
"Developer Mode closes Desktop Mode; the setup finishes on its own and FramePort connects "
|
||||
"Developer Mode closes the desktop; the setup finishes on its own and FramePort connects "
|
||||
"by itself. You only do this once.")),
|
||||
]
|
||||
hint = getattr(app.pairing, "hint", "")
|
||||
@@ -235,8 +236,8 @@ class FrameView:
|
||||
weight=ft.FontWeight.W_600),
|
||||
C.body(hint, T.TEXT),
|
||||
C.meta(tr("Or turn on Developer Mode on the Frame (Settings → System → Developer): it then shows "
|
||||
"up above. Open Settings → Developer → Pair new host on the Frame, click Connect here "
|
||||
"and approve FramePort. That way needs no connection into this computer.")),
|
||||
"up under step 2. Open Settings → Developer → Pair new host on the Frame, click Connect "
|
||||
"there and approve FramePort. That way needs no connection into this computer.")),
|
||||
], spacing=T.S2), "warn"))
|
||||
if update:
|
||||
pair_box.update()
|
||||
@@ -252,7 +253,12 @@ class FrameView:
|
||||
width=T.px(240), tooltip=C.tip(HELP["password"]), **style)
|
||||
saved = saved_targets()
|
||||
offline = None
|
||||
if saved and app.frame_state == "offline":
|
||||
if getattr(app, "_not_paired", False): # it answered, but doesn't know FramePort yet
|
||||
offline = C.callout(ft.Row([
|
||||
C.body(tr("Your Frame isn't set up for FramePort yet. Run the first-time setup once (step 1)."), T.TEXT,
|
||||
expand=True),
|
||||
C.primary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair)]), "warn")
|
||||
elif saved and app.frame_state == "offline":
|
||||
unreachable = tr("{label} ({host}) isn't reachable. Make sure it's switched on and on the same "
|
||||
"network.").format(label=saved[0].label, host=saved[0].host)
|
||||
offline = C.callout(ft.Row([C.body(unreachable, T.TEXT, expand=True),
|
||||
@@ -280,10 +286,12 @@ class FrameView:
|
||||
return ft.Column([
|
||||
app.top_bar(tr("Connect your Steam Frame"), tr("FramePort installs games on the Frame over your network")),
|
||||
*([offline] if offline else []),
|
||||
step(1, tr("On your network"), tr("Frames in Developer Mode show up here."), found, help="developer_mode"),
|
||||
step(2, tr("First-time setup"), tr("New Frame? Run one command on it and FramePort does the rest."),
|
||||
C.secondary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair), pair_box,
|
||||
step(1, tr("First-time setup"), tr("New Frame? Run one command on it and FramePort does the rest. Did this "
|
||||
"once already? Your Frame appears under step 2."),
|
||||
C.primary(tr("Show setup command"), ft.Icons.TERMINAL_ROUNDED, pair), pair_box,
|
||||
help="first_time_setup"),
|
||||
step(2, tr("Already set up: on your network"), tr("Frames in Developer Mode show up here."), found,
|
||||
help="developer_mode"),
|
||||
step(3, tr("Enter the address"), tr("If you know the Frame's address."),
|
||||
ft.Row([addr, pw, C.primary(tr("Connect"),
|
||||
on_click=lambda e: app.connect_manual(addr.value, pw.value))],
|
||||
|
||||
@@ -18,6 +18,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -39,7 +40,7 @@ REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
|
||||
LATEST_API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
CHECK_EVERY = 6 * 3600 # seconds between automatic checks
|
||||
ASSETS = {"win32": "FramePort-windows-x64.zip", "darwin": "FramePort-macos-arm64.zip",
|
||||
"linux": "FramePort-linux-x64.tar.gz"}
|
||||
"linux": "FramePort-linux-x64.tar.gz", "linux-arm64": "FramePort-linux-arm64.tar.gz"} # never rename
|
||||
SUMS = "SHA256SUMS.txt"
|
||||
|
||||
|
||||
@@ -75,6 +76,8 @@ def is_newer(candidate: str, current: str = __version__) -> bool:
|
||||
|
||||
def platform_asset() -> str | None:
|
||||
key = "linux" if sys.platform.startswith("linux") else sys.platform
|
||||
if key == "linux" and platform.machine().lower() in ("aarch64", "arm64"):
|
||||
key = "linux-arm64" # the ARM64 bundle (from 0.6.0)
|
||||
return ASSETS.get(key)
|
||||
|
||||
|
||||
|
||||
+67
-1
@@ -369,11 +369,32 @@ def test_launch_uses_steam_shortcut(monkeypatch, tmp_path):
|
||||
(anchor / "deployment.json").write_text(json.dumps(deployment))
|
||||
calls = []
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **k: (calls.append(cmd), SimpleNamespace(returncode=0, stdout=""))[1])
|
||||
cfg = tmp_path / ".local/share/Steam/userdata/42/config"
|
||||
cfg.mkdir(parents=True)
|
||||
with pytest.raises(a.AgentError, match="not in the Frame's Steam library"):
|
||||
a.cmd_launch({"package": "com.x.y"})
|
||||
exe = f'"{anchor}/launch.sh"'
|
||||
a.upsert_shortcut(str(cfg / "shortcuts.vdf"), exe, "Old title", str(anchor)) # keeps its first id
|
||||
r = a.cmd_launch({"package": "com.x.y"})
|
||||
assert r["gameid"] == (2546384938 << 32) | 0x02000000
|
||||
assert r["gameid"] == (a.shortcut_appid(exe, "Old title") << 32) | 0x02000000
|
||||
assert calls[-1][-1] == f"steam://rungameid/{r['gameid']}" and calls[-1][0] == "systemd-run"
|
||||
|
||||
|
||||
def test_library_users_prefers_the_signed_in_account(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
steam = tmp_path / ".local/share/Steam"
|
||||
with pytest.raises(a.AgentError, match="signed-in"):
|
||||
a.library_users()
|
||||
for u in ("12345", "999"):
|
||||
(steam / "userdata" / u / "config").mkdir(parents=True)
|
||||
assert a.library_users() == ["12345", "999"] # unknown: every account (used to fail with 2 accounts)
|
||||
(steam / "config").mkdir()
|
||||
(steam / "config/loginusers.vdf").write_text(
|
||||
'"users"\n{\n\t"76561197960278073"\n\t{\n\t\t"MostRecent"\t\t"1"\n\t}\n'
|
||||
'\t"76561197960266727"\n\t{\n\t\t"MostRecent"\t\t"0"\n\t}\n}\n')
|
||||
assert a.library_users() == ["12345"]
|
||||
|
||||
|
||||
def test_uninstall_quest_keeping_saves_drops_the_install_record(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "Applications/quest-frame/com.x.y"
|
||||
@@ -793,3 +814,48 @@ def test_keyboard_session_reports_a_refused_device(monkeypatch, tmp_path):
|
||||
out = io.StringIO()
|
||||
assert a.keyboard_session(io.StringIO(""), out) == 1
|
||||
assert json.loads(out.getvalue())["ready"] is False
|
||||
|
||||
|
||||
def test_boot_state_finds_unclean_previous_boot_and_last_launch(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
base = tmp_path / "game"
|
||||
base.mkdir()
|
||||
(base / "launch.log").write_text("x")
|
||||
os.utime(base / "launch.log", (1000, 1000))
|
||||
monkeypatch.setattr(a, "cmd_list_installed", lambda args: {"games": [{"package": "com.x", "title": "X",
|
||||
"base": str(base)}]})
|
||||
real_open = open
|
||||
|
||||
def fake_open(path, *args, **kw):
|
||||
if path == "/proc/sys/kernel/random/boot_id":
|
||||
from io import StringIO
|
||||
return StringIO("abc\n")
|
||||
if path == "/proc/stat":
|
||||
from io import StringIO
|
||||
return StringIO("cpu 1 2 3\nbtime 2000\n")
|
||||
return real_open(path, *args, **kw)
|
||||
monkeypatch.setattr(a, "open", fake_open, raising=False)
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(stdout="kernel: msm_dpu ... fault\n"))
|
||||
st = a.boot_state()
|
||||
assert st == {"boot_id": "abc", "boot_time": 2000, "prev_clean": False,
|
||||
"last_launch": {"package": "com.x", "title": "X", "time": 1000.0}}
|
||||
monkeypatch.setattr(a, "run", lambda cmd, **kw: SimpleNamespace(stdout="Reached target System Power Off\n"))
|
||||
assert a.boot_state()["prev_clean"] is False # cached for this boot: worked out once
|
||||
|
||||
|
||||
def test_flat_windows_game_launcher_and_shortcut(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "compat_command", lambda d: ["/proton", "waitforexitandrun"])
|
||||
anchor, base = tmp_path / "anchor", tmp_path / "base"
|
||||
anchor.mkdir()
|
||||
tool = {"dir": "/tools/proton", "name": "Proton 11"}
|
||||
a.write_proton_launcher(str(anchor), str(base), "rift.game", "Game", 123, tool, "Game.exe", False, {}, vr=False)
|
||||
flat = (anchor / "launch.sh").read_text()
|
||||
a.write_proton_launcher(str(anchor), str(base), "rift.game", "Game", 123, tool, "Game.exe", False, {})
|
||||
vr = (anchor / "launch.sh").read_text()
|
||||
assert "SteamGameId" not in flat and "export SteamGameId=123" in vr # Proton sets up VR only with it
|
||||
assert "STEAM_COMPAT_APP_ID=123" in flat
|
||||
vdf = str(tmp_path / "shortcuts.vdf")
|
||||
a.upsert_shortcut(vdf, '"/x/launch.sh"', "Game", "/x", tag="Windows game on Frame", openvr=False)
|
||||
entry = a.vdf_decode(open(vdf, "rb").read())["shortcuts"]["0"]
|
||||
assert entry["OpenVR"] == 0 and entry["tags"]["0"] == "Windows game on Frame"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Logging in to the Frame (Reddit 2026-10-03: "Couldn't connect: no authentication methods available" even with the
|
||||
right password). paramiko ends the key/agent attempt with a plain SSHException when this PC has no SSH keys; that must
|
||||
not stop the password attempt, and the final error must say what to do."""
|
||||
import paramiko
|
||||
import pytest
|
||||
|
||||
from frameport.errors import explain
|
||||
from frameport.frame import connection as C
|
||||
|
||||
|
||||
class _Client:
|
||||
"""Stands in for paramiko.SSHClient: `script` maps attempt kind -> exception (or None = success)."""
|
||||
script: dict = {}
|
||||
tried: list = []
|
||||
|
||||
def load_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def set_missing_host_key_policy(self, policy):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def save_host_keys(self, path):
|
||||
pass
|
||||
|
||||
def connect(self, pkey=None, password=None, allow_agent=False, look_for_keys=False, **kw):
|
||||
kind = "password" if password else "agent" if allow_agent else "app_key"
|
||||
_Client.tried.append(kind)
|
||||
exc = _Client.script.get(kind)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_ssh(monkeypatch):
|
||||
monkeypatch.setattr(C.paramiko, "SSHClient", _Client)
|
||||
monkeypatch.setattr(C, "app_keys", lambda: ["key"])
|
||||
_Client.tried = []
|
||||
return _Client
|
||||
|
||||
|
||||
NO_METHODS = paramiko.SSHException("No authentication methods available")
|
||||
|
||||
|
||||
def test_no_ssh_keys_on_the_pc_still_tries_the_password(fake_ssh, monkeypatch):
|
||||
fake_ssh.script = {"app_key": paramiko.AuthenticationException("Authentication failed."), "agent": NO_METHODS,
|
||||
"password": paramiko.AuthenticationException("Authentication failed.")}
|
||||
frame = C.Frame(C.FrameTarget("10.0.0.2"), password="secret")
|
||||
with pytest.raises(C.FrameNotPaired) as err:
|
||||
frame.connect()
|
||||
assert fake_ssh.tried == ["app_key", "agent", "password"]
|
||||
assert err.value.tried_password and "password was refused" in str(err.value)
|
||||
assert "refused that password" in explain(err.value)
|
||||
|
||||
|
||||
def test_unpaired_frame_without_password_explains_the_setup(fake_ssh):
|
||||
fake_ssh.script = {"app_key": paramiko.AuthenticationException("Authentication failed."), "agent": NO_METHODS}
|
||||
with pytest.raises(C.FrameNotPaired) as err:
|
||||
C.Frame(C.FrameTarget("10.0.0.2")).connect()
|
||||
assert "authentication failed" in str(err.value).lower() # app.connect's devkit fallback looks for this
|
||||
assert "first-time setup" in explain(err.value) and "isn't set up" in explain(err.value)
|
||||
assert explain(NO_METHODS).startswith("This Frame isn't set up")
|
||||
|
||||
|
||||
def test_network_errors_still_end_the_attempts(fake_ssh):
|
||||
fake_ssh.script = {"app_key": paramiko.SSHException("Error reading SSH protocol banner")}
|
||||
with pytest.raises(paramiko.SSHException, match="banner"):
|
||||
C.Frame(C.FrameTarget("10.0.0.2"), password="x").connect()
|
||||
assert fake_ssh.tried == ["app_key"]
|
||||
+9
-2
@@ -216,12 +216,17 @@ def test_catalog_from_issue(tmp_path):
|
||||
assert s.main([str(_write(tmp_path, BODY)), "--issue", "7", "--out", str(tmp_path)]) == 0
|
||||
d = yaml.safe_load((tmp_path / "com.example.game.yaml").read_text())
|
||||
assert d["frame"] == ["frame.ovrstubs"] and d["verified"]["issue"] == 7
|
||||
with_device = BODY.replace("- frame.ovrstubs", "- frame.ovrstubs\ndevice:\n- device.text_input_window")
|
||||
assert s.main([str(_write(tmp_path, with_device)), "--out", str(tmp_path)]) == 0
|
||||
assert yaml.safe_load((tmp_path / "com.example.game.yaml").read_text())["device"] == ["device.text_input_window"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["package: ../../etc/passwd\ntitle: x", "package: com.a.b\ntitle: x\nevil: 1",
|
||||
"package: com.a.b\ntitle: x\nstatus: great",
|
||||
"package: com.a.b\ntitle: x\nframe: [\"$(rm)\"]",
|
||||
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list"])
|
||||
"package: com.a.b\ntitle: x\ndevice_files: {'../x': 1}", "- a list",
|
||||
"package: com.a.b\ntitle: x\nframe: [frame.no_such_patch]",
|
||||
"package: com.a.b\ntitle: x\ndevice: device.text_input_window"])
|
||||
def test_catalog_from_issue_rejects(tmp_path, bad):
|
||||
s = _script()
|
||||
body = BODY.split("```yaml")[0] + "```yaml\n" + bad + "\n```\n"
|
||||
@@ -260,7 +265,9 @@ def test_agent_collect_diag(monkeypatch, tmp_path):
|
||||
(xr / "XRService-12-31-50.log").write_text("xr")
|
||||
host = a.cmd_collect_diag({})
|
||||
assert host["agent_version"] == a.AGENT_VERSION and "openxr_layers" in host["host"]
|
||||
assert host["files"] == {"XRService-12-31-50.log": "xr"}
|
||||
# (plus the previous boot's journal excerpts when this machine has a journal)
|
||||
assert {k: v for k, v in host["files"].items() if not k.startswith("previous-boot")} == \
|
||||
{"XRService-12-31-50.log": "xr"}
|
||||
res = a.cmd_collect_diag({"package": "com.x.y", "max_bytes": 1000})
|
||||
f = res["files"]
|
||||
assert res["installed"] and {"deployment.json", "launch.sh", "settings.conf", "launch.log", "logcat-main.log",
|
||||
|
||||
@@ -37,6 +37,7 @@ def test_known_failures(exc, start):
|
||||
|
||||
def test_other_errors_keep_their_message():
|
||||
auth = ConnectionError("SSH authentication failed (bad key). Run the FramePort bootstrap on the Frame.")
|
||||
assert explain(auth) == str(auth) and not is_connection_error(auth) # already says what to do
|
||||
# login refused: points at the first-time setup, and isn't a "Frame went away" (the queue mustn't wait for it)
|
||||
assert explain(auth).startswith("This Frame isn't set up") and not is_connection_error(auth)
|
||||
assert explain(AgentFailed("Lepton is not installed (Developer Mode → …)")).startswith("Lepton is not installed")
|
||||
assert explain(RuntimeError()) == "RuntimeError"
|
||||
@@ -0,0 +1,20 @@
|
||||
from frameport.ui.restart import crashed_game
|
||||
|
||||
LAUNCH = {"package": "com.x", "title": "X", "time": 1000.0}
|
||||
|
||||
|
||||
def boot(**kw):
|
||||
return {"boot_id": "b2", "boot_time": 2000, "prev_clean": False, "last_launch": LAUNCH, **kw}
|
||||
|
||||
|
||||
def test_unclean_restart_soon_after_a_launch_blames_the_game():
|
||||
assert crashed_game(boot(), "b1") == LAUNCH
|
||||
|
||||
|
||||
def test_no_blame_for_clean_shutdowns_first_sight_old_launches_or_same_boot():
|
||||
assert crashed_game(boot(prev_clean=True), "b1") is None # powered off / battery shutdown
|
||||
assert crashed_game(boot(prev_clean=None), "b1") is None # journal unknown
|
||||
assert crashed_game(boot(), None) is None # never seen this Frame boot before: nothing to compare
|
||||
assert crashed_game(boot(), "b2") is None # same boot
|
||||
assert crashed_game(boot(boot_time=1000 + 4 * 3600), "b1") is None # the game ran hours before
|
||||
assert crashed_game(boot(last_launch=None), "b1") is None
|
||||
@@ -60,3 +60,10 @@ def test_crash_logcat_signatures():
|
||||
"(xrCreateSwapchain+424)\n")
|
||||
r = triage(log.replace("11 (SIGSEGV)", "6 (SIGABRT)"), "EXITED", "com.example.game", crash=abort)
|
||||
assert [(f.id, f.suggest) for f in r.findings] == [("swapchain-size-abort", ["frame.swapchain_limit"])]
|
||||
|
||||
|
||||
def test_ovrplugin_extension_probe_is_not_a_scene_game():
|
||||
probe = LOG_OK + "09-28 17:39:02.200 1147 1174 I OVRPlugin: Unavailable OpenXR extension: XR_FB_scene\n"
|
||||
assert "scene-missing" not in {f.id for f in triage(probe, "RUNNING", "com.example.game").findings}
|
||||
used = LOG_OK + "09-28 17:39:04.000 1147 1174 E Unity : xrQuerySpacesFB failed: -12\n"
|
||||
assert "scene-missing" in {f.id for f in triage(used, "RUNNING", "com.example.game").findings}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unity XR settings fixed in libil2cpp.so: OVRManager's runtime MSAA and the Oculus XR Plugin's multiview."""
|
||||
from pathlib import Path
|
||||
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.analysis import il2cpp
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame import unity_runtime as R
|
||||
from frameport.patches.frame import unity_text_input as U
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
|
||||
def _lib() -> bytes:
|
||||
return sorted(Path(__file__).parent.joinpath("fixtures").glob("libfake*_arm64.so"))[0].read_bytes()
|
||||
|
||||
|
||||
def test_all_il2cpp_patches_share_one_cpp2il_lookup():
|
||||
base.load_all()
|
||||
for p in (U.UnityTextInput, R.UnityRuntimeMsaa, R.UnityMultiPass):
|
||||
for cls, methods in p.targets.items():
|
||||
assert set(methods) <= set(U.ALL_TARGETS[cls])
|
||||
# keyed by the build's metadata: an earlier patch's bytes in libil2cpp.so don't force another Cpp2IL run
|
||||
assert il2cpp._cache_file(b"a" * 10, b"meta") == il2cpp._cache_file(b"b" * 10, b"meta")
|
||||
assert il2cpp._cache_file(b"a" * 10, b"meta") != il2cpp._cache_file(b"a" * 10, b"other")
|
||||
|
||||
|
||||
def test_runtime_patches_write_zero_returns():
|
||||
lib = _lib()
|
||||
lo, _hi = U._executable(lib)[0]
|
||||
a, b = (lo + 0x40) & ~3, (lo + 0x80) & ~3
|
||||
found = {"Assembly-CSharp/OVRDisplay.cs": {"get_recommendedMSAALevel": (a, 0x5C)},
|
||||
"Unity.XR.Oculus/Unity/XR/Oculus/OculusSettings.cs": {"GetStereoRenderingMode": (b, 8)}}
|
||||
msaa, notes = U.patch_methods(lib, found, R.UnityRuntimeMsaa.targets)
|
||||
assert msaa[a:a + 8] == R.RET_ZERO and msaa[b:b + 8] == lib[b:b + 8] and "OVRDisplay" in notes[0]
|
||||
multi, _ = U.patch_methods(lib, found, R.UnityMultiPass.targets)
|
||||
assert multi[b:b + 8] == R.RET_ZERO and multi[a:a + 8] == lib[a:a + 8]
|
||||
|
||||
|
||||
def test_runtime_msaa_suggested_for_gles_ovr_games_only():
|
||||
base.load_all()
|
||||
p = base.get("frame.unity_runtime_msaa_off")
|
||||
gles = _analysis(libs=["libil2cpp.so", "libunity.so"], graphics="GLES or unknown (no Vulkan declaration)",
|
||||
extra={"ovr_runtime_msaa": True})
|
||||
assert p.detect(gles).recommended
|
||||
assert p.detect(_analysis(libs=["libil2cpp.so"], extra={"ovr_runtime_msaa": True})) is None # Vulkan
|
||||
gles.extra["ovr_runtime_msaa"] = False
|
||||
assert p.detect(gles) is None and p.applies(gles)
|
||||
assert base.get("frame.unity_multipass").detect(gles) is None # opt-in: only for the symptom
|
||||
assert not base.get("frame.unity_multipass").applies(_analysis(libs=["libunity.so"])) # Mono: no Cpp2IL
|
||||
|
||||
|
||||
def test_triage_points_runtime_msaa_to_the_patch():
|
||||
log = ("09-28 17:39:01.000 1000 1000 I ActivityManager: Start proc 1147:com.example.game/u0a55 for activity\n"
|
||||
"09-28 17:39:04.000 1147 1174 I Unity : The current MSAA level is 0, but the recommended MSAA level is "
|
||||
"4. Switching to the recommended level.\n")
|
||||
f = {x.id: x for x in triage(log, "RUNNING", "com.example.game").findings}
|
||||
assert "frame.unity_runtime_msaa_off" in f["unity-runtime-msaa"].suggest
|
||||
|
||||
|
||||
|
||||
def test_library_recipes_follow_their_catalog_entry(tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.recommend import catalog, engine
|
||||
|
||||
monkeypatch.setattr(library, "_path", lambda: tmp_path / "library.json")
|
||||
entry = catalog.CatalogEntry.from_dict({"package": "com.x.game", "title": "X", "status": "issues",
|
||||
"frame": ["frame.unity_multipass"], "updated": "2026-10-04"}, "bundled")
|
||||
monkeypatch.setattr(catalog, "lookup", lambda pkg: entry if pkg in ("com.x.game", "com.x.mine") else None)
|
||||
an = {"package": "com.x.game", "engine": "Unity", "libs": ["libil2cpp.so", "libunity.so"], "abis": ["arm64-v8a"]}
|
||||
games = {"com.x.game": {"analysis": an, "recipe": {"package": "com.x.game", "source": "heuristics"}},
|
||||
"com.x.mine": {"analysis": dict(an, package="com.x.mine"),
|
||||
"recipe": {"package": "com.x.mine", "source": "user", "patches": {"frame.adapter": {}}}}}
|
||||
(tmp_path / "library.json").write_text(json.dumps({"games": games, "settings": {}}))
|
||||
got = library.load()["games"]
|
||||
r = got["com.x.game"]["recipe"]
|
||||
assert "frame.unity_multipass" in r["patches"] and r["status"] == "issues" and r["catalog_rev"] == entry.rev()
|
||||
mine = got["com.x.mine"]["recipe"] # the user's own choice stays (one-time migrations aside)
|
||||
assert "frame.adapter" in mine["patches"] and "frame.unity_multipass" not in mine["patches"]
|
||||
assert not library._follow_catalog(library.load()) # nothing changed since: no re-derive on every load
|
||||
assert engine.suggest(library.analysis_from_dict(an)).catalog_rev == entry.rev()
|
||||
|
||||
|
||||
def test_maintained_entry_verified_later_beats_the_users_shared_one():
|
||||
from frameport.recommend import catalog
|
||||
|
||||
def e(origin, **kw):
|
||||
return catalog.CatalogEntry.from_dict({"package": "p.q", "title": "P", **kw}, origin)
|
||||
user = e("user", verified={"date": "2026-10-03"})
|
||||
fixed, old = e("bundled", updated="2026-10-04"), e("bundled", verified={"date": "2026-09-28"})
|
||||
assert catalog._newer(fixed, user) and not catalog._newer(old, user) and not catalog._newer(None, user)
|
||||
@@ -130,3 +130,20 @@ def test_reanalyze_refreshes_the_suggestion_and_keeps_a_users_recipe(monkeypatch
|
||||
g = library.game("com.x")
|
||||
assert "frame.unity_text_input" not in g["recipe"]["patches"] # the user's own choices stay
|
||||
assert "frame.unity_text_input" in g["suggested"]["patches"]
|
||||
|
||||
|
||||
def test_flat_windows_game_keeps_only_proton_patches():
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.recommend import engine
|
||||
|
||||
base.load_all()
|
||||
a = _analysis(package="rift.somegame", engine="Unity", xr="?", libs=[],
|
||||
extra={"kind": "rift", "flat": True, "vr_found": False, "exe": "Game.exe"})
|
||||
vr = engine.suggest(_analysis(package="rift.somegame", engine="Unity", xr="OpenXR", libs=[],
|
||||
extra={"kind": "rift", "vr_found": True, "openxr": True, "exe": "Game.exe"}),
|
||||
use_catalog=False)
|
||||
r = engine.suggest(a, use_catalog=False)
|
||||
assert set(r.patches) <= set(engine.FLAT_WINDOWS_PATCHES)
|
||||
assert set(vr.patches) - set(engine.FLAT_WINDOWS_PATCHES) # the same game with VR gets VR patches
|
||||
@@ -246,3 +246,11 @@ def test_apply_reports_a_script_that_never_starts(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(updates.subprocess, "Popen", lambda *a, **k: type("P", (), {"poll": lambda self: None})())
|
||||
with pytest.raises(updates.UpdateError):
|
||||
updates.apply(staged, installed, relaunch=False, pid=1, platform="linux")
|
||||
|
||||
|
||||
def test_platform_asset_picks_the_arm64_linux_bundle(monkeypatch):
|
||||
monkeypatch.setattr(updates.sys, "platform", "linux")
|
||||
monkeypatch.setattr(updates.platform, "machine", lambda: "aarch64")
|
||||
assert updates.platform_asset() == "FramePort-linux-arm64.tar.gz"
|
||||
monkeypatch.setattr(updates.platform, "machine", lambda: "x86_64")
|
||||
assert updates.platform_asset() == "FramePort-linux-x64.tar.gz"
|
||||
Reference in new issue
Block a user