mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-16 18:01:23 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2103d9fb5 |
No files matched your search
@@ -223,6 +223,13 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
|
||||
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
|
||||
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
|
||||
- Newer versions (GitHub #161): game menu "Update with a newer APK…" (`pipeline.replace_apk`, outdated via
|
||||
`build.source_version`) and, for Linux apps / PC VR games, "Update with a newer version…" (`replace_linux_app` /
|
||||
`replace_rift_game` via `add_linux_app(replace=)` / `add_rift_game(replace=)`, CLI `frameport new-version`): the
|
||||
entry keeps package, name, art, tags, switches, user recipe (a confirmed exe if the new folder has it); a new
|
||||
`source_id` goes into the deployment's `recipe` dict at install (no agent change) → `C.source_replaced` = outdated
|
||||
(Frame + `pc_outdated`); such an install re-sends same-size files changed after the last install (mtime/ctime,
|
||||
`installer.changed_since`); the old Rift folder is in `replaced_dirs` (rescans skip it). Untested on the device.
|
||||
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
|
||||
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
|
||||
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
|
||||
|
||||
@@ -13,11 +13,11 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
|
||||
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
|
||||
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
1cc65695b223f7d56913275554c233f6b1d38a433068f26413fa97135f202a78 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
53a7d6340707766a7c7465f20084c520d259c76581f02a7e516a52cfeb624d51 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
package: com.asg.clockworkdev
|
||||
title: The Last Clockwinder
|
||||
title: Clockwork
|
||||
status: works
|
||||
tested_version: '1.29'
|
||||
engine: Unity
|
||||
|
||||
+1
-1
@@ -31,6 +31,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| BONELAB | Quest | ✅ Works | |
|
||||
| Carve Snowboarding | Quest | ✅ Works | |
|
||||
| Caves | Quest | ✅ Works | |
|
||||
| Clockwork | Quest | ✅ Works | |
|
||||
| COMPOUND | Quest | ✅ Works | |
|
||||
| Cook-Out | Quest | ✅ Works | |
|
||||
| Cosmodread | Quest | ✅ Works | |
|
||||
@@ -128,7 +129,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb 2 | Quest | ✅ Works | |
|
||||
| The Last Clockwinder | Quest | ✅ Works | |
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
| The Tale of Onogoro | Quest | ✅ Works | |
|
||||
| Time Crisis II VR (Test) | Quest | ✅ Works | |
|
||||
|
||||
@@ -167,6 +167,10 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
|
||||
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
|
||||
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
|
||||
**Update on Frame**.
|
||||
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
|
||||
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
|
||||
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
|
||||
PC**).
|
||||
- **Dev builds:** if you're asked to test a change before its release, use Settings → Updates → **Install the latest
|
||||
dev build…**. The next release then arrives as a normal update.
|
||||
|
||||
@@ -255,6 +259,7 @@ option. The main ones:
|
||||
|---|---|
|
||||
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
|
||||
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
|
||||
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
|
||||
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
|
||||
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for this PC), launch test |
|
||||
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
|
||||
|
||||
+3
-3
@@ -904,7 +904,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
- `frame.unity_gl_shim`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 15 more.
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 15 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
|
||||
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
|
||||
@@ -973,7 +973,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
|
||||
- `frame.unity_runtime_msaa_off` · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 20 more.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 20 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
|
||||
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
|
||||
@@ -1828,7 +1828,7 @@ For apps that only know Quest 3/Pro controllers: without it they get no buttons.
|
||||
- Game settings → Controllers → "Treat newer Quest controllers as Touch" (advanced).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped), 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.
|
||||
> Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped). Without it such apps get no controller input.
|
||||
|
||||
### <a id="adapter-proximity_emul"></a>Animate fingers from touch
|
||||
|
||||
|
||||
@@ -139,8 +139,6 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Unity game freezes on its loading screen ~10-15 s in (Yupitergrad, GitHub #235); launch.log has `GrantPermissionsActivity: Permission grant result … permission=android.permission.BLUETOOTH_CONNECT`, then OVRPlugin `SetupDisplayObjects(): Unable to find EGLConfig for configId 0` and no more frames; triage `bhaptics-permission-prompt` | bHaptics' vest SDK (`com.bhaptics.commons.PermissionUtils.requestBluetoothPermission`) asks for Android 12's BLUETOOTH_CONNECT; on Lepton's Android 11 it can't be granted at install, so Android's permission screen takes the foreground for a moment, the game pauses/resumes and OVRPlugin's GLES compositor stops drawing | `frame.bhaptics_permission` (suggested by analysis `bhaptics_permission`): the request returns at once (`Dex.return_early`) |
|
||||
| Unity game aborts at start: `Abort message: 'cannot create thread specific key for __cxa_get_globals()'` (SIGABRT in a library such as `libFirebaseCppApp-*.so`, for example Wander 0.3570, GitHub #198); triage `pthread-keys-exhausted` | Bionic allows 128 pthread keys per process; Unity 6 takes ~40 (most without a destructor), large native libraries (Gecko `libxul.so`, Firebase, avatar SDKs) and the Frame's own libraries (Mesa/Zink, FrameBridge, OVRPort, Valve's Vulkan layers) take the rest | `frame.unity_tls_key_pool` (opt-in; suggested for Unity 6 with Firebase C++ or Gecko): libunity.so's `pthread_key_*`/`pthread_get/setspecific` imports are renamed to `fp_tkey_*` (`elf.rename_imports`, versions set global) and `libfp_tkeys.so` (`native/tlskeys`) is added to its DT_NEEDED; destructor-less keys become slots behind one real key (logs `fp_tkeys: pooling Unity's thread keys`). Approach by Klownicle (#198) |
|
||||
| Controllers do nothing (for example stuck on a setup screen); log: `xrSuggestInteractionProfileBindings … XR_ERROR_PATH_UNSUPPORTED` | the game only suggests bindings for Meta's newer profiles (Touch Plus / Touch Pro); the Frame's Android runtime knows `oculus/touch_controller`, not those | adapter `profile_remap` (default on): the bindings are suggested again for Touch (log: `controller profile … -> oculus/touch_controller`) |
|
||||
| Controllers "don't sync": virtual hands/controllers lie on the floor below you, no buttons (Unity OpenXR games that only suggest Touch Plus/Pro, e.g. Hotel Infinity #327); log with `input_diag=1`: only `meta/touch_controller_plus` (or Pro) suggested, `controller profile … -> oculus/touch_controller` | the runtime then reports `oculus/touch_controller` for each hand, a profile the game never suggested: Unity's OpenXR input creates no device for it | adapter `profile_remap` (default on) reports the game's own profile for such hands (log: `profile_remap: /user/hand/left reported as meta/touch_controller_plus`) |
|
||||
| Controllers "don't sync": hands on the floor below you, no buttons, in a Unity OpenXR game with Meta's OpenXR features (e.g. Spatial Zombies #328); log: `xrCreateAction: XR_ERROR_PATH_UNSUPPORTED` (input_diag: `xrCreateAction -> XR_ERROR_PATH_UNSUPPORTED (thumbstick)`), then `xrGetCurrentInteractionProfile: XR_ERROR_ACTIONSET_NOT_ATTACHED` | Meta's "Detached … Controller" devices add `/user/detached_controller_meta/left|right` to Unity's merged actions; the Frame has no XR_META_detached_controllers, the action fails and Unity attaches no action set (OVRPlugin's input goes with it) | adapter `profile_remap` (default on) leaves those paths out of actions and bindings (log: `profile_remap: action thumbstick without 2 detached-controller path(s)`) |
|
||||
| Some buttons do nothing or the wrong thing (for example a Quest game's X/Y on the Frame's left controller) | the game binds only Quest Touch and SteamVR maps Touch onto the Frame controllers with its own remap (Touch left X/Y land on the d-pad, right X and Y repeat B), or the runtime rejected a profile or an input call | turn on adapter `input_diag` (diagnostics, off by default; `frameport settings <pkg> input_diag=1`) and look for `input_diag:` lines: `bindings: <profile> accepted` or `unsupported: interaction profile <profile> -> <result>` with the rejected paths, `bindings: /user/hand/<hand> uses <profile>`, `unsupported: function <name>`, `unsupported: xrCreateAction -> <result> (<action>)` |
|
||||
| Unity game hangs or the whole Frame restarts (for example in a menu); log: `The current MSAA level is 0, but the recommended MSAA level is 4. Switching to the recommended level.` (for example Lucky's Tale) | Meta's OVRManager (`useRecommendedMSAALevel`) turns 4x MSAA on at runtime, past QualitySettings; multisampled render-to-texture on GLES/Zink hangs the GPU | `frame.unity_runtime_msaa_off` (Cpp2IL: `OVRDisplay.get_recommendedMSAALevel` → 0) |
|
||||
| Unity game: one eye shows only effects or gray, the other is fine (for example I Am Cat, Oculus XR Plugin on GLES) | the game's single-pass multiview rendering goes wrong for array slice 1 (the adapter submits both slices correctly) | `frame.unity_multipass` (Cpp2IL: `OculusSettings.GetStereoRenderingMode` → MultiPass); `swap_eyes=1` tells whether the game or the adapter is at fault |
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by session_fixes.c: Meta's detached controllers (XR_META_detached_controllers: a controller put down while
|
||||
// hand tracking runs) on a runtime without that extension. Uses profile_remap, runtime_has_detached_controllers,
|
||||
// active_instance, lookup and LOG.
|
||||
//
|
||||
// Meta's Unity OpenXR features add "Detached ... Controller" devices for /user/detached_controller_meta/left|right.
|
||||
// Unity merges same-named actions of every enabled device into one action, so the first action with such a subaction
|
||||
// path (e.g. "thumbstick") fails with XR_ERROR_PATH_UNSUPPORTED on the Frame's runtime, Unity attaches no action set
|
||||
// at all and neither Unity nor OVRPlugin get controller input: hands lying at the play-space origin on the floor, no
|
||||
// buttons (Spatial Zombies, GitHub #328). With profile_remap on and the runtime lacking the extension, these paths are
|
||||
// left out of the game's actions and suggested bindings, and xrGetCurrentInteractionProfile answers "no profile"
|
||||
// for them (a detached controller that never shows up) instead of XR_ERROR_PATH_UNSUPPORTED.
|
||||
|
||||
#define DETACHED_PREFIX "/user/detached_controller_meta/"
|
||||
|
||||
static int detached_filter_on(void) { return profile_remap && !runtime_has_detached_controllers && active_instance; }
|
||||
|
||||
// 1 if `path` is a detached controller's top-level path or one of its input/output paths
|
||||
static int detached_path(XrPath path) {
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
char name[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
return path && to_string && XR_SUCCEEDED(to_string(active_instance, path, sizeof(name), &size, name)) &&
|
||||
!strncmp(name, DETACHED_PREFIX, strlen(DETACHED_PREFIX));
|
||||
}
|
||||
|
||||
// xrCreateAction: `info` without the detached subaction paths, in *copy (paths in `kept`, room for all of them).
|
||||
// Returns `info` itself when there is nothing to leave out.
|
||||
static const XrActionCreateInfo *detached_strip_action(const XrActionCreateInfo *info, XrActionCreateInfo *copy,
|
||||
XrPath *kept) {
|
||||
if (!info || !info->countSubactionPaths || !info->subactionPaths || !kept || !detached_filter_on()) return info;
|
||||
uint32_t n = 0;
|
||||
for (uint32_t i = 0; i < info->countSubactionPaths; ++i)
|
||||
if (!detached_path(info->subactionPaths[i])) kept[n++] = info->subactionPaths[i];
|
||||
if (n == info->countSubactionPaths) return info;
|
||||
*copy = *info;
|
||||
copy->countSubactionPaths = n;
|
||||
copy->subactionPaths = kept;
|
||||
static int logged;
|
||||
if (logged++ < 3)
|
||||
LOG("profile_remap: action %s without %u detached-controller path(s) (the runtime has no "
|
||||
"XR_META_detached_controllers)", info->actionName, info->countSubactionPaths - n);
|
||||
return copy;
|
||||
}
|
||||
|
||||
// xrSuggestInteractionProfileBindings: `suggested` without bindings of detached controllers, in *copy (bindings in
|
||||
// `kept`, room for all of them). Returns `suggested` itself when there is nothing to leave out.
|
||||
static const XrInteractionProfileSuggestedBinding *detached_strip_bindings(
|
||||
const XrInteractionProfileSuggestedBinding *suggested, XrInteractionProfileSuggestedBinding *copy,
|
||||
XrActionSuggestedBinding *kept) {
|
||||
if (!suggested || !suggested->countSuggestedBindings || !suggested->suggestedBindings || !kept ||
|
||||
!detached_filter_on())
|
||||
return suggested;
|
||||
uint32_t n = 0;
|
||||
for (uint32_t i = 0; i < suggested->countSuggestedBindings; ++i)
|
||||
if (!detached_path(suggested->suggestedBindings[i].binding)) kept[n++] = suggested->suggestedBindings[i];
|
||||
if (n == suggested->countSuggestedBindings) return suggested;
|
||||
*copy = *suggested;
|
||||
copy->countSuggestedBindings = n;
|
||||
copy->suggestedBindings = kept;
|
||||
static int logged;
|
||||
if (logged++ < 8) {
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
char profile[XR_MAX_PATH_LENGTH] = "?";
|
||||
uint32_t size = 0;
|
||||
if (to_string) to_string(active_instance, suggested->interactionProfile, sizeof(profile), &size, profile);
|
||||
LOG("profile_remap: %s bindings without %u detached-controller path(s)", profile,
|
||||
suggested->countSuggestedBindings - n);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
// xrGetCurrentInteractionProfile: a detached controller's path the runtime doesn't know has no profile.
|
||||
static XrResult detached_current_profile(XrPath user, XrInteractionProfileState *state, XrResult result) {
|
||||
if (result != XR_ERROR_PATH_UNSUPPORTED || !state || !detached_filter_on() || !detached_path(user)) return result;
|
||||
state->interactionProfile = XR_NULL_PATH;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
@@ -147,7 +147,6 @@ static int sync_guard; // xrSyncActions one at a time with xrPollEvent, pa
|
||||
static int profile_remap = 1; // Meta's newer controller profiles (rejected by the Frame) -> oculus/touch_controller
|
||||
static int proximity_emul; // finger proximity from capacitive touch: 1 thumb, 2 thumb + index (session_fixes.c)
|
||||
static int runtime_has_proximity; // the runtime has XR_FB_touch_controller_proximity itself
|
||||
static int runtime_has_detached_controllers; // the runtime has XR_META_detached_controllers (detached_controllers.c)
|
||||
static int layer_debug; // diagnostics: layers, swapchains, session states, spaces, aim/grip, refresh rates
|
||||
static int input_diag; // diagnostics: controller profiles, bindings, missing functions, failing input calls
|
||||
static int stable_local; // keep every LOCAL space the app creates on the session-start origin
|
||||
@@ -423,8 +422,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
for (uint32_t j = 0; available && j < available_count; ++j)
|
||||
if (!strcmp(available[j].extensionName, "XR_FB_composition_layer_image_layout")) runtime_has_image_layout = 1;
|
||||
else if (!strcmp(available[j].extensionName, "XR_FB_touch_controller_proximity")) runtime_has_proximity = 1;
|
||||
else if (!strcmp(available[j].extensionName, "XR_META_detached_controllers"))
|
||||
runtime_has_detached_controllers = 1;
|
||||
// Layer types are only valid if their extension ends up enabled; some apps submit them regardless.
|
||||
int has_equirect = 0, has_equirect2 = 0, has_cylinder = 0, has_cube = 0;
|
||||
for (uint32_t i = 0; i < kept; ++i) {
|
||||
@@ -2007,7 +2004,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(XrSession session,
|
||||
PFN_xrGetCurrentInteractionProfile fn =
|
||||
(PFN_xrGetCurrentInteractionProfile)lookup(active_instance, "xrGetCurrentInteractionProfile");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrResult result = detached_current_profile(user, state, fn(session, user, state));
|
||||
XrResult result = fn(session, user, state);
|
||||
if (input_diag && XR_SUCCEEDED(result) && state) input_diag_current_profile(user, state->interactionProfile);
|
||||
if (layer_debug && active_instance) { // which device each hand has, as the runtime reports it
|
||||
PFN_xrPathToString str = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
@@ -2021,7 +2018,16 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(XrSession session,
|
||||
static int logged;
|
||||
if (logged++ < 40) LOG("layer_debug: interaction profile of %s: %s (result %d)", who, what, result);
|
||||
}
|
||||
if (XR_SUCCEEDED(result) && state) current_profile_fix(user, state);
|
||||
if (!controller_fix || XR_FAILED(result) || !state || !state->interactionProfile || !active_instance)
|
||||
return result;
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(active_instance, "xrStringToPath");
|
||||
char name[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
if (to_string && to_path &&
|
||||
XR_SUCCEEDED(to_string(active_instance, state->interactionProfile, sizeof(name), &size, name)) &&
|
||||
(strstr(name, "/valve/") || strstr(name, "/khr/generic_controller")))
|
||||
to_path(active_instance, "/interaction_profiles/oculus/touch_controller", &state->interactionProfile);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2193,7 +2199,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
if (layer_debug) HOOK_AS(xrGetActionStateBoolean, hook_xrGetActionStateBoolean)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag || proximity_emul)
|
||||
HOOK_AS(xrSuggestInteractionProfileBindings, hook_xrSuggestInteractionProfileBindings)
|
||||
if (proximity_emul || profile_remap) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (proximity_emul) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (layer_debug || aim_correction_on()) {
|
||||
HOOK_AS(xrCreateActionSpace, hook_xrCreateActionSpace)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by session_fixes.c (after TOUCH_PROFILE and app_suggested_touch): what xrGetCurrentInteractionProfile
|
||||
// tells the game about each hand. Uses controller_fix, profile_remap, active_instance, lookup and LOG.
|
||||
// * controller_fix: SteamVR's own profiles (valve/..., khr/generic_controller) are reported as oculus/touch_controller,
|
||||
// the profile Quest games have bindings for.
|
||||
// * profile_remap: when the game suggested bindings only for one of Meta's newer profiles (Touch Plus/Pro) and
|
||||
// hook_xrSuggestInteractionProfileBindings suggested them again for Touch, the runtime reports
|
||||
// oculus/touch_controller, a profile the game never suggested. Unity's OpenXR plugin creates its input devices by
|
||||
// the reported profile, finds none for Touch and leaves the controllers unconnected: hands lying at the play-space
|
||||
// origin on the floor, no buttons (e.g. Hotel Infinity, GitHub #327). Such a hand is reported with the game's own
|
||||
// profile instead (the last one remapped: the Touch bindings the runtime uses are that profile's).
|
||||
|
||||
static char remapped_profile[XR_MAX_PATH_LENGTH]; // set by hook_xrSuggestInteractionProfileBindings
|
||||
|
||||
static void current_profile_fix(XrPath user, XrInteractionProfileState *state) {
|
||||
if ((!controller_fix && !profile_remap) || !state->interactionProfile || !active_instance) return;
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(active_instance, "xrStringToPath");
|
||||
char name[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
if (!to_string || !to_path ||
|
||||
XR_FAILED(to_string(active_instance, state->interactionProfile, sizeof(name), &size, name)))
|
||||
return;
|
||||
int touch = !strcmp(name, TOUCH_PROFILE);
|
||||
if (controller_fix && (strstr(name, "/valve/") || strstr(name, "/khr/generic_controller")) &&
|
||||
XR_SUCCEEDED(to_path(active_instance, TOUCH_PROFILE, &state->interactionProfile)))
|
||||
touch = 1;
|
||||
if (!touch || !profile_remap || !*remapped_profile || app_suggested_touch ||
|
||||
XR_FAILED(to_path(active_instance, remapped_profile, &state->interactionProfile)))
|
||||
return;
|
||||
static int logged;
|
||||
if (logged++ < 4) {
|
||||
char who[XR_MAX_PATH_LENGTH] = "?";
|
||||
to_string(active_instance, user, sizeof(who), &size, who);
|
||||
LOG("profile_remap: %s reported as %s (the app's profile) instead of oculus/touch_controller", who,
|
||||
remapped_profile + strlen("/interaction_profiles/"));
|
||||
}
|
||||
}
|
||||
@@ -296,8 +296,6 @@ static const char *const TOUCH_LEFT[] = {"input/x/click", "input/x/touch", "inpu
|
||||
static const char *const TOUCH_RIGHT[] = {"input/a/click", "input/a/touch", "input/b/click", "input/b/touch",
|
||||
"input/system/click"};
|
||||
static int app_suggested_touch;
|
||||
#include "profile_report.c" // remapped_profile, current_profile_fix (xrGetCurrentInteractionProfile)
|
||||
#include "detached_controllers.c" // detached controllers' paths on a runtime without them
|
||||
|
||||
static int in_list(const char *s, const char *const *list, size_t n) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
@@ -334,13 +332,8 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrCreateAction(XrActionSet set, const
|
||||
XrAction *action) {
|
||||
PFN_xrCreateAction fn = (PFN_xrCreateAction)lookup(active_instance, "xrCreateAction");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrActionCreateInfo copy;
|
||||
XrPath *kept = info && info->countSubactionPaths ? calloc(info->countSubactionPaths, sizeof(*kept)) : NULL;
|
||||
XrResult r = fn(set, detached_strip_action(info, ©, kept), action);
|
||||
free(kept);
|
||||
if (XR_FAILED(r) && input_diag) // this hook replaces input_diag's wrapper of xrCreateAction
|
||||
diag_call_failed("xrCreateAction", r, diag_name_detail(info ? info->actionName : NULL));
|
||||
if (proximity_emul && XR_SUCCEEDED(r) && info && action) { // OVRPlugin's action names
|
||||
XrResult r = fn(set, info, action);
|
||||
if (XR_SUCCEEDED(r) && info && action) { // OVRPlugin's action names
|
||||
if (!strcmp(info->actionName, "hand_thumb_proximity")) thumb_proximity_action = *action;
|
||||
else if (!strcmp(info->actionName, "hand_trigger_proximity")) trigger_proximity_action = *action;
|
||||
else return r;
|
||||
@@ -436,26 +429,11 @@ static XrResult remap_to_touch(XrInstance instance, PFN_xrSuggestInteractionProf
|
||||
return r;
|
||||
}
|
||||
|
||||
static XrResult suggest_bindings(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
const XrInteractionProfileSuggestedBinding *suggested);
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(XrInstance instance,
|
||||
const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
PFN_xrSuggestInteractionProfileBindings fn =
|
||||
(PFN_xrSuggestInteractionProfileBindings)lookup(instance, "xrSuggestInteractionProfileBindings");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
XrInteractionProfileSuggestedBinding copy;
|
||||
XrActionSuggestedBinding *kept = suggested && suggested->countSuggestedBindings
|
||||
? calloc(suggested->countSuggestedBindings, sizeof(*kept)) : NULL;
|
||||
const XrInteractionProfileSuggestedBinding *use = detached_strip_bindings(suggested, ©, kept);
|
||||
// a profile with only detached-controller bindings: nothing the runtime could bind
|
||||
XrResult r = use != suggested && !use->countSuggestedBindings ? XR_SUCCESS : suggest_bindings(instance, fn, use);
|
||||
free(kept);
|
||||
return r;
|
||||
}
|
||||
|
||||
static XrResult suggest_bindings(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
PFN_xrPathToString to_string = (PFN_xrPathToString)lookup(instance, "xrPathToString");
|
||||
if (suggested && to_string && (layer_debug || aim_correction_on()))
|
||||
for (uint32_t i = 0; i < suggested->countSuggestedBindings; ++i) {
|
||||
@@ -478,7 +456,6 @@ static XrResult suggest_bindings(XrInstance instance, PFN_xrSuggestInteractionPr
|
||||
}
|
||||
if (r != XR_ERROR_PATH_UNSUPPORTED || app_suggested_touch || !IN(profile, NEWER_TOUCH)) return r;
|
||||
XrResult remapped = remap_to_touch(instance, fn, to_string, suggested, profile);
|
||||
if (XR_SUCCEEDED(remapped)) snprintf(remapped_profile, sizeof(remapped_profile), "%s", profile);
|
||||
return XR_SUCCEEDED(remapped) ? XR_SUCCESS : r;
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -150,7 +150,8 @@ def main() -> int:
|
||||
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
|
||||
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
|
||||
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
|
||||
# last: the right-click menu stays open over whatever comes next
|
||||
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
|
||||
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
|
||||
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(game))))
|
||||
if args.docs:
|
||||
@@ -278,6 +279,7 @@ def main() -> int:
|
||||
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
|
||||
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
|
||||
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
|
||||
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
|
||||
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
|
||||
a.library_view.open_menu(LINUX_APPIMAGE)))]
|
||||
if args.links:
|
||||
|
||||
@@ -398,6 +398,30 @@ def rename(package: str = typer.Argument(..., help="the game (package or part of
|
||||
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
|
||||
|
||||
|
||||
@app.command("new-version")
|
||||
def new_version(package: str = typer.Argument(..., help="the game (package or part of its title)"),
|
||||
path: Path = typer.Argument(..., exists=True, help="the newer version: an APK (Quest/Android games), "
|
||||
"an AppImage/archive/folder (Linux apps) or the game folder (PC VR)")):
|
||||
"""Point a game at a newer version of it (GitHub #161). Its name, artwork, tags and your own recipe stay; install
|
||||
it again (frameport install) to update it on the Frame."""
|
||||
[pkg] = _pkgs(package, False)
|
||||
try:
|
||||
res = pipeline.replace_source(pkg, path, printing_reporter(verbose=False))
|
||||
except pipeline.WrongPackage as exc:
|
||||
raise typer.BadParameter(f"that APK is a different app: {exc}") from None
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc)) from None
|
||||
g = res["entry"]
|
||||
typer.echo(f"{pkg}: now {path}" + (f" (program {g.get('exe')})" if g.get("kind") in ("rift", "linux") else ""))
|
||||
if res.get("older"):
|
||||
typer.echo("Heads-up: that APK is an older version than the one before.", err=True)
|
||||
if res.get("different"):
|
||||
typer.echo(f"Heads-up: {res.get('name')} looks like a different app than {g.get('title')}.", err=True)
|
||||
if g.get("kind") == "rift" and g.get("exe_confirmed") is False:
|
||||
typer.echo(f"More than one program could start it; FramePort picked {g.get('exe')}. Another one: "
|
||||
f"frameport recipe {pkg} --exe <program>", err=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def patches():
|
||||
"""List every available patch."""
|
||||
|
||||
@@ -220,6 +220,28 @@ class PcvrPlan:
|
||||
art_lookup: str | None = None # package whose store art to use
|
||||
dest: str | None = None
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
source_id: str | None = None # which version of the game folder (GitHub #161: a newer one was added)
|
||||
changed_since: float | None = None # a newer version: game files changed after this are sent again (see below)
|
||||
|
||||
|
||||
def changed_since(path: Path, since: float | None) -> bool:
|
||||
"""A newer version of a Linux app / PC VR game (GitHub #161): uploads skip files the Frame has with the same
|
||||
size, so a changed file of the same size would stay old. Files written (or unpacked: ctime) after the last
|
||||
install are sent again; since=None (no newer version) never, 0 (last install unknown) always."""
|
||||
if since is None:
|
||||
return False
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return True
|
||||
return max(st.st_mtime, st.st_ctime) > since
|
||||
|
||||
|
||||
def _recipe_record(recipe: dict | None, source_id: str | None) -> dict | None:
|
||||
"""The deployment's recipe record, with the source version it was installed from (ui.components.source_replaced)."""
|
||||
if not source_id:
|
||||
return recipe
|
||||
return {**(recipe or {}), "source_id": source_id}
|
||||
|
||||
|
||||
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
@@ -259,7 +281,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
|
||||
sources["helpers"] = helpers
|
||||
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
|
||||
if prep["existing"].get(t, {}).get(rel) != size]
|
||||
if prep["existing"].get(t, {}).get(rel) != size
|
||||
or t == "game" and changed_since(plan.game_dir / rel, plan.changed_since)]
|
||||
need = sum(manifests[t][rel] for t, rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
@@ -290,7 +313,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
|
||||
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, vr=not is_flat_windows(plan.package),
|
||||
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
|
||||
recipe=_recipe_record({"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, plan.source_id),
|
||||
overlay=plan.overlay,
|
||||
)
|
||||
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
@@ -325,6 +349,8 @@ class LinuxPlan:
|
||||
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
|
||||
desktop_icon: bool = True # with it, a copy on Desktop Mode's desktop (GitHub #212)
|
||||
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
|
||||
source_id: str | None = None # which version of the app (GitHub #161: a newer one was added)
|
||||
changed_since: float | None = None # a newer version: files changed after this are sent again
|
||||
|
||||
|
||||
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
@@ -339,7 +365,8 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
else:
|
||||
manifest = local_data_manifest(plan.root)
|
||||
manifest = {k: v for k, v in manifest.items() if not k.startswith(".unpacked")}
|
||||
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size]
|
||||
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size
|
||||
or changed_since(plan.root / rel, plan.changed_since)]
|
||||
need = sum(manifest[rel] for rel in to_send)
|
||||
if need > prep["free_bytes"] - (1 << 30):
|
||||
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
|
||||
@@ -358,7 +385,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
|
||||
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
|
||||
executables=executables, tags=_tags(plan.package), dest=plan.dest,
|
||||
desktop_entry=plan.desktop_entry, desktop_icon=plan.desktop_icon, overlay=plan.overlay,
|
||||
timeout=900)
|
||||
recipe=_recipe_record(None, plan.source_id), timeout=900)
|
||||
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
|
||||
log_overlay(result.get("overlay"), reporter)
|
||||
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
"Appearance": "",
|
||||
"AppImage": "",
|
||||
"Apply with Update on Frame.": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped), 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.": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped). Without it such apps get no controller input.": "",
|
||||
"Artwork for {get}": "",
|
||||
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
|
||||
"Artwork updated for {get}": "",
|
||||
@@ -664,6 +664,7 @@
|
||||
"Hardware video decoding (H.264, HEVC, VP9)": "",
|
||||
"Hardware video decoding on the Frame": "",
|
||||
"Heads-up: that APK is an older version than the one before.": "",
|
||||
"Heads-up: {name} looks like a different app than {title}.": "",
|
||||
"headset found": "",
|
||||
"Hi there!": "",
|
||||
"Hide Android's navigation bar": "",
|
||||
@@ -941,6 +942,8 @@
|
||||
"New recipe applied. Click Update on Frame to install it.": "",
|
||||
"Newer APK for {title}": "",
|
||||
"Newer APK of {title}": "",
|
||||
"Newer version for {title}": "",
|
||||
"Newer version of {title}": "",
|
||||
"Next": "",
|
||||
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
|
||||
"No": "",
|
||||
@@ -1173,6 +1176,7 @@
|
||||
"Reading the file list from the Frame…": "",
|
||||
"Reading the install link": "",
|
||||
"Reading the new APK": "",
|
||||
"Reading the new version": "",
|
||||
"ready": "",
|
||||
"Ready": "",
|
||||
"Ready to play": "",
|
||||
@@ -1711,6 +1715,7 @@
|
||||
"Update the app only?": "",
|
||||
"Update tools": "",
|
||||
"Update with a newer APK…": "",
|
||||
"Update with a newer version…": "",
|
||||
"Updates": "",
|
||||
"Updates every game marked \"Update ready\", one after another.": "",
|
||||
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
|
||||
@@ -1971,6 +1976,8 @@
|
||||
"{title}: name updated in Steam": "",
|
||||
"{title}: newer APK added": "",
|
||||
"{title}: newer APK added. Press Update on Frame to install it.": "",
|
||||
"{title}: newer version added": "",
|
||||
"{title}: newer version added. Press Update to install it.": "",
|
||||
"{title}: ready": "",
|
||||
"{title}: Steam artwork updated on the Frame": "",
|
||||
"{title}: {line}": "",
|
||||
|
||||
@@ -106,10 +106,7 @@ SETTINGS = [
|
||||
("profile_remap", "int", 1, "Newer Touch profiles as Touch",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's "
|
||||
"runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead "
|
||||
"(components Touch lacks are dropped), 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."),
|
||||
"(components Touch lacks are dropped). Without it such apps get no controller input."),
|
||||
("haptic_scale", "float", 1.0, "Vibration strength",
|
||||
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and "
|
||||
"PCM buffers. For games whose vibrations feel much stronger than on a Quest."),
|
||||
|
||||
+123
-13
@@ -137,18 +137,25 @@ def _existing_rift(folder: Path) -> dict | None:
|
||||
|
||||
|
||||
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
|
||||
force: bool = False, art: bool = False) -> dict | None:
|
||||
force: bool = False, art: bool = False, replace: dict | None = None) -> dict | None:
|
||||
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
|
||||
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
|
||||
force)."""
|
||||
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
|
||||
name."""
|
||||
from .analysis import rift
|
||||
from .recommend import catalog
|
||||
|
||||
folder = Path(folder)
|
||||
old = _existing_rift(folder)
|
||||
if replace is None and not force and any(str(folder) in (g.get("replaced_dirs") or []) for g in library.games()
|
||||
if g.get("kind") == "rift"):
|
||||
if reporter: # the older copy of a game updated from another folder: a rescan must not bring it back
|
||||
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
|
||||
return None
|
||||
old = replace or _existing_rift(folder)
|
||||
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
|
||||
forced = exe is not None # set_exe: always analyze with the user's choice
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
|
||||
if replace is None:
|
||||
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
|
||||
if old and not forced and old_extra.get("fingerprint") and \
|
||||
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
|
||||
if reporter:
|
||||
@@ -177,7 +184,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
|
||||
suggested=library.recipe_to_dict(recipe))
|
||||
if not old or (old.get("recipe") or {}).get("source") != "user":
|
||||
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
|
||||
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
if replace is not None:
|
||||
pass # a newer version of the game: its name stays
|
||||
elif not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
|
||||
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
|
||||
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
|
||||
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
|
||||
@@ -400,14 +409,20 @@ def missing_obb(entry: dict) -> bool:
|
||||
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
|
||||
|
||||
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
|
||||
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None,
|
||||
replace: str | None = None) -> dict:
|
||||
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
|
||||
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder). replace: the package of
|
||||
the library entry this is a newer version of (replace_linux_app): it keeps that package, its name and its art."""
|
||||
from .analysis import linux
|
||||
from .core.models import Recipe
|
||||
|
||||
path = Path(path)
|
||||
info = linux.inspect(path)
|
||||
if replace and not exe and not info["files"]: # a folder: the program the app started before, if it's still there
|
||||
before = (library.game(replace) or {}).get("exe")
|
||||
if before and before != info["exe"] and (Path(info["root"]) / before).is_file():
|
||||
exe = before
|
||||
if exe:
|
||||
info["exe"] = exe
|
||||
x86 = info["machine"] == linux.EM_X86_64
|
||||
@@ -417,7 +432,7 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
if x86 and reporter:
|
||||
reporter.log(f"{path.name} is an x86_64 build: the Frame runs it through FEX (x86 translation; an arm64 "
|
||||
"build runs faster if there is one)")
|
||||
package = f"linux.{linux.slug(info['title'])}"
|
||||
package = replace or f"linux.{linux.slug(info['title'])}"
|
||||
if reporter:
|
||||
reporter.log(f"{info['title']}: program {info['exe']}{' (AppImage)' if info['appimage'] else ''}"
|
||||
f"{', OpenXR (VR)' if info['openxr'] else ''}")
|
||||
@@ -439,6 +454,8 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
|
||||
if not old:
|
||||
fields.update(title=info["title"], recipe=library.recipe_to_dict(recipe), status="unknown")
|
||||
library.upsert_game(package, **fields)
|
||||
if replace: # a newer version: the artwork stays as it is
|
||||
return library.game(package)
|
||||
try:
|
||||
from .artwork import fetch
|
||||
|
||||
@@ -580,7 +597,7 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
reporter, x86_64=bool(extra.get("x86_64")),
|
||||
desktop_entry=entry.get("desktop_entry", True) is not False,
|
||||
desktop_icon=entry.get("desktop_icon", True) is not False,
|
||||
overlay=linux_overlay(entry))
|
||||
overlay=linux_overlay(entry), **_source_args(entry, target.label))
|
||||
if extra.get("x86_64"):
|
||||
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
|
||||
"there; not checked ahead)")
|
||||
@@ -591,10 +608,23 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
|
||||
reporter.check("Libraries on the Frame", True, "everything the app needs is there")
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
|
||||
"source_id": entry.get("source_id")})
|
||||
return result
|
||||
|
||||
|
||||
def _source_args(entry: dict, label: str) -> dict:
|
||||
"""source_id (recorded with the install) and changed_since for a Linux app / PC VR game install: after a newer
|
||||
version was added (replace_linux_app/replace_rift_game), files changed since the last install there are sent
|
||||
again even when the Frame has a file of the same size (0 = the last install is unknown: every file)."""
|
||||
sid = entry.get("source_id")
|
||||
if not sid:
|
||||
return {}
|
||||
rec = (entry.get("installs") or {}).get(label) or {}
|
||||
return {"source_id": sid,
|
||||
"changed_since": None if rec.get("source_id") == sid else float(rec.get("time") or 0)}
|
||||
|
||||
|
||||
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
|
||||
if reporter:
|
||||
reporter.log(f"analyzing {src.apk.name}")
|
||||
@@ -662,6 +692,85 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
|
||||
"new_version": new_vc}
|
||||
|
||||
|
||||
def _looks_different(before: str | None, now: str | None) -> bool:
|
||||
"""A newer version whose name has little in common with the old one's (maybe another app): the caller warns."""
|
||||
import difflib
|
||||
|
||||
a, b = _norm_title(before or ""), _norm_title(now or "")
|
||||
if not a or not b or a in b or b in a:
|
||||
return False
|
||||
return difflib.SequenceMatcher(None, a, b).ratio() < 0.5
|
||||
|
||||
|
||||
def _mark_source_replaced(package: str, **fields) -> dict:
|
||||
"""A Linux app or PC VR game got a newer version: a new source id, which installs record (in the deployment's
|
||||
recipe); an install without it shows the game as outdated (ui.components.source_replaced)."""
|
||||
import uuid
|
||||
|
||||
return library.upsert_game(package, source_id=uuid.uuid4().hex[:12], source_updated=time.time(), **fields)
|
||||
|
||||
|
||||
def replace_linux_app(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of a Linux app already in the library (GitHub #161): an AppImage/archive or a folder, analysed
|
||||
like "Add a Linux app…" (program, arm64/x86_64, AppImage, OpenXR). The app keeps its package (so its Steam
|
||||
shortcut on the Frame), name, artwork, tags and switches (Desktop Mode entry, overlay app); a folder keeps the
|
||||
program chosen before if it's still there. Returns {"entry", "different": its name looks like another app's,
|
||||
"name": the new version's own name}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or not is_linux(entry):
|
||||
raise ValueError("only Linux apps can get a newer version this way")
|
||||
before = ((entry.get("analysis") or {}).get("label")) or entry.get("title")
|
||||
old_source = ((entry.get("analysis") or {}).get("extra") or {}).get("source")
|
||||
if reporter:
|
||||
reporter.log(f"reading {Path(path).name}")
|
||||
stored = add_linux_app(path, reporter, replace=package)
|
||||
name = (stored.get("analysis") or {}).get("label")
|
||||
stored = _mark_source_replaced(package, replaced_source=old_source)
|
||||
return {"entry": stored, "different": _looks_different(before, name), "name": name}
|
||||
|
||||
|
||||
def replace_rift_game(package: str, folder: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of a PC VR (Rift) game already in the library (GitHub #161): its new game folder, analysed like
|
||||
a scanned one (programs, VR runtimes, fingerprint). The game keeps its package, name, artwork, tags and the user's
|
||||
own recipe; a program the user confirmed stays if the new folder has it at the same place, else FramePort picks
|
||||
one again (exe_confirmed False when that's unclear: the caller asks). The old folder is left out of later scans.
|
||||
Returns {"entry", "different", "name", "exe_changed"}."""
|
||||
entry = library.game(package)
|
||||
if entry is None or not is_rift(entry):
|
||||
raise ValueError("only PC VR games can get a newer version folder")
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
raise ValueError(f"{folder} isn't a folder")
|
||||
other = _existing_rift(folder)
|
||||
if other and other["package"] != package:
|
||||
raise ValueError(f"{folder.name} is already in the library as {other.get('title') or other['package']}")
|
||||
before = (entry.get("analysis") or {}).get("label") or entry.get("title")
|
||||
old_dir, old_exe = entry.get("game_dir"), entry.get("exe")
|
||||
keep = old_exe if entry.get("exe_confirmed") and old_exe and (folder / old_exe).is_file() else None
|
||||
stored = add_rift_game(folder, reporter, exe=keep, force=True, replace=entry)
|
||||
if stored is None:
|
||||
raise ValueError(f"no game program found in {folder.name}")
|
||||
replaced = [d for d in (entry.get("replaced_dirs") or []) + [old_dir] if d and d != str(folder)]
|
||||
# build=None: the next install checks the new folder first (prepare_rift); the exe's checksum comes from there
|
||||
stored = _mark_source_replaced(package, build=None, replaced_dirs=list(dict.fromkeys(replaced)))
|
||||
name = (stored.get("analysis") or {}).get("label")
|
||||
return {"entry": stored, "different": _looks_different(before, name), "name": name,
|
||||
"exe_changed": stored.get("exe") != old_exe}
|
||||
|
||||
|
||||
def replace_source(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
|
||||
"""A newer version of any game in the library: an APK (Quest/Android), an app file or folder (Linux) or a game
|
||||
folder (PC VR). See replace_apk, replace_linux_app, replace_rift_game."""
|
||||
entry = library.game(package)
|
||||
if entry is None:
|
||||
raise ValueError(f"{package} isn't in the library")
|
||||
if is_linux(entry):
|
||||
return replace_linux_app(package, path, reporter)
|
||||
if is_rift(entry):
|
||||
return replace_rift_game(package, path, reporter)
|
||||
return replace_apk(package, Path(path), reporter)
|
||||
|
||||
|
||||
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
|
||||
src = source_of(entry)
|
||||
if reporter:
|
||||
@@ -1275,10 +1384,11 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
|
||||
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
|
||||
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
|
||||
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
|
||||
overlay=rift_overlay(entry, recipe))
|
||||
overlay=rift_overlay(entry, recipe), **_source_args(entry, target.label))
|
||||
if add_to_library:
|
||||
target.add_to_library([package], reporter)
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
|
||||
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
|
||||
"source_id": entry.get("source_id")})
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ class Target(ABC):
|
||||
raise NotImplementedError(f"{self.label} can't launch games")
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True, overlay=None, desktop_icon=True) -> dict:
|
||||
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None,
|
||||
changed_since=None) -> dict:
|
||||
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
|
||||
raise NotImplementedError(f"{self.label} can't install Linux apps")
|
||||
|
||||
|
||||
@@ -86,14 +86,15 @@ class FrameLeptonTarget(Target):
|
||||
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
|
||||
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
|
||||
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"))
|
||||
dest=self.install_dest(), overlay=extra.get("overlay"),
|
||||
source_id=extra.get("source_id"), changed_since=extra.get("changed_since"))
|
||||
return installer.install_pcvr(self.connect().frame, plan, reporter)
|
||||
|
||||
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
|
||||
desktop_entry=True, overlay=None, desktop_icon=True):
|
||||
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None, changed_since=None):
|
||||
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
|
||||
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay,
|
||||
desktop_icon=desktop_icon)
|
||||
desktop_icon=desktop_icon, source_id=source_id, changed_since=changed_since)
|
||||
return installer.install_linux(self.connect().frame, plan, reporter)
|
||||
|
||||
def drives(self) -> list[dict]:
|
||||
|
||||
@@ -191,7 +191,9 @@ class PcReviveTarget(Target):
|
||||
"tuning": dict(recipe.params("pcvr.steamvr_tuning"), enabled=True)
|
||||
if "pcvr.steamvr_tuning" in recipe.patches else None,
|
||||
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
|
||||
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source,
|
||||
**({"source_id": extra["source_id"]} if extra.get("source_id") else {})},
|
||||
"time": time.time()}
|
||||
exe_field, _, _ = shortcut_fields(dep)
|
||||
d = pc_dir() / package
|
||||
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
|
||||
|
||||
+58
-2
@@ -880,7 +880,7 @@ class FramePortApp:
|
||||
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:
|
||||
if job.kind in ("scan", "task") and self.exe_queue: # (a task: a newer PC VR game version)
|
||||
self.page.run_thread(self.next_exe_choice)
|
||||
elif job and job.active and job.package and self.route[:2] == ("game", job.package):
|
||||
pkg, transit = self.hero_transit or (None, None)
|
||||
@@ -1152,6 +1152,7 @@ class FramePortApp:
|
||||
"move": lambda e: self.move_game(pkg),
|
||||
"update_app": lambda e: self.update_app_only(pkg),
|
||||
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
|
||||
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
|
||||
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
|
||||
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
|
||||
"remove": lambda e: self.remove_from_library(pkg),
|
||||
@@ -1168,7 +1169,8 @@ class FramePortApp:
|
||||
settings=self.has_game_settings(g), programs=rift or (linux and bool(self.linux_programs(g))),
|
||||
selectable=lv is not None,
|
||||
media_button=C.is_media_player(g) and not rift,
|
||||
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux)
|
||||
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
|
||||
new_version=rift or linux)
|
||||
return menu_sections(g, st, handlers.__getitem__)
|
||||
|
||||
def open_game_menu(self, pkg: str) -> None:
|
||||
@@ -1621,6 +1623,60 @@ class FramePortApp:
|
||||
return tr("{title}: newer APK added").format(title=self._title(pkg))
|
||||
self.submit(tr("Newer APK for {title}").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
async def update_with_version(self, pkg: str) -> None:
|
||||
"""Game menu "Update with a newer version…" for Linux apps and PC VR games (GitHub #161): pick the new app
|
||||
file or folder (how the app was added) / the new game folder; the game keeps its name, art, tags and switches,
|
||||
and its page then offers Update on Frame (or on this PC)."""
|
||||
from ..analysis import linux as linux_analysis
|
||||
|
||||
if self.jobs.busy_with(pkg):
|
||||
return
|
||||
g = library.game(pkg) or {}
|
||||
rift = g.get("kind") == "rift"
|
||||
title = tr("Newer version of {title}").format(title=self._title(pkg))
|
||||
if rift or self._linux_folder_app(g):
|
||||
path = await C.pick_dir(self.page, dialog_title=title)
|
||||
if not path:
|
||||
return
|
||||
else:
|
||||
paths = await C.pick_files(self.page, dialog_title=title)
|
||||
if not paths:
|
||||
return
|
||||
path = paths[0]
|
||||
if not linux_analysis.looks_like_linux_app(Path(path)):
|
||||
self.toast(tr("{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)")
|
||||
.format(name=Path(path).name), error=True)
|
||||
return
|
||||
on_frame = self.frame_state == "connected" and \
|
||||
C.install_state(g, self.frame_info) in ("installed", "outdated")
|
||||
on_pc = rift and pkg in self.pc_installs()
|
||||
|
||||
def run(job: Job):
|
||||
job.reporter.stage(tr("Reading the new version"))
|
||||
res = pipeline.replace_source(pkg, Path(path), job.reporter)
|
||||
if res.get("different"):
|
||||
self.toast(tr("Heads-up: {name} looks like a different app than {title}.").format(
|
||||
name=res.get("name") or Path(path).name, title=self._title(pkg)))
|
||||
if rift and res["entry"].get("exe_confirmed") is False: # which program starts it? (asked after the job)
|
||||
self.exe_queue.append(pkg)
|
||||
self.open_game(pkg)
|
||||
if on_frame or on_pc:
|
||||
return tr("{title}: newer version added. Press Update to install it.").format(
|
||||
title=self._title(pkg))
|
||||
return tr("{title}: newer version added").format(title=self._title(pkg))
|
||||
self.submit(tr("Newer version for {title}").format(title=self._title(pkg)), run, pkg, "task")
|
||||
|
||||
@staticmethod
|
||||
def _linux_folder_app(g: dict) -> bool:
|
||||
"""A Linux app added from a folder (its newer version is a folder too), not from an AppImage/archive."""
|
||||
from ..analysis import linux as linux_analysis
|
||||
|
||||
extra = (g.get("analysis") or {}).get("extra") or {}
|
||||
source = Path(extra.get("source") or g.get("game_dir") or "")
|
||||
if source.exists():
|
||||
return source.is_dir()
|
||||
return not extra.get("files") and not source.name.lower().endswith(linux_analysis.ARCHIVES)
|
||||
|
||||
def updatable(self) -> list[tuple[str, str]]:
|
||||
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
|
||||
out = []
|
||||
|
||||
@@ -178,6 +178,8 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
# still installed (GitHub #90)
|
||||
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
|
||||
return "missing"
|
||||
if source_replaced(game, dep):
|
||||
return "outdated" # a newer version of the Linux app / PC VR game was added since this install (GitHub #161)
|
||||
b = game.get("build") or {}
|
||||
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
|
||||
from ..game_update import plan_update
|
||||
@@ -194,6 +196,16 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
|
||||
return "outdated" if settings_diff(game, frame_info) else "installed"
|
||||
|
||||
|
||||
def source_replaced(game: dict, dep: dict | None) -> bool:
|
||||
"""A Linux app or PC VR game got a newer version ("Update with a newer version…", GitHub #161) that this install
|
||||
(the Frame's or the PC's deployment record) doesn't have yet."""
|
||||
sid = game.get("source_id")
|
||||
if not sid or game.get("kind") not in ("linux", "rift"):
|
||||
return False
|
||||
rec = (dep or {}).get("recipe")
|
||||
return (rec.get("source_id") if isinstance(rec, dict) else None) != sid
|
||||
|
||||
|
||||
def recipe_changed(game: dict) -> bool:
|
||||
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
|
||||
|
||||
@@ -216,7 +228,7 @@ def pc_outdated(g: dict, dep: dict) -> bool:
|
||||
args = game_args(recipe_from_dict(g["recipe"]))
|
||||
except Exception: # noqa: BLE001
|
||||
args = dep.get("game_args") or []
|
||||
return installed != want or list(dep.get("game_args") or []) != args
|
||||
return installed != want or list(dep.get("game_args") or []) != args or source_replaced(g, dep)
|
||||
|
||||
|
||||
def one_choice():
|
||||
|
||||
@@ -35,6 +35,7 @@ class MenuState:
|
||||
media_button: bool = False # the game page shows "Add videos" already
|
||||
movable: bool = False # the Frame knows which drive it's on (agent v63+, microSD): Move to…
|
||||
android: bool = False # a Quest/Android game (not PC VR, not a Linux app): it can get a newer APK
|
||||
new_version: bool = False # a Linux app or PC VR game: it can get a newer version (file or folder)
|
||||
|
||||
|
||||
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
@@ -55,6 +56,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
|
||||
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
|
||||
if st.android and not job: # GitHub #161: the game keeps its name, art and recipe
|
||||
top.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
|
||||
if st.new_version and not job: # the same for Linux apps and PC VR games
|
||||
top.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
|
||||
if st.on_frame and not rift and not linux and not job:
|
||||
# the new build only; the Frame's game data stays (e.g. changed OBBs a normal update would replace)
|
||||
top.append((tr("Update app only…"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, act("update_app")))
|
||||
@@ -103,6 +106,8 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
|
||||
out += st.installs[:1]
|
||||
if st.android:
|
||||
out.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
|
||||
if st.new_version:
|
||||
out.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
|
||||
if st.settings:
|
||||
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
|
||||
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
|
||||
|
||||
+18
-2
@@ -31,9 +31,11 @@ def _states():
|
||||
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
|
||||
settings=True, selectable=True, android=True)),
|
||||
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
|
||||
installs=[_install("Reinstall on this PC")])),
|
||||
installs=[_install("Reinstall on this PC")], new_version=True)),
|
||||
("linux on Frame", LINUX, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
|
||||
selectable=True)),
|
||||
selectable=True, new_version=True)),
|
||||
("linux job running", LINUX, MenuState(connected=True, on_frame=True, job=True, selectable=True,
|
||||
new_version=True)),
|
||||
("job running", QUEST, MenuState(connected=True, on_frame=True, job=True, settings=True, selectable=True,
|
||||
android=True)),
|
||||
]
|
||||
@@ -172,3 +174,17 @@ def test_newer_apk_for_quest_games_in_both_menus():
|
||||
want = g is QUEST and not st.job
|
||||
assert ("Update with a newer APK…" in quick) == want, name
|
||||
assert ("Update with a newer APK…" in full) == want, name
|
||||
|
||||
|
||||
def test_newer_version_for_linux_apps_and_pc_vr_games():
|
||||
"""GitHub #161: Linux apps and PC VR games get "Update with a newer version…" (a file or folder), not the APK
|
||||
entry, in both menus; never while a job runs."""
|
||||
for name, g, st in _states():
|
||||
quick = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
st.quick = False
|
||||
full = [e[0] for e in _items(menu_sections(g, st, _key))]
|
||||
want = g is not QUEST and not st.job
|
||||
assert ("Update with a newer version…" in quick) == want, name
|
||||
assert ("Update with a newer version…" in full) == want, name
|
||||
if g is not QUEST:
|
||||
assert "Update with a newer APK…" not in quick + full, name
|
||||
@@ -0,0 +1,230 @@
|
||||
"""GitHub #161: a newer version of a Linux app or a PC VR game already in the library (game menu "Update with a newer
|
||||
version…", CLI `frameport new-version`)."""
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from test_install_drives import FakeFrame
|
||||
from test_linux_apps import elf
|
||||
from test_rift import unity_game
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.analysis import linux
|
||||
from frameport.core import library
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.install import installer
|
||||
from frameport.ui import components as C
|
||||
|
||||
|
||||
def _appimage(path, extra=b""):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(elf(linux.EM_AARCH64, appimage=True, extra=extra))
|
||||
return path
|
||||
|
||||
|
||||
def _frame(package, recipe=None):
|
||||
return {"installed": [{"package": package, "sha256": None, "recipe": recipe}]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ Linux apps
|
||||
def test_newer_appimage_keeps_name_art_and_switches(tmp_path):
|
||||
old = _appimage(tmp_path / "v1" / "Venera-Prime-2.4.2-aarch64.AppImage")
|
||||
pkg = pipeline.add_linux_app(old)["package"]
|
||||
library.upsert_game(pkg, title="My Venera", title_locked=True, tags=["mine"], desktop_entry=False,
|
||||
desktop_icon=False, vr_overlay=True)
|
||||
assert C.install_state(library.game(pkg), _frame(pkg)) == "installed" # never updated: no source id needed
|
||||
new = _appimage(tmp_path / "v2" / "Venera-Prime-2.5.0-aarch64.AppImage", extra=b"newer")
|
||||
res = pipeline.replace_linux_app(pkg, new)
|
||||
g = library.game(pkg)
|
||||
assert g["package"] == pkg and g["exe"] == new.name and g["game_dir"] == str(new.parent)
|
||||
assert g["analysis"]["extra"]["source"] == str(new) and g["analysis"]["extra"]["files"] == [new.name]
|
||||
assert g["data_bytes"] == new.stat().st_size
|
||||
assert (g["title"], g["title_locked"], g["tags"]) == ("My Venera", True, ["mine"])
|
||||
assert (g["desktop_entry"], g["desktop_icon"], g["vr_overlay"]) == (False, False, True)
|
||||
assert g["replaced_source"] == str(old) and g["source_id"] and res["different"] is False
|
||||
# the Frame's copy (installed before) is outdated until an install records this version
|
||||
assert C.install_state(g, _frame(pkg)) == "outdated"
|
||||
assert C.install_state(g, _frame(pkg, {"source_id": "older"})) == "outdated"
|
||||
assert C.install_state(g, _frame(pkg, {"source_id": g["source_id"]})) == "installed"
|
||||
|
||||
|
||||
def test_newer_linux_folder_keeps_the_chosen_program(tmp_path):
|
||||
def app(root, size):
|
||||
(root / "bin").mkdir(parents=True)
|
||||
(root / "Tool").write_bytes(elf(linux.EM_AARCH64, extra=b"\0" * size)) # bigger: FramePort's pick
|
||||
(root / "bin" / "tool-vr").write_bytes(elf(linux.EM_AARCH64))
|
||||
return root
|
||||
pkg = pipeline.add_linux_app(app(tmp_path / "v1" / "Tool", 4096), exe="bin/tool-vr")["package"]
|
||||
assert library.game(pkg)["exe"] == "bin/tool-vr"
|
||||
pipeline.replace_linux_app(pkg, app(tmp_path / "v2" / "Tool", 8192))
|
||||
g = library.game(pkg)
|
||||
assert g["exe"] == "bin/tool-vr" and g["game_dir"] == str(tmp_path / "v2" / "Tool")
|
||||
# the program isn't there any more: FramePort's pick
|
||||
(tmp_path / "v3" / "Tool").mkdir(parents=True)
|
||||
(tmp_path / "v3" / "Tool" / "Tool").write_bytes(elf(linux.EM_AARCH64))
|
||||
pipeline.replace_linux_app(pkg, tmp_path / "v3" / "Tool")
|
||||
assert library.game(pkg)["exe"] == "Tool"
|
||||
|
||||
|
||||
def test_another_linux_app_is_added_with_a_warning(tmp_path):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Venera-Prime-1.0-aarch64.AppImage"))["package"]
|
||||
res = pipeline.replace_linux_app(pkg, _appimage(tmp_path / "Blender-4.2-aarch64.AppImage"))
|
||||
assert res["different"] and res["name"] == "Blender" and library.game(pkg)["exe"].startswith("Blender")
|
||||
assert not library.game("linux.blender") # no second entry: the user's pick is the newer version
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_linux_app(pkg, tmp_path / "missing.AppImage")
|
||||
|
||||
|
||||
def test_only_the_right_kind(tmp_path):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "Tool-aarch64.AppImage"))["package"]
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_rift_game(pkg, tmp_path)
|
||||
library.upsert_game("com.x.quest", title="Quest", apk="/x.apk")
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_linux_app("com.x.quest", tmp_path / "Tool-aarch64.AppImage")
|
||||
|
||||
|
||||
def test_linux_update_sends_changed_files_of_the_same_size(tmp_path, monkeypatch):
|
||||
(tmp_path / "app").mkdir()
|
||||
for name in ("tool", "data.txt"):
|
||||
(tmp_path / "app" / name).write_bytes(b"x" * 10)
|
||||
old = time.time() - 3600
|
||||
os.utime(tmp_path / "app" / "data.txt", (old, old))
|
||||
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "apply_app_icon", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
|
||||
sent = []
|
||||
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
|
||||
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
|
||||
frame = FakeFrame({"prepare_linux": {"existing": {"app": {"tool": 10, "data.txt": 10}}, "free_bytes": 1 << 40,
|
||||
"incoming": "/x/incoming", "base": "/b"},
|
||||
"finalize_linux": {"base": "/b", "appid": 1}})
|
||||
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool")
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sent == [] and dict(frame.calls)["finalize_linux"]["recipe"] is None # same sizes: nothing sent
|
||||
# (st_ctime can't be set: the file's own ctime is now) a newer version: files changed since the last install
|
||||
plan = installer.LinuxPlan("linux.tool", "Tool", tmp_path / "app", "tool", source_id="v2",
|
||||
changed_since=time.time() + 60)
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sent == []
|
||||
plan.changed_since = time.time() - 60
|
||||
installer.install_linux(frame, plan, Reporter())
|
||||
assert sorted(sent) == ["app/data.txt", "app/tool"]
|
||||
assert frame.calls[-1][1]["recipe"] == {"source_id": "v2"}
|
||||
|
||||
|
||||
class _Null:
|
||||
def __enter__(self):
|
||||
return None
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def test_install_records_the_version(tmp_path, monkeypatch):
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-aarch64.AppImage"))["package"]
|
||||
got = {}
|
||||
|
||||
class T:
|
||||
label = "Frame"
|
||||
|
||||
def install_linux(self, *a, **k):
|
||||
got.clear()
|
||||
got.update(k)
|
||||
return {}
|
||||
monkeypatch.setattr(pipeline, "steam_title", lambda e: "Tool")
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert "source_id" not in got # never updated: installs as before
|
||||
pipeline.replace_linux_app(pkg, _appimage(tmp_path / "v2" / "Tool-aarch64.AppImage"))
|
||||
sid = library.game(pkg)["source_id"]
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert got["source_id"] == sid and 0 < got["changed_since"] <= time.time() # since the last install there
|
||||
assert library.game(pkg)["installs"]["Frame"]["source_id"] == sid
|
||||
pipeline.install_linux(pkg, T(), Reporter(), add_to_library=False)
|
||||
assert got["changed_since"] is None # this version is there already: resumes as usual
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ PC VR (Rift) games
|
||||
def _rift(tmp_path):
|
||||
first = unity_game(tmp_path / "v1")
|
||||
[g] = pipeline.add_path(tmp_path / "v1")
|
||||
pkg = g["package"]
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
r.source = "user"
|
||||
r.patches["pcvr.no_crash_reporter"] = {}
|
||||
pipeline.set_recipe(pkg, r)
|
||||
library.upsert_game(pkg, title="My Space", tags=["mine"], exe_confirmed=True,
|
||||
build={"ok": True, "sha256": "old", "kind": "rift"})
|
||||
return pkg, first
|
||||
|
||||
|
||||
def test_newer_rift_folder_keeps_name_recipe_and_program(tmp_path):
|
||||
pkg, first = _rift(tmp_path)
|
||||
new = unity_game(tmp_path / "v2")
|
||||
res = pipeline.replace_rift_game(pkg, new)
|
||||
g = library.game(pkg)
|
||||
assert g["game_dir"] == str(new) and g["exe"] == "Space Game.exe" and g["exe_confirmed"] is True
|
||||
assert (g["title"], g["tags"]) == ("My Space", ["mine"])
|
||||
assert "pcvr.no_crash_reporter" in g["recipe"]["patches"] and g["recipe"]["source"] == "user"
|
||||
assert g["build"] is None and g["replaced_dirs"] == [str(first)] and not res["different"]
|
||||
from frameport.patches.pcvr import game_args
|
||||
|
||||
dep = {"package": pkg, "sha256": "old", "recipe": {"patches": sorted(g["recipe"]["patches"])},
|
||||
"game_args": game_args(library.recipe_from_dict(g["recipe"]))}
|
||||
assert C.install_state(g, {"installed": [dep]}) == "outdated"
|
||||
assert C.pc_outdated(g, dep)
|
||||
dep["recipe"]["source_id"] = g["source_id"]
|
||||
assert C.install_state(g, {"installed": [dep]}) == "installed" and not C.pc_outdated(g, dep)
|
||||
# a rescan of the folders doesn't bring the old copy back
|
||||
assert pipeline.add_path(tmp_path / "v1") == [] and library.game(pkg)["game_dir"] == str(new)
|
||||
|
||||
|
||||
def test_newer_rift_folder_without_the_program_picks_again(tmp_path):
|
||||
pkg, _ = _rift(tmp_path)
|
||||
new = unity_game(tmp_path / "v2", name="Space Game VR")
|
||||
res = pipeline.replace_rift_game(pkg, new)
|
||||
assert res["exe_changed"] and library.game(pkg)["exe"] == "Space Game VR.exe"
|
||||
with pytest.raises(ValueError):
|
||||
pipeline.replace_rift_game(pkg, tmp_path / "nothing")
|
||||
other = unity_game(tmp_path / "other", name="Moon Game")
|
||||
pipeline.add_path(tmp_path / "other")
|
||||
with pytest.raises(ValueError, match="already in the library"):
|
||||
pipeline.replace_rift_game(pkg, other)
|
||||
|
||||
|
||||
def test_rift_update_resends_changed_game_files(tmp_path, monkeypatch):
|
||||
game = unity_game(tmp_path)
|
||||
monkeypatch.setattr(installer, "upload_steam_art", lambda *a, **k: None)
|
||||
monkeypatch.setattr(installer, "_tags", lambda pkg: [])
|
||||
monkeypatch.setattr(installer, "transfer_link", lambda *a: _Null())
|
||||
sent = []
|
||||
monkeypatch.setattr(installer, "upload_files", lambda xfer, files, *a: sent.extend(f[1] for f in files))
|
||||
monkeypatch.setattr("frameport.artwork.sources.has_art", lambda pkg: True)
|
||||
manifest = installer.local_data_manifest(game)
|
||||
frame = FakeFrame({"prepare_pcvr": {"proton": {"display_name": "Proton"}, "openxr": None,
|
||||
"existing": {"game": dict(manifest)}, "free_bytes": 1 << 40,
|
||||
"incoming": "/i", "base": "/b"},
|
||||
"finalize_pcvr": {"base": "/b", "proton": "p", "appid": 1}})
|
||||
from frameport.core.models import Recipe
|
||||
|
||||
plan = installer.PcvrPlan("rift.space_game", "Space", game, "Space Game.exe", Recipe(package="rift.space_game"),
|
||||
None, source_id="v2", changed_since=time.time() - 60)
|
||||
installer.install_pcvr(frame, plan, Reporter())
|
||||
assert sorted(sent) == sorted(f"game/{rel}" for rel in manifest)
|
||||
assert frame.calls[-1][1]["recipe"]["source_id"] == "v2"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ CLI
|
||||
def test_cli_new_version(tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
pkg = pipeline.add_linux_app(_appimage(tmp_path / "v1" / "Tool-1.0-aarch64.AppImage"))["package"]
|
||||
new = _appimage(tmp_path / "v2" / "Tool-1.1-aarch64.AppImage")
|
||||
out = CliRunner().invoke(cli.app, ["new-version", pkg, str(new)])
|
||||
assert out.exit_code == 0, out.output
|
||||
assert library.game(pkg)["exe"] == new.name and "program Tool-1.1-aarch64.AppImage" in out.output
|
||||
(tmp_path / "empty").mkdir()
|
||||
bad = CliRunner().invoke(cli.app, ["new-version", pkg, str(tmp_path / "empty")]) # no program in it
|
||||
assert bad.exit_code == 2
|
||||
@@ -1,223 +0,0 @@
|
||||
"""What FrameBridge's xrGetCurrentInteractionProfile reports (native/adapter/profile_report.c): SteamVR's profiles as
|
||||
Touch (controller_fix) and, after profile_remap moved a game's Touch Plus/Pro bindings to Touch, the game's own profile
|
||||
(GitHub #327: Unity's OpenXR input found no device for oculus/touch_controller, the hands lay on the floor). Compiled
|
||||
for this host with a small harness standing in for the adapter and the runtime.
|
||||
Opt-in (FRAMEPORT_NATIVE_TESTS=1, -m native): needs the OpenXR headers in native/.cache (from native/build.py)."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INC = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
|
||||
CC = shutil.which("cc") or shutil.which("clang")
|
||||
pytestmark = [pytest.mark.native, pytest.mark.skipif(
|
||||
not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not INC or not CC,
|
||||
reason="set FRAMEPORT_NATIVE_TESTS=1 (needs the OpenXR headers from native/build.py and a host C compiler)")]
|
||||
|
||||
HARNESS = r"""
|
||||
#include <openxr/openxr.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
|
||||
static XrInstance active_instance = (XrInstance)(uintptr_t)1;
|
||||
static int controller_fix = 1, profile_remap = 1, app_suggested_touch;
|
||||
static const char *const TOUCH_PROFILE = "/interaction_profiles/oculus/touch_controller";
|
||||
static char paths[16][XR_MAX_PATH_LENGTH];
|
||||
static int path_count;
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_string_to_path(XrInstance i, const char *s, XrPath *out) {
|
||||
(void)i;
|
||||
for (int k = 0; k < path_count; ++k) if (!strcmp(paths[k], s)) { *out = (XrPath)(k + 1); return XR_SUCCESS; }
|
||||
snprintf(paths[path_count], XR_MAX_PATH_LENGTH, "%s", s);
|
||||
*out = (XrPath)++path_count;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_path_to_string(XrInstance i, XrPath p, uint32_t cap, uint32_t *n,
|
||||
char *out) {
|
||||
(void)i;
|
||||
if (!p || p > (XrPath)path_count) return XR_ERROR_PATH_INVALID;
|
||||
*n = (uint32_t)snprintf(out, cap, "%s", paths[p - 1]) + 1;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance i, const char *name) {
|
||||
(void)i;
|
||||
if (!strcmp(name, "xrPathToString")) return (PFN_xrVoidFunction)fake_path_to_string;
|
||||
if (!strcmp(name, "xrStringToPath")) return (PFN_xrVoidFunction)fake_string_to_path;
|
||||
return NULL;
|
||||
}
|
||||
#include "profile_report.c"
|
||||
static XrPath P(const char *s) { XrPath p; fake_string_to_path(0, s, &p); return p; }
|
||||
static const char *answer(const char *hand, const char *runtime) {
|
||||
XrInteractionProfileState state = {XR_TYPE_INTERACTION_PROFILE_STATE, NULL, runtime ? P(runtime) : XR_NULL_PATH};
|
||||
current_profile_fix(P(hand), &state);
|
||||
return state.interactionProfile ? paths[state.interactionProfile - 1] : "(none)";
|
||||
}
|
||||
int main(void) {
|
||||
const char *touch = "/interaction_profiles/oculus/touch_controller";
|
||||
const char *frame = "/interaction_profiles/valve/frame_controller";
|
||||
const char *plus = "/interaction_profiles/meta/touch_controller_plus";
|
||||
// no remap: SteamVR's profile becomes Touch, Touch stays Touch, none stays none
|
||||
printf("%s\n", answer("/user/hand/left", frame));
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
printf("%s\n", answer("/user/hand/left", NULL));
|
||||
// the game's Touch Plus bindings were moved to Touch: Touch (and SteamVR's profile) are reported as Touch Plus
|
||||
snprintf(remapped_profile, sizeof(remapped_profile), "%s", plus);
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
printf("%s\n", answer("/user/hand/right", frame));
|
||||
printf("%s\n", answer("/user/hand/right", NULL));
|
||||
printf("%s\n", answer("/user/hand/right", "/interaction_profiles/htc/vive_controller"));
|
||||
// controller_fix off: SteamVR's own profile is passed on unchanged, Touch is still reported as the game's
|
||||
controller_fix = 0;
|
||||
printf("%s\n", answer("/user/hand/left", frame));
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
// the game suggested Touch bindings itself: Touch is reported as Touch
|
||||
controller_fix = 1;
|
||||
app_suggested_touch = 1;
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
// profile_remap off: Touch as Touch
|
||||
app_suggested_touch = 0;
|
||||
profile_remap = 0;
|
||||
printf("%s\n", answer("/user/hand/left", touch));
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
STDOUT = [
|
||||
"/interaction_profiles/oculus/touch_controller",
|
||||
"/interaction_profiles/oculus/touch_controller",
|
||||
"(none)",
|
||||
"/interaction_profiles/meta/touch_controller_plus",
|
||||
"/interaction_profiles/meta/touch_controller_plus",
|
||||
"(none)",
|
||||
"/interaction_profiles/htc/vive_controller",
|
||||
"/interaction_profiles/valve/frame_controller",
|
||||
"/interaction_profiles/meta/touch_controller_plus",
|
||||
"/interaction_profiles/oculus/touch_controller",
|
||||
"/interaction_profiles/oculus/touch_controller",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def harness(tmp_path_factory):
|
||||
d = tmp_path_factory.mktemp("profile_report")
|
||||
(d / "harness.c").write_text(HARNESS)
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
|
||||
"-Wno-missing-field-initializers", "-I", str(INC), "-I", str(ROOT / "native/adapter"),
|
||||
str(d / "harness.c"), "-o", str(d / "harness")], check=True)
|
||||
return d / "harness"
|
||||
|
||||
|
||||
def test_reports_the_games_profile_after_a_remap(harness):
|
||||
p = subprocess.run([str(harness)], capture_output=True, text=True)
|
||||
assert p.returncode == 0, p.stderr
|
||||
assert p.stdout.splitlines() == STDOUT
|
||||
logged = [line for line in p.stderr.splitlines() if line.startswith("profile_remap:")]
|
||||
assert logged[:2] == [
|
||||
"profile_remap: /user/hand/left reported as meta/touch_controller_plus (the app's profile) instead of "
|
||||
"oculus/touch_controller",
|
||||
"profile_remap: /user/hand/right reported as meta/touch_controller_plus (the app's profile) instead of "
|
||||
"oculus/touch_controller",
|
||||
]
|
||||
assert len(logged) == 3 # once per report (at most 4)
|
||||
|
||||
|
||||
DETACHED = r"""
|
||||
#include <openxr/openxr.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
|
||||
static XrInstance active_instance = (XrInstance)(uintptr_t)1;
|
||||
static int profile_remap = 1, runtime_has_detached_controllers;
|
||||
static char paths[32][XR_MAX_PATH_LENGTH];
|
||||
static int path_count;
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_string_to_path(XrInstance i, const char *s, XrPath *out) {
|
||||
(void)i;
|
||||
for (int k = 0; k < path_count; ++k) if (!strcmp(paths[k], s)) { *out = (XrPath)(k + 1); return XR_SUCCESS; }
|
||||
snprintf(paths[path_count], XR_MAX_PATH_LENGTH, "%s", s);
|
||||
*out = (XrPath)++path_count;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_path_to_string(XrInstance i, XrPath p, uint32_t cap, uint32_t *n,
|
||||
char *out) {
|
||||
(void)i;
|
||||
if (!p || p > (XrPath)path_count) return XR_ERROR_PATH_INVALID;
|
||||
*n = (uint32_t)snprintf(out, cap, "%s", paths[p - 1]) + 1;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance i, const char *name) {
|
||||
(void)i;
|
||||
if (!strcmp(name, "xrPathToString")) return (PFN_xrVoidFunction)fake_path_to_string;
|
||||
return NULL;
|
||||
}
|
||||
#include "detached_controllers.c"
|
||||
static XrPath P(const char *s) { XrPath p; fake_string_to_path(0, s, &p); return p; }
|
||||
static void show_action(const XrActionCreateInfo *info) {
|
||||
printf("%s:", info->actionName);
|
||||
for (uint32_t i = 0; i < info->countSubactionPaths; ++i) printf(" %s", paths[info->subactionPaths[i] - 1]);
|
||||
printf("\n");
|
||||
}
|
||||
int main(void) {
|
||||
XrPath sub[4] = {P("/user/hand/left"), P("/user/hand/right"), P("/user/detached_controller_meta/left"),
|
||||
P("/user/detached_controller_meta/right")}, kept[4];
|
||||
XrActionCreateInfo info = {XR_TYPE_ACTION_CREATE_INFO, NULL, "thumbstick", XR_ACTION_TYPE_VECTOR2F_INPUT, 4, sub,
|
||||
"Thumbstick"}, copy;
|
||||
show_action(detached_strip_action(&info, ©, kept));
|
||||
info.countSubactionPaths = 2; // hands only: passed on as it is
|
||||
printf("%d\n", detached_strip_action(&info, ©, kept) == &info);
|
||||
|
||||
XrActionSuggestedBinding b[3] = {{(XrAction)(uintptr_t)7, P("/user/hand/left/input/thumbstick")},
|
||||
{(XrAction)(uintptr_t)7, P("/user/detached_controller_meta/left/input/thumbstick")},
|
||||
{(XrAction)(uintptr_t)8, P("/user/hand/right/input/a/click")}}, kb[3];
|
||||
XrInteractionProfileSuggestedBinding s = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, NULL,
|
||||
P("/interaction_profiles/oculus/touch_controller"), 3, b}, scopy;
|
||||
const XrInteractionProfileSuggestedBinding *u = detached_strip_bindings(&s, &scopy, kb);
|
||||
for (uint32_t i = 0; i < u->countSuggestedBindings; ++i) printf("%s\n", paths[u->suggestedBindings[i].binding - 1]);
|
||||
|
||||
XrInteractionProfileState st = {XR_TYPE_INTERACTION_PROFILE_STATE, NULL, 5};
|
||||
XrResult r = detached_current_profile(sub[2], &st, XR_ERROR_PATH_UNSUPPORTED);
|
||||
printf("%d %llu\n", r, (unsigned long long)st.interactionProfile);
|
||||
printf("%d\n", detached_current_profile(sub[0], &st, XR_ERROR_PATH_UNSUPPORTED)); // hands: unchanged
|
||||
printf("%d\n", detached_current_profile(sub[2], &st, XR_ERROR_ACTIONSET_NOT_ATTACHED)); // other errors stay
|
||||
|
||||
runtime_has_detached_controllers = 1; // the runtime knows them: nothing changes
|
||||
info.countSubactionPaths = 4;
|
||||
printf("%d %d %d\n", detached_strip_action(&info, ©, kept) == &info,
|
||||
detached_strip_bindings(&s, &scopy, kb) == &s,
|
||||
detached_current_profile(sub[2], &st, XR_ERROR_PATH_UNSUPPORTED));
|
||||
runtime_has_detached_controllers = 0;
|
||||
profile_remap = 0; // off: nothing changes
|
||||
printf("%d %d\n", detached_strip_action(&info, ©, kept) == &info, detached_strip_bindings(&s, &scopy, kb) == &s);
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_detached_controller_paths_left_out(tmp_path):
|
||||
(tmp_path / "detached.c").write_text(DETACHED)
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
|
||||
"-Wno-missing-field-initializers", "-I", str(INC), "-I", str(ROOT / "native/adapter"),
|
||||
str(tmp_path / "detached.c"), "-o", str(tmp_path / "detached")], check=True)
|
||||
p = subprocess.run([str(tmp_path / "detached")], capture_output=True, text=True)
|
||||
assert p.returncode == 0, p.stderr
|
||||
assert p.stdout.splitlines() == [
|
||||
"thumbstick: /user/hand/left /user/hand/right",
|
||||
"1",
|
||||
"/user/hand/left/input/thumbstick",
|
||||
"/user/hand/right/input/a/click",
|
||||
"0 0",
|
||||
str(-22), # XR_ERROR_PATH_UNSUPPORTED
|
||||
str(-46), # XR_ERROR_ACTIONSET_NOT_ATTACHED
|
||||
"1 1 -22",
|
||||
"1 1",
|
||||
]
|
||||
assert p.stderr.splitlines() == [
|
||||
"profile_remap: action thumbstick without 2 detached-controller path(s) (the runtime has no "
|
||||
"XR_META_detached_controllers)",
|
||||
"profile_remap: /interaction_profiles/oculus/touch_controller bindings without 1 detached-controller path(s)",
|
||||
]
|
||||
Reference in new issue
Block a user