mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-24 09:02:04 +02:00
Foveation as a per-game setting; "Sharpness" is "Render resolution" (#365)
Foveation (Default / Eye-tracked / Fixed / Off, radius, test) moves from Customize to Game settings → Picture. It's a user preference, not part of the recipe: existing recipes are migrated once and Share working config leaves it out. Agent v98 set_settings rewrites only the launcher's foveation exports, so a change applies at the next game start without a rebuild (checked on the Frame: Fixed adds the FDM exports, Default removes them). The APK-side parts (Batman allocation fix, Climb 2 bridge) stay in the build. The scale setting's label is now "Render resolution (supersampling)". ui_smoke: game-settings-dialog step.
This commit is contained in:
1 parent
55c6da7010
commit
d7f0d51472
28 files changed
+534
-113
No files matched your search
@@ -45,7 +45,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 97
|
||||
AGENT_VERSION = 98
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -4184,6 +4184,7 @@ def configure_install(args, prep, moved):
|
||||
"agent_version": AGENT_VERSION, "time": time.time()}
|
||||
if previous.get("eye_base"): # the runtime's eye size learned from an earlier start (device.config_sync)
|
||||
dep["eye_base"] = previous["eye_base"]
|
||||
dep["foveation_env"] = {key: (args.get("env") or {}).get(key) for key in FOVEATION_ENV_KEYS}
|
||||
dep["managed_config_files"] = new_managed
|
||||
# An app before agent v86 records none of these: what it installed is unknown, so a newer PC mustn't skip work
|
||||
# on the strength of an older record. Art the agent didn't replace keeps its record.
|
||||
@@ -5448,6 +5449,31 @@ def cmd_keep_awake(args):
|
||||
return {"awake": False}
|
||||
|
||||
|
||||
FOVEATION_ENV_KEYS = {"FDM_DEBUG", "ENABLE_VULKAN_FDM_INJECTION_LAYER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER",
|
||||
"LEPTON_ENV_FRAMEPORT_FOVEATION_TEST", "LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS",
|
||||
"LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO", "LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE"}
|
||||
|
||||
|
||||
def update_foveation_launcher(pkg, dep, env):
|
||||
if not isinstance(env, dict) or set(env) - FOVEATION_ENV_KEYS or any(
|
||||
v is not None and (not isinstance(v, str) or "\n" in v or "\r" in v) for v in env.values()):
|
||||
raise AgentError("invalid foveation environment")
|
||||
path = os.path.join(ANCHORS, pkg, "launch.sh")
|
||||
text = open(path).read()
|
||||
# Replace only managed exports; preserve all unrelated launcher customizations.
|
||||
text = "".join(line for line in text.splitlines(keepends=True)
|
||||
if not any(line.startswith("export " + key + "=") for key in env))
|
||||
lines = "".join(f"export {key}={shlex.quote(value)}\n" for key, value in env.items() if value is not None)
|
||||
text = text.replace("\n", "\n" + lines, 1)
|
||||
with open(path + ".tmp", "w") as f:
|
||||
f.write(text)
|
||||
os.chmod(path + ".tmp", 0o755)
|
||||
os.replace(path + ".tmp", path)
|
||||
dep["foveation_env"] = env
|
||||
write_deployment(pkg, dep)
|
||||
|
||||
|
||||
def cmd_set_settings(args):
|
||||
pkg = check_pkg(args["package"])
|
||||
dep = deployment(pkg) or {}
|
||||
@@ -5465,6 +5491,8 @@ def cmd_set_settings(args):
|
||||
if not re.fullmatch(r"[a-z_]+", k) or not re.fullmatch(r"-?(?:[0-9]+\.?[0-9]*|\.[0-9]+)", str(v)):
|
||||
raise AgentError(f"bad setting {k}={v}") # checked before anything is written
|
||||
current[k] = str(v)
|
||||
if "foveation_env" in args:
|
||||
update_foveation_launcher(pkg, dep, args["foveation_env"])
|
||||
text = "".join(f"{k}={v}\n" for k, v in current.items())
|
||||
with open(conf, "w") as f:
|
||||
f.write(text)
|
||||
@@ -5485,7 +5513,8 @@ def cmd_set_settings(args):
|
||||
else int(number)
|
||||
dep["configuration_fp"] = hashlib.sha256(json.dumps(applied, sort_keys=True).encode()).hexdigest()[:32]
|
||||
write_deployment(pkg, dep)
|
||||
return {"settings": current, "controller_models": models, "config_sync": synced,
|
||||
return {"settings": current, "foveation_env": dep.get("foveation_env", {}),
|
||||
"controller_models": models, "config_sync": synced,
|
||||
"refresh_rate": sync_refresh_rate(pkg, dep)}
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -86,7 +86,7 @@ AXRB/ ← AXRB's download folder (Downloads/AX
|
||||
|
||||
Every game has three places; the answers below refer to them.
|
||||
|
||||
- **Game settings:** right-click the game in the **Library** → **Game settings…**. Sharpness, refresh rate,
|
||||
- **Game settings:** right-click the game in the **Library** → **Game settings…**. Render resolution (supersampling), refresh rate,
|
||||
controllers, menus and more, in plain words. Changes apply the next time the game starts.
|
||||
- **Patches:** click the game in the **Library** to open its page, then **Customize** (in the **Patches** section).
|
||||
Turn a patch on or off, then click **Update on Frame** at the top of the page.
|
||||
@@ -154,7 +154,7 @@ need **Update on Frame**).
|
||||
| One eye shimmers while menus look steady | Game page → **Customize** → **Eye-tracked foveation (Valve)**: **Fixed**, else **Off** |
|
||||
| Menus or screens upside down | **Game settings…** → **Fix upside-down menus** |
|
||||
| Curved menus or video screens missing | **Game settings…** → **Show curved menus and screens** |
|
||||
| Blurry | **Game settings…** → raise **Sharpness** (costs frame rate) |
|
||||
| Blurry | **Game settings…** → raise **Render resolution (supersampling)** (costs frame rate) |
|
||||
|
||||
The [patch reference](PATCHES.md) lists every patch with what it does. If nothing helps, report it with a short
|
||||
description of what you see.
|
||||
@@ -164,7 +164,7 @@ description of what you see.
|
||||
How to tell: open **Monitor** in the sidebar while you play. Smooth play needs the game's frame rate at the refresh
|
||||
rate (for example 72 or 90 fps).
|
||||
|
||||
- **Below the refresh rate:** right-click the game → **Game settings…** → lower **Sharpness** (for example to 0.8×) or
|
||||
- **Below the refresh rate:** right-click the game → **Game settings…** → lower **Render resolution (supersampling)** (for example to 0.8×) or
|
||||
pick a lower **Refresh rate**. After a session, the game's page may suggest a value.
|
||||
- **At the refresh rate but it still judders:** report it; that points to timing, not speed.
|
||||
|
||||
@@ -187,7 +187,7 @@ Right-click the game → **Game settings…**:
|
||||
How to tell: if it only happens on battery and never while plugged in, it's power, not FramePort. Heavy games can
|
||||
draw more than the battery delivers, especially at lower charge, and the Frame then turns off without an error.
|
||||
|
||||
- Play plugged in, or right-click the game → **Game settings…** → lower **Sharpness** to reduce the load.
|
||||
- Play plugged in, or right-click the game → **Game settings…** → lower **Render resolution (supersampling)** to reduce the load.
|
||||
- It also turns off while plugged in: report it. The diagnostics include the log of the boot that ended.
|
||||
|
||||
## My progress isn't saved
|
||||
|
||||
+24
-6
@@ -396,7 +396,8 @@ over Quest games.
|
||||
|
||||
## Shared foveation controls (tested 2026-10-12)
|
||||
|
||||
Under a Quest game's Advanced patches, choose **Foveated rendering → Eye-tracked**, then **Update on Frame**.
|
||||
Under a Quest game's **Game settings → Picture**, choose **Foveated rendering → Eye-tracked**, then **Save**.
|
||||
Changes apply at the next game start.
|
||||
The full-resolution area selector defaults to **Default (30% radius)**: no radius override, preserving the
|
||||
runtime profile used in the headset experiments. Larger radii retain more detail and save less shading work.
|
||||
Custom radii set `FOVE_INNER`; `FOVE_OUTER` is twice that value, matching the runtime's profile proportions.
|
||||
@@ -404,7 +405,7 @@ The inspected runtime's built-in inner/outer pairs are 0.30/0.60, 0.25/0.50 and
|
||||
The shared-texture injection path selects profile 1: 0.30 inner radius and 0.60 outer radius.
|
||||
|
||||
`device.foveation` enables Valve's shared Vulkan injection and suppresses competing native foveation through
|
||||
`foveation_fix=1`. Recipes and shared catalog entries preserve mode, radius and test selection. This remains
|
||||
`foveation_fix=1`. Game settings stores mode, radius and test selection as a per-game library preference, outside shared recipes. This remains
|
||||
experimental: complete images were confirmed in RE4, Batman, Deadpool, The Climb 2 and Asgard's Wrath 2,
|
||||
not all Quest games or renderers. RE4 and Batman checks include title/calibration scenes; these are not
|
||||
equivalent to extended gameplay coverage. Asgard gameplay was visually confirmed, without an on/off benchmark.
|
||||
@@ -420,14 +421,14 @@ installation. The runtime correction additionally checks the exact Valve library
|
||||
instructions before touching executable memory; unsupported libraries request injection be disabled and log
|
||||
the reason. No system library is changed. A SteamOS update requires revalidation of this compatibility path.
|
||||
Both-eye gaze following was confirmed in Climb's darkening diagnostic, but long-session stability remains
|
||||
experimental. Off rebuilds from the source APK and removes this private support, including its visibility fix.
|
||||
experimental. Off disables injection through launcher exports; the private APK compatibility support remains installed.
|
||||
|
||||
**Darken lower-resolution regions (test)** adds an optional Vulkan shader diagnostic. It dims RGB where the
|
||||
**Darken coarse fragments (test)** enables the dormant Vulkan shader diagnostic. It dims RGB where the
|
||||
actual `FragSizeEXT` footprint exceeds one pixel; alpha is unchanged. Deadpool's shaded regions were confirmed
|
||||
to follow gaze in the headset. Unsupported shader output interfaces are skipped, and multiple passes may
|
||||
compound darkening, so this is a diagnostic rather than a precise composited mask. Do not benchmark with it on.
|
||||
Uncheck it and update from the original source APK to remove the diagnostic library, except in Climb where
|
||||
the same library also carries the compatibility correction. Darkening stays dormant without its launcher opt-in.
|
||||
Uncheck it and save Game settings to remove the launcher opt-in. The library stays installed and dormant;
|
||||
in Climb the same library also carries the compatibility correction.
|
||||
|
||||
High-resolution Deadpool samples crossed from about 45 to 90 FPS with injection enabled, but temperatures and
|
||||
power rose as it rendered twice as many frames. Foveation does not guarantee lower temperature at uncapped FPS.
|
||||
@@ -455,3 +456,20 @@ sighting, command lines checked once per process, render fds cached (rescanned e
|
||||
game processes without one), temperatures every 2 s, the CPU-cluster power rails every 5 s and the battery gauge every
|
||||
2-10 s, the stream measured ~10-13 ms CPU per 1 s tick on the device (idle clocks; idle or with 4XVR running) = about
|
||||
1 % of one core, ~0.15 % of the whole CPU. The I2C sensors (power monitors, battery gauge) are the slowest reads.
|
||||
|
||||
### Foveation user preferences (discussion #365)
|
||||
|
||||
Game settings → Picture provides Default / Eye-tracked / Fixed / Off, radius, and an advanced diagnostic switch.
|
||||
`set_settings` (agent 98) atomically rewrites just the managed launcher exports and records them in deployment.json;
|
||||
no APK rebuild, reinstall or live device intervention is needed. Changes apply at the next start. Default restores
|
||||
recipe/catalog launcher defaults; explicit preferences win. One-time library migration extracts old recipe choices
|
||||
and leaves internal APK compatibility support. Catalog defaults (including Climb 2 and Batman) still apply to new
|
||||
entries. Shared working configs omit `device.foveation` entirely.
|
||||
|
||||
APK-side: adapter builds automatically prepare Batman's hash-verified allocation fix and Climb 2's
|
||||
renderer-guarded private bridge for verified builds, independently of the runtime choice. Unknown versions skip
|
||||
automatic compatibility edits; explicit catalog requests still fail validation rather than guessing offsets. FrameBridge
|
||||
builds include the dormant diagnostic layer (activated only by `FRAMEPORT_FOVEATION_TEST=1`), so future test toggles
|
||||
need only a launcher update. Existing builds need one normal update to acquire that support. Render resolution
|
||||
(supersampling) changes the game's render resolution: higher is sharper but slower. Climb 2 foveation still requires
|
||||
1.0 scale.
|
||||
+9
-8
@@ -156,7 +156,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 3 |
|
||||
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
|
||||
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
|
||||
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 9 |
|
||||
| [Render resolution (supersampling)](#adapter-scale) | `adapter.scale` | Game settings | 9 |
|
||||
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 12 |
|
||||
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
|
||||
@@ -520,7 +520,7 @@ Runs a 32-bit game (Gear VR, Oculus Go, early Quest) on the Frame, which only ru
|
||||
|
||||
FramePort's layer between the game and the Steam Frame: fills in what the Frame lacks (controllers, room, passthrough). Needed for every converted game.
|
||||
|
||||
- `frame.adapter` · on by default
|
||||
- `frame.adapter` · on by default · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Required for every OpenXR/overport build on the Frame.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -2056,16 +2056,16 @@ Re-creates the game's play space after the first frames. For testing.
|
||||
|
||||
> Recreate spaces after the first frames (diagnostic).
|
||||
|
||||
### <a id="adapter-scale"></a>Sharpness
|
||||
### <a id="adapter-scale"></a>Render resolution (supersampling)
|
||||
|
||||
Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.
|
||||
Changes the game's render resolution. Higher is sharper but slower; 1.0× is the game's own resolution.
|
||||
|
||||
- `adapter.scale`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: float, default `1.0` (always written).
|
||||
- Game settings → Picture → "Sharpness": slider 0.5–2 in steps of 0.1.
|
||||
- Game settings → Picture → "Render resolution (supersampling)": slider 0.5–2 in steps of 0.1.
|
||||
- **Catalog games (9):** 4XVR Video Player, BlazeRush, CrazyTaxiVR, cubism, FGO VR, I Expect You To Die 2, The 7th Guest VR, Vampire: The Masquerade - Justice and 1 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
@@ -2410,7 +2410,7 @@ Files, environment and window options written on the Frame when a Quest game or
|
||||
|
||||
### <a id="device-config_sync"></a>Keep the game's saved resolution in step
|
||||
|
||||
Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a sharpness change).
|
||||
Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a render resolution change).
|
||||
|
||||
- `device.config_sync`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
@@ -2454,13 +2454,14 @@ Render less detail outside your gaze; adjust the full-resolution area or darken
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- `mode`: str, default empty — tracked, fixed or off (empty = Valve's default)
|
||||
- `support`: bool, default `False` — Internal APK compatibility support
|
||||
- `mode`: str, default empty — Catalog default: tracked, fixed or off (empty = Valve's default)
|
||||
- `radius`: float, default `0.0`, 0–0.45 — Full-resolution radius; 0 keeps the current runtime default
|
||||
- `test`: bool, default `False` — Darken lower-resolution regions (diagnostic)
|
||||
- **Catalog games (5):** Ocean Rift, Quantum Void, Tea For God, The 7th Guest VR, Vampire: The Masquerade - Justice.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments; rebuild/reinstall to toggle it. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.
|
||||
> Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.
|
||||
|
||||
### <a id="device-headset_model"></a>Headset model
|
||||
|
||||
|
||||
+6
-1
@@ -21,7 +21,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Updating a flat Android app after saving its recipe fails with `no OVRPort patches selected` (GitHub #271, Plezy) | older FramePort loads the saved recipe without restoring its unchanged-install setting or applying the flat-app build rules, then tries an empty VR conversion | update FramePort: saved recipes keep `as_is`, and flat apps still skip OVRPort; existing catalog-derived recipes refresh at the next app update. Until then, enable "Install as is" for an app whose APK needs no fixes |
|
||||
| A launch test suggests a patch (for example triage `sdl-no-clipboard` → `frame.sdl_clipboard`) but the game page doesn't show it / lists it as not applicable; or a game added long ago lacks warnings newer ones get (Android version, web wrapper, missing OBB) (GitHub #104, Dramatic Shape) | the library entry was analyzed by an older FramePort: the analysis field the patch reads (`sdl_java`, `min_sdk`, `web_wrapper`, `expects_obb`, …) is missing, so `applies()` is false | entries whose `analysis.extra.analysis_version` is below `detect.ANALYSIS_VERSION` are analyzed again at the GUI's start (background) and before a build (`pipeline.refresh_analyses`); by hand: game menu → "Analyze again", then Update on Frame. An unreadable APK is marked `analysis_failed` (retried after the next version bump). Adding an analysis field: bump `ANALYSIS_VERSION` |
|
||||
| FDM layer loads but diagnostic logging reports `fdm_injection_CreateInstance: skipped` | Layer loading alone does not enable injection on the tested SteamOS 0.4.5 build; a skipped helper instance alone also does not establish game-wide inactivity | Opt-in `device.foveation` mode `tracked`: loads Valve's layer and sets `FDM_DEBUG=enable`. Batman probe reached foveated render passes with changing per-eye offsets. Validate graphics and matched gameplay GPU time; do not enable globally. Use `debug` only for brief diagnostics, preserving existing flags; never benchmark with debug spam. See FRAME_RUNTIME.md. |
|
||||
| One eye (usually the right) shimmers or jitters in the 3D view while menus and panels look steady; pacing is clean (GitHub #69, e.g. RTCWQuest, Moss) | Valve's eye-tracked foveation layer (`VALVE_fdm_injection`) moves its low-density regions with each eye's gaze, and the right eye's gaze filter restarts often (`CEyePoseUKF R: Large dt … Bootstrapped` in `~/.local/share/Steam/logs/eyetracking.txt`) | Customize → "Eye-tracked foveation (Valve)" (`device.foveation`): **Fixed** (`FDM_DEBUG=disable_offsets`; verify injection remains active on the installed runtime) or **Off** (`VK_INSTANCE_LAYERS=""`); catalog field `foveation:` |
|
||||
| One eye (usually the right) shimmers or jitters in the 3D view while menus and panels look steady; pacing is clean (GitHub #69, e.g. RTCWQuest, Moss) | Valve's eye-tracked foveation layer (`VALVE_fdm_injection`) moves its low-density regions with each eye's gaze, and the right eye's gaze filter restarts often (`CEyePoseUKF R: Large dt … Bootstrapped` in `~/.local/share/Steam/logs/eyetracking.txt`) | Game settings → Picture → "Foveated rendering (Valve)": **Fixed** (`FDM_DEBUG=disable_offsets`; verify injection remains active on the installed runtime) or **Off** (`VK_INSTANCE_LAYERS=""`); catalog field `foveation:` |
|
||||
| With `device.foveation` Eye-tracked/Fixed, the sharp area looks right but everything around it is black with scattered coloured blocks (menus on quads too); pacing fine (GitHub #303, Medieval Dynasty; Vampire Survivors reported the same) | Unity's Oculus XR plugin asks for SUBSAMPLED eye images (`XR_META_vulkan_swapchain_create_info`, `VK_IMAGE_CREATE_SUBSAMPLED_BIT_EXT`); under Valve's FDM their coarse areas stay in the compact subsampled layout, which the compositor reads as an ordinary image. `foveation_fix=1` only dropped the `XrSwapchainCreateInfoFoveationFB` nodes and passed the META node on. With `foveation_fix=0` + `foveation_storage_fix=1` the adapter logs `foveation_storage_fix: shared allocation SUBSAMPLED cleared` for the eye swapchains | `device.foveation` (tracked/fixed) now also sets `foveation_storage_fix=1`, which clears SUBSAMPLED whatever `foveation_fix` is (generic version of Batman's OVRPlugin allocation fix). Rebuild + reinstall (new adapter). Headless-checked only (72 fps, bit cleared); picture needs the headset |
|
||||
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
|
||||
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
|
||||
@@ -293,3 +293,8 @@ Scan a folder with Rift game dumps (Windows game folders) like Quest dumps; they
|
||||
| `VK_ERROR_DEVICE_LOST` under DXVK | freedreno GPU hang | `pcvr.proton_log` to capture; PC mode |
|
||||
| Game page shows another game's description or screenshots, or they stay wrong after Rename (GitHub #377: "Lollipop" matched an adult Steam game) | Store details were looked up by title and kept after a rename; Steam exact-title matches had no content check | Rename/reset drops title-matched details and screenshots and fetches them again under the new name (package-matched details and custom art stay); Steam matches with sexual-content descriptors are rejected for details and artwork |
|
||||
| `Rec.709 color space unavailable`, the game quits before xrCreateSession (GitHub #336, OutRun Quest VR) | The Frame's runtime has no XR_FB_color_space | Adapter setting `fb_color_space=1` (triage `color-space-missing` suggests it): FrameBridge offers the extension (v3), reports Rec.709 and accepts any colour space without converting. Reaches games with their own OpenXR loader; OVRPort's dispatcher can't forward the new functions. Device-unverified |
|
||||
|
||||
Foveation mode, radius and diagnostic test are per-game user preferences (discussion #365), excluded from
|
||||
shared working recipes. Saving Game settings rewrites launcher exports through `set_settings`; changes apply
|
||||
at the next game start without rebuilding or reinstalling. Existing builds need one update for dormant diagnostic
|
||||
support. Catalog `device.foveation` defaults and the guarded Batman/Climb 2 APK compatibility work remain supported.
|
||||
@@ -48,7 +48,7 @@ def from_code() -> tuple[dict, list[str]]:
|
||||
def from_data() -> dict:
|
||||
"""Help texts and patch titles/descriptions (defined as data, translated where they're shown)."""
|
||||
from frameport.patches import base
|
||||
from frameport.patches.settings import GROUPS, UI
|
||||
from frameport.patches.settings import FOVEATION_UI, GROUPS, UI
|
||||
from frameport.ui.help import HELP
|
||||
|
||||
texts = {dict.__getitem__(HELP, k): "" for k in HELP}
|
||||
@@ -61,11 +61,11 @@ def from_data() -> dict:
|
||||
texts[label] = ""
|
||||
for _, title in GROUPS: # the Game settings dialog
|
||||
texts[title] = ""
|
||||
for ui in UI.values():
|
||||
for ui in [*UI.values(), *FOVEATION_UI.values()]:
|
||||
for t in (ui.get("label"), ui.get("help")):
|
||||
if t:
|
||||
texts[t] = ""
|
||||
if ui["control"][0] == "choice":
|
||||
if ui.get("control", (None,))[0] == "choice":
|
||||
for _, label in ui["control"][1]:
|
||||
if any(c.isalpha() for c in label):
|
||||
texts[label] = ""
|
||||
|
||||
+2
-1
@@ -148,7 +148,8 @@ def main() -> int:
|
||||
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
|
||||
steps += [("share-dialog", lambda a: a.share_config_dialog(game)),
|
||||
("report-dialog", lambda a: (a.page.pop_dialog(), a.report_problem_dialog(game))),
|
||||
("custom-art-dialog", lambda a: (a.page.pop_dialog(), a.custom_artwork(game)))]
|
||||
("custom-art-dialog", lambda a: (a.page.pop_dialog(), a.custom_artwork(game))),
|
||||
("game-settings-dialog", lambda a: (a.page.pop_dialog(), a.settings_dialog(game)))]
|
||||
# a pretend install of --game, mid-upload: the transit in the sidebar, the game page and Activity
|
||||
steps += [("transit-game", lambda a: (a.page.pop_dialog(), start_fake_install(a, game), a.open_game(game))),
|
||||
("transit-activity", lambda a: a.show_activity(True))]
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
},
|
||||
"adapter.scale": {
|
||||
"anchor": "adapter-scale",
|
||||
"title": "Sharpness",
|
||||
"title": "Render resolution (supersampling)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scene_depth": {
|
||||
|
||||
@@ -38,6 +38,9 @@ def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
|
||||
|
||||
def apk_patches(selection: dict) -> list[base.Patch]:
|
||||
patches = [base.get(pid) for pid in selection if base.get(pid).stage == "apk"]
|
||||
if "frame.adapter" in selection and "device.foveation" not in selection:
|
||||
# Prepare verified game-specific support independently of the player's runtime preference.
|
||||
patches.append(base.get("device.foveation"))
|
||||
return sorted(patches, key=lambda p: p.order)
|
||||
|
||||
|
||||
|
||||
@@ -180,6 +180,16 @@ 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", [])
|
||||
changed = False
|
||||
if "foveation_preference" not in done:
|
||||
for g in (data.get("games") or {}).values():
|
||||
recipe = g.get("recipe") or {}
|
||||
params = (recipe.get("patches") or {}).get("device.foveation")
|
||||
if params is not None:
|
||||
g.setdefault("foveation", dict(params))
|
||||
recipe["patches"]["device.foveation"] = {"support": True}
|
||||
recipe.get("reasons", {}).pop("device.foveation", None)
|
||||
done.append("foveation_preference")
|
||||
changed = True
|
||||
if "no_crash_reporter" not in done:
|
||||
# the Unreal crash reporter is disabled by default for PC VR Unreal games, also those added before
|
||||
for g in (data.get("games") or {}).values():
|
||||
|
||||
@@ -113,6 +113,16 @@ def _links(template: str, fields: dict[str, str], trim: list[str], layout: dict,
|
||||
|
||||
def _working_config_fields(g: dict, recipe_yaml: str, status: str, notes: str, env: dict,
|
||||
red: redact.Redactor | None) -> dict[str, str]:
|
||||
import yaml
|
||||
|
||||
parsed = yaml.safe_load(recipe_yaml)
|
||||
if isinstance(parsed, dict):
|
||||
for key in ("foveation", "foveation_radius", "foveation_test"):
|
||||
parsed.pop(key, None)
|
||||
for key in ("patches", "reasons"):
|
||||
if isinstance(parsed.get(key), dict):
|
||||
parsed[key].pop("device.foveation", None)
|
||||
recipe_yaml = yaml.safe_dump(parsed, sort_keys=False)
|
||||
red = red or redact.Redactor()
|
||||
title = g.get("title") or g["package"]
|
||||
kind = {"rift": "Oculus Rift (PC VR)", "linux": "Linux app (arm64)"}.get(g.get("kind"), "Meta Quest")
|
||||
|
||||
@@ -46,10 +46,20 @@ def configuration_args(entry: dict, recipe: Recipe) -> dict:
|
||||
from ..game_update import configuration, configuration_fingerprint
|
||||
|
||||
ctx = install_context(recipe)
|
||||
from ..patches.settings import foveation_env, foveation_preference
|
||||
|
||||
overrides = foveation_env(recipe.package, foveation_preference(entry), ctx.adapter_settings, ctx.env)
|
||||
for key, value in overrides.items():
|
||||
if value is None:
|
||||
ctx.env.pop(key, None)
|
||||
else:
|
||||
ctx.env[key] = value
|
||||
original = entry["recipe"]
|
||||
build = entry.get("build") or {}
|
||||
applied = configuration(original, recipe.package, build.get("superseded"))
|
||||
settings = applied["settings"]
|
||||
settings = dict(applied["settings"])
|
||||
if foveation_preference(entry).get("mode") in ("tracked", "fixed"):
|
||||
settings.update(foveation_fix=1, foveation_storage_fix=1)
|
||||
return dict(settings=settings, files={k: v.decode() if isinstance(v, bytes) else v
|
||||
for k, v in ctx.files.items()}, env=ctx.env,
|
||||
flatscreen=show_window(ctx, _flatscreen(recipe.package)), config_sync=ctx.config_sync,
|
||||
|
||||
@@ -185,7 +185,6 @@
|
||||
"APK": "",
|
||||
"Appearance": "",
|
||||
"AppImage": "",
|
||||
"Apply with Update on Frame.": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped), and the runtime's answer for each hand is reported to them as their own profile (Unity's OpenXR input only connects controllers of a profile it knows). Meta's detached-controller paths (XR_META_detached_controllers, which the Frame lacks) are left out of the app's actions and bindings: one such path made Unity attach no controller input at all. Without it such apps get no controller input.": "",
|
||||
"Artwork for {get}": "",
|
||||
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
|
||||
@@ -251,6 +250,7 @@
|
||||
"Changes apply the next time FramePort starts.": "",
|
||||
"Changes apply to the next install. Hover a patch above to see why it's there.": "",
|
||||
"Changes take effect the next time the game starts. Not sure? Leave a setting as it is.": "",
|
||||
"Changes the game's render resolution. Higher is sharper but slower; 1.0× is the game's own resolution.": "",
|
||||
"Changes: ": "",
|
||||
"Change…": "",
|
||||
"Check for new versions automatically": "",
|
||||
@@ -379,7 +379,7 @@
|
||||
"Customize": "",
|
||||
"Cylinder layers (curved menus and movie screens, for example 4XVR), which the Frame's runtime lacks, are shown as a few flat strips along the curve. 0 = drop them. 360° (equirect) layers can't be shown on the Frame.": "",
|
||||
"Dansk": "",
|
||||
"Darken lower-resolution regions (test)": "",
|
||||
"Darken coarse fragments (test)": "",
|
||||
"Data": "",
|
||||
"data file": "",
|
||||
"data folder": "",
|
||||
@@ -417,6 +417,7 @@
|
||||
"Dev builds are early, less tested versions and may have bugs. You still get the next release as a normal update (you have {version}).": "",
|
||||
"Developer Mode lets FramePort find and connect to the Frame; the setup turns it on for you. With it on, you can also open Settings → Developer → Pair new host on the Frame and approve FramePort there.": "",
|
||||
"Diagnostic for Vulkan games that fail on their first frames with missing Quest foveation images.": "",
|
||||
"Diagnostic only; do not use for benchmarking.": "",
|
||||
"Diagnostics for frame.gl_multiview_fbo: checks for GL errors after every single-view draw of a multiview shader and logs counters every 5 seconds (tag GLMV in launch.log). 2 (frameport settings) also checks every draw and logs the draw state, failed texture uploads and shader compiles and, for idTech 4 engines, the game clock.": "",
|
||||
"Diagnostics: removes layer color fades. Some fades no longer show.": "",
|
||||
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK.": "",
|
||||
@@ -658,7 +659,7 @@
|
||||
"From the {src}": "",
|
||||
"Full (headset view size)": "",
|
||||
"Full launch log": "",
|
||||
"Full-resolution area size": "",
|
||||
"Full-resolution area": "",
|
||||
"Game": "",
|
||||
"Game data (.obb) is missing, so these games hang at start. Put the .obb files in a folder named after the game next to the APK, then add it again.": "",
|
||||
"Game data (.obb) is missing. Put it in a folder named {package} next to the APK and add the folder again.": "",
|
||||
@@ -748,7 +749,6 @@
|
||||
"High": "",
|
||||
"Higher is sharper but needs more power.": "",
|
||||
"Higher keeps more detail but uses more memory.": "",
|
||||
"Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.": "",
|
||||
"Home folder": "",
|
||||
"hottest: {part}": "",
|
||||
"How a PC VR game starts: Proton runs it on the Frame; Revive lets Oculus games run on SteamVR.": "",
|
||||
@@ -854,7 +854,7 @@
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keeps the hand and brush visible over Vermillion's painting canvas (the canvas is drawn in the scene instead of as a layer in front).": "",
|
||||
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a sharpness change).": "",
|
||||
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a render resolution change).": "",
|
||||
"Keeps your progress in games that file their saves under your Meta user.": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
@@ -863,6 +863,7 @@
|
||||
"Language": "",
|
||||
"Language packs from the game's files": "",
|
||||
"Large files upload together using OpenSSH when available. Default: 3. Lower this on a busy network.": "",
|
||||
"Larger areas retain more detail but save less GPU work.": "",
|
||||
"Last build": "",
|
||||
"Last launch test's log": "",
|
||||
"Last launch test: passed": "",
|
||||
@@ -1292,7 +1293,8 @@
|
||||
"Reconnect": "",
|
||||
"Recreate spaces after the first frames (diagnostic).": "",
|
||||
"Reduce motion": "",
|
||||
"Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments; rebuild/reinstall to toggle it. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.": "",
|
||||
"Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.": "",
|
||||
"Reduces shading away from the full-resolution area. Changes apply at the next game start.": "",
|
||||
"Refresh": "",
|
||||
"Refresh rate": "",
|
||||
"Refresh store details": "",
|
||||
@@ -1346,6 +1348,8 @@
|
||||
"Renamed to {title}. Steam restarts once to show it.": "",
|
||||
"Rename…": "",
|
||||
"Render less detail outside your gaze; adjust the full-resolution area or darken coarse regions to test it.": "",
|
||||
"Render resolution (supersampling)": "",
|
||||
"Render resolution (supersampling), foveated rendering, refresh rate, controllers, menus and more for this game. Changes take effect the next time it starts.": "",
|
||||
"Repairs OpenGL ES shaders that hang the graphics chip, from the game's recipe.": "",
|
||||
"Repairs shaders that hang the graphics chip at certain effects, from the game's recipe (for example Vader Immortal's lightspeed jump).": "",
|
||||
"Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that ask several times per frame got slightly different poses and rendered parts of the frame with different heads: judder (for example I Am Cat; proposed by Klownicle, GitHub #8).": "",
|
||||
@@ -1462,8 +1466,6 @@
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix): writes every distinct SPIR-V module the GL driver creates to Android/data/<package>/files/fp_spirv/<size>_<sha256>.spv, to capture a shader for a new zink_shader_fix. No effect on the game.": "",
|
||||
"Share working recipe · {title}": "",
|
||||
"Share working recipe…": "",
|
||||
"Sharpness": "",
|
||||
"Sharpness, refresh rate, controllers, menus and more for this game. Changes take effect the next time it starts.": "",
|
||||
"Show 360° backgrounds and videos": "",
|
||||
"Show advanced settings": "",
|
||||
"Show all patches ({len} don't apply to this game)": "",
|
||||
|
||||
@@ -35,6 +35,7 @@ class FrameBridgeAdapter(Patch):
|
||||
"Its settings are under Game settings."
|
||||
)
|
||||
category = "frame"
|
||||
revision = 2 # dormant foveation diagnostic support; runtime choice never changes APK inputs
|
||||
order = 10
|
||||
default_on = True
|
||||
|
||||
@@ -46,6 +47,9 @@ class FrameBridgeAdapter(Patch):
|
||||
if not ws.has(ws.lib(GENERIC)):
|
||||
raise RuntimeError("OVRPort output has no libopenxr_loader_generic.so (not an OVRPort build?)")
|
||||
adapter = artifact(ws.abi, GENERIC)
|
||||
if ws.abi == "arm64-v8a":
|
||||
from .foveation import LAYER
|
||||
adapter = elf.add_needed(adapter, LAYER)
|
||||
settings = settings_text(ctx.recipe_patches)
|
||||
changed = False
|
||||
if not ws.has(ws.lib(ORIGINAL)):
|
||||
@@ -70,11 +74,19 @@ class FrameBridgeAdapter(Patch):
|
||||
ws.add.pop(target, None)
|
||||
ws.replace.pop(target, None)
|
||||
changed = True
|
||||
if ws.abi == "arm64-v8a":
|
||||
from .foveation import diagnostic_support
|
||||
changed |= diagnostic_support(ws)
|
||||
return changed
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
ok = ws.has(ws.lib(ORIGINAL)) and ws.has(ws.lib(SETTINGS)) and ws.read_lib(GENERIC) == artifact(ws.abi, GENERIC)
|
||||
ok = ws.has(ws.lib(ORIGINAL)) and ws.has(ws.lib(SETTINGS))
|
||||
expected = artifact(ws.abi, GENERIC)
|
||||
if ws.abi == "arm64-v8a":
|
||||
from .foveation import LAYER
|
||||
expected = elf.add_needed(expected, LAYER)
|
||||
ok = ok and ws.read_lib(GENERIC) == expected
|
||||
return [("FrameBridge adapter present", ok, "adapter, original loader and settings library")]
|
||||
|
||||
|
||||
|
||||
@@ -58,10 +58,34 @@ def engine_lib(ws):
|
||||
return None
|
||||
|
||||
|
||||
def apply(ctx):
|
||||
def apk_options(ctx):
|
||||
"""Personal settings never select APK edits. Prepare only verified compatibility builds automatically."""
|
||||
from ..settings import foveation_options
|
||||
|
||||
mode, _radius, test = foveation_options(ctx.params)
|
||||
requested = foveation_options(ctx.params)
|
||||
if ctx.params.get("support") or requested[0] in ("", "off"):
|
||||
ws = ctx.ws
|
||||
if ctx.analysis.package == "com.camouflaj.manta":
|
||||
if not ws.has(ws.lib("libOVRPlugin.so")):
|
||||
return "", 0, False
|
||||
try:
|
||||
ordinary_storage(ws.read_lib("libOVRPlugin.so"))
|
||||
except ValueError:
|
||||
ctx.notes.append("unknown Batman OVRPlugin: automatic foveation allocation fix skipped")
|
||||
return "", 0, False
|
||||
elif ctx.analysis.package == "com.crytek.climb2":
|
||||
if (not ws.has(ws.lib("libCryRenderVulkan.so")) or
|
||||
hashlib.sha256(ws.read_lib("libCryRenderVulkan.so")).hexdigest() != CLIMB_RENDERER):
|
||||
ctx.notes.append("unknown Climb renderer: automatic foveation bridge skipped")
|
||||
return "", 0, False
|
||||
else:
|
||||
return "", 0, False
|
||||
return "tracked", 0, False
|
||||
return requested
|
||||
|
||||
|
||||
def apply(ctx):
|
||||
mode, _radius, test = apk_options(ctx)
|
||||
if mode not in ("tracked", "fixed"):
|
||||
return False
|
||||
ws = ctx.ws
|
||||
@@ -103,9 +127,7 @@ def apply(ctx):
|
||||
|
||||
|
||||
def validate(ctx):
|
||||
from ..settings import foveation_options
|
||||
|
||||
mode, _radius, test = foveation_options(ctx.params)
|
||||
mode, _radius, test = apk_options(ctx)
|
||||
checks = []
|
||||
if mode in ("tracked", "fixed") and ctx.analysis.package == "com.camouflaj.manta":
|
||||
data = ctx.ws.read_lib("libOVRPlugin.so")
|
||||
@@ -115,3 +137,23 @@ def validate(ctx):
|
||||
checks.append(("Foveation darkening layer loaded", bool(lib) and ctx.ws.has(ctx.ws.lib(LAYER))
|
||||
and LAYER in elf.needed(ctx.ws.read_lib(lib)), lib or "no renderer"))
|
||||
return checks
|
||||
|
||||
|
||||
def diagnostic_support(ws):
|
||||
"""The diagnostic constructor is dormant without FRAMEPORT_FOVEATION_TEST=1."""
|
||||
lib = engine_lib(ws) or "libopenxr_loader_generic.so"
|
||||
data = ws.read_lib(lib)
|
||||
changed = False
|
||||
if LAYER not in elf.needed(data):
|
||||
target = ws.lib(lib)
|
||||
updated = elf.add_needed(data, LAYER)
|
||||
if target in ws.add:
|
||||
ws.add[target] = updated
|
||||
else:
|
||||
ws.put(target, updated)
|
||||
changed = True
|
||||
layer = artifact(ws.abi, LAYER)
|
||||
if not ws.has(ws.lib(LAYER)) or ws.read_lib(LAYER) != layer:
|
||||
ws.put(ws.lib(LAYER), layer)
|
||||
changed = True
|
||||
return changed
|
||||
@@ -297,9 +297,19 @@ HIDDEN = {"surface_native", "gl_int_attribs"}
|
||||
GROUPS = [("picture", "Picture"), ("controllers", "Controllers"), ("screens", "Menus and screens"),
|
||||
("room", "Mixed reality"), ("video360", "360° pictures and video"), ("troubleshooting", "Troubleshooting")]
|
||||
REFRESH_CHOICES = [(0.0, "Game's choice"), *((float(hz), f"{hz} Hz") for hz in (72, 80, 90, 96, 108, 120, 144))]
|
||||
FOVEATION_UI = {
|
||||
"mode": dict(group="picture", level="common", label="Foveated rendering (Valve)",
|
||||
help="Reduces shading away from the full-resolution area. Changes apply at the next game start."),
|
||||
"radius": dict(group="picture", level="common", label="Full-resolution area",
|
||||
help="Larger areas retain more detail but save less GPU work."),
|
||||
"test": dict(group="picture", level="advanced", label="Darken coarse fragments (test)",
|
||||
help="Diagnostic only; do not use for benchmarking."),
|
||||
}
|
||||
|
||||
UI: dict[str, dict] = {
|
||||
"scale": dict(group="picture", level="common", label="Sharpness",
|
||||
help="Higher looks sharper but needs more power; lower runs smoother. 1.0× is the game's own.",
|
||||
"scale": dict(group="picture", level="common", label="Render resolution (supersampling)",
|
||||
help="Changes the game's render resolution. Higher is sharper but slower; 1.0× is the game's own "
|
||||
"resolution.",
|
||||
control=("slider", 0.5, 2.0, 0.1, "{:.1f}×")),
|
||||
"refresh_rate": dict(group="picture", level="common", label="Refresh rate",
|
||||
help="How often the picture updates. Higher is smoother if the game can keep up.",
|
||||
@@ -817,7 +827,7 @@ class Foveation(Patch):
|
||||
"Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction "
|
||||
"of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas "
|
||||
"retain more detail but save less GPU work. Fixed stops the area following your eyes. "
|
||||
"The optional test darkens actual coarse-shaded fragments; rebuild/reinstall to toggle it. "
|
||||
"The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. "
|
||||
"Tested on RE4, Deadpool and Asgard's Wrath 2. "
|
||||
"The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its "
|
||||
"hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may "
|
||||
@@ -828,7 +838,8 @@ class Foveation(Patch):
|
||||
revision = 3
|
||||
order = 95 # after renderer/bridge replacements
|
||||
experimental = True
|
||||
params = [Param("mode", "str", "", "tracked, fixed or off (empty = Valve's default)"),
|
||||
params = [Param("support", "bool", False, "Internal APK compatibility support"),
|
||||
Param("mode", "str", "", "Catalog default: tracked, fixed or off (empty = Valve's default)"),
|
||||
Param("radius", "float", 0.0, "Full-resolution radius; 0 keeps the current runtime default", 0, 0.45),
|
||||
Param("test", "bool", False, "Darken lower-resolution regions (diagnostic)")]
|
||||
CHOICES = (("", "Default"), ("tracked", "Eye-tracked"),
|
||||
@@ -930,3 +941,24 @@ register(SteamGamepad)
|
||||
register(TextInputWindow)
|
||||
register(DisplayMode)
|
||||
register(Foveation)
|
||||
|
||||
|
||||
FOVEATION_ENV_KEYS = ("FDM_DEBUG", "ENABLE_VULKAN_FDM_INJECTION_LAYER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER",
|
||||
"LEPTON_ENV_FRAMEPORT_FOVEATION_TEST", "LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS",
|
||||
"LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO", "LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE")
|
||||
|
||||
|
||||
def foveation_preference(entry):
|
||||
if "foveation" in entry:
|
||||
return dict(entry["foveation"])
|
||||
params = ((entry.get("recipe") or {}).get("patches") or {}).get("device.foveation") or {}
|
||||
return {k: v for k, v in params.items() if k in ("mode", "radius", "test")}
|
||||
|
||||
|
||||
def foveation_env(package, params, settings=None, baseline=None):
|
||||
"""Complete launcher override; None removes an obsolete export, including on Default."""
|
||||
from .base import InstallContext
|
||||
ctx = InstallContext(package, params, {}, dict(baseline or {}), dict(settings or {}))
|
||||
Foveation().install(ctx)
|
||||
return {key: ctx.env.get(key) for key in FOVEATION_ENV_KEYS}
|
||||
@@ -139,7 +139,7 @@ SUMMARIES = {
|
||||
"device.files": "Puts settings files next to the game on the Frame, for example to turn off an unsupported "
|
||||
"effect.",
|
||||
"device.config_sync": "Keeps the resolution the game saves in step with the headset's picture size (else the "
|
||||
"picture is shifted, with black borders, after a sharpness change).",
|
||||
"picture is shifted, with black borders, after a render resolution change).",
|
||||
"device.language": "Choose the language for games that follow the system language. The game must include it. "
|
||||
"Apply with Update on Frame.",
|
||||
"device.hide_navbar": "Hides Android's back, home and recents buttons, which cover the app's own buttons.",
|
||||
|
||||
@@ -50,7 +50,7 @@ class Target(ABC):
|
||||
return {}
|
||||
|
||||
@abstractmethod
|
||||
def set_settings(self, package: str, settings: dict) -> dict: ...
|
||||
def set_settings(self, package: str, settings: dict, *, foveation=None) -> dict: ...
|
||||
|
||||
@abstractmethod
|
||||
def uninstall(self, package: str, keep_data: bool = True) -> dict: ...
|
||||
|
||||
@@ -73,12 +73,24 @@ class FrameLeptonTarget(Target):
|
||||
def launch(self, package):
|
||||
return self.connect().frame.agent("launch", package=package, timeout=60)
|
||||
|
||||
def set_settings(self, package, settings):
|
||||
def set_settings(self, package, settings, *, foveation=None):
|
||||
from ..core import library
|
||||
from ..install.installer import install_context
|
||||
from ..patches.settings import foveation_env, foveation_preference
|
||||
|
||||
superseded = ((library.game(package) or {}).get("build") or {}).get("superseded") or {}
|
||||
entry = library.game(package) or {}
|
||||
superseded = (entry.get("build") or {}).get("superseded") or {}
|
||||
settings = {key: value for key, value in settings.items() if f"adapter.{key}" not in superseded}
|
||||
return self.connect().frame.agent("set_settings", package=package, settings=settings)
|
||||
extra = {}
|
||||
if settings or foveation is not None:
|
||||
preference = foveation if foveation is not None else foveation_preference(entry)
|
||||
ctx = install_context(library.recipe_from_dict(entry["recipe"])) if entry.get("recipe") else None
|
||||
effective = {**(ctx.adapter_settings if ctx else {}), **settings}
|
||||
if preference.get("mode") in ("tracked", "fixed"):
|
||||
settings.update(foveation_fix=1, foveation_storage_fix=1)
|
||||
if ctx or foveation is not None:
|
||||
extra["foveation_env"] = foveation_env(package, preference, effective, ctx.env if ctx else {})
|
||||
return self.connect().frame.agent("set_settings", package=package, settings=settings, **extra)
|
||||
|
||||
def uninstall(self, package, keep_data=True):
|
||||
return self.connect().frame.agent("uninstall", package=package, keep_data=keep_data, remove_shortcut=True)
|
||||
|
||||
@@ -336,7 +336,7 @@ class PcReviveTarget(Target):
|
||||
f"{f.diagnosis} [{f.evidence[:160]}]")
|
||||
return result, log
|
||||
|
||||
def set_settings(self, package, settings):
|
||||
def set_settings(self, package, settings, *, foveation=None):
|
||||
return {"settings": {}}
|
||||
|
||||
def uninstall(self, package, keep_data=True):
|
||||
|
||||
@@ -140,7 +140,8 @@ HELP: dict[str, str] = _Translated({
|
||||
"the library before the game.",
|
||||
"desktop_entry": "Adds the app to Desktop Mode's app menu and, if you like, its desktop. Some apps work better "
|
||||
"there with a mouse and keyboard. An icon you delete from the desktop stays deleted.",
|
||||
"adapter_settings": "Sharpness, refresh rate, controllers, menus and more for this game. Changes take effect the "
|
||||
"adapter_settings": "Render resolution (supersampling), foveated rendering, refresh rate, controllers, menus and "
|
||||
"more for this game. Changes take effect the "
|
||||
"next time it starts.",
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
|
||||
"games can run on the Frame.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Game settings (FrameBridge adapter settings) for non-technical users: only the settings that can matter for this
|
||||
game, in plain words, with switches, sliders and choices instead of number fields; advanced ones on request.
|
||||
Saved into the game's recipe (kept for reinstalls) and, when the game is on the Frame, applied there right away."""
|
||||
Adapter values are kept in the recipe; foveation is a separate library preference.
|
||||
Installed games receive settings and launcher changes without rebuilding the APK."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -11,7 +12,18 @@ from ... import pipeline
|
||||
from ...core import library
|
||||
from ...i18n import tr
|
||||
from ...patches import base
|
||||
from ...patches.settings import BASE_SETTINGS, GROUPS, HIDDEN, SETTINGS, UI, adapter_settings
|
||||
from ...patches.settings import (
|
||||
BASE_SETTINGS,
|
||||
FOVEATION_UI,
|
||||
GROUPS,
|
||||
HIDDEN,
|
||||
SETTINGS,
|
||||
UI,
|
||||
Foveation,
|
||||
adapter_settings,
|
||||
foveation_options,
|
||||
foveation_preference,
|
||||
)
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
|
||||
@@ -81,24 +93,67 @@ def show_adapter_dialog(app: FramePortApp, package: str) -> None:
|
||||
return
|
||||
|
||||
def load():
|
||||
cur = target.set_settings(package, {})["settings"] # no changes: returns the current values
|
||||
result = target.set_settings(package, {}) # no changes: returns the current values
|
||||
cur = result["settings"]
|
||||
vals = {key: default(key) for key in SPECS}
|
||||
vals.update({k: _num(k, v) for k, v in cur.items() if k in SPECS})
|
||||
_open(app, package, None, vals)
|
||||
env = result.get("foveation_env") or {}
|
||||
mode = ("off" if env.get("LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER") == "1" else
|
||||
("fixed" if "disable_offsets" in env.get("FDM_DEBUG", "") else "tracked")
|
||||
if env.get("ENABLE_VULKAN_FDM_INJECTION_LAYER") == "1" else "")
|
||||
pref = {"mode": mode}
|
||||
if mode in ("tracked", "fixed"):
|
||||
pref.update(radius=float(env.get("FOVE_INNER") or 0),
|
||||
test=env.get("LEPTON_ENV_FRAMEPORT_FOVEATION_TEST") == "1")
|
||||
_open(app, package, None, vals, pref)
|
||||
app.run_bg(load)
|
||||
return
|
||||
_open(app, package, g, recipe_values(g))
|
||||
|
||||
|
||||
def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None:
|
||||
def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveation_values=None) -> None:
|
||||
analysis = library.analysis_from_dict(g["analysis"]) if g and g.get("analysis") else None
|
||||
suggested = recipe_values({"recipe": g["suggested"]}) if g and g.get("suggested") else \
|
||||
{key: default(key) for key in SPECS}
|
||||
values = dict(values)
|
||||
foveation = dict(foveation_values) if foveation_values is not None else foveation_preference(g or {})
|
||||
foveation_controls = []
|
||||
foveation_rows = {}
|
||||
|
||||
def foveation_change(key, value):
|
||||
foveation[key] = value
|
||||
for name in ("radius", "test"):
|
||||
if name in foveation_rows:
|
||||
foveation_rows[name].visible = (foveation.get("mode") in ("tracked", "fixed") and
|
||||
(name != "test" or advanced["on"]))
|
||||
C.update(foveation_rows[name])
|
||||
|
||||
foveation_specs = (("mode", Foveation.CHOICES), ("radius", Foveation.RADIUS_CHOICES), ("test", None))
|
||||
for key, choices in (foveation_specs if not g or g.get("kind", "quest") == "quest" else ()):
|
||||
ui = FOVEATION_UI[key]
|
||||
if choices:
|
||||
value = str(foveation.get(key, "")) if key == "mode" else f"{float(foveation.get(key) or 0):g}"
|
||||
choices = list(choices)
|
||||
if value not in [str(v) for v, _ in choices]:
|
||||
choices.append((value, value))
|
||||
control = C.dropdown(value=value, width=T.px(220),
|
||||
options=[ft.DropdownOption(key=str(v), text=tr(label)) for v, label in choices],
|
||||
on_select=lambda e, k=key: foveation_change(k, float(e.control.value) if k == "radius"
|
||||
else e.control.value))
|
||||
else:
|
||||
control = C.switch(value=bool(foveation.get(key)),
|
||||
on_change=lambda e: foveation_change("test", bool(e.control.value)))
|
||||
foveation_rows[key] = ft.Container(ft.Row([
|
||||
ft.Column([C.body(tr(ui["label"]), T.TEXT, weight=ft.FontWeight.W_500),
|
||||
C.meta(tr(ui["help"]), T.TEXT_2)], spacing=T.px(2), expand=True), control],
|
||||
spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
padding=ft.Padding(0, T.px(8), 0, T.px(8)))
|
||||
foveation_controls.append(foveation_rows[key])
|
||||
controls: dict[str, ft.Control] = {} # key -> the input control
|
||||
rows: dict[str, ft.Container] = {} # key -> its row (visibility)
|
||||
readouts: dict[str, ft.Text] = {} # slider value labels
|
||||
advanced = {"on": any(UI[k]["level"] == "advanced" and values[k] != default(k) for k in SPECS)}
|
||||
advanced = {"on": bool(foveation.get("test"))
|
||||
or any(UI[k]["level"] == "advanced" and values[k] != default(k) for k in SPECS)}
|
||||
|
||||
def set_value(key, v):
|
||||
values[key] = _num(key, v)
|
||||
@@ -146,11 +201,13 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
if not keys:
|
||||
continue
|
||||
heading = C.body(tr(title), T.TEXT_2, weight=ft.FontWeight.W_600)
|
||||
section = ft.Column([heading, *[row(k) for k in keys]], spacing=0)
|
||||
section = ft.Column([heading, *[row(k) for k in keys],
|
||||
*(foveation_controls if group == "picture" else [])], spacing=0)
|
||||
sections.append((group, section, keys))
|
||||
body.append(section)
|
||||
|
||||
def apply_visibility():
|
||||
foveation_change("mode", foveation.get("mode", ""))
|
||||
for _group, section, keys in sections:
|
||||
shown = 0
|
||||
for k in keys:
|
||||
@@ -169,13 +226,18 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
for k in list(values):
|
||||
values[k] = suggested.get(k, default(k))
|
||||
app.page.pop_dialog()
|
||||
_open(app, package, g, values)
|
||||
_open(app, package, g, values, {})
|
||||
|
||||
before = dict(values)
|
||||
|
||||
def save(e):
|
||||
if foveation.get("mode", "") not in ("tracked", "fixed"):
|
||||
foveation.pop("radius", None)
|
||||
foveation.pop("test", None)
|
||||
foveation_options(foveation)
|
||||
app.page.pop_dialog()
|
||||
if g is not None:
|
||||
library.set_fields(package, foveation=dict(foveation))
|
||||
save_to_recipe(package, values)
|
||||
reinstall = any(UI[k].get("reinstall") and values[k] != before[k] for k in SPECS)
|
||||
installed = app.target is not None and (g is None or C.install_state(g, app.frame_info) in
|
||||
@@ -187,13 +249,13 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict) -> None
|
||||
target = app.target
|
||||
|
||||
def push():
|
||||
target.set_settings(package, {k: values[k] for k in SPECS})
|
||||
target.set_settings(package, {k: values[k] for k in SPECS}, foveation=foveation)
|
||||
app.toast(tr("Saved. Restart the game to use the new settings.") +
|
||||
(tr(" Reinstall it for the controller models.") if reinstall else ""))
|
||||
app.refresh_view()
|
||||
app.run_bg(push)
|
||||
|
||||
show_adv = any(UI[k]["level"] == "advanced" for _, _, keys in sections for k in keys)
|
||||
show_adv = bool(foveation_controls) or any(UI[k]["level"] == "advanced" for _, _, keys in sections for k in keys)
|
||||
content = ft.Column([
|
||||
C.body(tr("Changes take effect the next time the game starts. Not sure? Leave a setting as it is."), T.TEXT_2),
|
||||
*body,
|
||||
|
||||
@@ -765,10 +765,7 @@ class GameView:
|
||||
r = state["recipe"]
|
||||
value = e.control.value or ""
|
||||
if value: # a non-default choice is stored as the patch's parameter
|
||||
if pid == "device.foveation" and value in ("tracked", "fixed"):
|
||||
r.patches.setdefault(pid, {})[key] = value
|
||||
else:
|
||||
r.patches[pid] = {key: value}
|
||||
r.patches[pid] = {key: value}
|
||||
r.reasons[pid] = tr("Chosen by you.")
|
||||
else:
|
||||
if pid == "device.headset_model":
|
||||
@@ -776,19 +773,6 @@ class GameView:
|
||||
else:
|
||||
r.patches.pop(pid, None)
|
||||
save(r)
|
||||
if pid == "device.foveation":
|
||||
app.open_game(package, advanced=True, show_all=self.show_all)
|
||||
return handler
|
||||
|
||||
def foveation_param(key):
|
||||
def handler(e):
|
||||
r = state["recipe"]
|
||||
params = r.patches.setdefault("device.foveation", {})
|
||||
if params.get("mode") not in ("tracked", "fixed"):
|
||||
params["mode"] = "tracked"
|
||||
params[key] = bool(e.control.value) if key == "test" else float(e.control.value or 0)
|
||||
r.reasons["device.foveation"] = tr("Chosen by you.")
|
||||
save(r)
|
||||
return handler
|
||||
|
||||
def set_param(pid, key):
|
||||
@@ -823,7 +807,8 @@ class GameView:
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, T.S3, T.S4, T.S3)))
|
||||
continue
|
||||
rows = []
|
||||
for p in [p for p in base.all_patches() if p.category == cat and p.id in listed]:
|
||||
for p in [p for p in base.all_patches()
|
||||
if p.category == cat and p.id in listed and p.id != "device.foveation"]:
|
||||
on = p.id in recipe.patches
|
||||
reason = recipe.reasons.get(p.id, "")
|
||||
# plain by default; "Show technical details" adds the exact description, the id and the parameters
|
||||
@@ -847,21 +832,6 @@ class GameView:
|
||||
options=[ft.DropdownOption(key=k, text=tr(label)) for k, label in choices],
|
||||
on_select=choose(p.id, p.params[0].key))
|
||||
controls = [picker]
|
||||
if p.id == "device.foveation" and current in ("tracked", "fixed"):
|
||||
params = recipe.params(p.id)
|
||||
radius = f"{float(params.get('radius') or 0):g}"
|
||||
sizes = list(p.RADIUS_CHOICES)
|
||||
if radius not in dict(sizes):
|
||||
sizes.append((radius, f"{float(radius):.0%} radius"))
|
||||
controls += [C.meta(tr("Full-resolution area size")),
|
||||
C.dropdown(value=radius, width=T.px(280),
|
||||
options=[ft.DropdownOption(key=k, text=tr(label))
|
||||
for k, label in sizes],
|
||||
on_select=foveation_param("radius")),
|
||||
ft.Switch(label=tr("Darken lower-resolution regions (test)"),
|
||||
value=bool(params.get("test", False)),
|
||||
on_change=foveation_param("test"), active_color=T.ACCENT),
|
||||
C.meta(tr("Apply with Update on Frame."))]
|
||||
rows.append(ft.Container(ft.Row([
|
||||
ft.Column([ft.Row([C.body(C.patch_title(p.title, technical), T.TEXT,
|
||||
weight=ft.FontWeight.W_500)]
|
||||
@@ -869,7 +839,7 @@ class GameView:
|
||||
tooltip=C.tip(HELP["experimental"]))]
|
||||
if p.experimental else []), spacing=T.S2, wrap=True), *sub],
|
||||
spacing=T.px(3), expand=True),
|
||||
ft.Column(controls, spacing=T.S2, width=T.px(340 if p.id == "device.foveation" else 280)),
|
||||
ft.Column(controls, spacing=T.S2, width=T.px(280)),
|
||||
], spacing=T.S3), padding=ft.Padding(T.S4, T.px(10), T.S4, T.px(10)), border=ft.Border(
|
||||
top=ft.BorderSide(1, T.BORDER))))
|
||||
continue
|
||||
|
||||
@@ -2081,3 +2081,38 @@ def test_launcher_starts_lepton_again_once_when_the_game_restarts_itself(monkeyp
|
||||
(tmp_path / "starts").unlink()
|
||||
subprocess.run([str(launcher)], timeout=30, env={**env, "FRAMEPORT_NO_RESTART": "1"}, start_new_session=True)
|
||||
assert (tmp_path / "starts").read_text().count("started") == 1
|
||||
|
||||
|
||||
def test_foveation_settings_rewrite_only_launcher_exports(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
anchor = tmp_path / "anchors" / "test.game"
|
||||
anchor.mkdir(parents=True)
|
||||
base = tmp_path / "game"
|
||||
base.mkdir()
|
||||
launch = anchor / "launch.sh"
|
||||
launch.write_text("#!/bin/bash\nexport UNRELATED='keep me'\nexport FDM_DEBUG=old\n"
|
||||
"export FOVE_INNER=0.1\nrun_game\n")
|
||||
a.ANCHORS = str(anchor.parent)
|
||||
dep = {"base": str(base)}
|
||||
writes = []
|
||||
monkeypatch.setattr(a, "deployment", lambda pkg: dep)
|
||||
monkeypatch.setattr(a, "write_deployment", lambda pkg, record: writes.append(copy_record(record)))
|
||||
monkeypatch.setattr(a, "install_controller_models", lambda *args: {})
|
||||
monkeypatch.setattr(a, "sync_game_config", lambda *args: {})
|
||||
monkeypatch.setattr(a, "sync_refresh_rate", lambda *args: {})
|
||||
env = {"FDM_DEBUG": "enable,disable_offsets", "FOVE_INNER": None,
|
||||
"ENABLE_VULKAN_FDM_INJECTION_LAYER": "1"}
|
||||
result = a.cmd_set_settings({"package": "test.game", "settings": {"scale": 1.2}, "foveation_env": env})
|
||||
text = launch.read_text()
|
||||
assert "export FDM_DEBUG=enable,disable_offsets" in text and "FOVE_INNER=" not in text
|
||||
assert "export UNRELATED='keep me'\n" in text and text.endswith("run_game\n")
|
||||
assert result["foveation_env"] == env and writes[-1]["foveation_env"] == env
|
||||
assert (base / "settings.conf").read_text() == "scale=1.2\n"
|
||||
assert not list(anchor.glob("*.tmp"))
|
||||
with pytest.raises(a.AgentError):
|
||||
a.cmd_set_settings({"package": "test.game", "foveation_env": {"PATH": "bad"}})
|
||||
assert launch.read_text() == text
|
||||
|
||||
|
||||
def copy_record(record):
|
||||
return json.loads(json.dumps(record))
|
||||
+12
-15
@@ -133,16 +133,21 @@ def test_climb_private_bridge_guard_and_rebuild(tmp_path, quest_manifest, monkey
|
||||
assert foveation.LAYER in elf.needed(ws.read_lib("libvrapi.so"))
|
||||
assert not patch.apply(ctx)
|
||||
assert all(ok for _, ok, _ in patch.validate(ctx))
|
||||
# Rebuilding with Off from source removes private APK support.
|
||||
with ApkWorkspace(source) as ws:
|
||||
ctx = base.ApkContext(ws, SimpleNamespace(package="com.crytek.climb2"), {}, Reporter(), {})
|
||||
assert patch.apply(ctx) # support is prepared even with the runtime preference at Default
|
||||
assert foveation.LAYER in elf.needed(ws.read_lib("libvrapi.so"))
|
||||
assert all(ok for _, ok, _ in patch.validate(ctx))
|
||||
# Off retains verified APK support; the personal setting disables injection through the launcher.
|
||||
with ApkWorkspace(source) as ws:
|
||||
ctx = base.ApkContext(ws, SimpleNamespace(package="com.crytek.climb2"),
|
||||
{"mode": "off"}, Reporter(), {})
|
||||
assert not patch.apply(ctx)
|
||||
assert ws.read_lib("libvrapi.so") == original
|
||||
assert not ws.has(ws.lib(foveation.LAYER))
|
||||
assert patch.apply(ctx)
|
||||
assert ws.read_lib("libvrapi.so") != original
|
||||
assert ws.has(ws.lib(foveation.LAYER))
|
||||
|
||||
|
||||
def test_ui_controls_save_size_test_and_mode_without_losing_other_patches(monkeypatch):
|
||||
def test_customize_does_not_offer_foveation_choice(monkeypatch):
|
||||
import flet as ft
|
||||
|
||||
from frameport.core import library
|
||||
@@ -167,16 +172,8 @@ def test_ui_controls_save_size_test_and_mode_without_losing_other_patches(monkey
|
||||
yield from walk(control.content)
|
||||
|
||||
controls = list(walk(panel))
|
||||
mode = next(c for c in controls if isinstance(c, ft.Dropdown) and c.value == "tracked")
|
||||
radius = next(c for c in controls if isinstance(c, ft.Dropdown) and c.value == "0")
|
||||
test = next(c for c in controls if isinstance(c, ft.Switch) and "Darken" in (c.label or ""))
|
||||
radius.on_select(SimpleNamespace(control=SimpleNamespace(value="0.3")))
|
||||
test.on_change(SimpleNamespace(control=SimpleNamespace(value=True)))
|
||||
mode.on_select(SimpleNamespace(control=SimpleNamespace(value="fixed")))
|
||||
assert saved[-1]["patches"]["device.foveation"] == {"mode": "fixed", "radius": 0.3, "test": True}
|
||||
assert "frame.adapter" in saved[-1]["patches"]
|
||||
mode.on_select(SimpleNamespace(control=SimpleNamespace(value="off")))
|
||||
assert saved[-1]["patches"]["device.foveation"] == {"mode": "off"}
|
||||
assert not any(isinstance(c, ft.Dropdown) and c.value == "tracked" for c in controls)
|
||||
assert not saved
|
||||
|
||||
|
||||
def test_verified_batman_storage_and_other_byte_change_rejected():
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Discussion #365: personal runtime choices never become recipe/build inputs."""
|
||||
import copy
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from frameport.core import library
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.game_update import apk_recipe_fingerprint
|
||||
from frameport.patches.settings import UI, foveation_env, foveation_preference
|
||||
|
||||
|
||||
def test_migration_extracts_choice_once_without_overwriting_preference():
|
||||
data = {"games": {
|
||||
"old": {"recipe": {"patches": {"device.foveation": {"mode": "fixed", "radius": .2, "test": True},
|
||||
"frame.adapter": {}}}},
|
||||
"chosen": {"recipe": {"patches": {"device.foveation": {"mode": "tracked"}}},
|
||||
"foveation": {"mode": "off"}},
|
||||
"plain": {"recipe": {"patches": {}}}}}
|
||||
library._migrate(data)
|
||||
old = data["games"]["old"]
|
||||
assert old["foveation"] == {"mode": "fixed", "radius": .2, "test": True}
|
||||
assert old["recipe"]["patches"]["device.foveation"] == {"support": True}
|
||||
assert "frame.adapter" in old["recipe"]["patches"]
|
||||
assert data["games"]["chosen"]["foveation"] == {"mode": "off"}
|
||||
assert "foveation" not in data["games"]["plain"]
|
||||
snapshot = copy.deepcopy(data)
|
||||
library._migrate(data)
|
||||
assert data == snapshot
|
||||
|
||||
|
||||
def test_working_config_excludes_legacy_foveation_in_all_outputs():
|
||||
from frameport.diag import issue
|
||||
game = {"package": "test.game", "title": "Test", "foveation": {"mode": "fixed"}}
|
||||
recipe = yaml.safe_dump({"package": "test.game", "patches": {
|
||||
"device.foveation": {"mode": "tracked", "radius": .2, "test": True}, "frame.adapter": {}},
|
||||
"reasons": {"device.foveation": "Eye tracking"}})
|
||||
args = (game, recipe, "works", "", {})
|
||||
assert "device.foveation" not in issue.working_config_body(*args)
|
||||
assert "device.foveation" not in issue.working_config_url(*args)
|
||||
links = issue.working_config_links(*args)
|
||||
assert "device.foveation" not in str(links)
|
||||
assert "frame.adapter" in issue.working_config_body(*args)
|
||||
from frameport.recommend import catalog
|
||||
exported = catalog.to_yaml(catalog.CatalogEntry(package="test.game", title="Test", status="works",
|
||||
foveation="fixed", foveation_radius=.3, foveation_test=True))
|
||||
args = (game, exported, "works", "", {})
|
||||
assert "foveation" not in issue.working_config_body(*args)
|
||||
assert "foveation" not in issue.working_config_url(*args)
|
||||
assert "foveation" not in str(issue.working_config_links(*args))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,expected", [("tracked", "enable"), ("fixed", "enable,disable_offsets"),
|
||||
("off", None), ("", None)])
|
||||
def test_launcher_preference_update_uses_settings_command_without_changing_recipe(monkeypatch, mode, expected):
|
||||
from frameport.targets.frame_lepton import FrameLeptonTarget
|
||||
recipe = library.recipe_to_dict(Recipe("test.game", patches={"frame.adapter": {}}))
|
||||
entry = {"package": "test.game", "recipe": recipe, "foveation": {"mode": mode}}
|
||||
original = copy.deepcopy(recipe)
|
||||
before = apk_recipe_fingerprint(recipe)
|
||||
monkeypatch.setattr(library, "game", lambda pkg: entry)
|
||||
calls = []
|
||||
target = object.__new__(FrameLeptonTarget)
|
||||
target.frame = SimpleNamespace(agent=lambda command, **args: calls.append((command, args)))
|
||||
monkeypatch.setattr(target, "connect", lambda: target)
|
||||
target.set_settings("test.game", {"scale": 1.0}, foveation={"mode": mode})
|
||||
assert len(calls) == 1 and calls[0][0] == "set_settings"
|
||||
assert calls[0][1]["foveation_env"]["FDM_DEBUG"] == expected
|
||||
assert recipe == original and apk_recipe_fingerprint(recipe) == before
|
||||
|
||||
|
||||
def test_catalog_defaults_survive_and_explicit_choice_wins():
|
||||
from frameport.install.installer import configuration_args
|
||||
recipe = Recipe("com.crytek.climb2", patches={"device.foveation": {"mode": "tracked"}})
|
||||
entry = {"recipe": library.recipe_to_dict(recipe)}
|
||||
assert foveation_preference(entry) == {"mode": "tracked"}
|
||||
assert configuration_args(entry, recipe)["env"]["FDM_DEBUG"] == "enable,yflip"
|
||||
entry["foveation"] = {"mode": "off"}
|
||||
env = configuration_args(entry, recipe)["env"]
|
||||
assert env["VK_INSTANCE_LAYERS"] == "" and "FDM_DEBUG" not in env
|
||||
assert not any("CLIMB" in k for k in env)
|
||||
# Default restores the catalog recommendation.
|
||||
entry["foveation"] = {"mode": ""}
|
||||
assert configuration_args(entry, recipe)["env"]["FDM_DEBUG"] == "enable,yflip"
|
||||
|
||||
|
||||
def test_radius_test_and_off_reset_managed_exports():
|
||||
env = foveation_env("test.game", {"mode": "fixed", "radius": .2, "test": True})
|
||||
assert env["FOVE_INNER"] == "0.2" and env["FOVE_OUTER"] == "0.4"
|
||||
assert env["LEPTON_ENV_FRAMEPORT_FOVEATION_TEST"] == "1"
|
||||
off = foveation_env("test.game", {"mode": "off"}, baseline={k: v for k, v in env.items() if v})
|
||||
assert off["FOVE_INNER"] is None and off["LEPTON_ENV_FRAMEPORT_FOVEATION_TEST"] is None
|
||||
|
||||
|
||||
def test_resolution_label_explains_actual_effect():
|
||||
assert UI["scale"]["label"] == "Render resolution (supersampling)"
|
||||
assert "render resolution" in UI["scale"]["help"]
|
||||
assert "sharper but slower" in UI["scale"]["help"]
|
||||
|
||||
|
||||
def test_game_dialog_saves_personal_choice_without_touching_recipe(monkeypatch):
|
||||
import flet as ft
|
||||
|
||||
from frameport.ui.views import adapter_dialog as ad
|
||||
|
||||
recipe = library.recipe_to_dict(Recipe("test.game", patches={"frame.adapter": {}}))
|
||||
library.upsert_game("test.game", title="Test", recipe=recipe)
|
||||
game = library.game("test.game")
|
||||
dialogs = []
|
||||
app = SimpleNamespace(page=SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None),
|
||||
target=None, toast=lambda *a, **kw: None, refresh_view=lambda: None,
|
||||
_title=lambda pkg: "Test")
|
||||
ad._open(app, "test.game", game, ad.recipe_values(game))
|
||||
|
||||
def walk(control):
|
||||
yield control
|
||||
for child in getattr(control, "controls", []) or []:
|
||||
yield from walk(child)
|
||||
for attr in ("content", "title"):
|
||||
child = getattr(control, attr, None)
|
||||
if child is not None and not isinstance(child, str):
|
||||
yield from walk(child)
|
||||
for child in getattr(control, "actions", []) or []:
|
||||
yield from walk(child)
|
||||
|
||||
controls = list(walk(dialogs[-1]))
|
||||
mode = next(c for c in controls if isinstance(c, ft.Dropdown) and
|
||||
any(o.key == "tracked" for o in c.options))
|
||||
mode.on_select(SimpleNamespace(control=SimpleNamespace(value="fixed")))
|
||||
radius = next(c for c in controls if isinstance(c, ft.Dropdown) and
|
||||
any(o.key == "0.3" for o in c.options))
|
||||
radius.on_select(SimpleNamespace(control=SimpleNamespace(value="0.3")))
|
||||
advanced_row = next(c for c in controls if isinstance(c, ft.Row) and c.data == "switch" and
|
||||
any(getattr(t, "value", None) == "Show advanced settings" for t in walk(c)))
|
||||
advanced = next(c for c in walk(advanced_row) if isinstance(c, ft.Switch))
|
||||
advanced.on_change(SimpleNamespace(control=SimpleNamespace(value=True)))
|
||||
test_row = next(c for c in controls if isinstance(c, ft.Container) and
|
||||
isinstance(c.content, ft.Row) and any(
|
||||
getattr(t, "value", None) == "Darken coarse fragments (test)" for t in walk(c)))
|
||||
assert test_row.visible
|
||||
test_switch = next(c for c in walk(test_row) if isinstance(c, ft.Switch))
|
||||
test_switch.on_change(SimpleNamespace(control=SimpleNamespace(value=True)))
|
||||
# The dialog's primary Save button calls the only recipe-independent preference write.
|
||||
save = dialogs[-1].actions[-1]
|
||||
save.on_click(None)
|
||||
saved = library.game("test.game")
|
||||
assert saved["foveation"] == {"mode": "fixed", "radius": .3, "test": True}
|
||||
assert saved["recipe"] == game["recipe"]
|
||||
ad._open(app, "test.game", saved, ad.recipe_values(saved))
|
||||
mode = next(c for c in walk(dialogs[-1]) if isinstance(c, ft.Dropdown) and
|
||||
any(o.key == "tracked" for o in c.options))
|
||||
mode.on_select(SimpleNamespace(control=SimpleNamespace(value="off")))
|
||||
dialogs[-1].actions[-1].on_click(None)
|
||||
assert library.game("test.game")["foveation"] == {"mode": "off"}
|
||||
assert library.game("test.game")["recipe"] == game["recipe"]
|
||||
|
||||
|
||||
def test_adapter_build_prepares_internal_foveation_support():
|
||||
from frameport.build import apk_patches
|
||||
assert "device.foveation" in [p.id for p in apk_patches({"frame.adapter": {}})]
|
||||
assert "device.foveation" not in [p.id for p in apk_patches({"frame.launcher": {}})]
|
||||
@@ -46,6 +46,8 @@ def test_adapter_and_launcher(tmp_path, quest_manifest):
|
||||
for pid in ("frame.adapter", "frame.launcher"):
|
||||
ctx = base.ApkContext(ws, _analysis(), {}, Reporter(), recipe)
|
||||
assert base.get(pid).apply(ctx)
|
||||
assert all(ok for _, ok, _ in base.get("frame.adapter").validate(ctx))
|
||||
assert ws.has(ws.lib("libVkLayer_fdm_darken.so")) # dormant until the launcher test opt-in
|
||||
out = ws.write(tmp_path / "out.apk")
|
||||
with zipfile.ZipFile(out) as z:
|
||||
names = z.namelist()
|
||||
@@ -67,7 +69,10 @@ def test_adapter_refreshes_existing_wrapper(tmp_path, quest_manifest):
|
||||
patch = base.get("frame.adapter")
|
||||
ctx = base.ApkContext(ws, _analysis(), {}, Reporter(), {"frame.adapter": {}})
|
||||
assert patch.apply(ctx)
|
||||
assert ws.read_lib("libopenxr_loader_generic.so") == artifact(ws.abi, "libopenxr_loader_generic.so")
|
||||
from frameport.analysis import elf
|
||||
from frameport.patches.frame.foveation import LAYER
|
||||
assert ws.read_lib("libopenxr_loader_generic.so") == elf.add_needed(
|
||||
artifact(ws.abi, "libopenxr_loader_generic.so"), LAYER)
|
||||
assert ws.read_lib("libopenxr_loader_original.so") == b"original-loader"
|
||||
assert not patch.apply(ctx)
|
||||
|
||||
|
||||
@@ -130,7 +130,9 @@ def test_build_leaves_out_the_haptic_workaround_with_a_fixed_runtime(tmp_path, m
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("lib/arm64-v8a/libopenxr_loader.so", runtime)
|
||||
z.writestr("lib/arm64-v8a/libopenxr_loader_generic.so", b"loader")
|
||||
monkeypatch.setattr(adapter, "artifact", lambda abi, name: b"artifact " + name.encode())
|
||||
real_artifact = adapter.artifact
|
||||
monkeypatch.setattr(adapter, "artifact", lambda abi, name: real_artifact(abi, name)
|
||||
if name == adapter.GENERIC else b"artifact " + name.encode())
|
||||
recipe = Recipe("com.x", patches={"frame.adapter": {}, "adapter.haptic_fix": {"value": 1}})
|
||||
analysis = Analysis(package="com.x", version="1", label="X", abis=["arm64-v8a"], engine="Unity", xr="OpenXR",
|
||||
graphics="Vulkan", direct_vrapi=False, libs=["libOVRPlugin.so"], launcher_activity=None,
|
||||
|
||||
Reference in new issue
Block a user