mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-18 01:02:15 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ea677b40e | ||
|
|
6373ae8870 | ||
|
|
faa388600b | ||
|
|
0b9d8c6dde | ||
|
|
073adcc75b | ||
|
|
6c63105b13 | ||
|
|
a121f72f23 | ||
|
|
ba010139b6 | ||
|
|
0f418e5539 | ||
|
|
360658e9b5 | ||
|
|
48ab03a637 | ||
|
|
39443ef2ae | ||
|
|
b0f2a86a01 | ||
|
|
3f22994633 | ||
|
|
25e282d620 |
No files matched your search
@@ -553,6 +553,7 @@ Output is deterministic (same input + runtime → same bytes), which is what mak
|
||||
- VrApi-direct engines (CryEngine Climb 2, POTW) need the VrApi bridge; the bridge drops whole frames on unknown layer
|
||||
types (→ black screen with audio).
|
||||
|
||||
**Quest controller mode:** adapter `quest_buttons` (0/1/2, per game + library `input.quest_buttons`) suggests Frame Controller bindings translated from the game's Touch set (`suggest_frame`, mapping docs/input/quest_buttons.json = single source for C table, tests, diagram `scripts/controller_diagram.py`); runtime accepts them headless. → docs/DEVICE_NOTES.md#quest-controller-mode-quest_buttons-2026-10-17
|
||||
**Steam Frame controller models:** adapter setting `controller_models` (`native/adapter/render_model.c`, emulates XR_FB_render_model) + agent `install_controller_models` (Frame's SteamVR models → glb at finalize); no catalog game uses runtime models; Valve's models are never committed or copied off the Frame. → docs/DEVICE_NOTES.md#steam-frame-controller-models
|
||||
**overport's dispatcher (`libopenxr_loader.so`) only forwards functions in its own table** (`overportOXR: Unknown
|
||||
proc addr: …`), so adapter emulations of functions it doesn't know are unreachable. For XR_FB_render_model,
|
||||
|
||||
@@ -124,6 +124,7 @@ What FramePort adds: [Architecture](docs/ARCHITECTURE.md).
|
||||
| [Compatibility](docs/COMPATIBILITY.md) | What runs and how well |
|
||||
| [Tested games](docs/GAMES.md) | Tested games and how well they run |
|
||||
| [FAQ](docs/FAQ.md) | Game folders and common questions |
|
||||
| [Controller buttons](docs/CONTROLLERS.md) | Quest Touch buttons on the Frame: SteamVR default, Mirror and Quest pair |
|
||||
| [Porting playbook](docs/PLAYBOOK.md) | Symptoms and fixes per game |
|
||||
| [Steam Frame runtime reference](docs/FRAME_RUNTIME.md) | Facts about the Frame's runtime |
|
||||
| [Architecture](docs/ARCHITECTURE.md) | How the code is organized |
|
||||
|
||||
@@ -3597,7 +3597,7 @@ def set_gamepad_line(text, want):
|
||||
# BACK, which would close many apps): Steam's virtual pad reports Valve's ids, so Android would fall back to Generic.kl
|
||||
# (triggers on Z/RZ, right stick on RX/RY: games then mix up the right stick and the triggers). The D-pad keys
|
||||
# (BTN_DPAD_*, as AOSP's Generic.kl) cover a pad that sends its D-pad as buttons rather than the hat axes: the Frame's
|
||||
# controllers have no D-pad, so it only comes from a Steam Input layout (e.g. a stick set to D-pad, GitHub #162).
|
||||
# left controller has a D-pad; which inputs reach a 2D app still depends on its Steam Input layout (GitHub #162).
|
||||
GAMEPAD_KL = """# Steam Input virtual gamepad (Xbox 360 layout). Written by FramePort (device.steam_gamepad).
|
||||
key 304 BUTTON_A
|
||||
key 305 BUTTON_B
|
||||
|
||||
@@ -13,11 +13,11 @@ d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/li
|
||||
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
|
||||
36400eae4ba3807bcfcd54463daa217347032b07218792faa448d8cbbd086a0e ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
2d1726aaa8ee0ce788df1b8727bd55ab72efae749e7652f38f67e5e317b74b2f ./arm64-v8a/libopenxr_loader_generic.so
|
||||
7b63b609c773b5d14b14a23e35e14e0b61a1b8f410ba74ffc9125b67c0c89373 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
eba4e9ed8563fa0de00d88bb85f960e79f13a6f60eddd437b203c8d3b7d308af ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
cf09f4c503d8e42392637780ca9d7142e1f97ea41a705871c8b55e3e5a65e9d0 ./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.
@@ -0,0 +1,87 @@
|
||||
# Controller buttons
|
||||
|
||||
Quest games usually bind Quest Touch controllers. Steam Frame has a d-pad and View button on the left controller,
|
||||
and an A/B/X/Y diamond and Menu button on the right. Without a game's own Frame bindings, SteamVR maps Touch X/A
|
||||
to the bottom button and Touch Y/B to all three top buttons. View sends Touch Menu. The right X and Y therefore
|
||||
send Touch B, rather than the left-hand X and Y of a Quest controller.
|
||||
|
||||
**Controller buttons** chooses an alternative for Quest games running through FrameBridge:
|
||||
|
||||
- **SteamVR default (0)** leaves SteamVR's Touch remapping in place. This is the initial default.
|
||||
- **Mirror (1)** uses every face button. On each controller, bottom and inner send X/A; top and outer send Y/B.
|
||||
- **Quest pair (2)** uses two face buttons per controller, diagonally placed like Touch. The other face buttons
|
||||
send no Touch input.
|
||||
|
||||

|
||||
|
||||
The small label identifies the physical button; the larger label is the Touch input the game receives. Blue is
|
||||
X/A, orange is Y/B or Menu, and a dash means no Touch input. Left and right refer to the hand holding the controller.
|
||||
|
||||
<!-- quest-buttons-table:start -->
|
||||
| Frame input | SteamVR default (0) | Mirror (1) | Quest pair (2) |
|
||||
|---|---|---|---|
|
||||
| Left d-pad ↑ | Y | Y | — |
|
||||
| Left d-pad ↓ | X | X | X |
|
||||
| Left d-pad ← | Y | Y | Y |
|
||||
| Left d-pad → | Y | X | — |
|
||||
| Left View | Menu | Menu | Menu |
|
||||
| Right A (bottom) | A | A | A |
|
||||
| Right B (right) | B | B | B |
|
||||
| Right X (left) | B | A | — |
|
||||
| Right Y (top) | B | B | — |
|
||||
| Right Menu | — | — | — |
|
||||
| Left bumper | — | — | — |
|
||||
| Right bumper | — | — | — |
|
||||
<!-- quest-buttons-table:end -->
|
||||
|
||||
The table describes button clicks and face-button touches. View sends Menu click only. Triggers, grip pressure,
|
||||
thumbsticks, tracked poses and vibration keep their corresponding Touch bindings.
|
||||
|
||||
## Choose a mode
|
||||
|
||||
For one game, open its **Game settings → Controllers → Controller buttons**. Choose **Use global default** to
|
||||
follow Settings, or select a specific mode to keep an override for that game. An explicit **SteamVR default**
|
||||
keeps mode 0 even when the global choice is Mirror or Quest pair.
|
||||
|
||||
For the library default, open **Settings → Controllers**. The diagram follows the selected mode. New installs
|
||||
and configuration updates use this default for games without an override. With a Frame connected, choose
|
||||
**Apply to installed games now** to send it to installed Quest games that follow the global default. Overrides
|
||||
are kept. Changing the default does not mark every game as needing an update.
|
||||
|
||||
Changes take effect at the **next launch**. Close and relaunch a running game.
|
||||
Games installed before this feature need **Update on Frame** once for the mode to work.
|
||||
|
||||
The CLI can change an installed game's runtime setting:
|
||||
|
||||
```sh
|
||||
frameport settings <pkg> quest_buttons=2
|
||||
```
|
||||
|
||||
Use `0` for SteamVR default or `1` for Mirror. This command writes the installed settings directly; it does not
|
||||
save a per-game recipe override. To keep the value through later installs and global applies, also save it in
|
||||
the recipe, or use Game settings:
|
||||
|
||||
```sh
|
||||
frameport recipe <pkg> --set quest_buttons=2
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
- SteamVR's binding UI can still override these suggested bindings. Check custom bindings if the result differs
|
||||
from the diagram.
|
||||
- Bumpers have no Touch counterpart and are unbound. Thumbrest touch is dropped; the Frame profile has no
|
||||
corresponding input. System buttons keep their runtime role.
|
||||
- Mirror and Quest pair require the Frame runtime's `XR_VALVE_frame_controller_interaction` extension. If it is
|
||||
absent, FrameBridge logs that fact and leaves SteamVR's remap in place.
|
||||
- Games suggesting their own Frame Controller bindings keep those bindings and continue seeing the Frame
|
||||
Controller profile, even if they also suggest Touch bindings. These modes do not replace the game's own mapping.
|
||||
- Touch suggestions using bare pose, trigger, squeeze or button paths are translated too; explicit components
|
||||
such as `/pose`, `/value`, `/click` and `/touch` are also supported where the mapping provides them.
|
||||
- Finger proximity emulation (`proximity_emul`) continues to work with Mirror and Quest pair. Dropping the
|
||||
unsupported thumbrest path does not disable proximity emulation from the other touch inputs.
|
||||
- The setting applies to Quest VR games, including 32-bit games through the bridge. It does not change PC VR or
|
||||
flat Android Steam Input layouts.
|
||||
|
||||
The mapping follows [Valve's Frame Controller input reference](https://partner.steamgames.com/doc/steamhardware/steamframe/input)
|
||||
and the [shared mapping data](input/quest_buttons.json). To regenerate the diagram, app illustrations and table,
|
||||
run `uv run python scripts/controller_diagram.py`; add `--check` to verify them without writing files.
|
||||
@@ -718,3 +718,21 @@ can be enabled and persist after restart. Check actual runtime refresh/pacing se
|
||||
identity alone does not implement refresh-rate support. Rebuild with Quest 2 for the control: original `Oculus Quest2`,
|
||||
original Quest 2 UUID, type 9 if logged, and previous option visibility. Also smoke-test a Unity OVRPlugin game
|
||||
with the default Quest 3 choice for regressions. No device access was used during implementation.
|
||||
|
||||
## Quest controller mode (`quest_buttons`, 2026-10-17)
|
||||
|
||||
Adapter setting `quest_buttons` (0 SteamVR default, 1 Mirror, 2 Quest pair; contract docs/input/QUEST_BUTTONS.md,
|
||||
user page docs/CONTROLLERS.md). FrameBridge enables `XR_VALVE_frame_controller_interaction` and, after a game's Touch
|
||||
suggestion succeeds, suggests `/interaction_profiles/valve/frame_controller_valve` bindings translated from the
|
||||
accepted Touch set (SteamVR uses a Frame binding before its own Touch remap: Touch B/Y on all three top buttons).
|
||||
|
||||
Headless check on the device (I Am Cat, Unity + OVRPlugin, mode 2, `input_diag=1`): the runtime offers and enables the
|
||||
extension in a Lepton app, and accepts the generated Frame suggestion (`quest_buttons: frame_controller bindings
|
||||
(mode 2, 27 bindings, 7 dropped): 0`); the game's Touch (34 paths) and hand-interaction suggestions were accepted as
|
||||
before; 72 fps, no new errors. Translations as in the JSON: left `x/*` -> `dpad_down/*`, `y/*` -> `dpad_left/*`,
|
||||
`menu/click` -> `view/click`. Not provable headless (tracking needs the headset worn): that SteamVR then reads the
|
||||
buttons from the Frame binding; one launch in the headset settles it.
|
||||
|
||||
Pitfall seen during the test: OVRPort runs from two sessions on the same data folder share
|
||||
`overport-workspace/dec/working.apk` and fail at once (`OVRPort failed (exit 1)`, `FileAlreadyExistsException` in
|
||||
OVRPort's own output, which the CLI doesn't print without a verbose reporter). Build one game at a time per data folder.
|
||||
@@ -118,6 +118,26 @@
|
||||
- Display 72 Hz by default in tests. Head pose is only tracked while the headset is worn; otherwise flags 0x3 and
|
||||
the session stays below FOCUSED.
|
||||
|
||||
### Frame Controller input
|
||||
|
||||
The runtime offers `XR_VALVE_frame_controller_interaction`, with interaction profile
|
||||
`/interaction_profiles/valve/frame_controller_valve`. [Valve's input reference](https://partner.steamgames.com/doc/steamhardware/steamframe/input)
|
||||
describes two gamepad halves. Under `/user/hand/<hand>/input/`:
|
||||
|
||||
- Left: `dpad_up`, `dpad_down`, `dpad_left`, `dpad_right`, `view` (click and touch).
|
||||
- Right: `a`, `b`, `x`, `y`, `menu` (click and touch). The face diamond is A bottom, B right, X left, Y top.
|
||||
- Both: `system`, `bumper`; `squeeze` and `trigger` (click, touch, value); `thumbstick` (vector, x, y, click,
|
||||
touch); `grip/pose`, `aim/pose`. Haptics use `/user/hand/<hand>/output/haptic`.
|
||||
|
||||
SteamVR prefers a game's Frame Controller binding. Without one, its Touch remap sends left d-pad down as Touch X
|
||||
and left/up/right as Touch Y; right A as Touch A and X/Y/B as Touch B. Left View sends Touch Menu.
|
||||
FrameBridge's `quest_buttons` preference (`0` default, `1` Mirror, `2` Quest pair) enables the extension when offered
|
||||
and suggests a Frame binding translated from the game's Touch suggestions. Common pressure/trigger/thumbstick,
|
||||
pose and haptic paths keep their Touch counterparts; thumbrest touch and system click are dropped, bumpers stay
|
||||
unbound. The player's SteamVR custom bindings can override suggestions. This works on both bridge ABIs, without
|
||||
the arm64-only extension shim. See [Controller buttons](CONTROLLERS.md) and the [mapping contract](input/QUEST_BUTTONS.md).
|
||||
Device verification of the new modes is pending integration with the native stream.
|
||||
|
||||
## Lua native pointer compatibility
|
||||
|
||||
Frame's Android processes can map libraries above the lowest 2^47 bytes of address space. The verified xLua API 105
|
||||
|
||||
@@ -133,6 +133,14 @@ installed game keeps its Steam entry, artwork and saves (Steam restarts once).
|
||||
- A game on a card only starts while the card is inserted.
|
||||
- Cards formatted as FAT, exFAT or NTFS can't hold games: format the card in SteamOS first.
|
||||
|
||||
### Why do the controller buttons repeat or do the wrong thing?
|
||||
|
||||
SteamVR maps Quest Touch onto the Frame's controller buttons by default: three top buttons repeat Touch Y/B.
|
||||
Choose **Game settings → Controllers → Controller buttons** to use Mirror or Quest pair for one game, or
|
||||
**Settings → Controllers** for the library default. **Apply to installed games now** sends the default to Quest
|
||||
games without an override. Changes take effect at the next launch.
|
||||
See [Controller buttons](CONTROLLERS.md) for the diagram, mappings and limits.
|
||||
|
||||
### Android apps and Windows programs
|
||||
|
||||
- Android apps without VR are installed unchanged and shown as a window in the headset. If one shows nothing (or a
|
||||
|
||||
+17
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 182 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 183 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -12,7 +12,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (61)
|
||||
- [Game settings](#group-adapter) (73)
|
||||
- [Game settings](#group-adapter) (74)
|
||||
- [Files and settings on the Frame](#group-device) (10)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
@@ -152,6 +152,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 15 |
|
||||
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
|
||||
| [Animate fingers from touch](#adapter-proximity_emul) | `adapter.proximity_emul` | Game settings | |
|
||||
| [Controller buttons](#adapter-quest_buttons) | `adapter.quest_buttons` | Game settings | |
|
||||
| [Keep pictures in bounds](#adapter-rect_clamp) | `adapter.rect_clamp` | Game settings | |
|
||||
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 3 |
|
||||
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
|
||||
@@ -1999,6 +2000,20 @@ For games whose hands' thumbs never move when you touch the buttons or thumbstic
|
||||
|
||||
> Meta's OVRPlugin reports a thumb (or index finger) resting near the controller through XR_FB_touch_controller_proximity, which the Frame's runtime lacks: "near" stays false and games that animate the hands from it show a thumb that never moves (for example Vader Immortal; found by Klownicle, GitHub #49). Binds the game's proximity actions to the capacitive touch inputs instead: 1 = thumb (thumbstick, face buttons or thumb rest touched), 2 = also the index finger (trigger touched). Only when the runtime lacks the extension.
|
||||
|
||||
### <a id="adapter-quest_buttons"></a>Controller buttons
|
||||
|
||||
Choose which Quest Touch buttons the Frame's face buttons send. Takes effect at the next launch.
|
||||
|
||||
- `adapter.quest_buttons`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Controller buttons": SteamVR default (`0`), Mirror (`1`), Quest pair (`2`).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Choose how the Frame's face buttons send Quest Touch inputs: 0 = SteamVR default, 1 = Mirror, 2 = Quest pair. Used at the next launch; SteamVR custom bindings can override it. Requires the runtime's XR_VALVE_frame_controller_interaction extension. Mapping: https://github.com/spoopyghosty0/frameport/blob/main/docs/CONTROLLERS.md
|
||||
|
||||
### <a id="adapter-rect_clamp"></a>Keep pictures in bounds
|
||||
|
||||
Stops games from hanging when their eye picture is a little too big or empty (for example PowerWash Simulator). Leave it on.
|
||||
|
||||
+1
-1
@@ -157,7 +157,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| 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>)` |
|
||||
| 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 | choose **Game settings → Controllers → Controller buttons**: Mirror (`quest_buttons=1`) or Quest pair (`2`); `0` restores SteamVR default. **Settings → Controllers** sets the global default and can apply it to installed games without overrides. Used from the next launch; [mapping and limits](CONTROLLERS.md). For rejected bindings, turn on adapter `input_diag` (`frameport settings <pkg> input_diag=1`) and inspect `input_diag:` lines: accepted profiles, rejected paths and input calls. With a mode enabled, expect `quest_buttons: frame_controller bindings (mode N, K bindings, D dropped): <XrResult>` |
|
||||
| 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 |
|
||||
| Setup/intro loops every launch (Espire 2) | save folders created without write permission | launcher repairs permissions every 2 s (built in) |
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="750" viewBox="0 0 1080 750" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Quest Touch buttons on Steam Frame controllers</title>
|
||||
<desc id="desc">Quest Touch reference and three Frame mappings: SteamVR default, Mirror, and Quest pair. Each button shows its physical input above and the Touch input it sends below. A dash means unbound. Left Touch buttons are X and Y; right are A and B.</desc>
|
||||
<rect width="1080" height="750" rx="16" fill="#0D0E12"/>
|
||||
<g font-family="DejaVu Sans,Arial,sans-serif">
|
||||
<text x="20" y="32" fill="#EDEEF2" font-size="24" font-weight="600" text-anchor="start">Controller buttons</text>
|
||||
<text x="20" y="57" fill="#A7ABB7" font-size="14" font-weight="400" text-anchor="start">Blue: X / A · Orange: Y / B or Menu · —: no Touch input</text>
|
||||
<g transform="translate(16 72)">
|
||||
<rect x="0" y="0" width="520" height="334" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Quest Touch reference</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Physical button above · Touch input below</text>
|
||||
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<circle cx="163" cy="196" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="163" y="190" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">X</text>
|
||||
<text x="163" y="211" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<circle cx="117" cy="141" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="117" y="135" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">Y</text>
|
||||
<text x="117" y="156" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<rect x="83" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu: Menu</text>
|
||||
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<circle cx="357" cy="196" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="357" y="190" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="357" y="211" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<circle cx="403" cy="141" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="403" y="135" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">B</text>
|
||||
<text x="403" y="156" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<text x="260" y="306" fill="#A7ABB7" font-size="14" font-weight="400" text-anchor="middle">Lower: X / A · Upper: Y / B</text>
|
||||
</g>
|
||||
<g transform="translate(544 72)">
|
||||
<rect x="0" y="0" width="520" height="334" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">SteamVR default</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Physical button above · Touch input below</text>
|
||||
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<circle cx="140" cy="111" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="140,97 135,104 145,104" fill="#A7ABB7"/>
|
||||
<text x="140" y="126" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="90" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="86,151 93,146 93,156" fill="#A7ABB7"/>
|
||||
<text x="90" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="190" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="194,151 187,146 187,156" fill="#A7ABB7"/>
|
||||
<text x="190" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="140" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<polygon points="140,205 135,198 145,198" fill="#A7ABB7"/>
|
||||
<text x="140" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<rect x="83" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">View: Menu</text>
|
||||
<rect x="83" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<circle cx="380" cy="111" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="380" y="105" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">Y</text>
|
||||
<text x="380" y="126" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="330" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="330" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">X</text>
|
||||
<text x="330" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="430" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="430" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">B</text>
|
||||
<text x="430" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="380" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="380" y="205" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="380" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<rect x="323" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu: —</text>
|
||||
<rect x="323" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
</g>
|
||||
<g transform="translate(16 414)">
|
||||
<rect x="0" y="0" width="520" height="334" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Mirror</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Physical button above · Touch input below</text>
|
||||
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<circle cx="140" cy="111" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="140,97 135,104 145,104" fill="#A7ABB7"/>
|
||||
<text x="140" y="126" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="90" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="86,151 93,146 93,156" fill="#A7ABB7"/>
|
||||
<text x="90" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="190" cy="161" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<polygon points="194,151 187,146 187,156" fill="#A7ABB7"/>
|
||||
<text x="190" y="176" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<circle cx="140" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<polygon points="140,205 135,198 145,198" fill="#A7ABB7"/>
|
||||
<text x="140" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<rect x="83" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">View: Menu</text>
|
||||
<rect x="83" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<circle cx="380" cy="111" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="380" y="105" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">Y</text>
|
||||
<text x="380" y="126" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="330" cy="161" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="330" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">X</text>
|
||||
<text x="330" y="176" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<circle cx="430" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="430" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">B</text>
|
||||
<text x="430" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="380" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="380" y="205" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="380" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<rect x="323" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu: —</text>
|
||||
<rect x="323" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
</g>
|
||||
<g transform="translate(544 414)">
|
||||
<rect x="0" y="0" width="520" height="334" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Quest pair</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Physical button above · Touch input below</text>
|
||||
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<circle cx="140" cy="111" r="25" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<polygon points="140,97 135,104 145,104" fill="#A7ABB7"/>
|
||||
<text x="140" y="126" fill="#868A96" font-size="19" font-weight="600" text-anchor="middle">—</text>
|
||||
<circle cx="90" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="86,151 93,146 93,156" fill="#A7ABB7"/>
|
||||
<text x="90" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="190" cy="161" r="25" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<polygon points="194,151 187,146 187,156" fill="#A7ABB7"/>
|
||||
<text x="190" y="176" fill="#868A96" font-size="19" font-weight="600" text-anchor="middle">—</text>
|
||||
<circle cx="140" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<polygon points="140,205 135,198 145,198" fill="#A7ABB7"/>
|
||||
<text x="140" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<rect x="83" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">View: Menu</text>
|
||||
<rect x="83" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<circle cx="380" cy="111" r="25" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<text x="380" y="105" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">Y</text>
|
||||
<text x="380" y="126" fill="#868A96" font-size="19" font-weight="600" text-anchor="middle">—</text>
|
||||
<circle cx="330" cy="161" r="25" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<text x="330" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">X</text>
|
||||
<text x="330" y="176" fill="#868A96" font-size="19" font-weight="600" text-anchor="middle">—</text>
|
||||
<circle cx="430" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="430" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">B</text>
|
||||
<text x="430" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="380" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="380" y="205" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="380" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<rect x="323" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu: —</text>
|
||||
<rect x="323" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,47 @@
|
||||
# Quest controller mode: contract (`quest_buttons`)
|
||||
|
||||
This file is the contract between the native (FrameBridge) and app/docs halves of the feature. The mapping itself
|
||||
lives in [`quest_buttons.json`](quest_buttons.json); everything else follows it.
|
||||
|
||||
## Problem
|
||||
Quest games suggest bindings only for `/interaction_profiles/oculus/touch_controller`. The Steam Frame controllers
|
||||
are two gamepad halves (Valve: https://partner.steamgames.com/doc/steamhardware/steamframe/input):
|
||||
- left: `dpad_up|dpad_down|dpad_left|dpad_right` (click, touch), `view` (click, touch), `system`, `bumper`,
|
||||
`squeeze` (click, touch, value), `trigger` (click, touch, value), `thumbstick` (vector, x, y, click, touch),
|
||||
`grip/pose`, `aim/pose`, `output/haptic`
|
||||
- right: `a|b|x|y` (click, touch), `menu` (click, touch), `system`, `bumper`, and the same squeeze/trigger/thumbstick/
|
||||
poses/haptic.
|
||||
|
||||
SteamVR uses a game's Frame Controller binding first; without one it remaps Touch: Touch B/Y -> the three top buttons
|
||||
(left d-pad left/up/right, right X/Y/B), Touch A/X -> the bottom one, Touch menu -> View (mode `0` in the JSON).
|
||||
|
||||
## Setting
|
||||
FrameBridge adapter setting `quest_buttons` (int, read from settings like every other key; numbers only):
|
||||
- `0` = off (default): FrameBridge does nothing new, SteamVR's remap applies.
|
||||
- `1` = Mirror, `2` = Quest pair: see `modes` in the JSON.
|
||||
|
||||
Library setting `input.quest_buttons` (0/1/2, default 0) = the global default, used at install for games whose recipe
|
||||
doesn't name `adapter.quest_buttons`.
|
||||
|
||||
## Native behaviour (FrameBridge)
|
||||
1. At `xrCreateInstance`, when `quest_buttons` != 0 and the runtime lists `XR_VALVE_frame_controller_interaction`,
|
||||
add it to the enabled extensions (the adapter already rewrites this list). Not listed -> log and stay off.
|
||||
2. When the game's suggestion for the Touch profile succeeds (directly, or through `profile_remap`'s
|
||||
`remap_to_touch`), suggest a translated set for `/interaction_profiles/valve/frame_controller_valve`:
|
||||
- each binding `/user/hand/<hand>/input/<touch input>[/<component>]` maps by the JSON: `common` 1:1, button
|
||||
`x|y|a|b` + `/click` or `/touch` -> each listed Frame button + the same component, `menu/click` -> listed
|
||||
Frame button + `/click`; `output/haptic` 1:1; anything in `dropped` or unknown is left out.
|
||||
- a later Touch suggestion by the same game replaces the Frame set (suggest again; the last call per profile
|
||||
wins in OpenXR).
|
||||
- a failed Frame suggestion is logged and ignored: the game's own call result is never changed.
|
||||
3. `xrGetCurrentInteractionProfile`: with `quest_buttons` != 0, a `/valve/` answer is reported as Touch even when
|
||||
`controller_fix` is off.
|
||||
4. Log lines (logcat tag of the adapter):
|
||||
- `quest_buttons: extension XR_VALVE_frame_controller_interaction enabled` / `... not offered by the runtime`
|
||||
- `quest_buttons: frame_controller bindings (mode N, K bindings, D dropped): <XrResult>`
|
||||
- with `input_diag=1`: `input_diag: quest_buttons <touch path> -> <frame path>` per added binding.
|
||||
|
||||
## Owners
|
||||
- native stream: `native/adapter/*`, `tests/test_quest_buttons.py`, rebuilt adapter artifact.
|
||||
- app stream: `src/frameport/**` (settings, install fallback, UI, help), `docs/**` except this file and the JSON,
|
||||
`scripts/controller_diagram.py`, `README.md` link, `agent/frameport_agent.py` comment only, Python tests.
|
||||
@@ -0,0 +1,249 @@
|
||||
# App stream: Quest controller buttons
|
||||
|
||||
## Implementation
|
||||
|
||||
- Added `adapter.quest_buttons` (integer, default 0), common Controllers choice with SteamVR default / Mirror /
|
||||
Quest pair. It is available on both bridge ABIs, is not an XR shim setting and has no detection suggestion.
|
||||
No agent, patch, setting or analysis revision was changed.
|
||||
- `patches.settings.adapter_settings(..., defaults=...)` and the headset identity wrapper stay pure.
|
||||
`core.controllers.effective_adapter_settings` supplies the library preference at build, install, live suggestion
|
||||
and dialog boundaries. Recipes override it, including an explicit 0. APK `libframe_settings.so` and installed
|
||||
`settings.conf` receive the effective value.
|
||||
- Recipe/APK/configuration fingerprints do not read the global preference. The existing agent updates its
|
||||
configuration fingerprint during `set_settings`; `game_update.configuration_matches` therefore accepts all
|
||||
three inherited controller values, while still checking explicit overrides and every other configuration field.
|
||||
An offline test exercises the real agent handler to prevent a global apply making the library outdated.
|
||||
- Game settings has a **Use global default (mode)** option. Saving unchanged inherited settings keeps the recipe
|
||||
unchanged; choosing SteamVR default stores an explicit 0. Reset restores the effective global value.
|
||||
- Settings → Controllers stores `input.quest_buttons`, shows a diagram for the choice, and offers **Apply to
|
||||
installed games now** while connected. The existing FIFO Frame tool job pushes only `quest_buttons` through
|
||||
`target.set_settings`. It skips non-Quest games, flat Android apps known to the library, and recipe overrides.
|
||||
A library-less install with an override listed in its deployment is also skipped. Cancellation is checked per game.
|
||||
- The CLI already has `settings <pkg> quest_buttons=N` (runtime only) and `recipe <pkg> --set quest_buttons=N`
|
||||
(persistent recipe). No global CLI option was added: there is no existing general library configuration command,
|
||||
and `settings` specifically writes an installed game's settings. The global preference is available in the GUI.
|
||||
- `scripts/controller_diagram.py` reads the unmodified JSON contract and literal Portal theme colours. It generates
|
||||
the full four-panel SVG, three bundled compact SVGs and the marked Markdown table. `--check` checks all outputs;
|
||||
`--compact --mode N [--out path]` generates just one pair. Bundled images use the existing glyph asset copy and
|
||||
URL path, with no image bytes or filesystem reads in the diagram render helper.
|
||||
- Added the user guide and README/install/playbook/runtime links, regenerated the patch docs, site map and
|
||||
translation template. The only agent edit corrects the old D-pad comment. No shared smoke script was edited.
|
||||
|
||||
## Offline checks and visual review
|
||||
|
||||
Commands use the existing venv without resolving packages. The default uv cache is read-only in this sandbox;
|
||||
plain `uv run` attempted an editable metadata refresh requiring unavailable cached build dependencies.
|
||||
|
||||
```sh
|
||||
export UV_CACHE_DIR=/tmp/frameport-qb-uv
|
||||
export UV_NO_SYNC=1
|
||||
uv run pytest -q
|
||||
uv run python scripts/patch_docs.py --check
|
||||
uv run python scripts/controller_diagram.py --check
|
||||
uv run python scripts/i18n_extract.py --check
|
||||
```
|
||||
|
||||
The focused settings, update, translation, patch and UI tests passed (147 passed, 2 skipped before the final
|
||||
agent-fingerprint regression; the follow-up controller/update/UI-state run passed 89 tests, with the existing
|
||||
socket test deselected). Final full suite: **2015 passed, 45 skipped, 12 failed, 16 setup errors** (67.76 s). All 28 remaining failures/errors
|
||||
are existing local-socket tests rejected by the sandbox (deeplink, devkit, install links, link updates, livestream,
|
||||
setup URL and pairing). The translation failure from the first run was fixed. The changed feature's tests pass;
|
||||
the full suite cannot be reported as passing in this environment. The log is local at `/tmp/qb-app-final-pytest.log`.
|
||||
Patch docs, diagram and translation generation checks pass.
|
||||
|
||||
The real Flet smoke command was attempted:
|
||||
|
||||
```sh
|
||||
FRAMEPORT_NO_UPDATE_CHECK=1 FRAMEPORT_NO_ANNOUNCEMENTS=1 \
|
||||
uv run python scripts/ui_smoke.py --out .shots --fake-frame \
|
||||
--game com.BithellGames.Arcsmith --only tools,game-settings-dialog
|
||||
```
|
||||
|
||||
It cannot start here: the sandbox rejects even creation/binding of loopback sockets (`PermissionError: [Errno 1]
|
||||
Operation not permitted`). Flet reports it cannot bind its server on port 8557. Consequently no Settings or Game
|
||||
settings PNG was produced or inspected. This is an outstanding maintainer validation step, not a passed UI check.
|
||||
No Frame or external network connection was attempted.
|
||||
|
||||
The diagrams were rendered offline with ffmpeg and visually inspected. Both are readable, with correct left/right
|
||||
positions and dimmed unbound buttons. Local, uncommitted review PNGs:
|
||||
|
||||
- `.shots/quest-buttons.png` — all four panels.
|
||||
- `.shots/quest-buttons-compact.png` — the app's Mirror image.
|
||||
|
||||
The Impeccable mechanical detector returned no findings on the changed UI files; targeted Ruff and whitespace
|
||||
checks passed.
|
||||
|
||||
## Capture the actual UI on a machine that permits loopback
|
||||
|
||||
Use a test library in `FRAMEPORT_HOME` containing a Quest game. `ui_smoke.py` has no Controllers section option,
|
||||
so this temporary wrapper reuses its real browser/driver and scrolls the two relevant surfaces. It does not change
|
||||
tracked files. Set `pkg` to a Quest game in that test library first.
|
||||
|
||||
```sh
|
||||
pkg=com.example.questgame
|
||||
cat > .home/capture_controllers.py <<'PY'
|
||||
import sys
|
||||
import time
|
||||
sys.path.insert(0, 'scripts')
|
||||
import ui_smoke as smoke
|
||||
|
||||
original_driver = smoke.driver
|
||||
|
||||
def driver(app, steps, *args):
|
||||
adjusted = []
|
||||
for name, action in steps:
|
||||
if name == 'tools':
|
||||
def controllers(a, original=action):
|
||||
original(a)
|
||||
time.sleep(1)
|
||||
a.page.run_task(a.settings_view.show_section, 'controllers')
|
||||
adjusted.append(('settings-controllers', controllers))
|
||||
elif name == 'game-settings-dialog':
|
||||
def game_settings(a, original=action):
|
||||
original(a)
|
||||
time.sleep(1)
|
||||
dialog = next(d for d in reversed(a.page._dialogs.controls)
|
||||
if d.open and type(d).__name__ == 'AlertDialog')
|
||||
a.page.run_task(dialog.content.content.scroll_to, offset=400, duration=0)
|
||||
adjusted.append(('game-settings-controllers', game_settings))
|
||||
else:
|
||||
adjusted.append((name, action))
|
||||
return original_driver(app, adjusted, *args)
|
||||
|
||||
smoke.driver = driver
|
||||
raise SystemExit(smoke.main())
|
||||
PY
|
||||
FRAMEPORT_NO_UPDATE_CHECK=1 FRAMEPORT_NO_ANNOUNCEMENTS=1 \
|
||||
uv run python .home/capture_controllers.py --out .shots/controllers \
|
||||
--fake-frame --game "$pkg" --only tools,game-settings-dialog
|
||||
```
|
||||
|
||||
Inspect `.shots/controllers/01-settings-controllers.png` and
|
||||
`.shots/controllers/02-game-settings-controllers.png` at 1280×820; repeat with `--viewport 1024x768`. Verify the
|
||||
choice text, diagram, explanation and apply button fit; scroll a little in the dialog if the game's relevant
|
||||
Picture settings make offset 400 miss the row. Also choose all three modes and the global option in the actual
|
||||
GUI, checking that the diagram changes and re-opening preserves inheritance or the explicit override.
|
||||
|
||||
## Exact device verification after native integration
|
||||
|
||||
These steps are for the maintainer; none were executed in this stream. Merge the native stream and its adapter
|
||||
artifacts first. Use a Quest game that binds all four Touch face buttons (and, separately, an available 32-bit
|
||||
bridge game). Set `pkg` to its real library package; keep the same `FRAMEPORT_HOME` used for that test library and
|
||||
its paired Frame. Close the game before rebuilding/installing.
|
||||
|
||||
1. Build/install the integrated adapter once. With global mode Mirror and the game's **Use global default**
|
||||
selected, run:
|
||||
|
||||
```sh
|
||||
pkg=com.example.questgame
|
||||
uv run frameport build "$pkg"
|
||||
uv run frameport install "$pkg" --force --apk-only
|
||||
uv run frameport settings "$pkg" input_diag=1
|
||||
```
|
||||
|
||||
Confirm the build's `lib/<abi>/libframe_settings.so` contains `quest_buttons=1`. The following PC-side inspection
|
||||
reads the APK path recorded by the build:
|
||||
|
||||
```sh
|
||||
uv run python - "$pkg" <<'PY'
|
||||
import sys, zipfile
|
||||
from frameport.core import library
|
||||
with zipfile.ZipFile(library.game(sys.argv[1])['build']['apk']) as apk:
|
||||
for name in apk.namelist():
|
||||
if name.endswith('/libframe_settings.so'):
|
||||
print(name, apk.read(name).decode())
|
||||
PY
|
||||
```
|
||||
|
||||
2. For each mode, write it and launch from Steam/FramePort Play while wearing the headset:
|
||||
|
||||
```sh
|
||||
uv run frameport settings "$pkg" quest_buttons=1 input_diag=1
|
||||
# Close the game, then repeat with quest_buttons=2, then quest_buttons=0.
|
||||
```
|
||||
|
||||
On the Frame, set `pkg` to the same package and locate its actual base (works for internal storage or another
|
||||
drive). After each launch inspect:
|
||||
|
||||
```sh
|
||||
pkg=com.example.questgame
|
||||
base=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["base"])' \
|
||||
"$HOME/Applications/quest-frame/$pkg/deployment.json")
|
||||
grep '^quest_buttons=' "$base/settings.conf"
|
||||
grep -E 'quest_buttons:|input_diag: quest_buttons' "$base/launch.log"
|
||||
```
|
||||
|
||||
Expected for modes 1/2:
|
||||
|
||||
```text
|
||||
quest_buttons: extension XR_VALVE_frame_controller_interaction enabled
|
||||
quest_buttons: frame_controller bindings (mode N, K bindings, D dropped): <XrResult>
|
||||
input_diag: quest_buttons <touch path> -> <frame path>
|
||||
```
|
||||
|
||||
`N` is 1 or 2, `K` is nonzero, and the Frame suggestion result must indicate success. Input diagnostics must
|
||||
match the JSON paths. For mode 0 there should be no new FrameBridge Frame suggestion. Check the physical face
|
||||
buttons against every row of `docs/CONTROLLERS.md`: Mirror repeats X/A on bottom+inner, Y/B on top+outer;
|
||||
Quest pair leaves left up/right and right X/Y unbound. Left View sends Menu in every mode. Check touch actions
|
||||
where the game exposes them, triggers, grip pressure, sticks, poses and haptics. Bumpers are unbound. Repeat on
|
||||
the 32-bit bridge ABI. Existing custom SteamVR bindings should still take precedence.
|
||||
|
||||
3. Verify global apply with two installed games: one following the global choice, one explicitly set to SteamVR
|
||||
default (0). Set Settings → Controllers to Quest pair; click **Apply to installed games now**. Expect one FIFO
|
||||
Frame tool job, `quest_buttons=2` only on inherited games, and the explicit game's 0 retained. Check that neither
|
||||
game gains an Update ready badge solely from the global change/apply. Re-open Game settings: the inherited
|
||||
choice says **Use global default (Quest pair)**. Relaunch to verify the mode; a running game keeps its old mode.
|
||||
Also test changing an explicit mode and then resetting to the global choice.
|
||||
|
||||
4. If an extension-less runtime is available, modes 1/2 must log
|
||||
`quest_buttons: extension XR_VALVE_frame_controller_interaction not offered by the runtime` and retain the
|
||||
SteamVR default mapping without a launch failure. Otherwise leave this case to the native offline tests.
|
||||
|
||||
5. Undo: set the global choice to SteamVR default and apply to inherited installed games. Restore any pre-test
|
||||
per-game choices in Game settings, and disable diagnostics:
|
||||
|
||||
```sh
|
||||
uv run frameport settings "$pkg" quest_buttons=0 input_diag=0
|
||||
```
|
||||
|
||||
A persistent test override made with the recipe CLI can be removed with
|
||||
`uv run frameport recipe "$pkg" --disable adapter.quest_buttons`; then select the global choice and save in
|
||||
Game settings to push its current effective value.
|
||||
|
||||
## Contract questions / remaining integration work
|
||||
|
||||
- The contract translates successful Touch suggestions. A game suggesting only its own Frame bindings is
|
||||
unaffected. If a game suggests both Touch and its own Frame profile, ordering/priority is not specified by the
|
||||
contract; verify with the native stream before claiming both-profile games are unaffected. The user guide
|
||||
qualifies this limit accordingly.
|
||||
- No device claims were added. The runtime reference marks new-mode verification pending integration.
|
||||
- A direct CLI `settings` write is intentionally temporary relative to the recipe/global preference. Use
|
||||
`recipe --set quest_buttons=N` or the dialog for a persistent per-game override.
|
||||
- Actual UI screenshots and the socket-dependent full-suite tests require a less restricted local environment.
|
||||
|
||||
## Round 2
|
||||
|
||||
- Global apply still writes every inherited Quest install, but separates games ready for the next launch from
|
||||
games that need **Update on Frame first**. The job result supplies the completion toast, so both show the same
|
||||
counts. The Settings explanation includes the one-time update requirement.
|
||||
- `_build_inputs_changed` also checks the converter and source APK, so it cannot decide adapter support alone.
|
||||
Its manifest reader was extracted into `build.native_checksums` and is shared by `_build_inputs` and the
|
||||
controller check. Only adapter entries are compared. Missing/different checksums or an unknown/different
|
||||
installed APK are reported as needing an update; unrelated artifact changes do not affect this count. This is
|
||||
deliberately conservative when an older adapter already supports the feature but differs from the bundled one.
|
||||
- The section reads its default once into a local snapshot, uses that for the dropdown and preview, and updates it
|
||||
on selection. Regression tests check the single read and that the existing preview control changes in place.
|
||||
- The user guide now covers game-owned Frame bindings/profile preservation, bare binding paths and continued
|
||||
proximity emulation. This supersedes the earlier question about games suggesting both Frame and Touch profiles.
|
||||
The maintainer's triangle arrows and shared JSON were left unchanged.
|
||||
- Focused tests: **135 passed, 2 skipped** (`test_controller_preferences.py`, `test_patches.py`,
|
||||
`test_game_update.py`). Patch-doc, translation-template and controller-diagram checks pass; the translation
|
||||
template was regenerated. Targeted Ruff and whitespace checks pass.
|
||||
- Device follow-up: on an old/unknown install, choose Mirror and apply globally. Expect its setting to be pushed but
|
||||
the result/toast to count it under **needs Update on Frame first**. Update that game once, apply again, then
|
||||
relaunch: expect it in the next-launch count and the existing mode-1 binding logs described above. Keep a second
|
||||
game with an explicit override and verify it is skipped throughout. Undo by choosing SteamVR default and applying
|
||||
again; restore any per-game test overrides. No device or external network access was used in this round.
|
||||
- Final whole suite: **2029 passed, 116 skipped, 12 failed, 16 setup errors** (67.52 s). The remaining failures
|
||||
and errors are the existing local-socket tests blocked by the sandbox, as in Round 1. Full log:
|
||||
`/tmp/qb-app-round2-pytest.log`. No test skips or socket workarounds were added.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Quest controller mode: native stream
|
||||
|
||||
## Original implementation
|
||||
|
||||
- `native/adapter/quest_buttons.c` parses the numeric adapter setting. Only 0, 1 and 2 are accepted; malformed,
|
||||
fractional and out-of-range values select 0 and log `quest_buttons: invalid setting; using 0`.
|
||||
- `xrCreateInstance` reuses the existing next-layer extension enumeration. Modes 1 and 2 enable
|
||||
`XR_VALVE_frame_controller_interaction` only when offered, without adding a second copy when the game requested
|
||||
it. An unavailable/failed enumeration disables this feature. Failed instance creation clears the enabled flag.
|
||||
- `suggest_frame()` in `session_fixes.c` translates successful Touch suggestions using the tables corresponding to
|
||||
`quest_buttons.json`. It preserves action handles and click/touch components, supports both hands, poses,
|
||||
thumbstick vector/components and haptics, and counts unknown/dropped source bindings. Each input produces at most
|
||||
two Frame bindings. Mode 0 adds no runtime calls or feature logs.
|
||||
- Touch Pro/Plus fallback translates the filtered Touch-shaped copy after Touch succeeds. Proximity-emulation
|
||||
additions are deliberately excluded. A later Touch suggestion submits a fresh Frame set. Frame suggestion errors
|
||||
are logged without changing the game's result. A successful game-owned Frame suggestion prevents subsequent
|
||||
automatic Frame suggestions; if it arrives after an automatic suggestion, its runtime call replaces that set.
|
||||
- With the setting and extension enabled, `xrGetCurrentInteractionProfile` translates Valve profiles to Touch even
|
||||
with `controller_fix=0`. Existing `profile_remap` then still reports the game's Touch Pro/Plus profile when needed.
|
||||
Input diagnostics continue to show the runtime's original profile and additionally log every translated binding.
|
||||
- Both shipped adapter architectures were rebuilt, with updated `artifacts/SHA256SUMS`. No agent, analysis, patch
|
||||
or setting revision was changed. Contract files and the VrApi bridge are unchanged.
|
||||
|
||||
## Original offline validation
|
||||
|
||||
Commands run with `UV_CACHE_DIR=/tmp/frameport-qb-native-uv` to keep uv cache writes inside the writable sandbox;
|
||||
`--offline --no-sync` uses the existing worktree environment without fetching dependencies.
|
||||
|
||||
```sh
|
||||
FRAMEPORT_NATIVE_TESTS=1 uv run --offline --no-sync pytest -q tests/test_quest_buttons.py tests/test_profile_report.py
|
||||
uv run --offline --no-sync python native/build.py --only adapter
|
||||
uv run --offline --no-sync python scripts/patch_docs.py --check
|
||||
uv run --offline --no-sync pytest -q
|
||||
```
|
||||
|
||||
The new host harness includes `quest_buttons.c`, `input_diag.c`, and the complete `session_fixes.c`, plus verbatim
|
||||
copies of the adapter's exported create-instance/current-profile functions. It compiles with host `cc -Werror`.
|
||||
Python derives expected Frame bindings from the unchanged JSON contract, independently of the C mapping table.
|
||||
The targeted run passed 73 tests, including the existing profile-report tests. Two consecutive adapter builds
|
||||
produced identical SHA-256 hashes for both architectures; the manifest matches both files. Documentation checks pass.
|
||||
|
||||
The cached NDK/OpenXR inputs were already present. During the builds, the worktree's ignored `native/.cache` was
|
||||
temporarily a local directory containing input symlinks, so shader intermediates were written only inside this
|
||||
worktree. The original shared-cache symlink was restored afterward. No inputs were fetched or changed and no device
|
||||
was contacted.
|
||||
|
||||
Additional native baseline issue: `FRAMEPORT_NATIVE_TESTS=1 ... tests/test_input_diag.py` has an existing failure in
|
||||
`test_logs_each_finding_once_and_nothing_when_off`. Its harness requests `input_diag_hook("xrCreateAction")`, which
|
||||
the baseline implementation no longer exposes: action diagnostics moved to `session_fixes.c`. Compiling the harness
|
||||
against pristine `25e282d`'s `input_diag.c` reproduced the same line-85 failure. That unrelated behavior/test was left
|
||||
unchanged.
|
||||
|
||||
Full-suite result: **1,993 passed, 116 skipped, 12 failed, 16 errors**. All 28 failing/erroring cases stop at socket
|
||||
creation with `PermissionError: [Errno 1] Operation not permitted`: local HTTP fixtures in deep links, link updates,
|
||||
devkit, livestream, setup URL, and pairing UI tests. The sandbox disallows even loopback sockets. No feature
|
||||
assertion failed. The required unfiltered command was run; a green unfiltered run still requires a host that permits
|
||||
local sockets. The socket restriction was not relaxed.
|
||||
|
||||
An additional run deselecting exactly those 28 unsupported cases passed: **1,993 passed, 116 skipped, 28 deselected**.
|
||||
|
||||
## Integration questions and limits
|
||||
|
||||
- The contract has no missing mapping needed by this implementation. The right controller's Menu button has no
|
||||
mapping in modes 1/2; only left Touch `menu/click` maps to Frame `view/click`. Unknown paths, including menu touch
|
||||
and proximity, are intentionally dropped.
|
||||
- The game's own Frame bindings win only after its Frame suggestion succeeds. A rejected Frame suggestion does
|
||||
not suppress automatic translation. This avoids disabling working input because the game submitted an invalid set.
|
||||
- Headless/host tests cannot establish SteamVR's actual preference for these suggested bindings, physical button
|
||||
layout, capacitive touch behavior, haptics, or whether the installed Frame runtime offers the extension. These
|
||||
require the maintainer's checks below. An empty translated set is submitted and its result logged/ignored like
|
||||
any other Frame suggestion; the runtime can reject it.
|
||||
|
||||
## Maintainer device checks (not executed)
|
||||
|
||||
Use an isolated `.home` already containing a test library entry, its signing key/toolchain, and a paired test Frame.
|
||||
Use a normal Quest OpenXR game and a VrApi game using the bridge. Repeat with a game that suggests only Touch
|
||||
Pro/Plus if available. Replace the sample package name with each game's actual package. Stop other games first.
|
||||
|
||||
1. Rebuild/reinstall the test game so the new adapter is actually inside the APK. A setting update alone cannot
|
||||
upgrade an already installed adapter. Keep the original `controller_fix` and `input_diag` values for restoration.
|
||||
|
||||
```sh
|
||||
export FRAMEPORT_HOME="$PWD/.home"
|
||||
export PKG=com.example.game
|
||||
uv run frameport install "$PKG" --force
|
||||
```
|
||||
|
||||
2. Collect a headless startup log for each mode, with `controller_fix=0` and diagnostics enabled. Each launch test
|
||||
stops its game before the next iteration. The script uses the remembered Frame in this isolated home.
|
||||
|
||||
```sh
|
||||
for MODE in 0 1 2; do
|
||||
export MODE
|
||||
uv run frameport settings "$PKG" quest_buttons="$MODE" input_diag=1 controller_fix=0
|
||||
uv run python - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
from frameport.cli import _target
|
||||
from frameport.core.events import Reporter
|
||||
|
||||
target = _target(None)
|
||||
try:
|
||||
result, log = target.launch_test(os.environ["PKG"], Reporter(), seconds=30)
|
||||
output = Path(os.environ["FRAMEPORT_HOME"]) / f"quest-buttons-mode-{os.environ['MODE']}.log"
|
||||
output.write_text(log)
|
||||
print(output)
|
||||
for line in log.splitlines():
|
||||
if "quest_buttons" in line or "controller profile" in line:
|
||||
print(line)
|
||||
finally:
|
||||
target.frame.close()
|
||||
PY
|
||||
done
|
||||
```
|
||||
|
||||
Expected mode 0: no `quest_buttons:` or `input_diag: quest_buttons` lines. Modes 1/2, if offered:
|
||||
|
||||
```text
|
||||
quest_buttons: extension XR_VALVE_frame_controller_interaction enabled
|
||||
quest_buttons: frame_controller bindings (mode 1, K bindings, D dropped): 0
|
||||
input_diag: quest_buttons /user/hand/left/input/x/click -> /user/hand/left/input/dpad_down/click
|
||||
input_diag: quest_buttons /user/hand/left/input/x/click -> /user/hand/left/input/dpad_right/click
|
||||
input_diag: quest_buttons /user/hand/left/input/menu/click -> /user/hand/left/input/view/click
|
||||
```
|
||||
|
||||
`K`/`D` depend on the game. Mode 2 reports `mode 2` and only the first X mapping above. Face-button touch paths
|
||||
keep `/touch`. Frame suggestions must return `0`; a negative result indicates runtime rejection to investigate.
|
||||
A Touch Pro/Plus-only game should also log `controller profile ... -> oculus/touch_controller` and a Frame binding
|
||||
summary. If the runtime lacks the extension, expect
|
||||
`quest_buttons: extension XR_VALVE_frame_controller_interaction not offered by the runtime`, with no translated
|
||||
bindings; input should retain its previous behavior.
|
||||
|
||||
3. For each mode, set it with the command below, then Play from the FramePort library or Steam while wearing the
|
||||
headset. Exit through Steam before changing modes; settings are read at process startup.
|
||||
|
||||
```sh
|
||||
uv run frameport settings "$PKG" quest_buttons=1 input_diag=1 controller_fix=0
|
||||
```
|
||||
|
||||
Mirror: left down/right = Touch X; left up/left = Touch Y; right A/X = Touch A; right B/Y = Touch B. Quest pair:
|
||||
left down = X, left left = Y, right A = A, right B = B; the unused face buttons should not activate those actions.
|
||||
In both modes left View activates Touch Menu. Check thumbstick movement/click/touch, trigger/squeeze values,
|
||||
trigger touch, tracking/aim, and haptic feedback. Verify both hands remain connected with `controller_fix=0`.
|
||||
Mode 0 should match the previous SteamVR mapping. Capacitive-touch verification needs a game that exposes finger
|
||||
animation or an input sample; absence of a suggested touch action is not a failure of this mapping.
|
||||
|
||||
4. Restore settings after stopping the game. The example restores the usual defaults; substitute recorded original
|
||||
values if different. Reinstalling the prior known-good APK also removes the new adapter if an artifact rollback
|
||||
is needed; setting 0 alone disables the feature on the next launch.
|
||||
|
||||
```sh
|
||||
uv run frameport settings "$PKG" quest_buttons=0 input_diag=0 controller_fix=1
|
||||
```
|
||||
|
||||
For explicit current-profile verification in an OpenXR input sample, call `xrGetCurrentInteractionProfile` for
|
||||
`/user/hand/left` and `/user/hand/right`, then `xrPathToString` on each non-null result after action-set attachment.
|
||||
With modes 1/2 and `controller_fix=0`, expect `/interaction_profiles/oculus/touch_controller`, or the game's own
|
||||
Touch Pro/Plus profile when its binding was remapped. Existing `input_diag` profile lines intentionally show the raw
|
||||
runtime answer, so they cannot alone verify the profile returned to the game.
|
||||
|
||||
## Round 2
|
||||
|
||||
The review fixes supersede the original proximity exclusion, broad Valve-profile rewrite, empty-set submission,
|
||||
and failed-instance state handling described above:
|
||||
|
||||
1. Added bare `grip`/`aim` to the JSON common mapping and `FRAME_COMMON`; no other contract fields changed.
|
||||
2. Current-profile rewriting with `controller_fix=0` requires the exact Frame profile and a successful generated
|
||||
binding set still in effect. A successful game-owned Frame suggestion ends that ownership; failed suggestions
|
||||
leave the last accepted set in effect. Existing `controller_fix=1` behavior is retained.
|
||||
3. Frame bindings are generated at the point a Touch set succeeds, including proximity-emulation touch bindings.
|
||||
Direct Touch, Touch Pro/Plus remapping, and rejected-expansion fallback all use the effective accepted set.
|
||||
4. An all-dropped translation makes no Frame suggestion and logs
|
||||
`quest_buttons: frame_controller bindings (mode N, 0 bindings, D dropped): not suggested`. An earlier accepted
|
||||
generated set can remain stale; it is intentionally retained because an empty replacement is invalid.
|
||||
5. Extension decisions remain local until instance creation succeeds. Only successful creation publishes the active
|
||||
instance and resets binding ownership/Touch-remap state; a failed second attempt preserves the live first state.
|
||||
6. One mutex serializes each complete suggestion transaction, including fallback/remapping, Frame generation, and
|
||||
flags. Instance-state publication and current-profile reporting use the same mutex. Internal helpers call the
|
||||
next layer directly and never reacquire it.
|
||||
|
||||
Regression tests reproduced items 1–5 before the fixes (21 failed cases). Afterward, the requested host run passed
|
||||
97 tests, including timeout-bounded nested proximity fallback and Touch Pro remapping. Diagram regeneration made no
|
||||
changes; controller-diagram and patch-documentation checks pass. Both adapter architectures were rebuilt twice with
|
||||
identical hashes matching `SHA256SUMS`. The full suite finished with 2,015 passed, 140 skipped, 12 failed and 16 errors;
|
||||
all 28 failures/errors are the same sandbox-denied local socket creation described above.
|
||||
|
||||
Device follow-up: repeat the install/log/physical checks above, adding `proximity_emul=2` to the mode-1/mode-2 settings
|
||||
command for a game exposing finger proximity. Expect translated proximity actions on face-button/thumbstick touch
|
||||
and trigger touch, and working finger animation. In an OpenXR sample with `controller_fix=0`, also query profiles
|
||||
before any generated set, after a successful generated set, and after the app's own successful Frame suggestion:
|
||||
expect Frame, Touch, Frame respectively. Another Valve profile must stay unchanged. Restore the recorded original
|
||||
`proximity_emul` setting along with the other settings. No device operations were performed in this worktree.
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"_comment": "Single source of the quest_buttons mapping (FrameBridge adapter setting). Keys are Touch input names under /user/hand/<hand>/input/, values the Frame Controller inputs the binding is also suggested for. A button entry covers its bare path (e.g. input/x, which OpenXR resolves to click) and its click and touch components; menu covers the bare path and click only. native/adapter's table, tests/test_quest_buttons.py and scripts/controller_diagram.py follow this file. See docs/input/QUEST_BUTTONS.md.",
|
||||
"extension": "XR_VALVE_frame_controller_interaction",
|
||||
"touch_profile": "/interaction_profiles/oculus/touch_controller",
|
||||
"frame_profile": "/interaction_profiles/valve/frame_controller_valve",
|
||||
"source": "https://partner.steamgames.com/doc/steamhardware/steamframe/input",
|
||||
"common": {
|
||||
"squeeze": ["squeeze"],
|
||||
"squeeze/value": ["squeeze/value"],
|
||||
"trigger": ["trigger"],
|
||||
"trigger/value": ["trigger/value"],
|
||||
"trigger/touch": ["trigger/touch"],
|
||||
"thumbstick": ["thumbstick"],
|
||||
"thumbstick/x": ["thumbstick/x"],
|
||||
"thumbstick/y": ["thumbstick/y"],
|
||||
"thumbstick/click": ["thumbstick/click"],
|
||||
"thumbstick/touch": ["thumbstick/touch"],
|
||||
"grip": ["grip"],
|
||||
"grip/pose": ["grip/pose"],
|
||||
"aim": ["aim"],
|
||||
"aim/pose": ["aim/pose"]
|
||||
},
|
||||
"outputs": {"haptic": ["haptic"]},
|
||||
"dropped": ["thumbrest/touch", "system/click"],
|
||||
"buttons": {
|
||||
"left": ["x", "y"],
|
||||
"right": ["a", "b"]
|
||||
},
|
||||
"modes": {
|
||||
"0": {
|
||||
"name": "SteamVR default",
|
||||
"note": "FrameBridge suggests nothing for the Frame profile; SteamVR remaps Touch itself (documented by Valve). Listed for the diagram only.",
|
||||
"left": {"x": ["dpad_down"], "y": ["dpad_left", "dpad_up", "dpad_right"], "menu": ["view"]},
|
||||
"right": {"a": ["a"], "b": ["x", "y", "b"]}
|
||||
},
|
||||
"1": {
|
||||
"name": "Mirror",
|
||||
"note": "Every face button works; the left controller mirrors the right: bottom + inner = X/A, top + outer = Y/B.",
|
||||
"left": {"x": ["dpad_down", "dpad_right"], "y": ["dpad_up", "dpad_left"], "menu": ["view"]},
|
||||
"right": {"a": ["a", "x"], "b": ["b", "y"]}
|
||||
},
|
||||
"2": {
|
||||
"name": "Quest pair",
|
||||
"note": "Two buttons per controller, placed diagonally like Quest Touch; the other face buttons do nothing.",
|
||||
"left": {"x": ["dpad_down"], "y": ["dpad_left"], "menu": ["view"]},
|
||||
"right": {"a": ["a"], "b": ["b"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,7 @@ static XrTime last_predicted_time;
|
||||
static void render_model_init(const char *package);
|
||||
|
||||
#include "headset_model.c"
|
||||
#include "quest_buttons.c"
|
||||
|
||||
static void read_settings(const char *path) {
|
||||
FILE *f = fopen(path, "r");
|
||||
@@ -186,6 +187,7 @@ static void read_settings(const char *path) {
|
||||
float value;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
headset_model_setting(line);
|
||||
quest_buttons_setting(line);
|
||||
if (sscanf(line, "scale=%f", &value) == 1 && value >= 0.5f && value <= 2.0f) scale = value;
|
||||
if (sscanf(line, "foveation_fix=%f", &value) == 1) foveation_fix = value != 0;
|
||||
if (sscanf(line, "foveation_storage_fix=%f", &value) == 1) foveation_storage_fix = value != 0;
|
||||
@@ -361,7 +363,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
}
|
||||
|
||||
XrInstanceCreateInfo fixed = *info;
|
||||
const char **names = calloc(info->enabledExtensionCount + 7, sizeof(*names));
|
||||
const char **names = calloc(info->enabledExtensionCount + 8, sizeof(*names));
|
||||
if (!names) { free(available); return XR_ERROR_OUT_OF_MEMORY; }
|
||||
int requested_color_space = 0;
|
||||
uint32_t kept = 0;
|
||||
@@ -397,6 +399,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
names[kept++] = "XR_KHR_android_create_instance";
|
||||
LOG("added %s", "XR_KHR_android_create_instance");
|
||||
}
|
||||
int frame_enabled = quest_buttons_extensions(available, available_count, names, &kept);
|
||||
{ // no graphics API extension at all: Meta's runtime still accepts a GLES session, the Frame's doesn't
|
||||
// (Lambda1VR's TBXR enables only XR_EXT_local_floor). Add GLES, which every such app uses, if offered.
|
||||
int graphics = 0, gles_offered = 0;
|
||||
@@ -481,7 +484,13 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
LOG("color_space: emulating XR_FB_color_space; REC709 SDR compatibility, no colour conversion");
|
||||
}
|
||||
}
|
||||
if (XR_SUCCEEDED(result)) { active_instance = *instance; mx_audio_initialize(); }
|
||||
if (XR_SUCCEEDED(result)) {
|
||||
pthread_mutex_lock(&bindings_lock);
|
||||
active_instance = *instance;
|
||||
reset_bindings(frame_enabled);
|
||||
pthread_mutex_unlock(&bindings_lock);
|
||||
mx_audio_initialize();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2105,6 +2114,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;
|
||||
pthread_mutex_lock(&bindings_lock);
|
||||
XrResult result = detached_current_profile(user, state, fn(session, user, state));
|
||||
if (input_diag && XR_SUCCEEDED(result) && state) input_diag_current_profile(user, state->interactionProfile);
|
||||
if (layer_debug && active_instance) { // which device each hand has, as the runtime reports it
|
||||
@@ -2119,7 +2129,22 @@ 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 (XR_SUCCEEDED(result) && state) {
|
||||
// A Frame binding replaces SteamVR's Touch remap; Quest games still expect a Touch-shaped profile.
|
||||
if (quest_buttons && quest_buttons_enabled && generated_frame_bindings && !app_suggested_frame &&
|
||||
state->interactionProfile) {
|
||||
PFN_xrPathToString str = (PFN_xrPathToString)lookup(active_instance, "xrPathToString");
|
||||
PFN_xrStringToPath path = (PFN_xrStringToPath)lookup(active_instance, "xrStringToPath");
|
||||
char name[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
XrPath touch;
|
||||
if (str && path && XR_SUCCEEDED(str(active_instance, state->interactionProfile, sizeof(name), &size, name)) &&
|
||||
!strcmp(name, FRAME_PROFILE) && XR_SUCCEEDED(path(active_instance, TOUCH_PROFILE, &touch)))
|
||||
state->interactionProfile = touch;
|
||||
}
|
||||
current_profile_fix(user, state);
|
||||
}
|
||||
pthread_mutex_unlock(&bindings_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -2327,7 +2352,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
}
|
||||
if (sync_guard || layer_debug || input_diag) HOOK_AS(xrSyncActions, hook_xrSyncActions)
|
||||
if (layer_debug) HOOK_AS(xrGetActionStateBoolean, hook_xrGetActionStateBoolean)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag || proximity_emul)
|
||||
if (layer_debug || aim_correction_on() || profile_remap || input_diag || proximity_emul || quest_buttons)
|
||||
HOOK_AS(xrSuggestInteractionProfileBindings, hook_xrSuggestInteractionProfileBindings)
|
||||
if (action_dedup || proximity_emul || profile_remap || input_diag) HOOK_AS(xrCreateAction, hook_xrCreateAction)
|
||||
if (layer_debug || aim_correction_on()) {
|
||||
|
||||
@@ -84,6 +84,10 @@ static void input_diag_current_profile(XrPath user, XrPath profile) {
|
||||
if (diag_first(key)) LOG("input_diag: bindings: %s uses %s", u, p);
|
||||
}
|
||||
|
||||
static void input_diag_quest_binding(const char *touch, const char *frame) {
|
||||
if (input_diag) LOG("input_diag: quest_buttons %s -> %s", touch, frame);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- failing calls
|
||||
// xrSyncActions and xrSuggestInteractionProfileBindings are reported from session_fixes.c's hooks,
|
||||
// xrApplyHapticFeedback from frame_adapter.c's (always hooked for haptic_scale).
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by frame_adapter.c: opt-in Frame controller bindings need the runtime's extension enabled first.
|
||||
static int quest_buttons;
|
||||
// Published instance/binding state is guarded by bindings_lock, including current-profile reporting.
|
||||
static int quest_buttons_enabled;
|
||||
static int app_suggested_frame;
|
||||
static int generated_frame_bindings;
|
||||
static pthread_mutex_t bindings_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static void reset_bindings(int frame_enabled); // session_fixes.c, called with bindings_lock held
|
||||
static const char *const FRAME_PROFILE = "/interaction_profiles/valve/frame_controller_valve";
|
||||
static const char *const FRAME_EXTENSION = "XR_VALVE_frame_controller_interaction";
|
||||
|
||||
static void quest_buttons_setting(const char *line) {
|
||||
if (strncmp(line, "quest_buttons=", 14)) return;
|
||||
float value;
|
||||
char extra;
|
||||
if (sscanf(line, "quest_buttons=%f %c", &value, &extra) == 1 &&
|
||||
(value == 0 || value == 1 || value == 2)) quest_buttons = (int)value;
|
||||
else {
|
||||
quest_buttons = 0;
|
||||
LOG("quest_buttons: invalid setting; using 0");
|
||||
}
|
||||
}
|
||||
|
||||
// `names` has room for one more extension. Use only the next layer's list, never an emulated extension list.
|
||||
static int quest_buttons_extensions(const XrExtensionProperties *available, uint32_t count,
|
||||
const char **names, uint32_t *kept) {
|
||||
// This is a proposed instance's extension list; do not change the live instance until creation succeeds.
|
||||
if (!quest_buttons) return 0;
|
||||
int offered = 0, requested = 0;
|
||||
for (uint32_t i = 0; available && i < count; ++i)
|
||||
offered |= !strcmp(available[i].extensionName, FRAME_EXTENSION);
|
||||
for (uint32_t i = 0; i < *kept; ++i) requested |= !strcmp(names[i], FRAME_EXTENSION);
|
||||
if (offered) {
|
||||
if (!requested) names[(*kept)++] = FRAME_EXTENSION;
|
||||
LOG("quest_buttons: extension %s enabled", FRAME_EXTENSION);
|
||||
} else LOG("quest_buttons: extension %s not offered by the runtime", FRAME_EXTENSION);
|
||||
return offered;
|
||||
}
|
||||
@@ -300,6 +300,12 @@ 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 void reset_bindings(int frame_enabled) {
|
||||
quest_buttons_enabled = frame_enabled;
|
||||
app_suggested_frame = generated_frame_bindings = app_suggested_touch = 0;
|
||||
remapped_profile[0] = 0;
|
||||
}
|
||||
|
||||
static int in_list(const char *s, const char *const *list, size_t n) {
|
||||
for (size_t i = 0; i < n; ++i)
|
||||
if (!strcmp(s, list[i])) return 1;
|
||||
@@ -419,16 +425,117 @@ static int add_binding(XrInstance instance, PFN_xrStringToPath to_path, XrAction
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Mirrors docs/input/quest_buttons.json; the host test derives its expected bindings from that contract.
|
||||
static const char *const FRAME_COMMON[] = {"squeeze", "squeeze/value", "trigger", "trigger/value", "trigger/touch",
|
||||
"thumbstick",
|
||||
"thumbstick/x", "thumbstick/y", "thumbstick/click", "thumbstick/touch", "grip", "grip/pose", "aim", "aim/pose"};
|
||||
static const char *const FRAME_DROPPED[] = {"thumbrest/touch", "system/click"};
|
||||
static const struct {
|
||||
const char *hand, *touch;
|
||||
const char *targets[2][2]; // Mirror, Quest pair
|
||||
} FRAME_BUTTONS[] = {
|
||||
{"left", "x", {{"dpad_down", "dpad_right"}, {"dpad_down", NULL}}},
|
||||
{"left", "y", {{"dpad_up", "dpad_left"}, {"dpad_left", NULL}}},
|
||||
{"left", "menu", {{"view", NULL}, {"view", NULL}}},
|
||||
{"right", "a", {{"a", "x"}, {"a", NULL}}},
|
||||
{"right", "b", {{"b", "y"}, {"b", NULL}}},
|
||||
};
|
||||
|
||||
static void suggest_frame(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
if (!quest_buttons || !quest_buttons_enabled || app_suggested_frame) return;
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(instance, "xrStringToPath");
|
||||
XrPath frame;
|
||||
uint32_t n = suggested->countSuggestedBindings, kept = 0, dropped = 0;
|
||||
XrResult result = XR_ERROR_PATH_UNSUPPORTED;
|
||||
XrActionSuggestedBinding *bindings = NULL;
|
||||
if (!to_path || !to_string || XR_FAILED(to_path(instance, FRAME_PROFILE, &frame))) goto done;
|
||||
// Every source contributes at most two bindings; check before multiplying the uint32_t count.
|
||||
if (n > UINT32_MAX / 2 || !(bindings = calloc(n ? (size_t)n * 2 : 1, sizeof(*bindings)))) {
|
||||
result = XR_ERROR_OUT_OF_MEMORY;
|
||||
goto done;
|
||||
}
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
const XrActionSuggestedBinding *source = &suggested->suggestedBindings[i];
|
||||
char path[XR_MAX_PATH_LENGTH], target[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0, before = kept;
|
||||
if (XR_FAILED(to_string(instance, source->binding, sizeof(path), &size, path))) { ++dropped; continue; }
|
||||
const char *hand = NULL, *relative = NULL;
|
||||
if (!strncmp(path, "/user/hand/left/", 16)) { hand = "left"; relative = path + 16; }
|
||||
else if (!strncmp(path, "/user/hand/right/", 17)) { hand = "right"; relative = path + 17; }
|
||||
if (!hand) { ++dropped; continue; }
|
||||
const char *targets[2] = {NULL, NULL};
|
||||
char button_targets[2][64];
|
||||
if (!strcmp(relative, "output/haptic")) targets[0] = relative;
|
||||
else if (!strncmp(relative, "input/", 6)) {
|
||||
const char *input = relative + 6;
|
||||
if (IN(input, FRAME_COMMON)) targets[0] = relative;
|
||||
else if (!IN(input, FRAME_DROPPED)) {
|
||||
for (size_t b = 0; b < sizeof(FRAME_BUTTONS) / sizeof(*FRAME_BUTTONS); ++b) {
|
||||
const char *button = FRAME_BUTTONS[b].touch;
|
||||
size_t len = strlen(button);
|
||||
if (strcmp(hand, FRAME_BUTTONS[b].hand) || strncmp(input, button, len) ||
|
||||
(input[len] != '/' && input[len])) continue;
|
||||
// a bare button path (input/x) is legal OpenXR: the runtime picks click, so keep it bare
|
||||
const char *component = input[len] ? input + len + 1 : "";
|
||||
if (*component && strcmp(component, "click") &&
|
||||
(strcmp(button, "menu") == 0 || strcmp(component, "touch"))) continue;
|
||||
for (int t = 0; t < 2; ++t) {
|
||||
const char *name = FRAME_BUTTONS[b].targets[quest_buttons - 1][t];
|
||||
if (!name) continue;
|
||||
snprintf(button_targets[t], sizeof(button_targets[t]), "input/%s%s%s", name,
|
||||
*component ? "/" : "", component);
|
||||
targets[t] = button_targets[t];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int t = 0; t < 2; ++t) {
|
||||
if (!targets[t]) continue;
|
||||
snprintf(target, sizeof(target), "/user/hand/%s/%s", hand, targets[t]);
|
||||
XrPath p;
|
||||
if (XR_FAILED(to_path(instance, target, &p))) continue;
|
||||
bindings[kept++] = (XrActionSuggestedBinding){source->action, p};
|
||||
input_diag_quest_binding(path, target);
|
||||
}
|
||||
if (kept == before) ++dropped;
|
||||
}
|
||||
if (!kept) {
|
||||
LOG("quest_buttons: frame_controller bindings (mode %d, 0 bindings, %u dropped): not suggested",
|
||||
quest_buttons, dropped);
|
||||
free(bindings);
|
||||
return; // A previously accepted Frame set stays in effect: OpenXR cannot replace it with an empty set.
|
||||
}
|
||||
XrInteractionProfileSuggestedBinding copy = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, NULL,
|
||||
frame, kept, bindings};
|
||||
result = fn(instance, ©);
|
||||
if (XR_SUCCEEDED(result)) generated_frame_bindings = 1;
|
||||
done:
|
||||
LOG("quest_buttons: frame_controller bindings (mode %d, %u bindings, %u dropped): %d",
|
||||
quest_buttons, kept, dropped, result);
|
||||
free(bindings);
|
||||
}
|
||||
|
||||
// Run this for every Touch attempt so Frame bindings come from exactly the set the runtime accepted.
|
||||
static XrResult suggest_touch_set(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
XrResult r = fn(instance, suggested);
|
||||
if (XR_SUCCEEDED(r)) suggest_frame(instance, fn, to_string, suggested);
|
||||
return r;
|
||||
}
|
||||
|
||||
// Suggests Touch bindings with finger proximity bound to the touch inputs (proximity_emul), falling back to the
|
||||
// bindings as given when the runtime refuses them.
|
||||
static XrResult suggest_touch(XrInstance instance, PFN_xrSuggestInteractionProfileBindings fn,
|
||||
PFN_xrPathToString to_string, const XrInteractionProfileSuggestedBinding *suggested) {
|
||||
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(instance, "xrStringToPath");
|
||||
if (!proximity_emul || runtime_has_proximity || !to_path || !to_string) return fn(instance, suggested);
|
||||
if (!proximity_emul || runtime_has_proximity || !to_path || !to_string)
|
||||
return suggest_touch_set(instance, fn, to_string, suggested);
|
||||
XrAction thumb = thumb_proximity_action, index = proximity_emul > 1 ? trigger_proximity_action : XR_NULL_HANDLE;
|
||||
uint32_t n = suggested->countSuggestedBindings, kept = 0, dropped = 0, added = 0;
|
||||
XrActionSuggestedBinding *b = calloc(n + 2 * 5, sizeof(*b));
|
||||
if (!b) return fn(instance, suggested);
|
||||
if (!b) return suggest_touch_set(instance, fn, to_string, suggested);
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
char path[XR_MAX_PATH_LENGTH];
|
||||
uint32_t size = 0;
|
||||
@@ -449,15 +556,15 @@ static XrResult suggest_touch(XrInstance instance, PFN_xrSuggestInteractionProfi
|
||||
if (index) added += add_binding(instance, to_path, b, &kept, index, name, "trigger/touch");
|
||||
}
|
||||
XrResult r;
|
||||
if (!added && !dropped) r = fn(instance, suggested);
|
||||
if (!added && !dropped) r = suggest_touch_set(instance, fn, to_string, suggested);
|
||||
else {
|
||||
XrInteractionProfileSuggestedBinding copy = *suggested;
|
||||
copy.countSuggestedBindings = kept;
|
||||
copy.suggestedBindings = b;
|
||||
r = fn(instance, ©);
|
||||
r = suggest_touch_set(instance, fn, to_string, ©);
|
||||
LOG("proximity_emul: finger proximity bound to touch (%u binding(s) added, %u proximity path(s) replaced): %d",
|
||||
added, dropped, r);
|
||||
if (XR_FAILED(r)) r = fn(instance, suggested);
|
||||
if (XR_FAILED(r)) r = suggest_touch_set(instance, fn, to_string, suggested);
|
||||
}
|
||||
free(b);
|
||||
return r;
|
||||
@@ -498,6 +605,9 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(X
|
||||
PFN_xrSuggestInteractionProfileBindings fn =
|
||||
(PFN_xrSuggestInteractionProfileBindings)lookup(instance, "xrSuggestInteractionProfileBindings");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
// The last accepted call per profile wins. Keep the game's call, fallback, generated set and flags together.
|
||||
// Internal helpers use the next-layer function directly, so fallback paths never take this mutex twice.
|
||||
pthread_mutex_lock(&bindings_lock);
|
||||
XrInteractionProfileSuggestedBinding copy;
|
||||
XrActionSuggestedBinding *kept = suggested && suggested->countSuggestedBindings
|
||||
? calloc(suggested->countSuggestedBindings, sizeof(*kept)) : NULL;
|
||||
@@ -505,6 +615,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrSuggestInteractionProfileBindings(X
|
||||
// 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);
|
||||
pthread_mutex_unlock(&bindings_lock);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -525,6 +636,10 @@ static XrResult suggest_bindings(XrInstance instance, PFN_xrSuggestInteractionPr
|
||||
XrResult r = known && !strcmp(profile, TOUCH_PROFILE) ? suggest_touch(instance, fn, to_string, suggested)
|
||||
: fn(instance, suggested);
|
||||
input_diag_suggested(instance, suggested, r, to_string); // the runtime's answer to the game's own suggestion
|
||||
if (known && XR_SUCCEEDED(r) && !strcmp(profile, FRAME_PROFILE) && quest_buttons && quest_buttons_enabled) {
|
||||
app_suggested_frame = 1;
|
||||
generated_frame_bindings = 0;
|
||||
}
|
||||
if (!profile_remap || !known) return r;
|
||||
if (!strcmp(profile, TOUCH_PROFILE)) {
|
||||
if (XR_SUCCEEDED(r)) app_suggested_touch = 1; // the app's own Touch bindings win over a remap
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate controller illustrations and the user mapping table from the shared input contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CONTRACT = ROOT / "docs/input/quest_buttons.json"
|
||||
# d-pad direction marks: triangle corners around the label's centre
|
||||
ARROWS = {"↑": ((0, -4), (-5, 3), (5, 3)), "↓": ((0, 4), (-5, -3), (5, -3)),
|
||||
"←": ((-4, 0), (3, -5), (3, 5)), "→": ((4, 0), (-3, -5), (-3, 5))}
|
||||
DOC = ROOT / "docs/CONTROLLERS.md"
|
||||
START = "<!-- quest-buttons-table:start -->"
|
||||
END = "<!-- quest-buttons-table:end -->"
|
||||
|
||||
|
||||
def portal_colors() -> dict:
|
||||
# Read literal tokens without importing the GUI or consulting user preferences.
|
||||
tree = ast.parse((ROOT / "src/frameport/ui/theme.py").read_text(encoding="utf-8"))
|
||||
return next(ast.literal_eval(n.value) for n in tree.body if isinstance(n, ast.Assign)
|
||||
and any(isinstance(t, ast.Name) and t.id == "_PORTAL" for t in n.targets))
|
||||
|
||||
|
||||
def inverse(mode: dict, hand: str) -> dict:
|
||||
return {frame: touch.upper() if touch != "menu" else "Menu"
|
||||
for touch, frames in mode.get(hand, {}).items() for frame in frames}
|
||||
|
||||
|
||||
def mapping_table(data: dict) -> str:
|
||||
rows = ["| Frame input | SteamVR default (0) | Mirror (1) | Quest pair (2) |", "|---|---|---|---|"]
|
||||
inputs = (("left", "dpad_up", "Left d-pad ↑"), ("left", "dpad_down", "Left d-pad ↓"),
|
||||
("left", "dpad_left", "Left d-pad ←"), ("left", "dpad_right", "Left d-pad →"),
|
||||
("left", "view", "Left View"), ("right", "a", "Right A (bottom)"),
|
||||
("right", "b", "Right B (right)"), ("right", "x", "Right X (left)"),
|
||||
("right", "y", "Right Y (top)"), ("right", "menu", "Right Menu"),
|
||||
("left", "bumper", "Left bumper"), ("right", "bumper", "Right bumper"))
|
||||
for hand, key, label in inputs:
|
||||
values = [inverse(data["modes"][str(n)], hand).get(key, "—") for n in range(3)]
|
||||
rows.append("| " + " | ".join([label, *values]) + " |")
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def illustration(data: dict, colors: dict, mode: str | None = None) -> str:
|
||||
compact = mode is not None
|
||||
width, height = (520, 334) if compact else (1080, 750)
|
||||
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
|
||||
f'viewBox="0 0 {width} {height}" role="img" aria-labelledby="title desc">',
|
||||
'<title id="title">Quest Touch buttons on Steam Frame controllers</title>',
|
||||
'<desc id="desc">' + escape(
|
||||
(data["modes"][mode]["name"] + ": " + data["modes"][mode]["note"] if compact else
|
||||
"Quest Touch reference and three Frame mappings: SteamVR default, Mirror, and Quest pair.")
|
||||
+ " Each button shows its physical input above and the Touch input it sends below. "
|
||||
"A dash means unbound. Left Touch buttons are X and Y; right are A and B.") + '</desc>',
|
||||
f'<rect width="{width}" height="{height}" rx="16" fill="{colors["BG"]}"/>',
|
||||
'<g font-family="DejaVu Sans,Arial,sans-serif">']
|
||||
|
||||
def text(x, y, label, size=14, color=None, anchor="start", weight=400):
|
||||
parts.append(f'<text x="{x}" y="{y}" fill="{color or colors["TEXT"]}" font-size="{size}" '
|
||||
f'font-weight="{weight}" text-anchor="{anchor}">{escape(label)}</text>')
|
||||
|
||||
def button(x, y, physical, touch):
|
||||
color = colors["SECONDARY"] if touch in ("X", "A") else colors["ACCENT"]
|
||||
if touch == "—":
|
||||
color = colors["TEXT_3"]
|
||||
parts.append(f'<circle cx="{x}" cy="{y}" r="25" fill="{colors["SURFACE_2"]}" '
|
||||
f'stroke="{color}" stroke-width="1.5"/>')
|
||||
if physical in ARROWS: # drawn, not text: the app's SVG renderer has no arrow glyphs
|
||||
(ax, ay), (bx, by), (cx_, cy_) = ((x + px, y - 10 + py) for px, py in ARROWS[physical])
|
||||
parts.append(f'<polygon points="{ax},{ay} {bx},{by} {cx_},{cy_}" fill="{colors["TEXT_2"]}"/>')
|
||||
else:
|
||||
text(x, y - 6, physical, 11, colors["TEXT_2"], "middle")
|
||||
text(x, y + 15, touch, 19 if len(touch) == 1 else 13, color, "middle", 600)
|
||||
|
||||
def small(x, y, physical, touch):
|
||||
parts.append(f'<rect x="{x - 57}" y="{y - 16}" width="114" height="32" rx="8" '
|
||||
f'fill="{colors["SURFACE_2"]}"/>')
|
||||
text(x, y + 5, f"{physical}: {touch}", 12, colors["TEXT_2"], "middle")
|
||||
|
||||
def panel(x, y, key):
|
||||
parts.append(f'<g transform="translate({x} {y})">')
|
||||
parts.append(f'<rect x="0" y="0" width="520" height="334" rx="12" '
|
||||
f'fill="{colors["SURFACE"]}" stroke="{colors["BORDER"]}"/>')
|
||||
title = "Quest Touch reference" if key is None else data["modes"][key]["name"]
|
||||
text(20, 31, title, 20, weight=600)
|
||||
text(20, 55, "Physical button above · Touch input below", 13, colors["TEXT_2"])
|
||||
for hand, cx in (("left", 140), ("right", 380)):
|
||||
text(cx, 84, hand.capitalize(), 13, colors["TEXT_2"], "middle")
|
||||
if key is None:
|
||||
low, high = ("X", "Y") if hand == "left" else ("A", "B")
|
||||
inner = 23 if hand == "left" else -23
|
||||
button(cx + inner, 196, low, low)
|
||||
button(cx - inner, 141, high, high)
|
||||
if hand == "left":
|
||||
small(cx, 257, "Menu", "Menu")
|
||||
else:
|
||||
mapped = inverse(data["modes"][key], hand)
|
||||
positions = [(0, -50, "dpad_up", "↑"), (-50, 0, "dpad_left", "←"),
|
||||
(50, 0, "dpad_right", "→"), (0, 50, "dpad_down", "↓")] if hand == "left" else [
|
||||
(0, -50, "y", "Y"), (-50, 0, "x", "X"),
|
||||
(50, 0, "b", "B"), (0, 50, "a", "A")]
|
||||
for dx, dy, name, label in positions:
|
||||
button(cx + dx, 161 + dy, label, mapped.get(name, "—"))
|
||||
small(cx, 257, "View" if hand == "left" else "Menu", mapped.get("view", "—"))
|
||||
small(cx, 302, "Bumper", "—")
|
||||
if key is None:
|
||||
text(260, 306, "Lower: X / A · Upper: Y / B", 14, colors["TEXT_2"], "middle")
|
||||
parts.append('</g>')
|
||||
|
||||
if compact:
|
||||
panel(0, 0, mode)
|
||||
else:
|
||||
text(20, 32, "Controller buttons", 24, weight=600)
|
||||
text(20, 57, "Blue: X / A · Orange: Y / B or Menu · —: no Touch input", 14, colors["TEXT_2"])
|
||||
for x, y, key in ((16, 72, None), (544, 72, "0"), (16, 414, "1"), (544, 414, "2")):
|
||||
panel(x, y, key)
|
||||
return "\n".join([*parts, '</g>', '</svg>', ''])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="fail if generated files differ")
|
||||
parser.add_argument("--compact", action="store_true", help="generate one compact controller pair")
|
||||
parser.add_argument("--mode", choices=("0", "1", "2"), default="1")
|
||||
parser.add_argument("--out", type=Path, help="destination for --compact (default: the bundled app asset)")
|
||||
args = parser.parse_args()
|
||||
data = json.loads(CONTRACT.read_text(encoding="utf-8"))
|
||||
colors = portal_colors()
|
||||
assets = ROOT / "src/frameport/ui/icons"
|
||||
if args.compact:
|
||||
outputs = {args.out or assets / f"quest-buttons-{args.mode}.svg": illustration(data, colors, args.mode)}
|
||||
else:
|
||||
outputs = {ROOT / "docs/images/controllers/quest-buttons.svg": illustration(data, colors)}
|
||||
outputs.update({assets / f"quest-buttons-{n}.svg": illustration(data, colors, str(n)) for n in range(3)})
|
||||
doc = DOC.read_text(encoding="utf-8")
|
||||
before, rest = doc.split(START, 1)
|
||||
_, after = rest.split(END, 1)
|
||||
outputs[DOC] = before + START + "\n" + mapping_table(data) + "\n" + END + after
|
||||
stale = []
|
||||
for path, content in outputs.items():
|
||||
if args.check:
|
||||
if not path.exists() or path.read_text(encoding="utf-8") != content:
|
||||
stale.append(str(path.relative_to(ROOT) if path.is_relative_to(ROOT) else path))
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
if stale:
|
||||
print("Regenerate with uv run python scripts/controller_diagram.py: " + ", ".join(stale))
|
||||
return int(bool(stale))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -229,6 +229,11 @@
|
||||
"title": "Animate fingers from touch",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.quest_buttons": {
|
||||
"anchor": "adapter-quest_buttons",
|
||||
"title": "Controller buttons",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.rect_clamp": {
|
||||
"anchor": "adapter-rect_clamp",
|
||||
"title": "Keep pictures in bounds",
|
||||
|
||||
@@ -24,6 +24,19 @@ def sha256(path: Path) -> str:
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def native_checksums(names: list[str]) -> dict[str, str | None] | None:
|
||||
"""Current bundled checksums for selected APK artifacts; None means the manifest is unavailable."""
|
||||
from .core.paths import artifacts_dir
|
||||
|
||||
try:
|
||||
lines = (artifacts_dir() / "SHA256SUMS").read_text().splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
checksums = {parts[1].strip().removeprefix("./"): parts[0]
|
||||
for line in lines if len(parts := line.split(maxsplit=1)) == 2}
|
||||
return {name: checksums.get(name) for name in names}
|
||||
|
||||
|
||||
def overport_ids(recipe: Recipe, alt: bool = False) -> list[str]:
|
||||
"""OVRPort defaults (OVRPort's order) first, then extras in recipe order, then the alt-build extras."""
|
||||
chosen = [pid for pid in recipe.patches if base.get(pid).category == "overport"]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Controller preferences at the library boundary; recipe and configuration identities stay preference-free."""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import library
|
||||
|
||||
QUEST_BUTTONS = "adapter.quest_buttons"
|
||||
CHOICES = ((0, "SteamVR default"), (1, "Mirror"), (2, "Quest pair"))
|
||||
|
||||
|
||||
def quest_buttons_default() -> int:
|
||||
value = library.setting("input.quest_buttons", 0)
|
||||
return int(value) if value in (0, 1, 2, "0", "1", "2") else 0
|
||||
|
||||
|
||||
def effective_adapter_settings(patches: dict) -> dict:
|
||||
from ..patches.frame.headset_model import adapter_settings
|
||||
|
||||
return adapter_settings(patches, defaults={"quest_buttons": quest_buttons_default()})
|
||||
|
||||
|
||||
def _adapter_current(game: dict, installed: dict) -> bool:
|
||||
from ..build import native_checksums
|
||||
|
||||
build = game.get("build") or {}
|
||||
# A newer local build does not prove that its adapter reached the installed APK.
|
||||
sha = installed.get("sha256")
|
||||
if not sha or sha not in (build.get("sha256"), build.get("alt_sha256")):
|
||||
return False
|
||||
recorded = (build.get("build_inputs") or {}).get("native") or {}
|
||||
adapters = [name for name in recorded if name.endswith("/libopenxr_loader_generic.so")]
|
||||
current = native_checksums(adapters) or {}
|
||||
return bool(adapters) and all(recorded[name] and recorded[name] == current.get(name) for name in adapters)
|
||||
|
||||
|
||||
def apply_default(target, reporter) -> dict[str, list[str]]:
|
||||
"""Push only inherited Quest preferences. Read the library and device off the UI thread."""
|
||||
mode = quest_buttons_default()
|
||||
reporter.stage("Apply controller buttons")
|
||||
games = {g["package"]: g for g in library.games()}
|
||||
applied, needs_update = [], []
|
||||
for installed in target.installed():
|
||||
reporter.check_cancel()
|
||||
package = installed["package"]
|
||||
game = games.get(package, {})
|
||||
if installed.get("kind", "quest") != "quest" or game.get("kind", "quest") != "quest":
|
||||
continue
|
||||
analysis = game.get("analysis") or {}
|
||||
if (analysis.get("extra") or {}).get("vr_kind", "quest") != "quest":
|
||||
continue
|
||||
patches = (game.get("recipe") or {}).get("patches") or {}
|
||||
recorded = (installed.get("recipe") or {}).get("patches") or []
|
||||
if QUEST_BUTTONS in patches or (not game and QUEST_BUTTONS in recorded):
|
||||
continue
|
||||
target.set_settings(package, {"quest_buttons": mode})
|
||||
current = _adapter_current(game, installed)
|
||||
(applied if current else needs_update).append(package)
|
||||
when = "next launch" if current else "needs Update on Frame first"
|
||||
reporter.log(f"{package}: quest_buttons={mode} ({when})")
|
||||
return {"applied": applied, "needs_update": needs_update}
|
||||
@@ -53,6 +53,21 @@ def configuration_fingerprint(recipe: dict, package: str = "", superseded=None)
|
||||
return fingerprint(configuration(recipe, package, superseded))
|
||||
|
||||
|
||||
def configuration_matches(recipe: dict, applied: str | None, package: str = "", superseded=None) -> bool:
|
||||
current = configuration(recipe, package, superseded)
|
||||
if fingerprint(current) == applied:
|
||||
return True
|
||||
if "adapter.quest_buttons" in (recipe.get("patches") or {}):
|
||||
return False
|
||||
# set_settings records the live value in the agent's configuration fingerprint. Any inherited mode is
|
||||
# current: changing the library default is applied explicitly, without making the whole library outdated.
|
||||
for mode in (1, 2):
|
||||
current["settings"]["quest_buttons"] = mode
|
||||
if fingerprint(current) == applied:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdatePlan:
|
||||
rebuild: bool = False
|
||||
@@ -89,8 +104,8 @@ def plan_update(game: dict, deployment: dict | None, *, apk_only: bool = False,
|
||||
rebuild = True
|
||||
sha = build.get("alt_sha256") if recipe.get("use_alt") else build.get("sha256")
|
||||
apk = missing or rebuild or not sha or dep.get("sha256") != sha
|
||||
configure = apk or dep.get("configuration_fp") != configuration_fingerprint(
|
||||
recipe, package, build.get("superseded"))
|
||||
configure = apk or not configuration_matches(recipe, dep.get("configuration_fp"),
|
||||
package, build.get("superseded"))
|
||||
if data_manifest is None:
|
||||
data_manifest = game.get("data_manifest")
|
||||
data = False
|
||||
|
||||
@@ -11,11 +11,11 @@ from pathlib import Path
|
||||
|
||||
from ..artwork import fetch as artwork
|
||||
from ..build import sha256
|
||||
from ..core.controllers import effective_adapter_settings
|
||||
from ..core.events import Cancelled, Reporter
|
||||
from ..core.models import Recipe, data_manifest
|
||||
from ..frame.connection import Frame, sh_quote
|
||||
from ..patches import base
|
||||
from ..patches.frame.headset_model import adapter_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -31,7 +31,7 @@ class InstallPlan:
|
||||
|
||||
|
||||
def install_context(recipe: Recipe) -> base.InstallContext:
|
||||
ctx = base.InstallContext(recipe.package, {}, {}, {}, adapter_settings(recipe.patches))
|
||||
ctx = base.InstallContext(recipe.package, {}, {}, {}, effective_adapter_settings(recipe.patches))
|
||||
# device.foveation last: the player's explicit choice beats a recipe's lepton_env VK_INSTANCE_LAYERS
|
||||
for pid, params in sorted(recipe.patches.items(), key=lambda kv: kv[0] == "device.foveation"):
|
||||
patch = base.get(pid)
|
||||
@@ -58,6 +58,8 @@ def configuration_args(entry: dict, recipe: Recipe) -> dict:
|
||||
build = entry.get("build") or {}
|
||||
applied = configuration(original, recipe.package, build.get("superseded"))
|
||||
settings = dict(applied["settings"])
|
||||
# Preferences affect the runtime payload, never the recorded fingerprint.
|
||||
settings["quest_buttons"] = ctx.adapter_settings["quest_buttons"]
|
||||
if foveation_preference(entry).get("mode") in ("tracked", "fixed"):
|
||||
settings.update(foveation_fix=1, foveation_storage_fix=1)
|
||||
return dict(settings=settings, files={k: v.decode() if isinstance(v, bytes) else v
|
||||
|
||||
@@ -186,6 +186,12 @@
|
||||
"APK": "",
|
||||
"Appearance": "",
|
||||
"AppImage": "",
|
||||
"Applied to {n} game. Takes effect at the next launch.": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Apply controller buttons to installed games": "",
|
||||
"Apply to installed games now": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped), and the runtime's answer for each hand is reported to them as their own profile (Unity's OpenXR input only connects controllers of a profile it knows). Meta's detached-controller paths (XR_META_detached_controllers, which the Frame lacks) are left out of the app's actions and bindings: one such path made Unity attach no controller input at all. Without it such apps get no controller input.": "",
|
||||
"Artwork for {get}": "",
|
||||
"Artwork updated ({src}). The Frame's Steam library still shows the old art.": "",
|
||||
@@ -275,10 +281,13 @@
|
||||
"Choose a folder": "",
|
||||
"Choose a folder with Quest, Android or PC VR games. FramePort finds them and gets them ready.": "",
|
||||
"Choose Flat window if a phone or tablet app shows nothing in the headset, VR if a VR app opens as a flat window.": "",
|
||||
"Choose how the Frame's face buttons send Quest Touch inputs: 0 = SteamVR default, 1 = Mirror, 2 = Quest pair. Used at the next launch; SteamVR custom bindings can override it. Requires the runtime's XR_VALVE_frame_controller_interaction extension. Mapping: https://github.com/spoopyghosty0/frameport/blob/main/docs/CONTROLLERS.md": "",
|
||||
"Choose PNG, JPEG or WebP images. Missing shapes are made from the ones you choose, and FramePort never replaces them.": "",
|
||||
"Choose the headset identity the game sees; Quest 3 is the default.": "",
|
||||
"Choose the language for games that follow the system language. The game must include it. Apply with Update on Frame.": "",
|
||||
"Choose the OpenXR headset name and headset UUID reported to Quest games. Quest 3 is the default; Quest 2 preserves OVRPort's original answers. Requires a rebuild. Android Build.MODEL remains Lepton.": "",
|
||||
"Choose which Quest Touch buttons the Frame's face buttons send.": "",
|
||||
"Choose which Quest Touch buttons the Frame's face buttons send. Takes effect at the next launch.": "",
|
||||
"Choose…": "",
|
||||
"Chosen by you.": "",
|
||||
"Clean up Frida leftovers in smali": "",
|
||||
@@ -328,7 +337,9 @@
|
||||
"Connection lost, reconnecting…": "",
|
||||
"Content files from the game's data": "",
|
||||
"Continue": "",
|
||||
"Controller buttons": "",
|
||||
"Controller input diagnostics (log)": "",
|
||||
"Controller mapping guide": "",
|
||||
"Controllers": "",
|
||||
"Controllers as a gamepad (Steam Input)": "",
|
||||
"Converts Quest games to OpenXR": "",
|
||||
@@ -393,6 +404,7 @@
|
||||
"Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.": "",
|
||||
"Default": "",
|
||||
"Default (30% radius)": "",
|
||||
"Default controller buttons": "",
|
||||
"Delete": "",
|
||||
"Delete {names}?": "",
|
||||
"Delete {n} items…": "",
|
||||
@@ -607,6 +619,7 @@
|
||||
"Frame agent": "",
|
||||
"Frame battery: charging": "",
|
||||
"Frame battery: not charging": "",
|
||||
"Frame controller buttons: physical input above, Quest Touch input below. A dash means unbound.": "",
|
||||
"Frame not connected": "",
|
||||
"Frame OpenXR compatibility layer": "",
|
||||
"FrameBridge OpenXR adapter": "",
|
||||
@@ -988,6 +1001,7 @@
|
||||
"Meta's OVRPlugin reports a thumb (or index finger) resting near the controller through XR_FB_touch_controller_proximity, which the Frame's runtime lacks: \"near\" stays false and games that animate the hands from it show a thumb that never moves (for example Vader Immortal; found by Klownicle, GitHub #49). Binds the game's proximity actions to the capacitive touch inputs instead: 1 = thumb (thumbstick, face buttons or thumb rest touched), 2 = also the index finger (trigger touched). Only when the runtime lacks the extension.": "",
|
||||
"MiB": "",
|
||||
"Minecraft asks for desktop OpenGL's GL_DEPTH_COMPONENT32 depth textures. Some builds of LTW, the OpenGL-to-OpenGL ES wrapper in Minecraft launchers such as QuestCraft, pass that on unchanged; Quest's driver accepts it, the Frame's Mesa rejects it (GL_INVALID_OPERATION in glTexImage2D), so the depth buffer never exists and the picture stays black. LTW then loads OpenGL ES through a small shim that asks for GL_DEPTH_COMPONENT32F (or 24-bit) instead.": "",
|
||||
"Mirror": "",
|
||||
"Mirrored": "",
|
||||
"Mixed reality": "",
|
||||
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
|
||||
@@ -1269,6 +1283,8 @@
|
||||
"Quest game": "",
|
||||
"Quest games": "",
|
||||
"Quest games (Lepton)": "",
|
||||
"Quest games bind Touch controllers. SteamVR normally repeats Touch Y/B on three top buttons. Mirror gives the bottom and inner buttons X/A, the top and outer Y/B. Quest pair uses two diagonal buttons per hand. Requires the Frame runtime's controller extension; SteamVR custom bindings can override the choice. Changes apply at the next launch.": "",
|
||||
"Quest pair": "",
|
||||
"Quest Pro": "",
|
||||
"Quest ✓": "",
|
||||
"Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR games can run on the Frame.": "",
|
||||
@@ -1590,6 +1606,7 @@
|
||||
"SteamOS · Developer Mode": "",
|
||||
"steamos@frame.local or an IP address": "",
|
||||
"SteamVR": "",
|
||||
"SteamVR default": "",
|
||||
"SteamVR isn't running on the Frame: put the headset on, then take the screenshot.": "",
|
||||
"SteamVR overlay": "",
|
||||
"SteamVR overlay app (Frame)": "",
|
||||
@@ -1899,6 +1916,7 @@
|
||||
"Use controllers": "",
|
||||
"Use FramePort": "",
|
||||
"Use FramePort for these links": "",
|
||||
"Use global default ({mode})": "",
|
||||
"Use the new recipe": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use the setup command": "",
|
||||
@@ -1906,6 +1924,7 @@
|
||||
"Use this PC's keyboard on the Frame.": "",
|
||||
"Use this program": "",
|
||||
"Use your own artwork…": "",
|
||||
"Used when installing or updating games that follow the global default. Each game's Game settings can override it. Changes take effect at the next launch. Games installed before this feature need Update on Frame once for the mode to work.": "",
|
||||
"Uses its own Revive — experimental on the Frame": "",
|
||||
"Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.": "",
|
||||
"Uses the store title/icon (OVRPort image service) for the app label.": "",
|
||||
@@ -2089,6 +2108,10 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game needs Update on Frame first.": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n} game now uses the other version found there": [
|
||||
"",
|
||||
""
|
||||
|
||||
@@ -14,9 +14,9 @@ OVRPLUGIN = "libOVRPlugin.so"
|
||||
|
||||
|
||||
def settings_text(recipe_patches: dict) -> bytes:
|
||||
from .headset_model import adapter_settings
|
||||
from ...core.controllers import effective_adapter_settings
|
||||
|
||||
settings = adapter_settings(recipe_patches)
|
||||
settings = effective_adapter_settings(recipe_patches)
|
||||
if settings.get("headset_model") == 2:
|
||||
settings.pop("headset_model") # native default 0 preserves the old name; keep Quest 2 settings byte-stable
|
||||
return "".join(f"{k}={v}\n" for k, v in settings.items()).encode()
|
||||
|
||||
@@ -47,11 +47,11 @@ def model(params):
|
||||
return MODELS[key]
|
||||
|
||||
|
||||
def adapter_settings(recipe_patches):
|
||||
def adapter_settings(recipe_patches, *, defaults=None):
|
||||
"""Share the device choice with embedded settings and configuration-only updates."""
|
||||
from ..settings import adapter_settings as other_settings
|
||||
|
||||
out = other_settings(recipe_patches)
|
||||
out = other_settings(recipe_patches, defaults=defaults)
|
||||
if "device.headset_model" in recipe_patches:
|
||||
out["headset_model"] = model(recipe_patches["device.headset_model"])[0]
|
||||
return out
|
||||
|
||||
@@ -51,6 +51,11 @@ SETTINGS = [
|
||||
("controller_fix", "int", 1, "Report Touch controllers",
|
||||
"Reports Frame controllers as Oculus Touch and hides synthetic hand tracking. Set 0 for games that require hand "
|
||||
"tracking (for example Silhouette)."),
|
||||
("quest_buttons", "int", 0, "Controller buttons",
|
||||
"Choose how the Frame's face buttons send Quest Touch inputs: 0 = SteamVR default, 1 = Mirror, "
|
||||
"2 = Quest pair. Used at the next launch; SteamVR custom bindings can override it. Requires the runtime's "
|
||||
"XR_VALVE_frame_controller_interaction extension. Mapping: "
|
||||
"https://github.com/spoopyghosty0/frameport/blob/main/docs/CONTROLLERS.md"),
|
||||
("swapchain_fix", "int", 1, "Swapchain format fallback",
|
||||
"Retry rejected GLES formats/MSAA with sRGB, samples=1."),
|
||||
("cube_standin", "int", 1, "Stand-in cube swapchains",
|
||||
@@ -317,6 +322,10 @@ UI: dict[str, dict] = {
|
||||
"controller_fix": dict(group="controllers", level="common", label="Use controllers",
|
||||
help="Turn off only for games you play with your hands instead of controllers.",
|
||||
control=("switch",)),
|
||||
"quest_buttons": dict(group="controllers", level="common", label="Controller buttons",
|
||||
help="Choose which Quest Touch buttons the Frame's face buttons send. "
|
||||
"Takes effect at the next launch.",
|
||||
control=("choice", [(0, "SteamVR default"), (1, "Mirror"), (2, "Quest pair")])),
|
||||
"focus_hold_ms": dict(group="screens", level="advanced", label="Ignore focus dips up to", depends="focus_hold",
|
||||
help="Longer for headsets that briefly think they're off your head.",
|
||||
control=("slider", 500.0, 3000.0, 100.0, "{:.0f} ms")),
|
||||
@@ -629,10 +638,11 @@ class AdapterSetting(Patch):
|
||||
pass # collected by adapter_settings()
|
||||
|
||||
|
||||
def adapter_settings(recipe_patches: dict) -> dict:
|
||||
def adapter_settings(recipe_patches: dict, *, defaults: dict | None = None) -> dict:
|
||||
"""Ordered settings for libframe_settings.so / settings.conf: base keys first, then enabled overrides in
|
||||
selection order."""
|
||||
selection order. Callers supply preference defaults; this helper never reads the library."""
|
||||
out = dict(BASE_SETTINGS)
|
||||
out.update(defaults or {})
|
||||
for pid, params in recipe_patches.items():
|
||||
if pid.startswith("adapter."):
|
||||
key = pid.split(".", 1)[1]
|
||||
|
||||
@@ -1275,19 +1275,15 @@ def _build_lock(package: str) -> threading.Lock:
|
||||
|
||||
def _build_inputs(entry: dict, native_names: list[str] | None = None) -> dict:
|
||||
"""Only the native assets carried by this APK and the local converter; no downloads or Java invocations."""
|
||||
from .core.paths import artifacts_dir
|
||||
from .tools import toolchain
|
||||
|
||||
build = entry.get("build") or {}
|
||||
previous = build.get("build_inputs") or {}
|
||||
recipe = entry.get("recipe") or {}
|
||||
native_names = native_names if native_names is not None else build.get("native_names") or []
|
||||
native = previous.get("native") or {}
|
||||
manifest = artifacts_dir() / "SHA256SUMS"
|
||||
if manifest.is_file():
|
||||
checksums = {line.split(maxsplit=1)[1].strip().removeprefix("./"): line.split()[0]
|
||||
for line in manifest.read_text().splitlines() if len(line.split()) == 2}
|
||||
native = {name: checksums.get(name) for name in native_names}
|
||||
native = builder.native_checksums(native_names)
|
||||
if native is None:
|
||||
native = previous.get("native") or {}
|
||||
jar = toolchain.overport_jar() if recipe.get("overport", True) and not recipe.get("as_is") else None
|
||||
converter = previous.get("overport")
|
||||
if jar and jar.is_file():
|
||||
@@ -1769,10 +1765,11 @@ def apply_suggestions_live(package: str, suggestions: list[str], target: Target
|
||||
recipe = apply_suggestions(package, suggestions)
|
||||
if target is None or not adapter_only(suggestions):
|
||||
return False
|
||||
from .patches.settings import SETTINGS, adapter_settings
|
||||
from .core.controllers import effective_adapter_settings
|
||||
from .patches.settings import SETTINGS
|
||||
|
||||
numeric = {key for key, kind, *_ in SETTINGS if kind in ("int", "float")}
|
||||
target.set_settings(package, {k: v for k, v in adapter_settings(recipe.patches).items() if k in numeric})
|
||||
target.set_settings(package, {k: v for k, v in effective_adapter_settings(recipe.patches).items() if k in numeric})
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Bundled controller mapping illustrations, installed with the other UI SVG assets at startup."""
|
||||
from __future__ import annotations
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ..artwork.thumbs import asset_url
|
||||
from ..core.paths import user_data_dir
|
||||
from ..i18n import tr
|
||||
from . import glyphs as G
|
||||
from . import theme as T
|
||||
|
||||
|
||||
def diagram_source(mode: int) -> str:
|
||||
if mode not in (0, 1, 2):
|
||||
mode = 0 # retain an unknown CLI value in the choice while keeping a valid preview asset
|
||||
return asset_url(user_data_dir() / G.FOLDER / f"quest-buttons-{mode}.svg")
|
||||
|
||||
|
||||
def diagram(mode: int) -> ft.Image:
|
||||
return ft.Image(src=diagram_source(mode), width=T.px(440), height=T.px(283), fit=ft.BoxFit.CONTAIN,
|
||||
semantics_label=tr("Frame controller buttons: physical input above, Quest Touch input below. "
|
||||
"A dash means unbound."))
|
||||
@@ -148,6 +148,10 @@ HELP: dict[str, str] = _Translated({
|
||||
"frame_summary": "Quest ✓: Lepton is installed, so Quest games can run. PC VR ✓: Proton is installed, so PC VR "
|
||||
"games can run on the Frame.",
|
||||
# ---- settings
|
||||
"quest_buttons": "Quest games bind Touch controllers. SteamVR normally repeats Touch Y/B on three top "
|
||||
"buttons. Mirror gives the bottom and inner buttons X/A, the top and outer Y/B. Quest pair "
|
||||
"uses two diagonal buttons per hand. Requires the Frame runtime's controller extension; "
|
||||
"SteamVR custom bindings can override the choice. Changes apply at the next launch.",
|
||||
"data_folder": "FramePort's tools, library, artwork and your games' signing keys. Back it up: an update needs the "
|
||||
"same key, or the game must be reinstalled and loses its saves.",
|
||||
"catalog": "Recipes tested on a real Frame: built in, fetched online and the ones you saved as known-good.",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="334" viewBox="0 0 520 334" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Quest Touch buttons on Steam Frame controllers</title>
|
||||
<desc id="desc">SteamVR default: FrameBridge suggests nothing for the Frame profile; SteamVR remaps Touch itself (documented by Valve). Listed for the diagram only. Each button shows its physical input above and the Touch input it sends below. A dash means unbound. Left Touch buttons are X and Y; right are A and B.</desc>
|
||||
<rect width="520" height="334" rx="16" fill="#0D0E12"/>
|
||||
<g font-family="DejaVu Sans,Arial,sans-serif">
|
||||
<g transform="translate(0 0)">
|
||||
<rect x="0" y="0" width="520" height="334" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">SteamVR default</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Physical button above · Touch input below</text>
|
||||
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<circle cx="140" cy="111" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="140,97 135,104 145,104" fill="#A7ABB7"/>
|
||||
<text x="140" y="126" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="90" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="86,151 93,146 93,156" fill="#A7ABB7"/>
|
||||
<text x="90" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="190" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="194,151 187,146 187,156" fill="#A7ABB7"/>
|
||||
<text x="190" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="140" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<polygon points="140,205 135,198 145,198" fill="#A7ABB7"/>
|
||||
<text x="140" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<rect x="83" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">View: Menu</text>
|
||||
<rect x="83" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<circle cx="380" cy="111" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="380" y="105" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">Y</text>
|
||||
<text x="380" y="126" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="330" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="330" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">X</text>
|
||||
<text x="330" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="430" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="430" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">B</text>
|
||||
<text x="430" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="380" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="380" y="205" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="380" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<rect x="323" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu: —</text>
|
||||
<rect x="323" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="334" viewBox="0 0 520 334" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Quest Touch buttons on Steam Frame controllers</title>
|
||||
<desc id="desc">Mirror: Every face button works; the left controller mirrors the right: bottom + inner = X/A, top + outer = Y/B. Each button shows its physical input above and the Touch input it sends below. A dash means unbound. Left Touch buttons are X and Y; right are A and B.</desc>
|
||||
<rect width="520" height="334" rx="16" fill="#0D0E12"/>
|
||||
<g font-family="DejaVu Sans,Arial,sans-serif">
|
||||
<g transform="translate(0 0)">
|
||||
<rect x="0" y="0" width="520" height="334" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Mirror</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Physical button above · Touch input below</text>
|
||||
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<circle cx="140" cy="111" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="140,97 135,104 145,104" fill="#A7ABB7"/>
|
||||
<text x="140" y="126" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="90" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="86,151 93,146 93,156" fill="#A7ABB7"/>
|
||||
<text x="90" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="190" cy="161" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<polygon points="194,151 187,146 187,156" fill="#A7ABB7"/>
|
||||
<text x="190" y="176" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<circle cx="140" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<polygon points="140,205 135,198 145,198" fill="#A7ABB7"/>
|
||||
<text x="140" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<rect x="83" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">View: Menu</text>
|
||||
<rect x="83" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<circle cx="380" cy="111" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="380" y="105" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">Y</text>
|
||||
<text x="380" y="126" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="330" cy="161" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="330" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">X</text>
|
||||
<text x="330" y="176" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<circle cx="430" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="430" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">B</text>
|
||||
<text x="430" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="380" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="380" y="205" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="380" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<rect x="323" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu: —</text>
|
||||
<rect x="323" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="334" viewBox="0 0 520 334" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Quest Touch buttons on Steam Frame controllers</title>
|
||||
<desc id="desc">Quest pair: Two buttons per controller, placed diagonally like Quest Touch; the other face buttons do nothing. Each button shows its physical input above and the Touch input it sends below. A dash means unbound. Left Touch buttons are X and Y; right are A and B.</desc>
|
||||
<rect width="520" height="334" rx="16" fill="#0D0E12"/>
|
||||
<g font-family="DejaVu Sans,Arial,sans-serif">
|
||||
<g transform="translate(0 0)">
|
||||
<rect x="0" y="0" width="520" height="334" rx="12" fill="#16181E" stroke="#2A2D37"/>
|
||||
<text x="20" y="31" fill="#EDEEF2" font-size="20" font-weight="600" text-anchor="start">Quest pair</text>
|
||||
<text x="20" y="55" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="start">Physical button above · Touch input below</text>
|
||||
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
|
||||
<circle cx="140" cy="111" r="25" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<polygon points="140,97 135,104 145,104" fill="#A7ABB7"/>
|
||||
<text x="140" y="126" fill="#868A96" font-size="19" font-weight="600" text-anchor="middle">—</text>
|
||||
<circle cx="90" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<polygon points="86,151 93,146 93,156" fill="#A7ABB7"/>
|
||||
<text x="90" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">Y</text>
|
||||
<circle cx="190" cy="161" r="25" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<polygon points="194,151 187,146 187,156" fill="#A7ABB7"/>
|
||||
<text x="190" y="176" fill="#868A96" font-size="19" font-weight="600" text-anchor="middle">—</text>
|
||||
<circle cx="140" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<polygon points="140,205 135,198 145,198" fill="#A7ABB7"/>
|
||||
<text x="140" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">X</text>
|
||||
<rect x="83" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">View: Menu</text>
|
||||
<rect x="83" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="140" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
|
||||
<circle cx="380" cy="111" r="25" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<text x="380" y="105" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">Y</text>
|
||||
<text x="380" y="126" fill="#868A96" font-size="19" font-weight="600" text-anchor="middle">—</text>
|
||||
<circle cx="330" cy="161" r="25" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
|
||||
<text x="330" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">X</text>
|
||||
<text x="330" y="176" fill="#868A96" font-size="19" font-weight="600" text-anchor="middle">—</text>
|
||||
<circle cx="430" cy="161" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
|
||||
<text x="430" y="155" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">B</text>
|
||||
<text x="430" y="176" fill="#FF8A1F" font-size="19" font-weight="600" text-anchor="middle">B</text>
|
||||
<circle cx="380" cy="211" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
|
||||
<text x="380" y="205" fill="#A7ABB7" font-size="11" font-weight="400" text-anchor="middle">A</text>
|
||||
<text x="380" y="226" fill="#3AA8FF" font-size="19" font-weight="600" text-anchor="middle">A</text>
|
||||
<rect x="323" y="241" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="262" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu: —</text>
|
||||
<rect x="323" y="286" width="114" height="32" rx="8" fill="#1D1F27"/>
|
||||
<text x="380" y="307" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Bumper: —</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -10,6 +10,7 @@ import flet as ft
|
||||
|
||||
from ... import pipeline
|
||||
from ...core import library
|
||||
from ...core.controllers import QUEST_BUTTONS, effective_adapter_settings, quest_buttons_default
|
||||
from ...i18n import tr
|
||||
from ...patches import base
|
||||
from ...patches.settings import (
|
||||
@@ -20,12 +21,12 @@ from ...patches.settings import (
|
||||
SETTINGS,
|
||||
UI,
|
||||
Foveation,
|
||||
adapter_settings,
|
||||
foveation_options,
|
||||
foveation_preference,
|
||||
)
|
||||
from .. import components as C
|
||||
from .. import theme as T
|
||||
from ..controllers import diagram, diagram_source
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
@@ -57,20 +58,23 @@ def relevant(analysis, key: str, value) -> bool:
|
||||
|
||||
def recipe_values(g: dict) -> dict:
|
||||
vals = {key: default(key) for key in SPECS}
|
||||
vals.update({k: _num(k, v) for k, v in adapter_settings(library.recipe_from_dict(g["recipe"]).patches).items()
|
||||
if k in SPECS})
|
||||
patches = library.recipe_from_dict(g["recipe"]).patches
|
||||
vals.update({k: _num(k, v) for k, v in effective_adapter_settings(patches).items() if k in SPECS})
|
||||
return vals
|
||||
|
||||
|
||||
def save_to_recipe(package: str, values: dict) -> None:
|
||||
def save_to_recipe(package: str, values: dict, *, inherit_quest_buttons: bool | None = None) -> None:
|
||||
"""Adapter patches = the settings that differ from their defaults (BASE_SETTINGS are written anyway)."""
|
||||
g = library.game(package)
|
||||
r = library.recipe_from_dict(g["recipe"])
|
||||
before = {pid: dict(v or {}) for pid, v in r.patches.items() if pid.startswith("adapter.")}
|
||||
if inherit_quest_buttons is None:
|
||||
inherit_quest_buttons = (QUEST_BUTTONS not in r.patches
|
||||
and values.get("quest_buttons") == quest_buttons_default())
|
||||
for key in SPECS:
|
||||
pid = f"adapter.{key}"
|
||||
v = _num(key, values.get(key, default(key)))
|
||||
if v == default(key):
|
||||
if (inherit_quest_buttons if key == "quest_buttons" else v == default(key)):
|
||||
r.patches.pop(pid, None)
|
||||
r.reasons.pop(pid, None)
|
||||
else:
|
||||
@@ -111,11 +115,18 @@ def show_adapter_dialog(app: FramePortApp, package: str) -> None:
|
||||
_open(app, package, g, recipe_values(g))
|
||||
|
||||
|
||||
def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveation_values=None) -> None:
|
||||
def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveation_values=None,
|
||||
inherit_quest_buttons: bool | None = None) -> None:
|
||||
analysis = library.analysis_from_dict(g["analysis"]) if g and g.get("analysis") else None
|
||||
suggested = recipe_values({"recipe": g["suggested"]}) if g and g.get("suggested") else \
|
||||
{key: default(key) for key in SPECS}
|
||||
values = dict(values)
|
||||
global_mode = quest_buttons_default()
|
||||
inherited = {"on": (g is not None and QUEST_BUTTONS not in (g.get("recipe") or {}).get("patches", {}))
|
||||
if inherit_quest_buttons is None else inherit_quest_buttons}
|
||||
if inherited["on"]:
|
||||
values["quest_buttons"] = global_mode
|
||||
controller_preview = diagram(values["quest_buttons"])
|
||||
foveation = dict(foveation_values) if foveation_values is not None else foveation_preference(g or {})
|
||||
foveation_controls = []
|
||||
foveation_rows = {}
|
||||
@@ -156,7 +167,13 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
or any(UI[k]["level"] == "advanced" and values[k] != default(k) for k in SPECS)}
|
||||
|
||||
def set_value(key, v):
|
||||
if key == "quest_buttons":
|
||||
inherited["on"] = v == "global"
|
||||
v = global_mode if inherited["on"] else v
|
||||
values[key] = _num(key, v)
|
||||
if key == "quest_buttons":
|
||||
controller_preview.src = diagram_source(values[key])
|
||||
C.update(controller_preview)
|
||||
if key in readouts:
|
||||
readouts[key].value = UI[key]["control"][4].format(values[key])
|
||||
C.update(readouts[key])
|
||||
@@ -178,7 +195,11 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
choices = list(spec[0])
|
||||
if v not in [c for c, _ in choices]:
|
||||
choices.append((v, str(v))) # a value set elsewhere (CLI, catalog) stays selectable
|
||||
return C.dropdown(value=str(v), width=T.px(220),
|
||||
if key == "quest_buttons" and g is not None:
|
||||
name = next(label for mode, label in UI[key]["control"][1] if mode == global_mode)
|
||||
choices.insert(0, ("global", tr("Use global default ({mode})").format(mode=tr(name))))
|
||||
return C.dropdown(value="global" if key == "quest_buttons" and inherited["on"] else str(v),
|
||||
width=T.px(300 if key == "quest_buttons" else 220),
|
||||
options=[ft.DropdownOption(key=str(c), text=tr(label)) for c, label in choices],
|
||||
on_select=lambda e: set_value(key, e.control.value))
|
||||
|
||||
@@ -187,10 +208,12 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
label = tr(ui["label"]) if ui.get("label") else tr(base.get(f"adapter.{key}").title)
|
||||
help_text = tr(ui["help"]) if ui.get("help") else tr(base.get(f"adapter.{key}").description)
|
||||
controls[key] = control_for(key)
|
||||
rows[key] = ft.Container(ft.Row([
|
||||
line = ft.Row([
|
||||
ft.Column([C.body(label, T.TEXT, weight=ft.FontWeight.W_500), C.meta(help_text, T.TEXT_2)],
|
||||
spacing=T.px(2), expand=True),
|
||||
controls[key]], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
controls[key]], spacing=T.S4, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
rows[key] = ft.Container(ft.Column([line, controller_preview], spacing=T.S2)
|
||||
if key == "quest_buttons" else line,
|
||||
padding=ft.Padding(0, T.px(8), 0, T.px(8)))
|
||||
return rows[key]
|
||||
|
||||
@@ -226,7 +249,8 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
for k in list(values):
|
||||
values[k] = suggested.get(k, default(k))
|
||||
app.page.pop_dialog()
|
||||
_open(app, package, g, values, {})
|
||||
recommended = (g.get("suggested") or {}).get("patches", {}) if g else {}
|
||||
_open(app, package, g, values, {}, inherit_quest_buttons=g is not None and QUEST_BUTTONS not in recommended)
|
||||
|
||||
before = dict(values)
|
||||
|
||||
@@ -238,7 +262,7 @@ def _open(app: FramePortApp, package: str, g: dict | None, values: dict, foveati
|
||||
app.page.pop_dialog()
|
||||
if g is not None:
|
||||
library.set_fields(package, foveation=dict(foveation))
|
||||
save_to_recipe(package, values)
|
||||
save_to_recipe(package, values, inherit_quest_buttons=inherited["on"])
|
||||
reinstall = any(UI[k].get("reinstall") and values[k] != before[k] for k in SPECS)
|
||||
installed = app.target is not None and (g is None or C.install_state(g, app.frame_info) in
|
||||
("installed", "outdated"))
|
||||
|
||||
@@ -24,14 +24,14 @@ TOOL_WHY = {"java": tr("Runs OVRPort and apksigner"), "overport": tr("Converts Q
|
||||
# the page's sections in order (the index on the left lists them): what most users need first (updates of the app and
|
||||
# their games, installing, where games come from, looks), then what only some need (link handlers, tools FramePort
|
||||
# manages itself, PC VR on this PC), "Uninstall FramePort" last
|
||||
SECTIONS = ("updates", "installing", "folders", "appearance", "data", "feedback", "links", "tools", "pc", "about",
|
||||
"remove")
|
||||
SECTIONS = ("updates", "installing", "controllers", "folders", "appearance", "data", "feedback", "links", "tools",
|
||||
"pc", "about", "remove")
|
||||
|
||||
|
||||
def section_titles() -> dict[str, str]:
|
||||
"""Index labels of SECTIONS (the sections' own headings may be longer)."""
|
||||
return {"appearance": tr("Appearance"), "updates": tr("Updates"), "links": tr("Install links"),
|
||||
"folders": tr("Library folders"),
|
||||
"folders": tr("Library folders"), "controllers": tr("Controllers"),
|
||||
"installing": tr("Installing"), "tools": tr("Tools"), "pc": tr("This PC"), "data": tr("Data"),
|
||||
"feedback": tr("Problems and feedback"), "about": tr("About"), "remove": tr("Uninstall FramePort")}
|
||||
|
||||
@@ -172,6 +172,47 @@ class SettingsView:
|
||||
ft.Container(height=T.S2), self.ssh_keys()],
|
||||
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def controllers(self) -> ft.Control:
|
||||
from ...core import controllers, library
|
||||
from ..controllers import diagram, diagram_source
|
||||
|
||||
app = self.app
|
||||
mode = controllers.quest_buttons_default()
|
||||
preview = diagram(mode)
|
||||
|
||||
def changed(e):
|
||||
nonlocal mode
|
||||
mode = int(e.control.value)
|
||||
library.set_setting("input.quest_buttons", mode)
|
||||
preview.src = diagram_source(mode)
|
||||
C.update(preview)
|
||||
|
||||
def apply(e):
|
||||
def run(job):
|
||||
result = controllers.apply_default(app.target, job.reporter)
|
||||
message = tr_n("Applied to {n} game. Takes effect at the next launch.",
|
||||
"Applied to {n} games. Takes effect at the next launch.", len(result["applied"]))
|
||||
if result["needs_update"]:
|
||||
message += " " + tr_n("{n} game needs Update on Frame first.",
|
||||
"{n} games need Update on Frame first.", len(result["needs_update"]))
|
||||
return message # the job completion toast shows this result too
|
||||
app.submit(tr("Apply controller buttons to installed games"), run, kind="tool-frame")
|
||||
|
||||
choice = C.dropdown(label=tr("Default controller buttons"),
|
||||
value=str(mode), width=T.px(300),
|
||||
options=[ft.DropdownOption(str(v), tr(label)) for v, label in controllers.CHOICES],
|
||||
on_select=changed)
|
||||
return ft.Column([
|
||||
C.body(tr("Choose which Quest Touch buttons the Frame's face buttons send.")),
|
||||
choice, preview,
|
||||
C.meta(tr("Used when installing or updating games that follow the global default. "
|
||||
"Each game's Game settings can override it. Changes take effect at the next launch. "
|
||||
"Games installed before this feature need Update on Frame once for the mode to work.")),
|
||||
C.link(tr("Controller mapping guide"), REPO_URL + "/blob/main/docs/CONTROLLERS.md"),
|
||||
*([C.secondary(tr("Apply to installed games now"), G.SYNC, apply)]
|
||||
if app.frame_state == "connected" else []),
|
||||
], spacing=T.S3, horizontal_alignment=ft.CrossAxisAlignment.START)
|
||||
|
||||
def library_folders(self) -> ft.Control:
|
||||
"""Settings → Library folders: the folders "Rescan folders" scans (core/folders.py, GitHub discussion #351).
|
||||
Status (is it there, how many games come from it) is read off the UI thread."""
|
||||
@@ -653,6 +694,8 @@ class SettingsView:
|
||||
"folders": C.section(tr("Library folders"), C.card(self.library_folders(), padding=T.S4),
|
||||
help="library_folders"),
|
||||
"installing": C.section(tr("Installing"), C.card(self.installing(), padding=T.S4), help="launch_test"),
|
||||
"controllers": C.section(tr("Controllers"), C.card(self.controllers(), padding=T.S4),
|
||||
help="quest_buttons"),
|
||||
"tools": C.section(tr("Tools"), C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
subtitle=tr("FramePort keeps its own copies; nothing is installed system-wide"),
|
||||
action=ft.Row([C.ghost(tr("Update tools"), ft.Icons.UPDATE_ROUNDED,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Quest button preferences: inheritance, deployment payloads and generated mapping assets."""
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import flet as ft
|
||||
import pytest
|
||||
from test_game_update import game_and_dep, installed_agent
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport import pipeline
|
||||
from frameport.core import controllers, library, paths
|
||||
from frameport.core.events import Cancelled, Reporter
|
||||
from frameport.core.models import Recipe
|
||||
from frameport.game_update import configuration, configuration_fingerprint, plan_update
|
||||
from frameport.install.installer import configuration_args, install_context
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame.adapter import settings_text
|
||||
from frameport.patches.settings import SETTINGS, UI, XRSHIM_SETTINGS, adapter_settings
|
||||
from frameport.ui.views import adapter_dialog as ad
|
||||
from frameport.ui.views.settings import SettingsView
|
||||
|
||||
|
||||
def store(patches=None):
|
||||
recipe = Recipe("test.quest", patches=patches or {"frame.adapter": {}})
|
||||
library.upsert_game(recipe.package, title="Quest", recipe=library.recipe_to_dict(recipe))
|
||||
return library.game(recipe.package)
|
||||
|
||||
|
||||
def walk(control):
|
||||
yield control
|
||||
for child in getattr(control, "controls", []) or []:
|
||||
yield from walk(child)
|
||||
for attr in ("content", "title"):
|
||||
child = getattr(control, attr, None)
|
||||
if child is not None and not isinstance(child, str):
|
||||
yield from walk(child)
|
||||
for child in getattr(control, "actions", []) or []:
|
||||
yield from walk(child)
|
||||
|
||||
|
||||
def test_setting_metadata_and_no_suggestion():
|
||||
spec = next(s for s in SETTINGS if s[0] == "quest_buttons")
|
||||
assert spec[1:3] == ("int", 0)
|
||||
assert "docs/CONTROLLERS.md" in spec[4]
|
||||
assert UI["quest_buttons"]["control"] == ("choice", list(controllers.CHOICES))
|
||||
assert UI["quest_buttons"]["group"] == "controllers" and UI["quest_buttons"]["level"] == "common"
|
||||
patch = base.get(controllers.QUEST_BUTTONS)
|
||||
assert patch.detect(_analysis()) is None
|
||||
assert patch.a32 and "quest_buttons" not in XRSHIM_SETTINGS
|
||||
assert patch.applies(_analysis(abis=["armeabi-v7a"]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("global_mode", [0, 1, 2])
|
||||
@pytest.mark.parametrize("override", [None, 0, 1, 2])
|
||||
def test_effective_value_reaches_every_runtime_payload(global_mode, override):
|
||||
library.set_setting("input.quest_buttons", global_mode)
|
||||
patches = {controllers.QUEST_BUTTONS: {"value": override}} if override is not None else {}
|
||||
game = store(patches or {"frame.adapter": {}})
|
||||
recipe = library.recipe_from_dict(game["recipe"])
|
||||
expected = global_mode if override is None else override
|
||||
assert controllers.effective_adapter_settings(recipe.patches)["quest_buttons"] == expected
|
||||
assert install_context(recipe).adapter_settings["quest_buttons"] == expected
|
||||
args = configuration_args(game, recipe)
|
||||
assert args["settings"]["quest_buttons"] == expected
|
||||
assert f"quest_buttons={expected}\n".encode() in settings_text(recipe.patches)
|
||||
assert ad.recipe_values(game)["quest_buttons"] == expected
|
||||
# Fingerprinting is a pure operation on the recipe, not the active library preference.
|
||||
assert args["configuration"]["settings"]["quest_buttons"] == (override or 0)
|
||||
|
||||
|
||||
def test_pure_defaults_and_global_changes_do_not_mark_games_outdated(monkeypatch):
|
||||
def forbidden(*args):
|
||||
raise AssertionError("pure settings helper performed I/O")
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(library, "setting", forbidden)
|
||||
assert adapter_settings({}, defaults={"quest_buttons": 2})["quest_buttons"] == 2
|
||||
assert adapter_settings({controllers.QUEST_BUTTONS: {"value": 0}},
|
||||
defaults={"quest_buttons": 2})["quest_buttons"] == 0
|
||||
game, dep = game_and_dep()
|
||||
before = base.recipe_fingerprint(game["recipe"])
|
||||
config = configuration_fingerprint(game["recipe"])
|
||||
for mode in (1, 2, 0):
|
||||
library.set_setting("input.quest_buttons", mode)
|
||||
assert base.recipe_fingerprint(game["recipe"]) == before
|
||||
assert configuration_fingerprint(game["recipe"]) == config
|
||||
assert not plan_update(game, dep).changed
|
||||
|
||||
|
||||
def test_saving_game_keeps_inheritance_and_explicit_zero():
|
||||
library.set_setting("input.quest_buttons", 2)
|
||||
game = store()
|
||||
ad.save_to_recipe(game["package"], ad.recipe_values(game))
|
||||
assert library.game(game["package"])["recipe"] == game["recipe"]
|
||||
values = ad.recipe_values(game)
|
||||
values["quest_buttons"] = 0
|
||||
ad.save_to_recipe(game["package"], values, inherit_quest_buttons=False)
|
||||
assert library.game(game["package"])["recipe"]["patches"][controllers.QUEST_BUTTONS] == {"value": 0}
|
||||
ad.save_to_recipe(game["package"], values, inherit_quest_buttons=True)
|
||||
assert controllers.QUEST_BUTTONS not in library.game(game["package"])["recipe"]["patches"]
|
||||
assert ad.recipe_values(library.game(game["package"]))["quest_buttons"] == 2
|
||||
|
||||
|
||||
def test_agent_live_default_write_does_not_mark_inherited_game_outdated(monkeypatch, tmp_path):
|
||||
game, _ = game_and_dep()
|
||||
package = game["package"]
|
||||
agent, _, record = installed_agent(monkeypatch, tmp_path)
|
||||
record.update(configuration=configuration(game["recipe"], package),
|
||||
configuration_fp=configuration_fingerprint(game["recipe"], package))
|
||||
agent.write_deployment(package, record)
|
||||
for mode in (1, 2, 0):
|
||||
agent.cmd_set_settings({"package": package, "settings": {"quest_buttons": mode}})
|
||||
deployed = agent.deployment(package)
|
||||
assert not plan_update(game, deployed).changed
|
||||
agent.cmd_set_settings({"package": package, "settings": {"quest_buttons": 1}})
|
||||
deployed = agent.deployment(package)
|
||||
# Explicit overrides still need to match, and unrelated runtime changes are never ignored.
|
||||
game["recipe"]["patches"][controllers.QUEST_BUTTONS] = {"value": 0}
|
||||
assert plan_update(game, deployed).configure
|
||||
game["recipe"]["patches"].pop(controllers.QUEST_BUTTONS)
|
||||
game["recipe"]["patches"]["adapter.scale"] = {"value": 1.8}
|
||||
assert plan_update(game, deployed).configure
|
||||
|
||||
|
||||
def test_dialog_global_option_and_diagram_follow_choice():
|
||||
library.set_setting("input.quest_buttons", 1)
|
||||
game = store()
|
||||
dialogs = []
|
||||
app = SimpleNamespace(page=SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None),
|
||||
target=None, toast=lambda *a, **k: None, refresh_view=lambda: None,
|
||||
_title=lambda p: "Quest")
|
||||
ad._open(app, game["package"], game, ad.recipe_values(game))
|
||||
controls = list(walk(dialogs[-1]))
|
||||
choice = next(c for c in controls if isinstance(c, ft.Dropdown) and any(o.key == "global" for o in c.options))
|
||||
image = next(c for c in controls if isinstance(c, ft.Image) and "quest-buttons" in c.src)
|
||||
assert choice.value == "global" and "Mirror" in choice.options[0].text
|
||||
assert image.src.endswith("quest-buttons-1.svg")
|
||||
choice.on_select(SimpleNamespace(control=SimpleNamespace(value="0")))
|
||||
assert image.src.endswith("quest-buttons-0.svg")
|
||||
dialogs[-1].actions[-1].on_click(None)
|
||||
assert library.game(game["package"])["recipe"]["patches"][controllers.QUEST_BUTTONS] == {"value": 0}
|
||||
|
||||
|
||||
def test_dialog_reset_restores_global_value_without_pinning_it():
|
||||
library.set_setting("input.quest_buttons", 2)
|
||||
game = store({"frame.adapter": {}, controllers.QUEST_BUTTONS: {"value": 1}})
|
||||
dialogs = []
|
||||
app = SimpleNamespace(page=SimpleNamespace(show_dialog=dialogs.append, pop_dialog=lambda: None),
|
||||
target=None, toast=lambda *a, **k: None, refresh_view=lambda: None,
|
||||
_title=lambda p: "Quest")
|
||||
ad._open(app, game["package"], game, ad.recipe_values(game))
|
||||
dialogs[-1].actions[0].on_click(None)
|
||||
assert next(c for c in walk(dialogs[-1]) if isinstance(c, ft.Image) and "quest-buttons" in c.src
|
||||
).src.endswith("quest-buttons-2.svg")
|
||||
dialogs[-1].actions[-1].on_click(None)
|
||||
assert controllers.QUEST_BUTTONS not in library.game(game["package"])["recipe"]["patches"]
|
||||
|
||||
|
||||
class Target:
|
||||
def __init__(self, installed):
|
||||
self.games, self.calls = installed, []
|
||||
|
||||
def installed(self):
|
||||
return self.games
|
||||
|
||||
def set_settings(self, package, values):
|
||||
self.calls.append((package, values))
|
||||
|
||||
|
||||
def test_apply_default_skips_overrides_and_non_quest_games():
|
||||
library.set_setting("input.quest_buttons", 2)
|
||||
store({controllers.QUEST_BUTTONS: {"value": 0}})
|
||||
library.upsert_game("flat", analysis={"extra": {"vr_kind": "none"}})
|
||||
target = Target([{"package": "test.quest"}, {"package": "inherited"}, {"package": "flat"},
|
||||
{"package": "rift", "kind": "pcvr"}, {"package": "linux", "kind": "linux"},
|
||||
{"package": "remote-override", "recipe": {"patches": [controllers.QUEST_BUTTONS]}}])
|
||||
assert controllers.apply_default(target, Reporter()) == {"applied": [], "needs_update": ["inherited"]}
|
||||
assert target.calls == [("inherited", {"quest_buttons": 2})]
|
||||
reporter = Reporter()
|
||||
reporter.cancelled.set()
|
||||
with pytest.raises(Cancelled):
|
||||
controllers.apply_default(target, reporter)
|
||||
assert len(target.calls) == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter_manifest(monkeypatch, tmp_path):
|
||||
manifest = tmp_path / "artifacts" / "SHA256SUMS"
|
||||
manifest.parent.mkdir()
|
||||
manifest.write_text("\n".join(f"{'a' * 64} ./{abi}/libopenxr_loader_generic.so"
|
||||
for abi in ("arm64-v8a", "armeabi-v7a")) + "\n")
|
||||
monkeypatch.setattr(paths, "artifacts_dir", lambda: manifest.parent)
|
||||
return manifest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("abi", ["arm64-v8a", "armeabi-v7a"])
|
||||
@pytest.mark.parametrize(("recorded", "installed_sha", "ready"), [
|
||||
("a" * 64, "apk", True),
|
||||
("a" * 64, "alternate", True),
|
||||
("b" * 64, "apk", False),
|
||||
(None, "apk", False),
|
||||
("a" * 64, "older-apk", False),
|
||||
("a" * 64, None, False),
|
||||
])
|
||||
def test_apply_reports_current_old_and_unknown_adapters(adapter_manifest, abi, recorded, installed_sha, ready):
|
||||
game = store()
|
||||
native = {f"{abi}/libopenxr_loader_generic.so": recorded, f"{abi}/unrelated.so": "old"}
|
||||
library.set_fields(game["package"], build={"sha256": "apk", "alt_sha256": "alternate",
|
||||
"build_inputs": {"native": native}})
|
||||
target = Target([{"package": game["package"], "sha256": installed_sha}])
|
||||
result = controllers.apply_default(target, Reporter())
|
||||
assert result == {"applied": [game["package"]] if ready else [],
|
||||
"needs_update": [] if ready else [game["package"]]}
|
||||
assert target.calls == [(game["package"], {"quest_buttons": 0})]
|
||||
|
||||
|
||||
def test_apply_does_not_trust_unknown_adapter_or_manifest(adapter_manifest):
|
||||
game = store()
|
||||
library.set_fields(game["package"], build={"sha256": "apk", "build_inputs": {"native": {}}})
|
||||
target = Target([{"package": game["package"], "sha256": "apk"}])
|
||||
assert controllers.apply_default(target, Reporter())["needs_update"] == [game["package"]]
|
||||
library.set_fields(game["package"], build={"sha256": "apk", "build_inputs": {"native": {
|
||||
"arm64-v8a/libopenxr_loader_generic.so": "a" * 64}}})
|
||||
adapter_manifest.unlink()
|
||||
assert controllers.apply_default(target, Reporter())["needs_update"] == [game["package"]]
|
||||
|
||||
|
||||
def test_live_suggestion_push_uses_inherited_value(monkeypatch):
|
||||
library.set_setting("input.quest_buttons", 2)
|
||||
monkeypatch.setattr(pipeline, "apply_suggestions", lambda *a: Recipe("test.quest"))
|
||||
target = Target([])
|
||||
assert pipeline.apply_suggestions_live("test.quest", ["adapter.scale=1"], target)
|
||||
assert target.calls[0][1]["quest_buttons"] == 2
|
||||
|
||||
|
||||
def test_settings_apply_uses_frame_job_queue_and_selected_default(adapter_manifest):
|
||||
game = store()
|
||||
library.set_fields(game["package"], build={"sha256": "apk", "build_inputs": {"native": {
|
||||
"arm64-v8a/libopenxr_loader_generic.so": "a" * 64}}})
|
||||
target, jobs = Target([{"package": game["package"], "sha256": "apk"}, {"package": "old"}]), []
|
||||
app = SimpleNamespace(frame_state="connected", target=target,
|
||||
submit=lambda *a, **k: jobs.append((a, k)), toast=lambda *a: None)
|
||||
controls = list(walk(SettingsView(app).controllers()))
|
||||
choice = next(c for c in controls if isinstance(c, ft.Dropdown))
|
||||
choice.on_select(SimpleNamespace(control=SimpleNamespace(value="1")))
|
||||
apply = next(c for c in controls if getattr(c, "content", None) == "Apply to installed games now")
|
||||
apply.on_click(None)
|
||||
args, kwargs = jobs[0]
|
||||
assert kwargs["kind"] == "tool-frame" and not target.calls
|
||||
message = args[1](SimpleNamespace(reporter=Reporter()))
|
||||
assert "Applied to 1 game." in message and "1 game needs Update on Frame first." in message
|
||||
assert target.calls == [(game["package"], {"quest_buttons": 1}), ("old", {"quest_buttons": 1})]
|
||||
|
||||
|
||||
def test_controller_section_reads_default_once_and_updates_preview(monkeypatch):
|
||||
reads = []
|
||||
def snapshot():
|
||||
reads.append(True)
|
||||
assert len(reads) == 1
|
||||
return 1
|
||||
monkeypatch.setattr(controllers, "quest_buttons_default", snapshot)
|
||||
controls = list(walk(SettingsView(SimpleNamespace(frame_state="none")).controllers()))
|
||||
choice = next(c for c in controls if isinstance(c, ft.Dropdown))
|
||||
preview = next(c for c in controls if isinstance(c, ft.Image))
|
||||
assert choice.value == "1" and preview.src.endswith("quest-buttons-1.svg")
|
||||
choice.on_select(SimpleNamespace(control=SimpleNamespace(value="2")))
|
||||
assert preview.src.endswith("quest-buttons-2.svg") and len(reads) == 1
|
||||
assert library.setting("input.quest_buttons") == 2
|
||||
|
||||
|
||||
def test_generated_controller_assets_are_current():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
subprocess.run([sys.executable, str(root / "scripts/controller_diagram.py"), "--check"], check=True)
|
||||
@@ -98,15 +98,14 @@ int main(int argc, char **argv) {
|
||||
input_diag_current_profile(left, touch); // once
|
||||
input_diag_current_profile(right, XR_NULL_PATH);
|
||||
|
||||
PFN_xrCreateAction create = (PFN_xrCreateAction)input_diag_hook("xrCreateAction");
|
||||
CHECK(create && input_diag_hook("xrStringToPath") && !input_diag_hook("xrEndFrame"));
|
||||
// xrCreateAction is hooked by session_fixes.c (#323), which reports each created action here
|
||||
CHECK(!input_diag_hook("xrCreateAction") && input_diag_hook("xrStringToPath") && !input_diag_hook("xrEndFrame"));
|
||||
XrActionCreateInfo info = {XR_TYPE_ACTION_CREATE_INFO};
|
||||
XrAction action;
|
||||
snprintf(info.actionName, sizeof(info.actionName), "trigger pull");
|
||||
CHECK(create(XR_NULL_HANDLE, &info, &action) == XR_ERROR_NAME_INVALID);
|
||||
CHECK(create(XR_NULL_HANDLE, &info, &action) == XR_ERROR_NAME_INVALID); // once
|
||||
diag_log_action(&info, XR_ERROR_NAME_INVALID);
|
||||
diag_log_action(&info, XR_ERROR_NAME_INVALID); // the failure is reported once
|
||||
snprintf(info.actionName, sizeof(info.actionName), "trigger_pull");
|
||||
CHECK(create(XR_NULL_HANDLE, &info, &action) == XR_SUCCESS); // success: nothing
|
||||
diag_log_action(&info, XR_SUCCESS);
|
||||
|
||||
input_diag_lookup(active_instance, "xrCreateBodyTrackerFB", XR_ERROR_FUNCTION_UNSUPPORTED, NULL);
|
||||
input_diag_lookup(active_instance, "xrCreateBodyTrackerFB", XR_ERROR_FUNCTION_UNSUPPORTED, NULL); // once
|
||||
@@ -124,7 +123,10 @@ EXPECTED = [
|
||||
"input_diag: bindings: /interaction_profiles/oculus/touch_controller accepted (2 paths)",
|
||||
"input_diag: bindings: /user/hand/left uses /interaction_profiles/oculus/touch_controller",
|
||||
"input_diag: bindings: /user/hand/right uses none",
|
||||
'input_diag: action "trigger pull" ("") type 0, 0 subaction path(s) -> -45',
|
||||
"input_diag: unsupported: xrCreateAction -> XR_ERROR_NAME_INVALID (trigger pull)",
|
||||
'input_diag: action "trigger pull" ("") type 0, 0 subaction path(s) -> -45',
|
||||
'input_diag: action "trigger_pull" ("") type 0, 0 subaction path(s) -> 0',
|
||||
"input_diag: unsupported: function xrCreateBodyTrackerFB not provided by the runtime "
|
||||
"(XR_ERROR_FUNCTION_UNSUPPORTED)",
|
||||
]
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_adapter_and_launcher(tmp_path, quest_manifest):
|
||||
assert z.read("lib/arm64-v8a/libopenxr_loader_original.so") == b"\x7fELF-original-loader"
|
||||
assert z.read("lib/arm64-v8a/libopenxr_loader_generic.so").startswith(b"\x7fELF")
|
||||
assert (z.read("lib/arm64-v8a/libframe_settings.so")
|
||||
== b"scale=1.0\nfoveation_fix=1\ncontroller_fix=1\nscene_emul=1\n")
|
||||
== b"scale=1.0\nfoveation_fix=1\ncontroller_fix=1\nquest_buttons=0\nscene_emul=1\n")
|
||||
assert names.count("lib/arm64-v8a/libopenxr_loader_generic.so") == 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""FrameBridge Quest bindings, checked against the shared JSON contract with a fake OpenXR runtime.
|
||||
Opt-in (FRAMEPORT_NATIVE_TESTS=1, -m native): cached OpenXR headers and a host C compiler only, no device.
|
||||
"""
|
||||
import json
|
||||
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)")]
|
||||
CONTRACT = json.loads((ROOT / "docs/input/quest_buttons.json").read_text())
|
||||
TOUCH = CONTRACT["touch_profile"]
|
||||
FRAME = CONTRACT["frame_profile"]
|
||||
PRO = "/interaction_profiles/facebook/touch_controller_pro"
|
||||
|
||||
HARNESS = r"""
|
||||
#define XR_EXTENSION_PROTOTYPES
|
||||
#include <openxr/openxr.h>
|
||||
#include <math.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
|
||||
static XrInstance active_instance = (XrInstance)(uintptr_t)1;
|
||||
static int input_diag, controller_fix, profile_remap, runtime_has_detached_controllers;
|
||||
static int stable_local, layer_debug, sync_guard, action_dedup, proximity_emul, runtime_has_proximity;
|
||||
static float aim_pitch, aim_yaw, aim_forward, refresh_rate, focus_hold_ms;
|
||||
static XrTime last_predicted_time;
|
||||
static XrDuration last_display_period;
|
||||
static void emul_on_begin_session(const XrSessionBeginInfo *info) { (void)info; }
|
||||
static int hide_fb_foveation_vulkan, hide_fb_foveation, hide_space_warp, scene_emul, emulate_scene;
|
||||
static int controller_models, render_models_available, emulate_render_model, passthrough_emul, emulate_passthrough;
|
||||
static int foveation_eye_enabled, foveation_eye, foveation_fix, surface_emul, runtime_has_image_layout;
|
||||
static int no_equirect, no_equirect2, no_cylinder, no_cube, fb_color_space, emulate_color_space;
|
||||
static void *android_vm;
|
||||
static void mx_audio_initialize(void) {}
|
||||
static int is_scene_extension(const char *name) { (void)name; return 0; }
|
||||
#include "hide_fb_foveation.h"
|
||||
#include "quest_buttons.c"
|
||||
|
||||
static char paths[1024][XR_MAX_PATH_LENGTH];
|
||||
static int path_count, offered, enum_calls, create_calls;
|
||||
static XrResult reject_touch_once;
|
||||
static XrResult touch_result, frame_result, create_result;
|
||||
static XrPath runtime_profile;
|
||||
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; }
|
||||
if (path_count >= 1024) abort();
|
||||
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 XrPath P(const char *s) { XrPath p; fake_string_to_path(0, s, &p); return p; }
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_suggest(XrInstance i, const XrInteractionProfileSuggestedBinding *s) {
|
||||
(void)i;
|
||||
const char *profile = paths[s->interactionProfile - 1];
|
||||
printf("CALL %s %u\n", profile, s->countSuggestedBindings);
|
||||
for (uint32_t k = 0; k < s->countSuggestedBindings; ++k)
|
||||
printf("%llu %s\n", (unsigned long long)(uintptr_t)s->suggestedBindings[k].action,
|
||||
paths[s->suggestedBindings[k].binding - 1]);
|
||||
if (!strcmp(profile, FRAME_PROFILE)) return frame_result;
|
||||
if (!strcmp(profile, "/interaction_profiles/oculus/touch_controller")) {
|
||||
if (reject_touch_once) { XrResult r = reject_touch_once; reject_touch_once = 0; return r; }
|
||||
return touch_result;
|
||||
}
|
||||
return XR_ERROR_PATH_UNSUPPORTED;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_enumerate(const char *layer, uint32_t cap, uint32_t *n,
|
||||
XrExtensionProperties *out) {
|
||||
(void)layer;
|
||||
++enum_calls;
|
||||
printf("ENUM %u\n", cap);
|
||||
if (offered == 5 || (offered == 4 && cap)) return XR_ERROR_RUNTIME_FAILURE;
|
||||
*n = offered == 2 ? 0 : (offered == 1 ? 2 : 1);
|
||||
if (cap && *n) {
|
||||
snprintf(out[0].extensionName, sizeof(out[0].extensionName), "XR_KHR_vulkan_enable");
|
||||
if (*n > 1) snprintf(out[1].extensionName, sizeof(out[1].extensionName), "%s", FRAME_EXTENSION);
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_create(const XrInstanceCreateInfo *info, XrInstance *instance) {
|
||||
printf("CREATE %u\n", info->enabledExtensionCount);
|
||||
for (uint32_t k = 0; k < info->enabledExtensionCount; ++k) printf("EXT %s\n", info->enabledExtensionNames[k]);
|
||||
if (XR_SUCCEEDED(create_result)) *instance = (XrInstance)(uintptr_t)++create_calls;
|
||||
return create_result;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fake_current(XrSession s, XrPath hand, XrInteractionProfileState *out) {
|
||||
(void)s; (void)hand;
|
||||
out->interactionProfile = runtime_profile;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance i, const char *name) {
|
||||
(void)i;
|
||||
if (!strcmp(name, "xrStringToPath")) return (PFN_xrVoidFunction)fake_string_to_path;
|
||||
if (!strcmp(name, "xrPathToString")) return (PFN_xrVoidFunction)fake_path_to_string;
|
||||
if (!strcmp(name, "xrSuggestInteractionProfileBindings")) return (PFN_xrVoidFunction)fake_suggest;
|
||||
if (!strcmp(name, "xrEnumerateInstanceExtensionProperties"))
|
||||
return offered == 3 ? NULL : (PFN_xrVoidFunction)fake_enumerate;
|
||||
if (!strcmp(name, "xrCreateInstance")) return (PFN_xrVoidFunction)fake_create;
|
||||
if (!strcmp(name, "xrGetCurrentInteractionProfile")) return (PFN_xrVoidFunction)fake_current;
|
||||
return NULL;
|
||||
}
|
||||
#include "input_diag.c"
|
||||
#include "session_fixes.c"
|
||||
// The two exported adapter functions below are copied verbatim into this host translation unit by the fixture;
|
||||
// the rest of frame_adapter.c needs Android graphics headers and is covered by the NDK build.
|
||||
@ADAPTER_FUNCTIONS@
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc != 9) return 2;
|
||||
char setting[256];
|
||||
snprintf(setting, sizeof(setting), "quest_buttons=%s\n", argv[1]);
|
||||
quest_buttons_setting(setting);
|
||||
printf("MODE %d\n", quest_buttons);
|
||||
offered = atoi(argv[2]);
|
||||
input_diag = atoi(argv[4]);
|
||||
profile_remap = atoi(argv[5]);
|
||||
controller_fix = atoi(argv[6]);
|
||||
create_result = (XrResult)atoi(argv[7]);
|
||||
proximity_emul = atoi(argv[8]);
|
||||
const char *extensions[2] = {"XR_KHR_vulkan_enable", FRAME_EXTENSION};
|
||||
XrInstanceCreateInfo ci = {XR_TYPE_INSTANCE_CREATE_INFO};
|
||||
ci.enabledExtensionCount = atoi(argv[3]) ? 2 : 1;
|
||||
ci.enabledExtensionNames = extensions;
|
||||
XrInstance instance;
|
||||
xrCreateInstance(&ci, &instance);
|
||||
printf("ENABLED %d\n", quest_buttons_enabled);
|
||||
char command[32], profile[XR_MAX_PATH_LENGTH];
|
||||
while (scanf("%31s", command) == 1) {
|
||||
if (!strcmp(command, "create")) {
|
||||
int result;
|
||||
if (scanf("%d %d", &offered, &result) != 2) return 2;
|
||||
create_result = (XrResult)result;
|
||||
xrCreateInstance(&ci, &instance);
|
||||
printf("ENABLED %d\nACTIVE %llu\n", quest_buttons_enabled, (unsigned long long)(uintptr_t)active_instance);
|
||||
} else if (!strcmp(command, "proximity")) {
|
||||
unsigned thumb, trigger;
|
||||
if (scanf("%u %u", &thumb, &trigger) != 2) return 2;
|
||||
thumb_proximity_action = (XrAction)(uintptr_t)thumb;
|
||||
trigger_proximity_action = (XrAction)(uintptr_t)trigger;
|
||||
} else if (!strcmp(command, "reject_touch_once")) {
|
||||
int result;
|
||||
if (scanf("%d", &result) != 1) return 2;
|
||||
reject_touch_once = (XrResult)result;
|
||||
} else if (!strcmp(command, "profile")) {
|
||||
if (scanf("%255s", profile) != 1) return 2;
|
||||
runtime_profile = !strcmp(profile, "none") ? XR_NULL_PATH : P(profile);
|
||||
XrInteractionProfileState state = {XR_TYPE_INTERACTION_PROFILE_STATE};
|
||||
xrGetCurrentInteractionProfile(XR_NULL_HANDLE, P("/user/hand/left"), &state);
|
||||
printf("PROFILE %s\n", state.interactionProfile ? paths[state.interactionProfile - 1] : "none");
|
||||
} else if (!strcmp(command, "suggest")) {
|
||||
unsigned n;
|
||||
int tr, fr;
|
||||
if (scanf("%255s %u %d %d", profile, &n, &tr, &fr) != 4 || n > 128) return 2;
|
||||
touch_result = (XrResult)tr;
|
||||
frame_result = (XrResult)fr;
|
||||
XrActionSuggestedBinding b[128];
|
||||
for (unsigned k = 0; k < n; ++k) {
|
||||
unsigned action;
|
||||
char path[XR_MAX_PATH_LENGTH];
|
||||
if (scanf("%u %255s", &action, path) != 2) return 2;
|
||||
b[k] = (XrActionSuggestedBinding){(XrAction)(uintptr_t)action, P(path)};
|
||||
}
|
||||
XrInteractionProfileSuggestedBinding s = {XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, NULL,
|
||||
P(profile), n, b};
|
||||
printf("RESULT %d\n", hook_xrSuggestInteractionProfileBindings(instance, &s));
|
||||
} else return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def adapter_function(source, name):
|
||||
start = source.index(f"XRAPI_ATTR XrResult XRAPI_CALL {name}(")
|
||||
return source[start:source.index("\n}\n", start) + 3]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def harness(tmp_path_factory):
|
||||
d = tmp_path_factory.mktemp("quest_buttons")
|
||||
source = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
functions = "\n".join(adapter_function(source, name) for name in
|
||||
("xrCreateInstance", "xrGetCurrentInteractionProfile"))
|
||||
(d / "harness.c").write_text(HARNESS.replace("@ADAPTER_FUNCTIONS@", functions))
|
||||
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
|
||||
"-Wno-missing-field-initializers", "-pthread", "-I", str(INC),
|
||||
"-I", str(ROOT / "native/adapter"), str(d / "harness.c"), "-lm", "-o", str(d / "harness")],
|
||||
check=True)
|
||||
return d / "harness"
|
||||
|
||||
|
||||
def run(harness, mode=1, commands="", *, offered=1, requested=False, diag=False, remap=False,
|
||||
controller_fix=False, create_result=0, proximity=0):
|
||||
p = subprocess.run([str(harness), str(mode), str(offered), str(int(requested)), str(int(diag)),
|
||||
str(int(remap)), str(int(controller_fix)), str(create_result), str(proximity)],
|
||||
input=commands, capture_output=True, text=True, timeout=5)
|
||||
assert p.returncode == 0, p.stderr
|
||||
return p
|
||||
|
||||
|
||||
def suggest(bindings, profile=TOUCH, result=0, frame_result=0):
|
||||
return (f"suggest {profile} {len(bindings)} {result} {frame_result}\n" +
|
||||
"".join(f"{action} {path}\n" for action, path in bindings))
|
||||
|
||||
|
||||
def calls(p):
|
||||
lines = iter(p.stdout.splitlines())
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith("CALL "):
|
||||
_, profile, count = line.split()
|
||||
bindings = []
|
||||
for _ in range(int(count)):
|
||||
action, path = next(lines).split()
|
||||
bindings.append((int(action), path))
|
||||
out.append((profile, bindings))
|
||||
return out
|
||||
|
||||
|
||||
def translate(bindings, mode):
|
||||
"""Independent oracle: use only the shared JSON, never the C table."""
|
||||
if not mode:
|
||||
return []
|
||||
out = []
|
||||
for action, path in bindings:
|
||||
for hand, buttons in CONTRACT["buttons"].items():
|
||||
prefix = f"/user/hand/{hand}/"
|
||||
if not path.startswith(prefix):
|
||||
continue
|
||||
kind, _, component = path[len(prefix):].partition("/")
|
||||
targets = []
|
||||
if kind == "output":
|
||||
targets = CONTRACT["outputs"].get(component, [])
|
||||
elif kind == "input" and component not in CONTRACT["dropped"]:
|
||||
targets = CONTRACT["common"].get(component, [])
|
||||
button, _, suffix = component.partition("/")
|
||||
if ((button in buttons and suffix in ("", "click", "touch")) or
|
||||
(button == "menu" and suffix in ("", "click"))):
|
||||
targets = [f"{target}/{suffix}" if suffix else target for target in
|
||||
CONTRACT["modes"][str(mode)][hand].get(button, [])]
|
||||
out.extend((action, f"{prefix}{kind}/{target}") for target in targets)
|
||||
return out
|
||||
|
||||
|
||||
def full_bindings():
|
||||
paths = []
|
||||
for hand, buttons in CONTRACT["buttons"].items():
|
||||
parts = [f"input/{p}" for p in CONTRACT["common"]]
|
||||
parts += [f"output/{p}" for p in CONTRACT["outputs"]]
|
||||
parts += [f"input/{button}/{component}" for button in buttons for component in ("click", "touch")]
|
||||
parts += [f"input/{button}" for button in buttons] + ["input/menu"]
|
||||
parts += [f"input/{p}" for p in CONTRACT["dropped"]]
|
||||
parts += ["input/menu/click", "input/menu/touch", "input/unknown/click", "input/trigger/click",
|
||||
"input/squeeze/click", "input/thumb_fb/proximity_fb", "output/unknown", "input/a/value"]
|
||||
paths.extend(f"/user/hand/{hand}/{part}" for part in parts)
|
||||
paths += ["/user/head/input/aim/pose", "/user/hand/leftover/input/x/click"]
|
||||
return list(enumerate(paths, 1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1, 2])
|
||||
def test_json_mapping_all_components(harness, mode):
|
||||
bindings = full_bindings()
|
||||
p = run(harness, mode, suggest(bindings), diag=True)
|
||||
expected = translate(bindings, mode)
|
||||
assert calls(p) == [(TOUCH, bindings)] + ([(FRAME, expected)] if mode else [])
|
||||
assert p.stdout.endswith("RESULT 0\n")
|
||||
logs = [line for line in p.stderr.splitlines() if line.startswith("input_diag: quest_buttons ")]
|
||||
if mode:
|
||||
assert len(logs) == len(expected)
|
||||
for action, target in expected:
|
||||
source = dict(bindings)[action]
|
||||
assert f"input_diag: quest_buttons {source} -> {target}" in logs
|
||||
dropped = sum(not translate([binding], mode) for binding in bindings)
|
||||
assert (f"quest_buttons: frame_controller bindings (mode {mode}, {len(expected)} bindings, "
|
||||
f"{dropped} dropped): 0") in p.stderr
|
||||
else:
|
||||
assert not logs and "quest_buttons:" not in p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [1, 2])
|
||||
def test_vrapi_bridge_shaped_bindings(harness, mode):
|
||||
# Same action handles shared between hands, as in native/vrapi-bridge/input.cpp.
|
||||
bindings = []
|
||||
for hand, buttons in CONTRACT["buttons"].items():
|
||||
common = ["input/grip/pose", "input/trigger/value", "input/squeeze/value", "input/thumbstick",
|
||||
"input/thumbstick/click", "input/trigger/touch", "input/thumbstick/touch", "output/haptic"]
|
||||
bindings.extend((i, f"/user/hand/{hand}/{path}") for i, path in enumerate(common, 1))
|
||||
for i, button in enumerate(buttons):
|
||||
for offset, component in ((9, "click"), (11, "touch")):
|
||||
bindings.append((i + offset, f"/user/hand/{hand}/input/{button}/{component}"))
|
||||
bindings.append((13, "/user/hand/left/input/menu/click"))
|
||||
p = run(harness, mode, suggest(bindings))
|
||||
assert calls(p) == [(TOUCH, bindings), (FRAME, translate(bindings, mode))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1, 2])
|
||||
def test_touch_pro_fallback_and_current_profile(harness, mode):
|
||||
bindings = [(1, "/user/hand/left/input/x/click"), (1, "/user/hand/right/input/a/touch"),
|
||||
(2, "/user/hand/left/input/thumb_fb/proximity_fb")]
|
||||
touch = bindings[:2]
|
||||
p = run(harness, mode, suggest(bindings, PRO) + f"profile {FRAME}\nprofile {TOUCH}\n", remap=True)
|
||||
assert calls(p) == [(PRO, bindings), (TOUCH, touch)] + ([(FRAME, translate(touch, mode))] if mode else [])
|
||||
assert "RESULT 0\n" in p.stdout
|
||||
profiles = [line for line in p.stdout.splitlines() if line.startswith("PROFILE ")]
|
||||
assert profiles == [f"PROFILE {PRO if mode else FRAME}", f"PROFILE {PRO}"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame_result", [0, -2, -22])
|
||||
def test_frame_result_never_changes_game_result_and_later_touch_replaces(harness, frame_result):
|
||||
first = [(4, "/user/hand/right/input/a/click")]
|
||||
later = [(8, "/user/hand/left/input/y/touch")]
|
||||
p = run(harness, commands=suggest(first, frame_result=frame_result) + suggest(later))
|
||||
assert calls(p) == [(TOUCH, first), (FRAME, translate(first, 1)),
|
||||
(TOUCH, later), (FRAME, translate(later, 1))]
|
||||
assert p.stdout.count("RESULT 0\n") == 2
|
||||
assert f"0 dropped): {frame_result}" in p.stderr
|
||||
|
||||
|
||||
def test_failed_touch_does_not_suggest_frame(harness):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings, result=-22))
|
||||
assert calls(p) == [(TOUCH, bindings)]
|
||||
assert p.stdout.endswith("RESULT -22\n")
|
||||
assert "frame_controller bindings" not in p.stderr
|
||||
|
||||
|
||||
def test_nonzero_touch_success_is_preserved(harness):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings, result=1, frame_result=-2))
|
||||
assert calls(p) == [(TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
assert p.stdout.endswith("RESULT 1\n")
|
||||
assert "input_diag: quest_buttons" not in p.stderr
|
||||
|
||||
|
||||
def test_touch_pro_success_ignores_frame_failure(harness):
|
||||
bindings = [(1, "/user/hand/left/input/x/click")]
|
||||
p = run(harness, commands=suggest(bindings, PRO, frame_result=-22), remap=True)
|
||||
assert calls(p) == [(PRO, bindings), (TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
assert p.stdout.endswith("RESULT 0\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [1, 2])
|
||||
@pytest.mark.parametrize("previous", [False, True])
|
||||
def test_all_dropped_bindings_are_counted(harness, mode, previous):
|
||||
first = [(1, "/user/hand/right/input/a/click")]
|
||||
dropped = [(1, "/user/hand/left/input/thumbrest/touch")]
|
||||
commands = (suggest(first) if previous else "") + suggest(dropped) + f"profile {FRAME}\n"
|
||||
p = run(harness, mode, commands=commands)
|
||||
assert calls(p) == ([(TOUCH, first), (FRAME, translate(first, mode))] if previous else []) + [(TOUCH, dropped)]
|
||||
assert (f"quest_buttons: frame_controller bindings (mode {mode}, 0 bindings, 1 dropped): not suggested") in p.stderr
|
||||
assert p.stdout.endswith(f"PROFILE {TOUCH if previous else FRAME}\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("first", [False, True])
|
||||
def test_games_own_frame_suggestion_wins(harness, first):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
own = [(2, "/user/hand/right/input/y/click")]
|
||||
commands = suggest(own, FRAME) if first else suggest(bindings)
|
||||
if not first:
|
||||
commands += suggest(own, FRAME)
|
||||
commands += suggest(bindings)
|
||||
p = run(harness, commands=commands)
|
||||
expected = [(FRAME, own), (TOUCH, bindings)] if first else [
|
||||
(TOUCH, bindings), (FRAME, translate(bindings, 1)), (FRAME, own), (TOUCH, bindings)]
|
||||
assert calls(p) == expected
|
||||
|
||||
|
||||
def test_rejected_games_frame_binding_does_not_block_translation(harness):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings, FRAME, frame_result=-22) + suggest(bindings))
|
||||
assert calls(p) == [(FRAME, bindings), (TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0, 1, 2])
|
||||
@pytest.mark.parametrize("offered", [0, 1, 2, 3, 4, 5])
|
||||
@pytest.mark.parametrize("requested", [False, True])
|
||||
def test_extension_enabled_only_when_offered_and_never_twice(harness, mode, offered, requested):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, mode, suggest(bindings), offered=offered, requested=requested)
|
||||
enabled = bool(mode and offered == 1)
|
||||
assert f"ENABLED {int(enabled)}\n" in p.stdout
|
||||
exts = [line.removeprefix("EXT ") for line in p.stdout.splitlines() if line.startswith("EXT ")]
|
||||
# Existing pass-through policy retains app-requested extensions when enumeration is unavailable/fails.
|
||||
assert exts.count(CONTRACT["extension"]) == int(enabled or (requested and offered in (1, 2, 3, 4, 5)))
|
||||
assert calls(p) == [(TOUCH, bindings)] + ([(FRAME, translate(bindings, mode))] if enabled else [])
|
||||
if mode:
|
||||
message = "enabled" if enabled else "not offered by the runtime"
|
||||
assert f"quest_buttons: extension {CONTRACT['extension']} {message}" in p.stderr
|
||||
else:
|
||||
assert "quest_buttons:" not in p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "1", "2", "-1", "3", "1.5", "nan", "inf", "Mirror", "1junk", ""])
|
||||
def test_setting_numbers_only_invalid_values_disable(harness, value):
|
||||
p = run(harness, value)
|
||||
expected = int(value) if value in ("0", "1", "2") else 0
|
||||
assert p.stdout.startswith(f"MODE {expected}\n")
|
||||
assert ("quest_buttons: invalid setting; using 0" in p.stderr) == (value not in ("0", "1", "2"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offered", [0, 1])
|
||||
def test_current_profile_controller_fix_off(harness, offered):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings) + f"profile {FRAME}\nprofile {TOUCH}\nprofile none\n"
|
||||
"profile /interaction_profiles/khr/generic_controller\n", offered=offered)
|
||||
profiles = [line.removeprefix("PROFILE ") for line in p.stdout.splitlines() if line.startswith("PROFILE ")]
|
||||
assert profiles == [TOUCH if offered else FRAME, TOUCH, "none", "/interaction_profiles/khr/generic_controller"]
|
||||
|
||||
|
||||
def test_failed_instance_disables_feature(harness):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings) + f"profile {FRAME}\n", create_result=-2)
|
||||
assert "ENABLED 0\n" in p.stdout
|
||||
assert calls(p) == [(TOUCH, bindings)]
|
||||
assert p.stdout.endswith(f"PROFILE {FRAME}\n")
|
||||
|
||||
|
||||
def test_off_preserves_runtime_call_bytes(harness):
|
||||
bindings = [(9, "/user/hand/left/input/x/click"), (10, "/user/hand/right/output/haptic")]
|
||||
p = run(harness, 0, suggest(bindings) + suggest(bindings, PRO), remap=True)
|
||||
serialized = "".join(f"{a} {path}\n" for a, path in bindings)
|
||||
assert p.stdout == ("MODE 0\nENUM 0\nENUM 2\nCREATE 1\nEXT XR_KHR_vulkan_enable\nENABLED 0\n" +
|
||||
f"CALL {TOUCH} 2\n{serialized}RESULT 0\n" +
|
||||
f"CALL {PRO} 2\n{serialized}RESULT -22\n")
|
||||
# Without a game's own Touch suggestion, profile_remap still makes exactly its original two calls.
|
||||
p = run(harness, 0, suggest(bindings, PRO), remap=True)
|
||||
assert calls(p) == [(PRO, bindings), (TOUCH, bindings)]
|
||||
assert "quest_buttons:" not in p.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [1, 2])
|
||||
@pytest.mark.parametrize("profile", [TOUCH, PRO])
|
||||
def test_proximity_expansion_becomes_frame_bindings(harness, mode, profile):
|
||||
bindings = [(1, "/user/hand/left/input/thumb_fb/proximity_fb"),
|
||||
(2, "/user/hand/right/input/trigger/value")]
|
||||
# Pro's unsupported proximity path is filtered out during remap, as before; known action handles still expand.
|
||||
p = run(harness, mode, commands="proximity 1 3\n" + suggest(bindings, profile), proximity=2, remap=True)
|
||||
recorded = calls(p)
|
||||
touch = recorded[-2]
|
||||
assert touch[0] == TOUCH and len(touch[1]) > len(bindings)
|
||||
assert recorded[-1] == (FRAME, translate(touch[1], mode))
|
||||
frame_actions = {action for action, _ in recorded[-1][1]}
|
||||
assert {1, 2, 3} <= frame_actions
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [1, 2])
|
||||
def test_bare_pose_parents_are_translated(harness, mode):
|
||||
bindings = [(i, f"/user/hand/{hand}/input/{pose}") for i, (hand, pose) in
|
||||
enumerate(((h, p) for h in ("left", "right") for p in ("grip", "aim")), 1)]
|
||||
assert all(CONTRACT["common"][pose] == [pose] for pose in ("grip", "aim"))
|
||||
p = run(harness, mode, suggest(bindings))
|
||||
assert calls(p) == [(TOUCH, bindings), (FRAME, translate(bindings, mode))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario,expected", [
|
||||
("none", FRAME), ("rejected", FRAME), ("generated", TOUCH),
|
||||
("own", FRAME), ("generated_then_own", FRAME), ("generated_then_rejected", TOUCH),
|
||||
])
|
||||
def test_profile_rewrite_requires_generated_binding(harness, scenario, expected):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
commands = ""
|
||||
if scenario.startswith("generated"):
|
||||
commands += suggest(bindings)
|
||||
elif scenario == "rejected":
|
||||
commands += suggest(bindings, frame_result=-22)
|
||||
if scenario.endswith("own"):
|
||||
commands += suggest(bindings, FRAME)
|
||||
elif scenario == "generated_then_rejected":
|
||||
commands += suggest(bindings, FRAME, frame_result=-22)
|
||||
p = run(harness, commands=commands + f"profile {FRAME}\n")
|
||||
assert p.stdout.endswith(f"PROFILE {expected}\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("controller_fix", [False, True])
|
||||
def test_other_valve_profiles_keep_existing_controller_fix_behavior(harness, controller_fix):
|
||||
other = "/interaction_profiles/valve/index_controller"
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
p = run(harness, commands=suggest(bindings) + f"profile {other}\n", controller_fix=controller_fix)
|
||||
assert p.stdout.endswith(f"PROFILE {TOUCH if controller_fix else other}\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("offered", [0, 1])
|
||||
@pytest.mark.parametrize("owner", ["generated", "app"])
|
||||
def test_failed_second_instance_preserves_live_binding_state(harness, offered, owner):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
commands = suggest(bindings, FRAME if owner == "app" else TOUCH)
|
||||
commands += f"create {offered} -2\nprofile {FRAME}\n" + suggest(bindings)
|
||||
p = run(harness, commands=commands)
|
||||
assert "ENABLED 1\nACTIVE 1\n" in p.stdout
|
||||
assert f"PROFILE {FRAME if owner == 'app' else TOUCH}\n" in p.stdout
|
||||
expected = [(FRAME, bindings), (TOUCH, bindings)] if owner == "app" else [
|
||||
(TOUCH, bindings), (FRAME, translate(bindings, 1)), (TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
assert calls(p) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("owner", ["generated", "app"])
|
||||
def test_successful_second_instance_resets_binding_ownership(harness, owner):
|
||||
bindings = [(1, "/user/hand/right/input/a/click")]
|
||||
commands = suggest(bindings, FRAME if owner == "app" else TOUCH)
|
||||
commands += f"create 1 0\nprofile {FRAME}\n" + suggest(bindings)
|
||||
p = run(harness, commands=commands)
|
||||
assert "ENABLED 1\nACTIVE 2\n" in p.stdout
|
||||
assert f"PROFILE {FRAME}\n" in p.stdout
|
||||
initial = [(FRAME, bindings)] if owner == "app" else [(TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
assert calls(p) == initial + [(TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile", [TOUCH, PRO])
|
||||
def test_nested_proximity_fallback_does_not_deadlock_and_translates_accepted_set(harness, profile):
|
||||
bindings = [(1, "/user/hand/left/input/x/click")]
|
||||
commands = "proximity 3 4\nreject_touch_once -22\n" + suggest(bindings, profile) + suggest(bindings)
|
||||
p = run(harness, commands=commands, remap=True, proximity=2)
|
||||
recorded = calls(p)
|
||||
offset = int(profile == PRO)
|
||||
assert recorded[offset][0] == TOUCH and len(recorded[offset][1]) > len(bindings)
|
||||
assert recorded[offset + 1:offset + 3] == [(TOUCH, bindings), (FRAME, translate(bindings, 1))]
|
||||
# A second hook transaction also completes: all fallback paths released the mutex.
|
||||
assert recorded[-1] == (FRAME, translate(recorded[-2][1], 1))
|
||||
|
||||
|
||||
def test_settings_and_hook_wiring():
|
||||
source = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
settings = source[source.index("static void read_settings("):source.index("static void initialize(")]
|
||||
assert "quest_buttons_setting(line);" in settings
|
||||
hook = source[source.index("if (layer_debug || aim_correction_on() || profile_remap"):]
|
||||
assert "|| quest_buttons)\n HOOK_AS(xrSuggestInteractionProfileBindings" in hook
|
||||
Reference in new issue
Block a user