Compare commits

..
30 Commits
Author SHA1 Message Date
spoopyghosty0 ca10e109e6 Settings → Controllers recommends Test Quest Controllers for trying the button modes
A callout under the controller diagram (Get Test Quest Controllers: FramePort's own install question for its install link; Open … once it's in the library), a tooltip on the button, and the controller buttons help text points to it. ui_smoke gets a settings-controllers step.
2026-10-18 10:52:08 -04:00
spoopyghosty0 4e8486418f Install links: Check for updates on the game page; links without a version are checked too
Games installed from an install link get a row on the game page (where it came from, the installed version) with Check for updates, and the same entry in the page's menu: it asks the link now instead of waiting for the 6-hourly check. Manifests that named no version (e.g. an app's first release) were never checked; the installed APK's own versionName (from the analysis) is now the version to compare with, in the automatic check, the button and frameport link-updates.
2026-10-18 09:47:02 -04:00
spoopyghosty0 d801ff6bc8 Controller buttons: second button up and toward the other hand like Quest (Quest pair: Y = d-pad right, B = X; Mirror follows); d-pad touch is one sensor 2026-10-18 09:23:52 -04:00
spoopyghosty0 c67f79ebc6 Install-link update: take the new download instead of failing ('couldn't be read as an Android app')
Updating a game from its install link downloads the new APK into its own folder; the scan then finds it as another version of a library game (#351 relinking), switches the entry to it and returns nothing, and add_from_link reported that as an unreadable APK, so the update stopped before the link version was recorded. add_from_link now takes the entry from the scan report; if the scan left the library's copy (still on disk), the link switches the game to the download like Update with a newer APK.
2026-10-18 08:54:23 -04:00
spoopyghosty0 c469327590 Monitor: idle Frame no longer shows 100 % GPU (agent v102)
msm reported SteamVR's compositor engine time near 2^64, rising ~1000x faster than real time, so the summed GPU busy
was pinned at 100 % on an idle Frame. Each render fd is now tracked on its own; a delta longer than the interval
falls back to drm-cycles-gpu / GPU clock. Checked on the dev Frame: installed agent 100 %, this one 0.0 % at 231 MHz.
2026-10-18 08:51:02 -04:00
spoopyghosty0 b00abe3ce1 Install-link update toast gets an Update button
It said a newer version was available but offered no way to get it from there (the Library bar and the game page
had the button).
2026-10-18 08:46:30 -04:00
spoopyghosty0 eaf4e3d5b6 Game page: rounded save icon (UI style check) 2026-10-17 22:27:12 -04:00
spoopyghosty0 05f221b216 pop_dialog() closes the pop-up, never a toast (install-link question stayed open)
Flet 1.0 keeps toasts in the same stack as dialogs and pop_dialog() closes the topmost open entry. An install link's
job shows a "waiting for your answer" toast just as its question appears; when the toast came second, "Download
and install" closed the toast, the download ran, and the question stayed on screen. pop_dialog now skips toasts
(reproduced and checked with real clicks in the web build). Also a too-long test docstring line.
2026-10-17 22:25:11 -04:00
spoopyghosty0 472c607efd Restart after a source/wheel update keeps a hidden console on Windows
DETACHED_PROCESS left the restarted FramePort without a console, so every console program it started (ssh, git) opened
its own visible terminal, and the Frame connection's ssh kept one open. CREATE_NO_WINDOW gives it a hidden console,
as the dev shortcut does.
2026-10-17 22:15:32 -04:00
spoopyghosty0 33bb14e034 FrameBridge: cube-only frames as quads (#385); CI: lint, foveation test
HardBullet's loading frames submit only a cube layer, which the Frame's runtime doesn't composite, so the loading
screen stayed black. Adapter setting cube_quads (default on) draws a cube-only frame's six faces as quad layers
around the viewer; frames with a projection layer are unchanged. Adapter rebuilt on current main.

Also: the foveation test expected a numeric FOVE_LEVEL to pass through (Valve's layer ignores numbers since #394),
a long description line and an import order in adapter_dialog.py failed lint.
2026-10-17 21:52:39 -04:00
spoopyghosty0 0c06e1142f Install links: GitHub file pages become raw.githubusercontent.com; release manifests named via the API
The install page shows a manifest's name, description and icon only when the manifest's host allows CORS. GitHub file pages (github.com/<o>/<r>/blob|raw/<ref>/<path>) are now turned into their raw.githubusercontent.com address by the page (preview and the frameport:// link it hands over) and by deeplink.parse (a /blob/ page is HTML, so FramePort couldn't install from it). Release downloads can't be read by any web page: the page shows the release's name and the repo's description from GitHub's API instead of 'A game or app'. INSTALL_BUTTON.md says where to host a manifest.
2026-10-17 21:48:26 -04:00
spoopyghosty0 358db1360f quest_buttons: rebuild the adapter on current main, regenerate patch docs and translations 2026-10-17 21:44:01 -04:00
spoopyghosty0 1b43c1c544 quest_buttons on main: rebuild the adapter with main's Batman changes, harness stub for mx_batman_geometry_initialize 2026-10-17 21:37:39 -04:00
spoopyghosty0 5ae303fc45 Controller buttons: experimental (pill in Game settings and Settings, note in the guide) 2026-10-17 21:37:24 -04:00
spoopyghosty0 72edbebac1 Controller diagram: draw the Steam Frame controllers (d-pad, face buttons, sticks, View/Menu, Steam) 2026-10-17 21:37:09 -04:00
spoopyghosty0 ca80018d98 Device notes: quest_buttons accepted by the Frame runtime headless; OVRPort workspace collisions 2026-10-17 21:37:07 -04:00
spoopyghosty0 a585e24647 Rebuild reviewed adapters and record Round 2 validation 2026-10-17 21:36:42 -04:00
spoopyghosty0 b42d85d7c4 Fix Frame binding ownership and accepted Touch translation 2026-10-17 21:36:41 -04:00
spoopyghosty0 c9501bd446 Clarify controller mode support and Round 2 validation 2026-10-17 21:36:40 -04:00
spoopyghosty0 bc728bb121 Report controller installs that need Update on Frame 2026-10-17 21:36:39 -04:00
spoopyghosty0 838952b4f5 Test: input_diag reports created actions through session_fixes' hook since #323 2026-10-17 21:36:38 -04:00
spoopyghosty0 eab28b98c5 Controller diagram: draw d-pad arrows (the app's SVG renderer has no arrow glyphs) 2026-10-17 21:36:37 -04:00
spoopyghosty0 1f64d089e1 quest_buttons: bare trigger/squeeze/button paths map too (OpenXR resolves them to value/click) 2026-10-17 21:36:37 -04:00
spoopyghosty0 49de808650 Document Quest controller modes and integration checks 2026-10-17 21:36:35 -04:00
spoopyghosty0 54a2d84755 Keep inherited controller settings current after live updates 2026-10-17 21:36:23 -04:00
spoopyghosty0 31b4c684fe Add inherited Quest controller button preferences and mapping diagrams 2026-10-17 21:36:19 -04:00
spoopyghosty0 5d483746f9 Document native controller mode validation and device checks 2026-10-17 21:36:18 -04:00
spoopyghosty0 1f27cd47a3 Rebuild adapters and cover binding suggestion failures 2026-10-17 21:36:18 -04:00
spoopyghosty0 ff592e41e8 Add opt-in Quest controller bindings for Frame 2026-10-17 21:36:17 -04:00
spoopyghosty0 72d755973f Quest controller mode: contract and button mapping 2026-10-17 21:36:16 -04:00
134 changed files with 3534 additions and 7636 deletions

No files matched your search

+2 -4
View File
@@ -455,9 +455,7 @@ Repo is on an NTFS drive (`core.fileMode=false`); line endings are LF (`.gitattr
- No XR_FB_display_refresh_rate (`xrGetDisplayRefreshRateFB` -7), yet OVRPort's dispatcher offers it and answered 0 Hz
→ FrameBridge always answers with the measured rate (1/predictedDisplayPeriod, else 72). PreyVR (#306, idTech 4:
`USERCMD_MSEC = 1000 / GetRefresh()`) had 0 ms tics: async thread spinning, `com_frameTime` stuck at 0, menu black.
- Guardian STAGE bounds report 1×1 m (scene emulation uses ≥1.5 m). OVRPlugin treats that rect as a configured
boundary but can't outline it ("ovrpBoundary_Outer is not supported"): Demeter's guardian check indexed an empty
array → with `scene_emul` the app's STAGE `xrGetReferenceSpaceBoundsRect` returns XR_SPACE_BOUNDS_UNAVAILABLE.
- Guardian STAGE bounds report 1×1 m (scene emulation uses ≥1.5 m).
- GL goes through **Zink** (Mesa GL on Vulkan). Mesa GLSL is strict: `#pragma` before `#extension` fails, implicit
int/float conversions fail (enable `GL_EXT_shader_implicit_conversions`), num_views=2 shaders on single-view FBOs
give GL_INVALID_OPERATION. Some GLES games hit `zink: DEVICE LOST` (Unity MSAA RTT; Sniper Elite VR even without).
@@ -559,6 +557,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,
@@ -591,7 +590,6 @@ Toy Master doesn't request a model after that (it uses its own), so the glb load
**Vader Immortal (GitHub #49):** stuck on its loading card and leaked GPU mappings (solved below: `gl_error_clear`); diagnostics FrameBridge `layer_debug`, `strip_color_bias`, `frame_balance`, `snapshot=N` (`snapshot_gl.c`: saves the submitted left-eye image as `files/fb_snap_*.ppm`). → docs/DEVICE_NOTES.md#vader-immortal-headless-investigation
**SteamVR overlay apps:** host OpenVR overlays composite over Lepton games; detection `analysis/vroverlay.py`, patch `pcvr.vr_overlay`, Linux fields `vr_overlay`/`vr_overlay_autostart`, agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay`; SteamVR on the Frame reads only `binary_path_linux_arm`; FramePort's own autostart (`ensure_overlay_service`, `frameport-vr-overlays.service`). See also docs/FRAME_RUNTIME.md "Overlay apps". → docs/DEVICE_NOTES.md#steamvr-overlay-apps
**Vader's leak solved / AC Nexus flips:** stale GL errors broke SteamVR's GL interop → adapter `gl_error_clear` (GLES, catalog for Vader ep1-3); read `xrclient_<pkg>.txt` in Steam's logs for runtime errors; the runtime caps swapchains per session (~15) → flip emulation in place; adapter `flip_dump`. → docs/DEVICE_NOTES.md#vaders-leak-solved--ac-nexus-flips
**Mixed reality: scanned rooms (branch mr-scene):** agent `room_capture` records Valve's RoomView 3D depth mesh -> `fp_roomscan` -> `room.txt` + `room-mesh.bin` (docs/mr/ROOM_FORMAT.md) in `~/.local/share/frameport/rooms/<universe>/`, copied next to settings.conf (`_room_sync`); FrameBridge (`scene_room.c/scene_meta.c/scene_anchors.c`, all through xrshim) serves walls, volumes, planes, the room mesh, META discovery/persistence, saved anchors and `XR_META_boundary_visibility`; `scene_emul` + `frame.meta_permissions` for every USE_SCENE/USE_ANCHOR_API game (migrations `scene_emul_all`, `scene_permissions`); per-game `scene_hide_bounds` (Demeter). → docs/DEVICE_NOTES.md#mixed-reality-scanned-rooms
**Unreal's self-restart:** UE 4.21+ restarts itself after the first shader precompile and Lepton ends the container → launch.sh RESTART_BLOCK starts Lepton once more (`upgrade_launchers`; `FRAMEPORT_NO_RESTART=1` off). → docs/DEVICE_NOTES.md#unreals-self-restart
**xrWaitFrame runs ahead:** the runtime sometimes returns a display time <1 refresh after the last submit and rejects the second submission (`VRCompositorError_AlreadySubmitted`) → the VrApi bridge re-waits (`pacing 2s:` counters). → docs/DEVICE_NOTES.md#xrwaitframe-runs-ahead-journey-of-the-gods
**Lepton's logcat mirror dies** (EOF, read failure, or silent) → launch.sh `_logcat_keeper` reads `podman exec <container> logcat` itself into launch.log (log `<base>/logcat-keeper.log`). → docs/DEVICE_NOTES.md#leptons-logcat-mirror-dies
+1
View File
@@ -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 |
+31 -896
View File
File diff suppressed because it is too large. Load diff
+2 -21
View File
@@ -13,11 +13,11 @@ e32f9b3222bfe2e58c193db809ac2cf54086df36fd2ec6651aa2e58308f6b39d ./arm64-v8a/li
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
36400eae4ba3807bcfcd54463daa217347032b07218792faa448d8cbbd086a0e ./arm64-v8a/libframe_xrshim.so
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
d382fc8f9990ef536926d8865e524d4d115516fb6be9929af3e4e7a31c34be46 ./arm64-v8a/libopenxr_loader_generic.so
f0894d9cc08aed4c0f6468134e76909d583a208c3f5e528bc3ee795f51b19fcb ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
850a6e86c8f30ebd0abbac4523e04f5233797cfbc34bb1fa162a67b3230f0b44 ./armeabi-v7a/libopenxr_loader_generic.so
4e03a8389981ed5a27b4c159cc83168c4f93d0d75479a445b02e5669aa26a71b ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
@@ -27,25 +27,6 @@ b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
63af0050054ad3705b17a0b00ce0c076ba1af0b73abd673ba6c588f64b4591cd ./linux-arm64-bin/fp_roomscan
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
00c2dead0fd7497929833467f0e55699d57d26be1b85dce35402993852f7b111 ./roomscan/ceiling.png
c1962de31ed2810d31c760cf9ee63316ac2b8c9528ccbcd9299ebf5ba39b2995 ./roomscan/done.png
0dfaea4c68979b0a45a9fef06fdf1a92468ffc8f46f80a3a014fce1d805972d5 ./roomscan/failed.png
1147391822be3f1aa5351374b8fd9ec85f8b5ce40e3cb9f3146d778e6fbc652a ./roomscan/floor.png
eb3c8b55a9dbafa030797ca9f06a22c3ee3bbef96cb7adea26d433ebad43d43f ./roomscan/look_00.png
6ba965ff6d22a469c558f2a68229bd2ff48fc28badb95889ca245310d1dd3e96 ./roomscan/look_01.png
0a11cec25ebc76fe11d505d1f2ec162c9b4a6754393065800e73b1f81e2d94c8 ./roomscan/look_02.png
fa5a4c94d20af56797b9fcc111148767847a6633ade9217cab373de232efad90 ./roomscan/look_03.png
e80fa493e380fba39084be2ee62859adcff4e39aa66566abee10a4d6339c52e9 ./roomscan/look_04.png
133617f0d8d1e69c37408aac80033f71856b592b62e3b780cc12b446bd974680 ./roomscan/look_05.png
d5dd296ff23641c6973c57ac139147e99c7b783316ab60537adb2155dd196880 ./roomscan/look_06.png
c08570aa78f966ae937b2e21d0bad887c5352fbca56aa579a2a7ef8541fb96ad ./roomscan/look_07.png
3767e7db5fb6babc872fa62c42604f749c65936717d332101745b201cd450cd5 ./roomscan/look_08.png
2924dc65e5c497545340209edc16060e12e1834d11af6871d1c1baa50b7b2562 ./roomscan/look_09.png
cd0150dfc10b2aea39403c74eff72fd05fb0111178ef39b4c98657a6d16d105e ./roomscan/look_10.png
011355e412f73ade3896e9037490e607c4365d0cf8ceb807d236bf68b414d3ed ./roomscan/look_11.png
1b8b47ffb47568d540921f065b5958eeb44a6489498d58e0f33f5d1f535154e2 ./roomscan/look_12.png
926c7bc6a103adee5b9bf938f5d4e39095ab77fd6270b5301be3b0428edd9ae6 ./roomscan/start.png
c2c70f4af1a5c3e6089dff1130161dd417f8044f99ec639eeecf1083930aab16 ./win-x64/fp_oculushmd.exe
60ebc5fea05b8945082e2ca8b9f2eb74616012a26eb50133af273dff0fcddc42 ./win-x64/fp_vrsettings.exe
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

+1 -2
View File
@@ -12,7 +12,6 @@ overport_extra:
- patch_force_passthrough
adapter:
scene_emul: 1
scene_hide_bounds: 1
device:
- device.text_input_window
verified:
@@ -23,4 +22,4 @@ verified:
frame:
- frame.meta_permissions
- frame.unity_text_input
updated: '2026-10-17'
updated: '2026-10-15'
+2 -4
View File
@@ -5,10 +5,8 @@ source_hint: NOPE CHALLENGE
engine: Unreal
xr: OpenXR
status: works
notes: "Meta XR Audio telemetry patch + nodebug. MR mode: needs a scanned room."
details: The MR 'Reality Breach' mode needs passthrough and a room; standard VR mode was verified.
adapter:
scene_emul: 1
notes: Meta XR Audio telemetry patch + nodebug. MR "Reality Breach" mode unavailable.
details: The MR 'Reality Breach' mode needs passthrough; standard VR mode used.
alt_overport:
- patch_remove_unreal_force_quit
frame:
+1 -2
View File
@@ -1,14 +1,13 @@
package: com.zenstudios.PFXVRQuest
title: Pinball FX VR
status: issues
notes: "Plays; space warp off (fewer stutters). MR mode: needs a scanned room."
notes: Plays; mixed reality mode not working yet. Space warp off (fewer stutters).
tested_version: '1.9'
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
adapter:
scene_emul: 1
equirect_emul: 1
hide_space_warp: 1
verified:
-10
View File
@@ -6,16 +6,6 @@
# with diagnostics. Launch tests and real play sessions (agent session_log, `frameport session`) are both matched.
# Sources: every failure we hit porting 34 games to the Steam Frame (see docs/PLAYBOOK.md).
signatures:
- id: mr-no-room
pattern: 'FrameBridge[^\r\n]*scene: (?:capture requested -> (?:guardian box|using guardian room)|room from guardian|room [^\r\n]*(?:from guardian|configured/default size|box))'
severity: info
diagnosis: 'The game is using a simple fallback room. Scan your room for mixed reality, or use Frame > Room > Use my play area for walls without furniture. A recorded scan can be imported until headset capture is available.'
suggest: []
- id: mr-discovery-unsupported
pattern: '(?:Unknown proc addr:\s*xrDiscoverSpacesMETA|(?:MRUK[^\r\n]*)?NoRoomsFound)'
severity: warning
diagnosis: 'The game cannot discover a Meta room. Enable room layout (adapter.scene_emul), rebuild and reinstall for the updated extension shim, then save a room under Frame > Room.'
suggest: [adapter.scene_emul]
- id: quest2-headset-identity
pattern: 'venderId[^\r\n]*systemName Oculus Quest2'
severity: info
+93
View File
@@ -0,0 +1,93 @@
# Controller buttons
> **Experimental.** The Frame's runtime accepts these bindings, but the modes haven't been tried in the headset on
> many games yet. If buttons stop working in a game, set it back to **SteamVR default** (Game settings →
> Controllers) and report it.
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 outer send X/A; top and inner send Y/B, as on Quest controllers, whose second button sits up and toward the other hand.
- **Quest pair (2)** uses two face buttons per controller, placed like Touch's: left X = d-pad down, Y = d-pad
right; right A = A, B = X. The other face buttons send no Touch input.
![Quest Touch reference and the three Steam Frame button mappings](images/controllers/quest-buttons.svg)
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 | X | — |
| Left d-pad → | Y | Y | Y |
| Left View | Menu | Menu | Menu |
| Right A (bottom) | A | A | A |
| Right B (right) | B | A | — |
| Right X (left) | B | B | B |
| 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.
- The d-pad has a single touch sensor: a thumb resting on it touches X and Y together. The right controller's
face buttons sense touch one by one.
- 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.
+55 -27
View File
@@ -447,11 +447,34 @@ head-pose deadline ends VR mode without a worn headset (not a game bug).
## Cube swapchains
**Cube swapchains (GitHub #107, 2026-10-09):** the Frame's runtime refuses `faceCount=6` swapchains (-2,
XR_ERROR_RUNTIME_FAILURE); OVRPlugin ignores that ("CreateSwapchain for eye 0: 0x0, 0 stages") and crashes in
ovrp_EndFrame4 (memset). FrameBridge `cube_standin` (default on, `native/adapter/cube_standin.c`) serves a refused cube
swapchain as one GL cube-map texture in the app's context (GLES only) and drops its layers. Verified headless with
Budget Cuts Ultimate (2048² sRGB, 12 mips; runs on at ~70 fps); what the cube layer showed is simply missing.
**Cube swapchains (GitHub #107/#385, 2026-10-17):** the Frame's runtime refuses `faceCount=6`
swapchains (-2, XR_ERROR_RUNTIME_FAILURE). `cube_standin` (default on, `native/adapter/cube_standin.c`)
serves refused cubes with a GLES cube texture in the app's context or a Vulkan cube-compatible image.
This prevents OVRPlugin's empty-image-list crash (Budget Cuts Ultimate; Vulkan UNDERDOGS).
`cube_quads` (int, default 1) now copies mip 0 into six runtime quad swapchains, replacing each cube at
its original layer-list position. Face order is +X, -X, +Y, -Y, +Z, -Z; inward-facing quad right/up axes
match GL cube s/t. Vulkan copies reverse rows for its top-left quad image origin. The cube orientation
rotates the box; its centre follows the stereo view midpoint in the layer's space at displayTime
(falls back to the space origin without valid tracking). Faces are 100 m square at 50 m distance.
Eye visibility and layer flags are retained. Quads submitted by the game retain their order.
The Frame draws quads above **every** projection, regardless of order. This implementation conservatively
drops cubes whenever any projection is submitted, including alpha projections: it supports cube-only
loading screens with optional quad overlays, not skyboxes behind gameplay. Multiple cubes of the same
size/format share six swapchains, using separate array slices so their images cannot overwrite one another.
Each pool is copied once per frame. Other sizes need another six swapchains; runtime allocation refusal
(particularly near its ~15-swapchain cap) frees partial allocations and disables that pool until reset.
Pools are freed on stand-in creation/destruction, xrEndSession and session destruction; stand-ins are also freed at
session destruction. Set `cube_quads=0` for the previous drop-only behaviour.
Look for `cube_quads: created six ... face swapchains (GLES|Vulkan)` and
`cube_quads: submitting cube as six quads (half-size=50m, mip=0)`. Refusals log
`cube_quads: face swapchain creation failed (result=...); dropping cube layers`; mixed frames log
`cube_quads: skipping cube with projection; preserving scene visibility` once.
Host math/settings/artifact checks and Android builds are available; no device test yet. Headset checks
still needed: face seams/orientation, sRGB/alpha, GLES context availability, Vulkan synchronization and
loading-to-gameplay transitions (HardBullet's 1024²/32² Vulkan cubes; Budget Cuts GLES).
## Hardware video decoding
@@ -718,28 +741,6 @@ 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.
## Mixed reality: scanned rooms
**Mixed reality: scanned rooms (agent v92, 2026-10-16, branch mr-scene; docs/mr/):** Quest MR games read the room
through Meta's scene APIs; the Frame offers none, and Valve's chaperone is no room (2D outline raised to 2.43 m, often
the default 1 m circle -> the Android runtime's STAGE rect falls back to 1x1 m; XRService's plane detection is
horizontal-only, its persistent map sparse ORB, its XRIPC single-client; cameras are XRService's alone,
IVRTrackedCamera gives no frames). **The passthrough's RoomView 3D depth mesh is readable** (docs/mr/PHASE0.md:
IVRBlockQueue_005 `/xrservice/roomview3d/meshData|meshLayout`, metadata through the C++ `IVRPaths_002` on the block
handle, `meshIsUpToDate` on the queue handle; 132x128 fan, ~30/s; raw->standing moves during a session -> convert
with the final one), so FramePort scans rooms itself: agent `room_capture` (Background/Overlay OpenVR app, prompts =
artifacts/roomscan/*.png via IVROverlay_028 slots 1/3/20/22/35/43/44/63, `scripts/mr/render_prompts.py`) -> `.fprs`
dump -> `fp_roomscan` (native/roomscan, freestanding, offline) -> `room.txt` + `room-mesh.bin`
(docs/mr/ROOM_FORMAT.md; outline = positive shoelace in x-z, wall inward normal (-dz, dx)) in
`~/.local/share/frameport/rooms/<universe>/`; launch.sh copies it next to settings.conf (`_room_sync`, games with
`scene_emul`); FrameBridge (`scene_room.c/scene_meta.c/scene_anchors.c`) serves N walls, volumes (BOUNDED_3D),
planes, GLOBAL_MESH (XR_META_spatial_entity_mesh), XR_META discovery/persistence (newer Meta SDKs and MRUK), anchors
saved in `files/framebridge-anchors.txt`; all through xrshim (OVRPort's dispatcher drops unknown META calls).
`scene_emul` is now suggested for every USE_SCENE/USE_ANCHOR_API game (migration `scene_emul_all`; NOPE and Pinball
FX had none). PC: Frame page "Room" card, `frameport frame room scan|status|from-boundary|import|export|build|show`.
Real data (owner's room, 2026-10-16): floor at 0, table surfaces, orthogonal walls found; the ceiling needs "look up".
## Vulkan pipelines refused: Fossilize and Turnip (Transformers, #349)
Transformers Beyond Reality: Redux (Unreal, Vulkan) ended its first frames with Unreal's "Shader compilation failures
@@ -757,3 +758,30 @@ engine loads the shim; they aren't the renderer's. About six pipelines with the
(module 12944, ViewIndex) still fail in Turnip; a blank fragment shader alone doesn't help, blank vertex + fragment
does. Settings `vk_no_fossilize` + `vk_pipeline_fallback` (catalog entry): headless RUNNING after 72 s at 72 fps.
Headset check pending (some objects may be missing).
## 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 at the time: 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.
Headset round 2026-10-18 (owner, TestQuestControllers): the button mapping works. Owner's corrections, now in the JSON:
Quest's second button sits up and toward the other hand, so Quest pair = left Y on d-pad right, right B on X (Mirror
follows: X/A = bottom + outer, Y/B = top + inner). The d-pad has one capacitive sensor: a thumb on it touches every
direction, so X and Y touch light together (left as is, hardware limit); the right face buttons sense touch singly.
SteamVR's own Touch remap (`/opt/steamvr/drivers/frame_controller/resources/input/frame_controller_remapping.json`,
layout `oculus_touch`) is the diagram's "SteamVR default": X -> d-pad down, Y -> d-pad left/right/up, A -> A,
B -> B/X/Y, Menu -> View, plus trigger click -> bumper. Games with their own Valve binding file in that folder
(Beat Saber, Batman, Accounting+, In Death, ...) use that file instead.
+24 -26
View File
@@ -66,30 +66,6 @@
`launch.sh` at the new folder and only then deletes the old copy; on the same filesystem it renames instead.
Moving a game whose Lepton data holds absolute paths, or a PC VR prefix, is not yet verified on the device.
## Room data
- SteamVR on the Frame has no Meta scene/anchor extensions. Its chaperone supplies only a 2D outline raised to
2.43 m, without furniture, ceiling or a room mesh. Valve's default boundary is a 20-point circle of roughly 1 m
radius; FramePort refuses to treat it as a room.
- FrameBridge's `scene_emul=1` serves saved room data, with a guardian box when no room is available. OVRPort's
loader only dispatches functions in its own table: newer Meta entry points go through FramePort's extension shim.
MRUK uses discovery (`xrDiscoverSpacesMETA`, older versions `xrQuerySpacesFB`) and requests Space Setup when
discovery returns no rooms. Volumes hang below their top-face anchor in Meta's coordinate convention.
- Room files use STAGE coordinates (metres, +Y up, floor y=0, -Z forward). The format contract is
[mr/ROOM_FORMAT.md](mr/ROOM_FORMAT.md). On the Frame: `~/.local/share/frameport/rooms/<universe>/room.txt`,
optional `room-mesh.bin` and `scan.log`; `rooms/current` points to the most recently saved room.
- A room belongs to its chaperone universe. The agent reads the last universe switch from `vrserver.txt`, falling
back to the newest universe in `~/.config/openvr/config/chaperone_info.vrchap`; dummy 424242 means none.
A matching room is selected first, else `current`; the app reports `aligned: false` after a universe change.
- Quest launchers copy that room beside `settings.conf` before Lepton starts; FrameBridge reads
`dirname($FRAMEBRIDGE_CONFIG)/room.txt`. Stale copies are removed if no room exists or emulation is disabled.
Each game's persistent anchors stay in `/sdcard/Android/data/<pkg>/files/framebridge-anchors.txt`.
- Frame → Room and `frameport frame room` manage rooms. `from-boundary` creates walls without furniture;
`import` / `export` transfer room files; `show` writes an SVG preview; `build <dump.fprs>` syncs the bundled
`fp_roomscan` by SHA-256 and runs it on the Frame with `--offline`. `scan` = agent `room_capture`: records the
RoomView meshes (`/xrservice/roomview3d/meshData` + `/meshLayout`, `IVRBlockQueue_005`) while in-headset prompts
guide the player, then builds the room from that recording.
## Save backups (Quest games, agent v101, discussion #352)
- A Lepton game's saves are two folders of `<base>/lepton-data`: `internal/<pkg>` (= `/data/data/<pkg>`:
`shared_prefs/`, `files/`, `databases/`; Unreal's `files/UE4Game|UnrealGame/.../Saved` often lives here) and
@@ -148,7 +124,9 @@
`ComposeLayerQuad` and `ComposeLayerProjection`; cube/cylinder/equirect/equirect2 exist only as enum names, so
there is nothing to switch on. It has a layer limit ("Exceeded the layer limit"; the value is logged by
`layer_debug` from `maxLayerCount`). The adapter emulates cylinders (strips) and, per game, equirect layers
(`equirect_emul`, an adapter projection layer). Quad layers are drawn above every projection layer regardless of
(`equirect_emul`, an adapter projection layer). Refused cube swapchains use GLES/Vulkan stand-ins;
`cube_quads` (default on) copies their faces into six quads for frames without a projection
(see DEVICE_NOTES "Cube swapchains"). Quad layers are drawn above every projection layer regardless of
submission order (seen 2026-10-01: quads placed before 4XVR's projection layer covered it).
- Swapchain formats: GLES `GL_SRGB8_ALPHA8` (35907) / `GL_SRGB8` (35905) only, samples = 1. Vulkan: 43 (R8G8B8A8_SRGB)
and 50, not 37/44 (UNORM).
@@ -159,6 +137,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
@@ -525,7 +523,7 @@ All readable by the steamos user without root; the agent reads them directly (no
| Metric | Source | Notes |
|---|---|---|
| CPU | `/proc/stat` deltas; `cpufreq/policy{0,2,5,7}` | 8 cores in 4 clusters: 0-1 / 2-4 / 5-6 / 7 (max 2.27 / 3.15 / 2.96 / 3.05 GHz) |
| GPU busy | Sum of `drm-engine-gpu` (ns) deltas in `/proc/<pid>/fdinfo/<fd>` over every render-node fd (msm DRM) | Gives GPU % per process too. A process can hold several render fds: add them all. Lepton games see the node as `/dev/kgsl-3d0` (bind mount of `/dev/dri/renderD128`). `drm-total-memory` reads 0 |
| GPU busy | Sum of `drm-engine-gpu` (ns) deltas in `/proc/<pid>/fdinfo/<fd>` over every render-node fd (msm DRM) | Gives GPU % per process too. A process can hold several render fds: add them all. Lepton games see the node as `/dev/kgsl-3d0` (bind mount of `/dev/dri/renderD128`). `drm-total-memory` reads 0. msm can report garbage engine time (SteamVR's vrcompositor: near 2^64, rising ~1000x faster than real time; idle Frame at 100 %, 2026-10-18): a per-fd delta longer than the interval falls back to `drm-cycles-gpu` / GPU clock (agent v102 `gpu_busy_ns`) |
| GPU clock | `/sys/class/devfreq/3d00000.gpu/{cur,max}_freq` | 231–903 MHz in 12 steps |
| Memory / pressure | `/proc/meminfo`; `/proc/pressure/{cpu,memory,io}` (`some avg10`) | 16 GB RAM, 8 GB swap |
| Temperatures | `/sys/class/thermal/thermal_zone*/{type,temp}` | 48 zones: `cpu*`/`cpuss*`, `gpuss-*`, `ddr`, `nsph*` (NPU), `pm8550*`/`pm8010*` (power ICs), `modem*`, `camera*`, `video`, `max1720x_bat*` |
+8
View File
@@ -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
+8
View File
@@ -64,6 +64,14 @@ Versions compare number by number (`1.10` > `1.9`), and a suffix sorts before th
To build a link from Python: `frameport.deeplink.make_link(manifest_url="https://…/my-game.json")`.
**Where to host the manifest.** The install page shows the manifest's name, description and icon only when the
manifest's host lets web pages read it (CORS); otherwise it shows "A game or app" (FramePort itself reads any public
address). On GitHub, commit the manifest to the repository and link it as a file: `https://raw.githubusercontent.com/
<owner>/<repo>/main/my-game.json`, or the `https://github.com/<owner>/<repo>/blob/main/my-game.json` page address,
which the page and FramePort turn into the raw address. A manifest attached to a GitHub release
(`…/releases/download/…`) installs fine, but web pages can't read it: the page then shows the release's name from
GitHub's API instead.
## On a website
Image version (any page):
+41 -28
View File
@@ -2,7 +2,7 @@
# Patches
Every patch and game setting FramePort knows: 185 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: 186 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) (76)
- [Game settings](#group-adapter) (77)
- [Files and settings on the Frame](#group-device) (10)
- [PC VR (Proton and Revive)](#group-pcvr) (13)
@@ -113,6 +113,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Fix inside-out mixed reality](#adapter-alpha_blend_emul) | `adapter.alpha_blend_emul` | Game settings | |
| [Use controllers](#adapter-controller_fix) | `adapter.controller_fix` | Game settings | 1 |
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 6 |
| [Show cube loading screens](#adapter-cube_quads) | `adapter.cube_quads` | Game settings | |
| [Stand in for cube layers](#adapter-cube_standin) | `adapter.cube_standin` | Game settings | |
| [Show curved menus and screens](#adapter-cylinder_strips) | `adapter.cylinder_strips` | Game settings | |
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 7 |
@@ -152,15 +153,15 @@ 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 | 16 |
| [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 | |
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
| [Render resolution (supersampling)](#adapter-scale) | `adapter.scale` | Game settings | 14 |
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
| [Use room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 14 |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 12 |
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
| [Hide play-area size](#adapter-scene_hide_bounds) | `adapter.scene_hide_bounds` | Game settings | 1 |
| [Room width](#adapter-scene_width) | `adapter.scene_width` | Game settings | |
| [Eye-image snapshots (diagnostics)](#adapter-snapshot) | `adapter.snapshot` | Game settings | |
| [Keep menus in place](#adapter-stable_local) | `adapter.stable_local` | Game settings | 1 |
@@ -754,7 +755,6 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
- `frame.meta_permissions`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
- **Suggested (on):** Uses Meta's room or anchor APIs: their permissions must be granted, or the game never asks for the room (for example NOPE's MR mode).
- **Catalog games (4):** Arkio, BattleTalent, Demeter, Real VR Fishing.
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
@@ -1433,6 +1433,20 @@ For games that show the headset's own controllers. Takes effect after a reinstal
> Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning it on needs a rebuild (it adds a small library in front of OVRPort's loader).
### <a id="adapter-cube_quads"></a>Show cube loading screens
Shows 360° cube loading screens as six panels around you. Requires stand-in cube layers. Skipped while a 3D scene is submitted.
- `adapter.cube_quads`
- **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 `1`.
- Game settings → Troubleshooting → "Show cube loading screens" (advanced).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Copies cube-map loading screens into six panels around the viewer (GLES and Vulkan). Mixed projection frames are skipped because the Frame draws panels above the scene. Requires cube_standin.
### <a id="adapter-cube_standin"></a>Stand in for cube layers
Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts Ultimate). Leave it on.
@@ -1445,7 +1459,7 @@ Keeps games running that use a 360° cube layer the Frame lacks (for example Bud
- Game settings → Troubleshooting → "Stand in for cube layers" (advanced).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES games) and drops its layers, so the game runs on without that layer.
> The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself on GLES and Vulkan; cube_quads displays its loading screens.
### <a id="adapter-cylinder_strips"></a>Show curved menus and screens
@@ -2007,6 +2021,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` · experimental
- **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.
@@ -2093,23 +2121,23 @@ Changes the game's render resolution. Higher is sharper but slower; 1.0× is the
> Override the guardian depth (0 = use guardian, min 1.5 m).
### <a id="adapter-scene_emul"></a>Use room layout
### <a id="adapter-scene_emul"></a>Pretend room layout
Scene games get your saved room. Without one, they get a simple room from the play area.
Mixed-reality games that build their level from your room get a simple room with walls.
- `adapter.scene_emul`
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
- **Suggested (on), value `1`:** The game uses Meta's scene or spatial anchor APIs: serve the saved room on the Frame, or a simple play-area room until one is scanned.
- **Suggested (on), value `1`:** Mixed-reality game that builds its level from the room model: emulate a guardian-sized room (for example Demeter).
- **Suggested (on), value `1`:** The game asks Meta's system for your room layout (scene), which the Frame doesn't have: emulate a room from the play area (for example VR HOT's room setup).
- **Options:**
- Value: int, default `0`.
- Game settings → Mixed reality → "Use room layout".
- **Catalog games (14):** Arkio, AugmentedEmpire, Battlemarked, BattleTalent, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect and 6 more.
- Game settings → Mixed reality → "Pretend room layout".
- **Catalog games (12):** Arkio, AugmentedEmpire, Battlemarked, BattleTalent, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect and 4 more.
- **Suggested by launch-test signatures:**
- `mr-discovery-unsupported` (warning): The game cannot discover a Meta room.
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved room, use a simple play-area box (for example Demeter).
> Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (for example Demeter).
### <a id="adapter-scene_height"></a>Room height
@@ -2125,21 +2153,6 @@ Ceiling height of the pretend room.
> Ceiling height for scene_emul.
### <a id="adapter-scene_hide_bounds"></a>Hide play-area size
For games that reject the room because of the play area.
- `adapter.scene_hide_bounds`
- **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 → Mixed reality → "Hide play-area size" (advanced); shown while `adapter.scene_emul` is on.
- **Catalog games (1):** Demeter.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> With scene_emul, the game's play-area (STAGE bounds) query answers "no play area". The Frame reports a 1x1 m area whose outline OVRPlugin can't give, and some Unity MR games then fail their guardian check (for example Demeter). Off by default: other games stalled without a play area.
### <a id="adapter-scene_width"></a>Room width
0 = the size of your play area.
+4 -8
View File
@@ -109,6 +109,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| `xrConvert…TimeKHR` FUNCTION_UNSUPPORTED spam; VrApi bridge stalls before recenter | runtime lacks timespec conversion | current adapter emulates it |
| Crash on the first frame, backtrace `libVkLayer_fossilize.so` ← `FVulkanRenderPass::FVulkanRenderPass` (crash logcat) | the engine leaves the pNext of unused Vulkan attachment references uninitialized; Lepton always loads Steam's Fossilize layer (guest is userdebug, so even a non-debuggable APK gets it), which follows the pointer | `frame.vk_sanitize` (default on for Unreal; Vulkan shim `libfp_vk.so`; triage `fossilize-renderpass`), for example Deadpool VR |
| SIGABRT right after start, backtrace `libopenxr_loader.so (xrCreateSwapchain+…)` | overport's dispatcher aborts on swapchains > 4096 px ("Wrong createInfo size"); video players use 7680×3840 theater textures | `frame.swapchain_limit` (default on; guard → 16384 px; triage `swapchain-size-abort`), for example 4XVR |
| Cube loading screen missing/frozen, `cube_standin: dropped a layer (type=1000006000)` (HardBullet #385, Budget Cuts #107) | Runtime refuses cube swapchains and composites only quad/projection layers | Rebuild with `cube_standin=1`, `cube_quads=1` (defaults): mip-0 cube faces become six inward quads. Pools reuse six swapchains per size/format, with array slices for distinct cubes. Cubes are conservatively skipped in any frame containing a projection, since Frame draws quads above it. Allocation/copy failure falls back to dropping with `cube_quads:` diagnostics. Host-tested; headset visuals pending |
| Video/menu panel missing, log `dropped N unusable layer(s)` with cylinder (1000017000) or equirect2 (1000091000) layers | the Frame runtime lacks XR_KHR_composition_layer_cylinder/equirect(2)/cube (its SteamVR runtime only composites quad + projection layers) | adapter shows cylinders as flat strips (setting `cylinder_strips`, default on); 360° equirect layers (theaters, 360° videos): `adapter.equirect_emul` (GLES only, per game) |
| 360° theater / 360° video black or missing (for example 4XVR) | equirect layers dropped | `adapter.equirect_emul=1`: a worker thread with a shared GLES context converts each 360° image to a cube map when it changes and draws one adapter projection layer from it for every frame with exactly that frame's views; it replaces the 360° layers in place, so the game's own projection layer (balcony, controllers, 360° videos it draws itself) stays on top. Quads can't be used: the Frame draws quad layers above every projection layer whatever the order. Log: `first 360 view ready`, `in 5 s: N 360 image update(s) … M view redraw(s)`. Upside down / mirrored / behind you: `equirect_flip` 1/2/4 (`frameport settings <pkg> equirect_flip=1`, no rebuild). Never Vulkan (a Vulkan renderer hung the Frame's GPU in AC Nexus) |
| Fades to black don't show: you see the level loading behind a loading screen (Batman: Arkham Shadow, GitHub #197); with `layer_debug=1` launch.log shows `color scale 0.00 0.00 0.00 …` on the projection layer | the game fades by dimming its eye image with XR_KHR_composition_layer_color_scale_bias; whether the Frame's runtime applies it isn't confirmed in the headset yet | `adapter.fade_quad=1` (opt-in, `native/adapter/fade_quad.c`): FrameBridge adds a black, alpha-blended quad right after the dimmed projection layer (VIEW space, 1 m ahead, 6×6 m; opacity = 1 − scale in 16 steps from one 8×8 swapchain refilled only when the step changes: Vulkan clear / GLES clear; one swapchain per step hit XR_ERROR_LIMIT_REACHED in Batman), log `fade_quad: drawing fades over the picture`; `fade_quad=2` also removes the layer's own scale (if both show, the fade is doubled) |
@@ -129,7 +130,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Game stays on a loading or "Waiting" box although it reached FOCUSED; log `FrameBridge: xrEndFrame failed -25` then no more `pacing:` lines (for example PowerWash Simulator on some Frames, GitHub #39) | The eye image rect ends a few pixels past its swapchain (Unity rounds the swapchain width, the runtime's recommended size differs per Frame); SteamVR rejects every frame with XR_ERROR_SWAPCHAIN_RECT_INVALID | FrameBridge clamps every projection/quad image rect to its swapchain (`rect_clamp`, default on since 0.11.0; log `rect_clamp: view N image rect clamped`): rebuild + reinstall the game |
| Black from the start, `projection view 0: ... rect=0,0 0x0` then `xrEndFrame failed -25` twice and OVRPlugin "Unable to submit Oculus frame" (BattleTalent, GitHub #293; right after Unity's "Switching to the recommended level" MSAA line) | The game submits its first projection views with an empty image rect; SteamVR rejects them and OVRPlugin gives up submitting | `rect_clamp` replaces an empty rect with the whole swapchain image (log `rect_clamp: view N empty image rect -> full swapchain WxH`): rebuild + reinstall. Check the build output for a WARN from the Unity IL2CPP fixes (`frame.unity_runtime_msaa_off` etc. "code not found in this game"): turned on, they may still change nothing |
| Black flash, then the SteamVR home environment while the game keeps running (sound plays); vrserver.txt `[OpenXR] SetXrInputConfig failed. Failed to load manifest provided by SetXrInputConfig`, then the app "has exited" and reconnects as `VRApplication_OpenXRInstance`; headless launch tests pace at 10 fps (for example Scared By Squares, GitHub #323) | Two controller actions in one action set share a name (Unreal: an action mapping and an axis mapping both called "Turn"). The Frame's runtime accepts the duplicate but can't load the input manifest it builds and drops the app as a scene app | FrameBridge gives the later duplicate a free name (`action_dedup`, default on; log `action_dedup: action "turn" ("Turn") already exists in its set: created as "turn_2"`): rebuild + reinstall the game. `input_diag=1` logs every action set and action the game creates |
| Unity game (OVRPlugin, GLES or Vulkan) crashes ~10 s in: `FrameBridge: xrCreateSwapchain … faces=6 … result=-2`, OVRPlugin `CreateSwapchain for eye 0: 0x0, 0 stages`, then render-thread SIGSEGV in memset ← libOVRPlugin ← `ovrp_EndFrame4` (libgallium/ANR follow; for example Budget Cuts Ultimate, GitHub #107); triage `cube-swapchain-refused` | The game shows a cube-map layer (OVROverlay cubemap); the Frame's runtime has no cube layers and refuses cube swapchains (XR_ERROR_RUNTIME_FAILURE); OVRPlugin doesn't check and writes into the image list it never got | FrameBridge `cube_standin` (default on): a refused cube swapchain is served as a GL cube map in the game's context or a device-local Vulkan cube image transitioned to COLOR_ATTACHMENT_OPTIMAL, and its layers are dropped (log `cube_standin: runtime refused …`): rebuild + reinstall. Vulkan path covers UNDERDOGS (GitHub #296); host-tested, headset verification pending |
| Unity game (OVRPlugin, GLES or Vulkan) crashes ~10 s in: `FrameBridge: xrCreateSwapchain … faces=6 … result=-2`, OVRPlugin `CreateSwapchain for eye 0: 0x0, 0 stages`, then render-thread SIGSEGV in memset ← libOVRPlugin ← `ovrp_EndFrame4` (libgallium/ANR follow; for example Budget Cuts Ultimate, GitHub #107); triage `cube-swapchain-refused` | The game shows a cube-map layer (OVROverlay cubemap); the Frame's runtime has no cube layers and refuses cube swapchains (XR_ERROR_RUNTIME_FAILURE); OVRPlugin doesn't check and writes into the image list it never got | FrameBridge `cube_standin` (default on): a refused cube swapchain is served as a GL cube map in the game's context or a device-local Vulkan cube image transitioned to COLOR_ATTACHMENT_OPTIMAL, and `cube_quads` displays cube-only loading screens as six quads (log `cube_standin: runtime refused …`): rebuild + reinstall. Vulkan path covers UNDERDOGS (GitHub #296); host-tested, headset verification pending |
| Game (Unity, GLES) freezes, `zink: DEVICE LOST` | multisampled render-to-texture hangs the GPU | `frame.unity_no_msaa`; if it persists: unfixable → PC version |
| Unreal GLES game crashes ~3 s after start: `Fatal signal 11 … fault addr 0x10000 in tid … (RHIThread)`, `#01 … libgallium_dri.so` (GitHub #83 Star Wars Pinball VR, triage `unreal-msrtt-crash`) | Unreal's mobile MSAA renders through multisampled render-to-texture; Zink's `find_rp_state` then indexes `rendering_state_cache[6]` one past its end (sample count ≥ 32) and calls a junk hash function (0x10000). Mesa bug (no bounds check) | `frame.unreal_gl_shim` (GL shim with `gl_hide_msrtt`, multiview kept for Unreal: Unreal 4.25 only enables multiview with GL_OVR_multiview, GL_OVR_multiview2 **and** GL_OVR_multiview_multisampled_render_to_texture, so the shim keeps the last one visible for Unreal and maps glFramebufferTextureMultisampleMultiviewOVR to the single-sampled glFramebufferTextureMultiviewOVR; log `multiview multisampled render-to-texture (N samples) drawn single-sampled`) |
| Crash with `SIGILL (ILL_ILLOPN)` and `*pc=0xd50323bf` (autiasp), for example on Unreal's HttpManager thread (GitHub #83 Star Wars Pinball VR, triage `pac-unpaired`) | The engine's OpenSSL ARMv8 assembly has autiasp without a matching paciasp (libUE4.so: 38 paciasp, 40 autiasp; Poly1305 NEON). Quest CPUs treat PAC hints as NOPs, the Frame's CPU checks them | `frame.pac_hints`: in a library with unpaired counts every paciasp/autiasp becomes a NOP (in place) |
@@ -160,7 +161,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) |
@@ -198,12 +199,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Mixed reality inside out: the room shows on the game's objects, black where the room should be (Unreal, for example Star Wars: Beyond Victory, GitHub #329) | Unreal keeps 1 - coverage in the scene alpha and OVRPlugin chains XrCompositionLayerAlphaBlendFB (colour factors ONE_MINUS_SRC_ALPHA / SRC_ALPHA, `layer_debug` shows `n=1000041001(3,2/3,2)`) to the eye layer; the Frame's runtime ignores it | adapter `alpha_blend_emul=1` (default, Vulkan): inverts the alpha of the eye images in place while passthrough is emulated; logs `alpha_blend: inverting the alpha of swapchain=…` |
| Quest Touch controllers (or none) shown in a game that uses Meta's runtime controller models | XR_FB_render_model missing on the Frame | adapter `controller_models=1`: the agent converts the Frame's SteamVR controller models to glb at install, the adapter serves them. Games with their own controller meshes aren't affected (none of the 34 catalog games use runtime models) |
| `overportOXR: Unknown proc addr: xr…` for a function the adapter emulates | overport's dispatcher only forwards functions in its own table | route the plugin's lookups through `native/xrshim` (done for XR_FB_render_model when `controller_models=1`) |
| MR game stuck waiting for room data (e.g. Demeter) | No Meta scene API | `adapter.scene_emul=1` (+ `frame.meta_permissions`); Frame → Room → Use my play area, or import a scanned room |
| Unity MR game rejects every room / its "use guardian" fallback does nothing; `ovrpBoundary_Outer is not supported`, `IndexOutOfRangeException` in a guardian check (e.g. Demeter's `RemoveObjectNotInGuardian`) | OVRPlugin calls the Frame's 1x1 m STAGE rect a configured boundary but can't give its outline | `adapter.scene_hide_bounds=1` (with `scene_emul`): FrameBridge then answers the app's STAGE `xrGetReferenceSpaceBoundsRect` with `XR_SPACE_BOUNDS_UNAVAILABLE` (log `scene: STAGE bounds hidden`). Per game only: NOPE stalled without a play area. Demeter also needs a floor of at least 2.5 x 2.5 m, as on a Quest |
| MR game with `scene_emul` never asks for the room (no `scene: query`/`discovery` lines), black screen or MR mode with no room (e.g. NOPE, Pinball FX); `m_XR_META_boundary_visibility extension is not supported` every frame | Meta's scene/anchor permissions undeclared (Lepton can't grant them); boundary suppression unsupported | `frame.meta_permissions` (now with every `scene_emul` recipe of a USE_SCENE/USE_ANCHOR_API game, migration `scene_permissions`); FrameBridge emulates `XR_META_boundary_visibility` (log `scene: boundary suppressed`). `passthrough:` lines show the app's passthrough calls |
| Build says `Unity text fields: not fixed: Cpp2IL couldn't start on this PC` (older versions: `Cpp2IL couldn't read this game's code: at Cpp2IL.Program.Main`) | .NET aborts without libicu (fresh WSL/Linux) | FramePort runs Cpp2IL with `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1`; such failures aren't cached, so the next build retries |
| MR uses an empty/simple room; FrameBridge `scene: capture requested -> guardian box` / `using guardian room`, `room from guardian`, or fallback box dimensions (`mr-no-room`) | No saved room, so FrameBridge uses its guardian box | Save a room in Frame → Room. A real SteamVR boundary supplies walls; Valve's default circle is refused. Import a scanner-built room for furniture/mesh; headset capture is not implemented yet |
| `Unknown proc addr: xrDiscoverSpacesMETA` or MRUK `NoRoomsFound` (`mr-discovery-unsupported`) | Scene discovery is unavailable or no room was discovered | Enable `adapter.scene_emul`, rebuild/reinstall to get the updated extension shim, then save a room. All games declaring USE_SCENE or USE_ANCHOR_API now get this suggestion |
| MR game stuck waiting for room data (Demeter) | no Meta scene API | adapter `scene_emul=1` (+ `frame.meta_permissions`) |
| Hand-tracking game janky (Silhouette) | Frame synthesizes hands from controllers | `controller_fix=0` passes hands through; not really fixable |
| Eye distortion while moving (Arcsmith, Time Stall) | unknown (not eye swap, tracking, Valve layers, depth or pacing) | unresolved |
| The Climb 666.0: hand outlines ripple in both eyes while menus and gameplay work (GitHub #238) | Unknown. Sessions paced around 72 fps, controller tracking reported valid, and SteamVR motion smoothing was already off; these checks do not establish the cause | Unresolved after wearer comparisons on 2026-10-12: disabling Valve's Vulkan layers, setting `r_AntialiasingMode = 1`, and setting `r_AntialiasingMode = 0` with `r_MotionBlur = 0` each gave the same or worse result. These were config comparisons; runtime cvar values were not verified. Restored the playable configuration (`r_vr_foveated = 0` with the default Vulkan layers), retaining the startup/rendering crash fixes above. Do not keep the unsuccessful experimental settings as a fix |
+150
View File
@@ -0,0 +1,150 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="864" viewBox="0 0 1080 864" 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 Steam Frame controller button is labelled with the Quest Touch button it sends. A dash means it sends nothing. Left Touch buttons are X and Y; right are A and B.</desc>
<rect width="1080" height="864" 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 · —: sends nothing</text>
<g transform="translate(16 72)">
<rect x="0" y="0" width="520" height="380" 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">Two face buttons per hand, plus Menu on the left</text>
<text x="140" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
<circle cx="117" cy="206" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="117" y="212.84" fill="#3AA8FF" font-size="19" font-weight="700" text-anchor="middle">X</text>
<circle cx="163" cy="151" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="163" y="157.84" fill="#FF8A1F" font-size="19" font-weight="700" text-anchor="middle">Y</text>
<rect x="83" y="251" width="114" height="32" rx="8" fill="#1D1F27"/>
<text x="140" y="272" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Menu</text>
<text x="380" y="84" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
<circle cx="403" cy="206" r="25" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="403" y="212.84" fill="#3AA8FF" font-size="19" font-weight="700" text-anchor="middle">A</text>
<circle cx="357" cy="151" r="25" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="357" y="157.84" fill="#FF8A1F" font-size="19" font-weight="700" text-anchor="middle">B</text>
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" 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="380" 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">Steam Frame controllers: each button shows the Quest button it sends</text>
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
<text x="98" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="69" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="127" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="422" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="393" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="451" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
</g>
<g transform="translate(16 468)">
<rect x="0" y="0" width="520" height="380" 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">Steam Frame controllers: each button shows the Quest button it sends</text>
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
<text x="98" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="69" y="193.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
<text x="127" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="422" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="393" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="451" y="193.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
</g>
<g transform="translate(544 468)">
<rect x="0" y="0" width="520" height="380" 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">Steam Frame controllers: each button shows the Quest button it sends</text>
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
<text x="98" y="164.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
<text x="69" y="193.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
<text x="127" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
<text x="422" y="164.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="393" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#868A96" stroke-width="1.5"/>
<text x="451" y="193.04" fill="#868A96" font-size="14" font-weight="700" text-anchor="middle">—</text>
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

+47
View File
@@ -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.
+249
View File
@@ -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.
+192
View File
@@ -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.
+49
View File
@@ -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 + outer = X/A, top + inner = Y/B, as on Quest controllers.",
"left": {"x": ["dpad_down", "dpad_left"], "y": ["dpad_up", "dpad_right"], "menu": ["view"]},
"right": {"a": ["a", "b"], "b": ["x", "y"]}
},
"2": {
"name": "Quest pair",
"note": "Two buttons per controller, placed like Quest Touch's (the second one up and toward the other hand); the other face buttons do nothing.",
"left": {"x": ["dpad_down"], "y": ["dpad_right"], "menu": ["view"]},
"right": {"a": ["a"], "b": ["x"]}
}
}
}
-49
View File
@@ -1,49 +0,0 @@
# Phase 0: reading Valve's RoomView 3D mesh (dev Frame, SteamOS 0.4.5 build 20261007, 2026-10-16)
Result: **a third-party OpenVR client can read the passthrough depth mesh, and it carries enough of the room for
a scan** (floor, table-height surfaces, orthogonal walls; the ceiling only weakly).
## Access
- Background OpenVR app (`VR_InitInternal2(..., VRApplication_Background)`, never starts SteamVR) through
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`.
- `FnTable:IVRBlockQueue_005`: `Connect("/xrservice/roomview3d/meshData")` and `.../meshLayout` work (created by
Valve's cv driver; no exclusivity seen next to the compositor's own reader). Read with
`WaitAndAcquireReadOnlyBlock(queue, &block, &buffer, EBlockQueueReadType_New, timeout_ms)` + `ReleaseReadOnlyBlock`.
- Metadata through the C++ interface `IVRPaths_002` (vtable slot 0 `ReadPathBatch(root, PathRead_t*, n)`, slot 2
`StringToHandle(&handle, path)`; `FnTable:IVRPaths_001` doesn't exist), root = the **block** handle, except
`/meshData/meshIsUpToDate` which is a property of the **queue** handle:
| path | tag | meaning |
|---|---|---|
| `/meshLayout/layoutId` | 3 (u64) | id of the index layout (static while the mesh resolution stays) |
| `/meshLayout/vertexCount` | 2 (i32) | 16896 = 132 x 128 |
| `/meshLayout/indexCount` | 2 | 99822 (u32 triangle indices into the grid, row width 132) |
| `/meshData/meshId` | 3 | frame counter (~30 per second while passthrough runs) |
| `/meshData/layoutId` | 3 | matches the layout block |
| `/meshData/elementCount` | 2 | floats in the data block (vertexCount x 3) |
| `/meshData/rawFromMeshPose` | 20 (HmdMatrix34) | mesh -> SteamVR raw tracking space |
| `/meshData/posePredictionBaseTime` | 7 (f64) | seconds (system uptime clock) |
| `/meshData/meshIsUpToDate` (queue) | 4 (bool) | false in standby / headset off |
- STAGE (= standing universe) = `IVRSystem_023` vtable slot 13 `GetRawZeroPoseToStandingAbsoluteTrackingPose()` x
rawFromMeshPose. Check: the mesh origin is 6.7 cm from `GetDeviceToAbsoluteTrackingPose(Standing)`'s head
position in every frame (camera vs head centre). **The raw->standing transform changes during a session** (its
height went 1.60 -> 1.07 m while recording: Valve's floor estimate) -> capture raw poses and convert all frames
with the transform at the end of the capture.
## The mesh
- A fixed fan of view directions, +Z forward, rows top->bottom (row 0 = up in the image, -Y), ~126 deg across the
corners (corner directions ~(+-0.67, +-0.66, 0.34)); one depth per grid point. Not head-locked in world terms:
its pose is the passthrough camera pose at capture.
- Range: reliable ~0.3-4 m; far values are inpainted (up to ~10 m) -> drop > 4 m. Strongly smoothed (soft edges,
small objects lost); locally planar: plane-fit residual of 12x12 patches median 5-17 mm.
- First frames after tracking starts can have NaN poses -> skip non-finite frames.
- Fused normals (60 s of looking around, ~3 frames/s used): floor clearly at y ~= 0, a table-height surface at
0.7 m, a lower one at 0.5 m; vertical surfaces in two orientation clusters ~85 deg apart (orthogonal walls), wall
offsets readable; ceiling: few down-facing points (people rarely look straight up; prompt for it).
- Data rate: 16896 x 12 B vertices + 99822 x 4 B indices per frame (the dump repeats the indices per frame).
## Consequences for the design
- Capture = stdlib Python on the Frame (ctypes), as above; the C scanner works offline on the dump.
- Scanner input quality: smooth, noisy at depth discontinuities; walls/floor via normals + RANSAC are robust;
furniture only as coarse boxes; ceiling needs a guided "look up" step, else the default height.
-82
View File
@@ -1,82 +0,0 @@
# FramePort room files (mixed reality)
Quest MR games read a room model (floor, ceiling, walls, furniture, a global mesh) through Meta's scene APIs. The
Steam Frame has no such data for apps (see docs/FRAME_RUNTIME.md "Room data"), so FramePort scans the room itself
(`fp_roomscan`) and FrameBridge (`native/adapter/scene_emu.c`) serves it to games. This file is the contract between
the scanner, the Frame agent, the PC app and FrameBridge.
## Coordinates
- Everything is in **STAGE space** = SteamVR's standing universe = OpenXR `XR_REFERENCE_SPACE_TYPE_STAGE`:
metres, right-handed, +Y up, floor at y = 0, -Z = the universe's forward.
- A room belongs to one chaperone universe (`universe` line). The Frame sometimes starts a new universe without
relocalizing; then the stored room may be misaligned (the agent reports `aligned: false`, nothing breaks).
## room.txt (UTF-8 text, one record per line, `#` starts a comment, fields separated by spaces)
```
frameport-room 1 # magic + format version (required, first line)
universe <decimal u64> # chaperone universe id the room was scanned in (0 = unknown)
source scan|boundary|box|manual # how it was made
time <unix seconds>
room <uuid> # the room anchor
floor <uuid> <y> # floor height (normally 0)
ceiling <uuid> <y> # ceiling height
outline <n> x0 z0 x1 z1 ... x(n-1) z(n-1)
# floor polygon, n >= 3, not closed, POSITIVE signed area:
# sum(x[i]*z[i+1] - x[i+1]*z[i]) > 0 (turning from +X towards +Z; seen from
# above with -Z forward that is clockwise). Readers reverse a negative outline.
wall <uuid> x0 z0 x1 z1 <y0> <y1> [LABEL]
# one wall face from (x0,z0) to (x1,z1), bottom y0, top y1.
# Its normal points INTO the room: walls follow the outline's order, and the
# inward normal is (-dz, dx)/|d| for d = (x1-x0, z1-z0).
# LABEL (optional, default WALL_FACE): INVISIBLE_WALL_FACE for an edge the
# scanner closed without seeing a wall (open side of a room, doorway gap).
volume <uuid> <LABEL> cx cz top_y yaw_deg w d h
# furniture box: top-face centre (cx, top_y, cz), yaw about +Y in degrees
# (0 = local X along world X), width w along local X, depth d along the
# horizontal local axis perpendicular to it, height h downwards from the top
plane <uuid> <LABEL> px py pz qx qy qz qw w h
# extra 2D plane (DOOR_FRAME, WINDOW_FRAME, WALL_ART, ...): pose of its centre,
# Meta convention (bounds in local XY, local +Z = the plane's normal)
mesh <uuid> <file name> <vertex count> <index count>
# global mesh sidecar in the same folder (GLOBAL_MESH entity)
```
- `<uuid>` = 32 lowercase hex digits (no dashes). New ones per scan; FrameBridge uses them as the XrUuidEXT bytes.
- Labels are Meta's semantic label strings: FLOOR, CEILING, WALL_FACE, INVISIBLE_WALL_FACE, TABLE, COUCH, BED,
STORAGE, SCREEN, LAMP, PLANT, DOOR_FRAME, WINDOW_FRAME, WALL_ART, OTHER, GLOBAL_MESH.
- Readers ignore unknown record types (forward compatibility) and reject a file whose first line isn't
`frameport-room <version>` with a version they know. Missing ceiling -> 2.5 m above the floor. Missing walls ->
walls from the outline edges. Missing outline -> the bounding rectangle of the walls.
### How FrameBridge maps records to Meta entities (scene_emu.c)
- ROOM: ROOM_LAYOUT (floor, ceiling, walls in file order) + SPACE_CONTAINER (every other entity) + LOCATABLE/STORABLE.
- floor/ceiling: pose at the outline centroid at height y; local +Z = +Y (floor) / -Y (ceiling);
BOUNDED_2D = the outline's bounding rect in local XY; BOUNDARY_2D = the outline polygon in local XY.
- wall: pose at the segment midpoint, height (y0+y1)/2; local +X along the wall (x0,z0)->(x1,z1), local +Y up,
local +Z = inward normal; BOUNDED_2D = length x (y1-y0) centred.
- volume: pose at the top-face centre, rotation = yaw about +Y then local +Z pointing up (+Y); BOUNDED_3D offset
(-w/2, -d/2, -h), extent (w, d, h) (Meta: volumes hang below their anchor); BOUNDED_2D = the top face (w x d).
- plane: pose as given; BOUNDED_2D = w x h centred.
- mesh: entity at the identity pose labelled GLOBAL_MESH with TRIANGLE_MESH (XR_META_spatial_entity_mesh).
## room-mesh.bin (little-endian)
```
char[4] "FPRM", u32 version = 1, u32 vertex_count, u32 index_count,
float32 xyz[vertex_count], u32 indices[index_count] (triangles; counter-clockwise seen from the room's inside)
```
Coordinates in STAGE space. Kept below 200k triangles (decimated).
## Where the files live
- Frame: `~/.local/share/frameport/rooms/<universe>/room.txt` + `room-mesh.bin` (+ `scan.log`).
`~/.local/share/frameport/rooms/current` = symlink to the newest room's folder.
- Per game: launch.sh copies the current universe's room (else `current`) to `<app_dir>/room.txt` (+ mesh) before
Lepton starts, next to `settings.conf`; FrameBridge reads `dirname($FRAMEBRIDGE_CONFIG)/room.txt`. No room ->
the old guardian box.
- A game's own saved anchors: `/sdcard/Android/data/<pkg>/files/framebridge-anchors.txt`
(`anchor <uuid> px py pz qx qy qz qw` per line, STAGE space), written by FrameBridge on save/erase.
## Scan dumps (*.fprs) — recorded RoomView 3D meshes, input of `fp_roomscan --offline`
```
char[4] "FPRS", u32 version = 1, then frames until EOF:
f64 time_s, float32 pose[7] (px py pz qx qy qz qw: mesh -> STAGE), u32 vertex_count, u32 index_count,
float32 xyz[vertex_count] (mesh space), u32 indices[index_count]
```
-216
View File
@@ -1,216 +0,0 @@
# Scene emulation stream
Implemented against `ROOM_FORMAT.md` without changing the contract, patch revisions, agent version or analysis version.
No Frame access or external downloads were used. Headset behavior still needs maintainer verification.
## Implementation
- `scene_emu.c` owns entities, component APIs, pose transforms, bounded query snapshots and the event queue.
`scene_room.c` parses room files and meshes; `scene_meta.c` implements META discovery and batch persistence;
`scene_anchors.c` implements per-game saved anchors. All are included by the adapter, with no new dependencies.
- At session creation, read `dirname(FRAMEBRIDGE_CONFIG)/room.txt`. Invalid magic/version, unusable geometry or
absent files retain the original guardian box, settings and seven deterministic UUIDs. Unknown records are
ignored; malformed records are logged and skipped. Missing ceiling, outline and walls follow the contract.
- Floor/ceiling use the polygon area centroid and real local-XY polygons. Walls retain file order, with local +X
along their segment, +Y up and +Z inward. Furniture poses use yaw then +Z up; volume bounds extend downwards.
The room container includes every other room-file entity, excluding independently created application anchors.
- Geometry exposes BOUNDED_2D, BOUNDED_3D, semantic labels and TRIANGLE_MESH as appropriate. OpenXR has no separate
`BOUNDARY_2D` component enum: `xrGetSpaceBoundary2DFB` belongs to BOUNDED_2D. Labels preserve comma-separated strings.
All variable-sized outputs support count-only and insufficient-capacity calls, including independent mesh arrays.
- Limits: 2,048 entities including application anchors, 1,024 outline points, 255 label bytes, 64 KiB record lines,
one global mesh, 600,000 vertices/indices (200,000 triangles) and 16 MiB mesh payload. Sidecar filenames cannot
escape the config directory. Magic, version, counts, exact byte size, finite coordinates and indices are checked.
- FB queries and META discovery snapshot results under the mutation lock, before queuing available/complete events.
META component/UUID filters intersect; unrecognized filters impose no restriction. FB max-result limits are honored.
There are 64 retained query snapshots and 63 queued events; calls that cannot fit their events return
`XR_ERROR_LIMIT_REACHED`. Geometry readers share the mutation lock with persistence.
- Application anchors use kernel randomness (`getrandom`, then `/dev/urandom`), never index-derived UUIDs.
Saved poses are in STAGE space. FB/META saves write a private temporary file, flush/fsync, then rename to
`/sdcard/Android/data/<package>/files/framebridge-anchors.txt`. A process suffix such as `:game` is stripped.
Erase removes application anchors from disk and the discoverable in-memory set. Failed writes report failure
through completion events and restore memory. Room-file entities remain owned by the scan: save/erase are no-ops
for those entities. Storage is local; there is no cloud synchronization.
- A new session reloads the room and saved anchors. Fake handles fit both adapter ABIs and include a session
generation; erased/stale fake spaces are never passed to SteamVR. Session generations wrap after 256 sessions.
- Capture completes immediately and logs exactly `scene: capture requested -> scanned room` for a usable room file,
or `scene: capture requested -> guardian box` for the fallback. The former covers all file sources, including manual
and boundary files. The load summary preserves the actual `source` and `universe`.
The cached `openxr-f2448a8797` headers already contain discovery, persistence and mesh declarations and enum values.
No local structure/enum substitutes were necessary. Both META extensions are added to the existing conditional
extension advertisement/instance filtering. Their system capability structures report support when emulation is active.
Passthrough and unrelated adapter settings are unchanged.
## Routing and integration
All of the following use `scene_emulation` through **both** the adapter's normal `xrGetInstanceProcAddr` and
`framebridge_extension_proc`, reached through xrshim's existing name-agnostic lookup:
```
xrQuerySpacesFB xrRetrieveSpaceQueryResultsFB
xrGetSpaceUuidFB xrEnumerateSpaceSupportedComponentsFB
xrSetSpaceComponentStatusFB xrGetSpaceComponentStatusFB
xrGetSpaceContainerFB xrGetSpaceRoomLayoutFB
xrGetSpaceBoundingBox2DFB xrGetSpaceBoundingBox3DFB
xrGetSpaceBoundary2DFB xrGetSpaceSemanticLabelsFB
xrRequestSceneCaptureFB xrCreateSpatialAnchorFB
xrSaveSpaceFB xrEraseSpaceFB
xrGetSpaceTriangleMeshMETA xrDiscoverSpacesMETA
xrRetrieveSpaceDiscoveryResultsMETA xrSaveSpacesMETA
xrEraseSpacesMETA
```
The shim additionally routes `xrPollEvent`, `xrGetSystemProperties`, `xrLocateSpace`, `xrLocateSpaces`,
`xrLocateSpacesKHR` and `xrDestroySpace` to the adapter while scene emulation is active. Other shim names continue to
OVRPort, preserving existing haptics/render-model handling. The real shim is host-tested against a dispatcher that
refuses every scene function; every emulated scene lookup succeeds and an actual discovery/retrieve round trip runs.
**Plumb-stream requirement:** ensure `scene_emul=1` also installs xrshim and rewrites OVRPlugin's loader string to it.
The pre-existing adapter patch only did this for other shim settings. These files are outside this stream's ownership.
Delivery of room files before Lepton starts and detection of universe misalignment also belong to plumb. FrameBridge
serves the supplied coordinates; it does not silently transform a room belonging to another universe.
No contract changes are needed. Whether each MRUK version accepts a room without further permissions, how it chooses
volume versus mesh geometry, and whether its visible placement matches the scan need headset tests. The existing
single-active-session architecture is retained; simultaneous independent sessions are not implemented.
## Offline verification
- 25 host tests passed, compiling the actual emulation and shim. Coverage includes malformed records, unknown
records, mesh corruption/path/cap checks, derived geometry, 300 walls, poses/normals, all buffer sizing idioms,
discovery event order/filters/backpressure, UUID query/discovery of saved anchors, new sessions/processes,
FB/META save/erase and atomic-write failure rollback.
- ASan/UBSan: scanned room, generated walls and box cases passed. LeakSanitizer was disabled because this sandbox
prohibits ptrace; its default exit failed with that environmental limitation.
- `python native/build.py --only adapter,xrshim` succeeded from cached tools. Two final builds were byte-identical,
including `SHA256SUMS`. The shim was rebuilt and is byte-identical to its previous artifact (the routing lives in
the adapter). Artifact content changes are the two adapter binaries and two checksum lines.
- Full pytest was attempted: **1,771 passed, 40 skipped, 11 failed, 16 errors**. All 27 failing/erroring tests create
local HTTP sockets, which the sandbox prohibits (`PermissionError: [Errno 1] Operation not permitted`). They are
in `test_deeplink`, `test_devkit`, `test_install_links_extra`, `test_livestream`, `test_setup_url` and `test_ui_state`.
With exactly those 27 tests deselected, and the additional real-shim test included: **1,772 passed, 40 skipped,
27 deselected**. An unrestricted maintainer run of the whole suite is still required.
- The worktree had no usable venv. Verification reused the existing local dependencies through an ignored venv
`.pth`, with `PYTHONPATH="$PWD/src"`, `UV_CACHE_DIR=/tmp/frameport-uv-cache`, `UV_OFFLINE=true` and
`uv run --no-sync pytest`. No dependencies were downloaded. The build cache was made a local directory with
symlinks to cached tools/headers so generated shader files stayed inside this writable worktree.
Adapter SHA-256:
```
arm64-v8a: a90c8d58904857bc198a48fd7a411b7807f68f14fa58fd268c57e2c2ca87e389
armeabi-v7a: 92daecaf5b86e1857ee8a61b76598e5645f8b427e5f373067c1f87bf8ff99d07
```
## Maintainer device tests
These commands are a handoff, not commands run by this agent. Use an isolated populated `FRAMEPORT_HOME`, cached
build tools, a paired Frame and legally obtained game sources. Stop games before changing their files. Back up the
existing recipe, APK, room files and saved anchors first. Integrate plumb before testing normal room delivery.
On the PC, from the integrated checkout (replace `frame.local` if necessary):
```sh
export FRAMEPORT_HOME="$PWD/.home"
export FRAMEPORT_NO_UPDATE_CHECK=1 FRAMEPORT_NO_CATALOG_UPDATE=1
FRAME=steamos@frame.local
PKG=com.Novelab.Demeter
uv run frameport recipe "$PKG" --set scene_emul=1
uv run frameport install "$PKG" --frame "$FRAME" --force
uv run frameport test "$PKG" --frame "$FRAME" --seconds 45
ssh "$FRAME"
```
In that Frame shell, find the deployment's actual base (also supports a game moved to another drive):
```sh
PKG=com.Novelab.Demeter
BASE=$(python3 -c 'import json,os,sys; p=os.path.expanduser("~/Applications/quest-frame/"+sys.argv[1]+"/deployment.json"); print(json.load(open(p)).get("base",os.path.dirname(p)))' "$PKG")
grep -E 'scene:|extension shim:.*(Spaces|TriangleMesh|BoundingBox3D|PollEvent)' "$BASE/launch.log"
```
With a current scan delivered beside `settings.conf`, expect:
```
scene: room from scan file (N walls, M volumes, P planes, K mesh triangles, universe U)
scene: capture requested -> scanned room
```
The capture line appears only if the game requests capture. A source made from a boundary/manual file names that
source in the summary. The shim logs each requested function, for example
`extension shim: xrDiscoverSpacesMETA -> FrameBridge`. There must be no `Unknown proc addr` for a scene function
that the game obtains through the shim. Repeat the rebuild/install/headset check for `com.gritGames.Nope` and
`com.zenstudios.PFXVRQuest` with `scene_emul=1`; these exercise newer MRUK discovery/persistence paths.
Wear the headset and launch from Steam. Enter each game's MR mode and request room setup if offered. Verify the
floor polygon, every wall, ceiling and furniture match the scan; tables sit below their top-face anchors; doors and
windows have the correct orientation. Headless startup alone cannot prove tracking or visual placement.
Fallback/undo test, while stopped: preserve `$BASE/room.txt` and `$BASE/room-mesh.bin` under a separate backup
folder, then disable plumb's room delivery for this run or move the source room out of `rooms/current` as well.
Without that precaution launch.sh will restore the file. Launch again; expect the original box summary and
`scene: capture requested -> guardian box`. Test `scene_width=3 scene_depth=4 scene_height=2.7` with the existing
`frameport settings` command and expect `scene: room 3.00 x 4.00 x 2.70 m`. Restore the saved files, recipe/settings
and normal room delivery afterward. Do not delete a real game's saved anchors to test the fallback.
### Android persistence probe (independent of a game's UI)
This probe uses the **real scene code with the fake runtime from the host harness**. It checks Android storage,
process-name handling and both persistence APIs; the headset tests above check the real SteamVR integration.
The following cross-compilation command was verified offline:
```sh
native/.cache/ndk-27.2.12479018/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android29-clang \
-std=gnu11 -O2 -Wall -Wextra -Werror -Wno-unused-function \
-I native/.cache/openxr-f2448a8797 -I native/adapter tests/native/scene_emu_test.c \
-lm -o /tmp/frameport-scene-test-android
scp /tmp/frameport-scene-test-android "$FRAME":/tmp/
```
Keep a freshly installed game running so its Lepton container exists, then run in the Frame shell:
```sh
APPID=$(python3 -c 'import json,os,sys; print(json.load(open(os.path.expanduser("~/Applications/quest-frame/"+sys.argv[1]+"/deployment.json")))["appid"])' "$PKG")
CONTAINER=lepton-steamlaunch-$APPID
podman cp /tmp/frameport-scene-test-android "$CONTAINER":/data/local/tmp/frameport-scene-test
podman exec "$CONTAINER" test ! -e /sdcard/Android/data/org.frameport.scenetest
# Continue only if that test succeeds: use a fresh test-only package directory.
podman exec "$CONTAINER" mkdir -p /sdcard/Android/data/org.frameport.scenetest/files /data/local/tmp/frameport-scene-probe
for mode in save reload erase-failure reload erase empty; do
podman exec "$CONTAINER" sh -c "FRAMEBRIDGE_CONFIG=/data/local/tmp/frameport-scene-probe/settings.conf exec -a org.frameport.scenetest /data/local/tmp/frameport-scene-test $mode" || break
done
podman exec "$CONTAINER" wc -l /sdcard/Android/data/org.frameport.scenetest/files/framebridge-anchors.txt
```
Every invocation must exit 0 without `FAIL`; logs show `anchors saved`, `loaded 2 saved anchors`, a deliberate
`anchor write failed` during `erase-failure` (then the following reload still finds two), and `anchors erased`.
The final file has zero lines. `save` also recreates a session internally, while separate invocations validate
new-process reload. Undo, before stopping this container:
```sh
podman exec "$CONTAINER" rm -rf /sdcard/Android/data/org.frameport.scenetest /data/local/tmp/frameport-scene-probe
podman exec "$CONTAINER" rm -f /data/local/tmp/frameport-scene-test
rm -f /tmp/frameport-scene-test-android
```
Remove the PC's temporary probe binary too. Restore backed-up game files/settings and install the previous APK if
rolling the feature back. No probe command changes real application anchors.
## Commit handoff under the sandbox
This worktree's real Git metadata is on a read-only mount. Commits therefore live on `mr/emul` in
`/tmp/frameport-mr-emul.git`, with a portable bundle at `/tmp/frameport-mr-emul.bundle`. The actual worktree contains
all final source/artifact edits but its original branch metadata cannot be advanced here. No local instruction,
app-data or credential files were staged. The base is `4bf48d3`.
After reviewing the worktree and verifying it contains exactly these changes, the maintainer can import the commits
and align the existing worktree index without discarding its files:
```sh
git bundle verify /tmp/frameport-mr-emul.bundle
git fetch /tmp/frameport-mr-emul.bundle mr/emul
git log --oneline 4bf48d3..FETCH_HEAD
git reset --mixed FETCH_HEAD
```
Run the unrestricted whole suite before merging. The bundle preserves the ordered implementation/test/build commits.
-171
View File
@@ -1,171 +0,0 @@
# Room plumbing stream
Implemented on `mr/plumb`; no Frame access or device verification. The shared `ROOM_FORMAT.md`, agent version,
analysis version and patch revisions were left unchanged. No native binaries belong to this stream.
## What changed
- `frameport.mr.room`: room/entity dataclasses, v1 text and binary-mesh validation, canonical serialization,
missing ceiling/wall/outline defaults, clockwise outline/wall repair, summaries and top-down SVG previews.
Mesh counts, indices, finite coordinates, UUIDs and sidecar basenames are checked before import.
- Standalone stdlib agent room commands live in one marked block. Rooms are staged under
`~/.local/share/frameport/rooms/<universe>/`; a `current` symlink selects the latest saved room. Uploads validate
before replacing a room. Deleting the current room selects the newest remaining room.
- `room_status` returns `rooms`, current `universe`, selected `room`, `room_text`, `aligned`, `default_circle`
(null when no usable boundary exists) and `scanner_installed`. Summaries include wall/plane counts, furniture
counts by label, floor area and ceiling height. Status text lets the UI preview a room without another transfer.
- Universe detection takes the last matching vrserver log line, then the newest chaperone record if no log match
exists. Dummy universe 424242 means unknown. Boundary conversion chains collision quads' bottom edges and refuses
the default 20-point, roughly 1 m circle. Boundary ceilings default to 2.5 m; SteamVR's 2.43 m wall extrusion
does not measure the real ceiling.
- Finalize records `scene_room` from adapter settings. A marked `_room_sync` launcher line follows the existing
config-sync pattern, before Lepton starts. Launcher upgrades also handle old recipes listing
`adapter.scene_emul`. Matching universe wins over `current`; absent rooms remove stale sidecars. Live setting
changes update the launcher and clear copies when disabled. Non-scene launchers keep their existing layout.
- PC room helpers implement status/import/export/delete/boundary/build/show/capture. Scanner upload reuses the
SHA-256 helper extracted from live view; `fp_venc` retains its soft-failure behavior. Import uploads canonical
text. Export checks mesh counts; build uploads FPRS dumps, runs the scanner and cleans its staging directory.
- CLI: `frameport frame room status|from-boundary|import|export|delete|build|show`, with `--frame` on each command.
`show --output <path.svg>` writes a preview (default below `FRAMEPORT_HOME/rooms`). Build accepts `--universe`.
- Frame page Room card: checklist, asset-URL preview, boundary/delete/capture actions, refresh and recoverable errors.
SSH and preview writes run in a background worker; state is cached per connection. Scene-game pages link to Room
setup when the connected Frame has no room. `ui_smoke.py --fake-frame` supplies local room-command answers.
- Every USE_SCENE/USE_ANCHOR_API game gets the scene-emulation suggestion. Migration `scene_emul_all` enables it once
in automatic recipes, preserving user recipes. NOPE and Pinball FX keep their other recipe fields and now request
scene emulation. Triage explains guardian fallback and missing discovery; patch docs and translation template
were regenerated.
## Integration questions and limits
1. Headset capture is intentionally unfinished: `room_capture` returns `{"error":"not implemented yet"}`. The
`capture_roomview(seconds, out_path)` hook documents the pending RoomView queue work. The GUI explains this and
offers the boundary path. Do not present the capture button as a completed scan workflow yet.
2. This branch has no `fp_roomscan` artifact; merge the scan stream before using `frame room build`. The exact scanner
invocation is `fp_roomscan --offline <dump> --out <temporary directory> --universe U --json`. Stdout must contain
one JSON value, stderr is saved as `scan.log`, and output must include a valid `room.txt` with the requested U.
3. Merge the emulation/shim stream before expecting games to use saved rooms, meshes or new Meta entry points.
Runtime log spelling is owned there; triage accepts both existing `using guardian room`/dimension logs and the
brief's `guardian box`/`room from guardian` messages. Reconcile new success logs at integration.
4. Confirm the chaperone JSON path, exact universe-switch logs and whether collision quads are already in STAGE on
the device. This implementation follows the brief's path and coordinate contract; it does not apply a second
standing-origin transform. A universe match alone cannot detect Valve changing an origin without changing its ID.
5. Agent and desktop perform independent validation because the deployed agent cannot import the desktop package.
The desktop canonicalizes outlines and fills defaults; the agent preserves scanner output and validates it.
The format contract needed no changes.
6. Rooms are shared across games; per-game saved anchors are not deleted or rewritten by these commands. Replacing
a room creates new entity UUIDs, so a game may need its own room-setup reset. No automatic game reset was added.
## Offline validation
All commands used `FRAMEPORT_HOME` in this worktree (tests use temporary isolated homes), an existing dependency
venv and offline/no-sync uv settings. No dependencies or native toolchains were downloaded.
- Room, PC/CLI, UI, heuristics, migration, MR triage and translation checks passed: 63 tests. The related
agent/launcher/screenshots/codec/gamepad regression group passed 170 tests. The final deployment/configuration
regression check passed 48 tests, including all four recipe/settings precedence cases.
- Ruff over changed Python files and `scripts/patch_docs.py --check` passed. The UI detector reported no findings.
`scripts/i18n_extract.py` regenerated `src/frameport/locales/template.json`.
- `pytest -m games`: 3 skipped (no game dumps). Dev/holdout evaluation used only the checked-in demo analysis
snapshot, 21 catalog matches: dev dated 9/17 exact, F1 81.1%; holdout dated 1/4 exact, F1 73.3%. These are limited
fixture results, not a new score for the owner's complete collection; the evaluator exits 1 for recipe differences.
- Final full suite: **1790 passed, 40 skipped, 11 failed, 16 errors** in 87.35 s. All 27 failures/errors are
`PermissionError: [Errno 1] Operation not permitted` when existing tests create sockets. The whole-suite green
requirement cannot be met in this sandbox; rerun `uv run pytest -q` on a host permitting loopback sockets.
Browser UI smoke also requires a loopback server, so it
was not run. New UI tests construct the real Flet controls and exercise deferred preview/error/cache states.
## Exact device checks (maintainer only)
Run these after integrating emul + scan. Substitute the paired Frame address below; keep the current worktree's
`FRAMEPORT_HOME=.home` and use explicit `--frame` so the test does not open another app data directory. The source
connection syncs the agent by digest even without a version bump, unless the Frame has a newer agent version.
The maintainer must perform integration version bumps before testing against a newer installed agent.
```sh
export FRAME='steamos@<frame>'
export PKG='com.gritGames.Nope' # repeat with com.zenstudios.PFXVRQuest and a known scene game
uv run frameport frame info --frame "$FRAME"
uv run frameport frame room status --frame "$FRAME" > .home/mr-status.json
# Back up ALL rooms on the Frame before destructive checks; use an unused backup name.
ssh "$FRAME" 'test ! -e ~/.local/share/frameport/rooms.before-mr-test && { test ! -d ~/.local/share/frameport/rooms || cp -a ~/.local/share/frameport/rooms ~/.local/share/frameport/rooms.before-mr-test; }'
```
1. Inspect status: no room gives `room: null`, `aligned: false`. With Valve's default circle, `default_circle: true`;
`uv run frameport frame room from-boundary --frame "$FRAME"` must fail with the default-circle explanation.
Set a real play area in SteamVR, repeat `from-boundary`: expect source `boundary`, one wall per boundary edge,
correct floor area and `aligned: true`. Open Frame → Room: visible preview, warning for the unscanned room,
aligned row ready; the capture button should show the friendly unavailable message without crashing.
2. Exercise import/export and preview with a manual fixture in the current universe:
```sh
uv run frameport frame room status --frame "$FRAME" > .home/mr-status.json
uv run python - <<'PY'
import json
from pathlib import Path
from frameport.mr.room import Mesh, Volume, box_room, dumps, write_mesh
p = Path('.home/mr-check'); p.mkdir(parents=True, exist_ok=True)
r = box_room(4, 3, 2.5)
r.source = 'manual'
r.universe = json.loads(Path('.home/mr-status.json').read_text())['universe']
r.volumes.append(Volume('a'*32, 'TABLE', .5, 0, .8, 20, 1.2, .7, .8))
r.mesh = Mesh(vertices=[(-2, 0, -1.5), (2, 0, -1.5), (2, 0, 1.5)], indices=[0, 1, 2])
(p/'room.txt').write_text(dumps(r))
(p/'room-mesh.bin').write_bytes(write_mesh(r.mesh))
PY
uv run frameport frame room import .home/mr-check/room.txt --frame "$FRAME"
uv run frameport frame room export .home/mr-export --frame "$FRAME"
uv run frameport frame room show --output .home/mr-check/preview.svg --frame "$FRAME"
cmp .home/mr-check/room-mesh.bin .home/mr-export/room-mesh.bin
```
Expect four walls, one TABLE, area 12 m² and height 2.5 m. The preview must label TABLE and the game must show it
hanging below its top-face anchor. Check the corresponding universe folder and `rooms/current` on the Frame.
3. Use a recorded dump from the same standing universe (do not invent one). Copy it to `.home/mr-check/scan.fprs`,
then run the command below. Expect the scanner JSON under `report`, source `scan`, aligned room data, `scan.log`
on the Frame and an installed executable. Run twice: an unchanged scanner must not upload again.
```sh
uv run frameport frame room build .home/mr-check/scan.fprs --frame "$FRAME"
uv run frameport frame room status --frame "$FRAME"
```
If the recording is from another universe, pass its original `--universe <id>`; do not relabel its coordinates.
4. Prepare the game's source and its existing backed-up signing key in the isolated test library, then force a
rebuild/reinstall after integration: `uv run frameport install "$PKG" --frame "$FRAME" --force --apk-only`.
Repeat for both catalog games. In each deployment, expect `scene_room: true`. Run `frame info` twice to upgrade
old launchers and check one marker only:
```sh
uv run frameport frame info --frame "$FRAME"
uv run frameport frame info --frame "$FRAME"
ssh "$FRAME" "grep -c '# frameport-room-sync' ~/Applications/quest-frame/$PKG/launch.sh"
uv run frameport settings "$PKG" scene_emul=1 --frame "$FRAME"
```
Expected marker count: 1. Start the game with Steam's Play button in the headset, enter its MR mode, then inspect
`<deployment.base>/room-sync.log`: JSON has the selected universe and alignment. The two room files must be
beside `<deployment.base>/settings.conf`. FrameBridge must load that room; `xrDiscoverSpacesMETA` must resolve
through the extension shim, with no `Unknown proc addr`/MRUK `NoRoomsFound`. Exact new native success log strings
should be taken from the emul stream. Verify walls/table placement physically; never infer alignment from FPS.
5. Exit the game. Switch `scene_emul=0` using Game settings (or the settings command above with 0): marker and game
sidecars disappear. Switch it back to 1: one marker returns. Delete the room and launch again: stale copies are
removed and existing FrameBridge guardian fallback logs trigger informational `mr-no-room` triage.
6. To check a universe mismatch, back up/export the room, change its `universe` record to another valid ID and import
it, then delete the original universe's room with `frame room delete --universe <original-id>`. Status must select
`current` but report `aligned: false`; the Room card warns. Restore the original file before playing physically.
The dummy universe 424242 should report current universe 0 rather than claim alignment.
## Undo device checks
Close all games. Restore the backed-up store (or remove only the fixture universes if there was no previous store):
```sh
ssh "$FRAME" 'if test -d ~/.local/share/frameport/rooms.before-mr-test; then rm -rf ~/.local/share/frameport/rooms; mv ~/.local/share/frameport/rooms.before-mr-test ~/.local/share/frameport/rooms; fi'
```
Set each tested game's `scene_emul` back to its recorded original value. To remove a test room without a backup:
`uv run frameport frame room delete --universe <fixture-universe> --frame "$FRAME"`. Start a scene game once to
refresh/remove its copies. The scanner can be removed with
`ssh "$FRAME" 'rm -f ~/.local/share/frameport/bin/fp_roomscan'`; the next build reinstalls it. Game saves and
`framebridge-anchors.txt` are untouched. Restore the previous app/agent/native artifacts through the maintainer's
normal integration rollback if testing the combined native implementation fails.
-338
View File
@@ -1,338 +0,0 @@
# Offline room scanner (scan stream)
`fp_roomscan` converts recorded FPRS v1 meshes to the [room contract](ROOM_FORMAT.md), including the optional
wall label adopted from upstream commit `8cb2783`:
`room.txt` and `room-mesh.bin`. It does not capture RoomView queues, contact a headset, install rooms, or modify
the current-room symlink. Scanner work changes `native/roomscan/`, its build target and artifact/checksum,
`scripts/mr/synth_scan.py`, `tests/test_roomscan.py`, and this note. The upstream contract commit also brings its
existing prompt assets and renderer unchanged. No runtime, agent, patch, or analysis versions were bumped.
## Build and run
Use the cached NDK; no downloads are needed for this target:
```sh
python native/build.py --only roomscan
```
The result is `artifacts/linux-arm64-bin/fp_roomscan`, a static aarch64 Linux ELF with its own `_start`, raw Linux
syscalls, and no interpreter, dynamic section, libc, libm, UAPI headers, generated tables, or new dependencies.
`platform.h` is the reconstruction code's entire I/O/allocation boundary. The same five C sources compile on the
host:
```sh
cc -std=c11 -O2 -Wall -Wextra -Werror -ffp-contract=off -fno-math-errno -DFP_HOSTED native/roomscan/*.c -o /tmp/fp_roomscan
python scripts/mr/synth_scan.py /tmp/room.fprs
/tmp/fp_roomscan --offline /tmp/room.fprs --out /tmp/scanned-room --universe 0 --json
```
The output directory's parent must exist; the scanner creates the final directory if needed. It always prints
exactly one JSON summary, whether `--json` is supplied or omitted. Success exits 0, errors exit 2 with
`{"ok":false,"error":"..."}`. `--help` prints usage and exits 0. Successful summaries contain:
```json
{"ok":true,"frames":30,"skipped_frames":0,"far_vertices":0,"discontinuity_triangles":0,
"walls":4,"detected_walls":4,"volumes":2,"floor_y":-0.0023,
"ceiling_y":2.5992,"coverage_percent":100.0,"hint_used":false,"triangles":70997,
"accepted_triangles":118804,"rejected_triangles":704,"warnings":[],
"timings_ms":{"read":0,"fusion":0,"model":0,"mesh":0,"total":0}}
```
The example above illustrates the schema; actual counts and timings depend on the input. Heights are absolute
STAGE coordinates. `frames` includes skipped frames; `skipped_frames`, `far_vertices`, and
`discontinuity_triangles` count input exclusions once, not once per pass. `walls` counts output polygon
edges including synthesized edges; `detected_walls` counts fitted physical planes. Coverage means the percentage
of the output perimeter supported by observed wall planes, **not camera coverage of all room surfaces**. Timings
include parsing, fusion, structural/furniture modelling, mesh generation, and total time including output.
All frame headers, timestamps/vertices/poses, approximately unit quaternions, counts, indices and EOF boundaries
are checked before publication. A non-finite timestamp/pose/vertex or invalid tracking quaternion skips the entire
frame after consuming and structurally validating its record, with a count and warning. A bad frame never erases
usable frames elsewhere in the dump. Vertices outside the camera-relative range (default 4 m) and every triangle
touching them are discarded. Triangles with a camera-depth jump over `--depth-jump + --depth-ratio * nearest_depth`
are rejected before fusion; this complements long-edge and grazing rejection. Frame counts are capped at
1 million vertices / 3 million indices;
oversized records fail rather than allocating from untrusted counts. A header-only dump or one without usable
triangles fails. Empty frames may accompany useful frames. Structural corruption (truncation, unsupported header,
bad counts or indices) remains fatal, preserves any existing output byte for byte and creates no output directory.
UUIDs come from `getrandom`, fresh per scan; mesh geometry is repeatable.
Both complete files are written to exclusive, randomly named temporary files, then individually renamed, with
the mesh first and `room.txt` last. A write failure removes the temporary files. These are atomic **file**
replacements, not a two-file filesystem transaction: the installer should scan into a fresh staging directory
and publish that directory/symlink after success. Do not run concurrent writers into the same output directory.
There is no fsync-based power-loss durability promise. Neither scan nor build writes application data outside
the worktree's `FRAMEPORT_HOME`; fixtures and test output use temporary directories.
## Reconstruction
1. The first streaming pass validates every frame, rotates its mesh by the supplied quaternion, and translates
it into STAGE. It rejects degenerate triangles, excessively long edges, out-of-range points, and grazing
surfaces. Winding is corrected toward the observing camera. A deterministic bounded reservoir of 32,768
accepted triangle centroids determines median position and padded bounds. No frames are retained across reads.
2. The second pass rasterizes accepted triangles at 0.75 voxel spacing. Per-cell surfels average positions and
inward normals, weighted by area and squared incidence. A capped accumulated weight lets later views correct
earlier ones. The grid is sized from the sample bounds and capped at 8 x 8 x 4 metres about the data median.
Unobserved cells have zero weight. This implementation uses a bounded dense backing array for the sparse
observations, rather than a hash table; the 4.2-million-cell hard cap bounds all later work. Complete regular
fans are recognized from their index topology, without hardcoding a camera resolution. Their normals come from
12 x 12 neighbourhood covariance plane fits, rejecting patches above `--patch-residual` RMS; irregular meshes
retain triangle normals. Each cell counts contributing frames once and needs `--min-views` evidence, capped at
ten percent of usable frames (rounded down, at least one) for brief captures. This suppresses transient stereo
geometry without requiring a short scan to see each surface five times.
3. Horizontal normal/height histograms select the dominant floor within the STAGE floor band and a ceiling above
the minimum ceiling height. Missing floor uses STAGE y=0 with a warning; missing ceiling uses floor plus
`--ceiling-default`, warning exactly `ceiling not seen`. A ceiling must also have at least 15% as many supported
cells as all upward-facing horizontal surfaces, so a few isolated down-facing patches do not invent a ceiling.
4. RANSAC fits near-vertical planes using distance and normal agreement, then refines them by position covariance.
Minimum height, span, and area reject furniture faces. Wall directions within the snap tolerance of the
dominant wall's orthogonal directions are snapped, with offsets refitted. Directed wall segments are connected
by nearest endpoints and adjacent line intersections. This preserves the L-shaped fixture's re-entrant corner.
Parallel same-facing planes within 25 cm / approximately 14° merge, suppressing duplicate softened faces.
Planes below 20% of the strongest original plane's area are discarded. When at least 65% of candidate wall area
supports an orthogonal family, directions outside 12° of that family are discarded (`--snap-degrees 0` disables
this orthogonal consensus filter). Large gaps close along observed endpoints. Ambiguous endpoints with three
or four planes can use inward half-plane intersections; concave rooms retain segment ordering. Otherwise a
warned observed-bounds rectangle is used. Polygon checks reject crossing edges. The output follows the contract's positive
shoelace ordering in (x,z), with left-facing inward normals.
5. Furniture tops are clustered separately from vertical faces, within supported horizontal height bands. A
softened edge connecting a tabletop to a wall can no longer absorb the whole room into one box. Top boxes use
a minimum-area horizontal orientation search and the median surface height; vertical screen candidates use
face-direction harmonics / PCA. Bounds trim the outer 2% to suppress stereo halos. Top boxes extend down to the
floor (the footprint and lower extent remain geometric estimates). Weak/sparse top patches are discarded.
Labels follow height, area, long-side (with two voxels of measurement tolerance),
thinness and near-wall rules, in order BED, COUCH, TABLE, SCREEN, STORAGE, OTHER. Only volumes are emitted;
optional table plane records are not required. Floor/ceiling/wall surfaces are excluded before clustering.
6. Surfel splats form an incidence/area-weighted signed distance field within three voxels of each surface. The
sign is positive in observed free room space. Marching cubes traces zero crossings on cube faces, resolving
ambiguous faces from their centre sign. It fans closed contours and orients triangles toward the positive
gradient. Unknown corners suppress extraction rather than bridging unobserved space. If necessary, extraction
is repeated on a coarser lattice (strides 2–4) until at most 200,000 triangles remain, with a decimation warning;
excess beyond that fails. Vertices are unshared triangle vertices, so at most 600,000 vertices. Structural
planes are semantic anchors, not substituted for the observed global mesh.
Peak allocation stays below 260 MiB, including maximum frame buffers, the grid, model worklists and mesh buffers
(the latter phases do not retain frame buffers). Default synthetic scenes use a much smaller grid. Thresholds
are CLI options below or named constants next to their algorithms. No room-specific coordinates or dimensions are
used; on-device quality and speed still need validation.
## Tuning
Distances are metres, areas m², volumes m³. `--universe` accepts the full unsigned 64-bit range; 0 means unknown.
| Option | Default | Accepted range / meaning |
| --- | ---: | --- |
| `--voxel` | 0.05 | 0.04–0.20, spatial resolution; lower values cost more memory/work |
| `--ceiling-default` | 2.5 | 1.8–3.8, height above floor when ceiling is unseen |
| `--truncation` | 3 | 1–5, TSDF band in voxel units |
| `--max-edge` | 0.6 | 0.05–1.5, reject longer triangle edges |
| `--grazing` | 0.12 | 0.01–0.9, minimum absolute camera/normal cosine |
| `--range` | 4 | 1–10, maximum camera-relative vertex distance; >4 m opts back into inpainted depth |
| `--depth-jump` | 0.25 | 0.02–1, absolute part of a triangle's maximum depth discontinuity |
| `--depth-ratio` | 0.12 | 0–0.5, additional fraction of nearest vertex depth |
| `--min-views` | 5 | 1–16, temporal support target; brief scans cap it as described above |
| `--patch-residual` | 0.03 | 0.005–0.1, maximum RMS residual of a regular-fan normal patch |
| `--plane-distance` | 0.045 | 0.01–0.15, point-to-plane inlier tolerance |
| `--min-plane-area` | 0.45 | 0.1–4, minimum occupied plane area estimate |
| `--wall-height` | 1.5 | 0.5–3, minimum observed vertical wall span |
| `--snap-degrees` | 5 | 0–15, orthogonal snap tolerance |
| `--floor-band` | 0.35 | 0.05–1, floor search distance from STAGE y=0 |
| `--ceiling-min` | 1.8 | 1.5–3, minimum absolute ceiling y |
| `--min-volume` | 0.05 | 0.005–1, reject smaller furniture boxes |
| `--cluster-gap` | 0.10 | 0.04–0.20, voxel neighbourhood gap (rounded up to cells) |
| `--gap` | 0.65 | 0.1–1.5, maximum adjacent-wall endpoint extrapolation |
| `--horizontal` | 0.85 | 0.6–0.99, minimum floor/ceiling/top normal cosine |
Additional constants in `model.c` include RANSAC trials, normal agreement, wall span, furniture margins,
label height/area thresholds, box trimming and circle recognition. Fusion sampling/weight limits are in
`fusion.c`; mesh splat radii/decimation limits in `mesh.c`; hard resource limits in `scan.h`.
`--hint outline.txt` accepts one `x z` point per line, decimal/scientific notation, optional `#` comments, either
winding, at most 96 points / 8 KiB. At least eight points with radial coefficient of variation below 5% count as
the default circle and are ignored. A non-circular hint is used only when walls are incomplete **and every
observed wall has a compatible hint edge** within `--gap`. Complete scans are never constrained to chaperone.
Incompatible inset play boundaries are ignored with a warning. Hint-completed edges retain synthesized status.
## Limits for integration
- Synthesized wall records now append `INVISIBLE_WALL_FACE` as their optional eighth field. Observed walls omit
the label and default to `WALL_FACE`; comments are no longer used to carry semantics. This matches upstream's
authorized contract change exactly; adapters need that same contract revision.
- A private RoomView recording was used for offline robustness work. It, its derived geometry, and its results
stay outside committed files. Synthetic fixtures independently exercise the input characteristics. They are
regressions, not proof
of headset accuracy. Furniture labels are geometric heuristics; adjacent/touching objects can merge, occluded
objects may split, top-only clusters have inferred depth, and thin fragments can be screen candidates. Nearby
parallel walls and small wall jogs can merge under coarse depth uncertainty. Large disconnected coplanar wall segments are
currently fitted together; complicated rooms can fall back to a warned rectangle.
- Cropped or sparse captures can produce an incomplete mesh and synthesized outline. Surfaces are not filled
through unseen regions. Furniture is not an accessibility/safety boundary. Rooms beyond the grid cap are
cropped with a warning. Default-circle recognition may also ignore an intentionally circular boundary.
- Output paths are fixed by the contract. The plumbing stream should stage a new directory and switch `current`
after scanner success, preserving the previous room for rollback. Assign the recording's actual universe id;
this standalone scanner cannot query SteamVR or validate universe alignment.
## Offline validation
`tests/test_roomscan.py` builds with the host compiler (skips only if none exists). Its 41 tests cover the noisy
4 x 3 x 2.6 m room with table/couch, six-wall L, moving and rotated/translated mesh poses, ceiling fallback, a single
frame, missing walls, circular/matching hints, correct UUIDs/wire layout, interior mesh winding, triangle budget,
repeatable mesh bytes with fresh UUIDs, truncated/corrupt dumps, invalid counts/indices/poses and invalid CLI values.
The test module takes about fourteen seconds including synthetic rendering and compilation (under the 60 s budget).
New cases cover non-finite frames before/after usable frames, per-vertex far rejection, depth jumps below the edge
limit, weak ceiling evidence, wall labels and a complete camera-fan scan. The deterministic stdlib renderer now
defaults to a 132 x 128 full index fan (99,822 indices), +Z forward / -Y image up, with poses mapping to STAGE.
It adds separable depth smoothing, spatially correlated noise, far inpainting, two initial NaN poses, 3 Hz timestamps
and head-height drift from 0.9 to 1.7 m. `--profile legacy` retains the smaller 110° x 76° test cone. New CLI controls
are `--profile`, `--invalid-start`, `--smoothing` and optional resolution overrides. Use `--invalid-start 0` for a
usable one-frame fixture; default RoomView warm-up frames otherwise have no valid tracking.
```sh
UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run pytest -q tests/test_roomscan.py
UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run ruff check scripts/mr/synth_scan.py tests/test_roomscan.py
```
The synthetic camera fan passes AddressSanitizer + UndefinedBehaviorSanitizer. Two consecutive final native builds
produce identical SHA-256 `0d88e5adfa1a21da7e0cfec2698a835d7767961e15c0b41c84235fa24ae1e751` (54,248 bytes),
matching `artifacts/SHA256SUMS`. ELF inspection confirms aarch64, little-endian, no PT_INTERP / PT_DYNAMIC, and no
embedded local build paths.
The required full `uv run pytest -q` run completed with **1,788 passed, 40 skipped, 11 failed, 16 errors** in 79 s.
All 27 failures/errors are the sandbox's `PermissionError: [Errno 1] Operation not permitted` on socket creation
in existing HTTP/pairing/live-stream tests (`test_deeplink`, `test_devkit`, `test_install_links_extra`,
`test_livestream`, `test_setup_url`, `test_ui_state`). This environment forbids sockets, including loopback.
The unchanged baseline had the identical 27 failures/errors before the scanner tests were added (1,747 passed).
Consequently the whole-suite-pass requirement cannot be satisfied here; rerun it outside that restriction at
integration. No tests were changed/skipped to hide the restriction and no network/device access was attempted.
No patch/settings/catalog/triage files were touched, so the conditional patch documentation regeneration is
unnecessary.
## Ten-minute capture budget (synthetic benchmark)
The scanner is streaming and single-threaded. On an Intel Core i7-14700K host, a **synthetic-only** 1,800-frame
132 x 128 dump (1,083,751,208 bytes, equivalent to ten minutes at 3 Hz) took **7.41 s elapsed / 7.08 s CPU**, with
**27,208 KiB maximum RSS** at the default voxel size. The file repeats a 30-view synthetic sweep, including its
warm-up frames; it tests parsing/fusion throughput rather than 1,800 independent viewpoints. Geometry-dependent
model/mesh work and storage caches affect timing. No private recording or derived room data is in that benchmark.
Reproduce with bounded copies rather than loading a gigabyte into Python:
```sh
python scripts/mr/synth_scan.py /tmp/roomview.fprs
python - <<'PY'
import shutil
with open('/tmp/roomview.fprs', 'rb') as source, open('/tmp/roomscan-1800.fprs', 'wb') as out:
out.write(source.read(8))
for repeat in range(60):
source.seek(8)
shutil.copyfileobj(source, out, 1024 * 1024)
PY
/usr/bin/time -v /tmp/fp_roomscan --offline /tmp/roomscan-1800.fprs --out /tmp/roomscan-benchmark --json
```
For the Frame's Cortex-A720/X4-class cores, budgeting **3–5 times the host CPU time plus storage** gives roughly
**23–38 s** for this workload, leaving headroom under the requested ~60 s. This is an estimate, not an ARM
measurement or an 8-core speedup claim. Worst-case allocations are bounded below 260 MiB independently of frame
count (48-byte cells, 4.2-million-cell cap, bounded frame buffers/worklists), below both the original 300 MB and
follow-up 600 MB limits. A maximum-sized complex room can add model/mesh work beyond this small synthetic sweep.
The maintainer must time a ten-minute real capture on the Frame and check CPU time, wall time and RSS before
claiming the target is achieved on-device. Hardware square root instructions replace iterative division; neither
build links libm. The build and host command both require `-fno-math-errno` for that intrinsic.
## Exact device checks (maintainer only; none executed by this stream)
1. On the development PC, build, then render the canonical fixtures:
```sh
python native/build.py --only roomscan
python scripts/mr/synth_scan.py /tmp/box.fprs
python scripts/mr/synth_scan.py /tmp/l.fprs --shape l
python scripts/mr/synth_scan.py /tmp/down.fprs --no-ceiling
python scripts/mr/synth_scan.py /tmp/one.fprs --frames 1 --invalid-start 0
scp artifacts/linux-arm64-bin/fp_roomscan /tmp/box.fprs /tmp/l.fprs /tmp/down.fprs /tmp/one.fprs steamos@<FRAME>:~/Downloads/
```
2. In a terminal **on the Frame**, use an isolated directory; no game or current room is changed:
```sh
mkdir -p "$HOME/.local/share/frameport/roomscan-test"
cd "$HOME/.local/share/frameport/roomscan-test"
cp "$HOME/Downloads/fp_roomscan" ./fp_roomscan
chmod 700 ./fp_roomscan
./fp_roomscan --help
./fp_roomscan --offline "$HOME/Downloads/box.fprs" --out box --universe 0 --json > box.json
cat box.json
./fp_roomscan --offline "$HOME/Downloads/l.fprs" --out l --json > l.json
./fp_roomscan --offline "$HOME/Downloads/down.fprs" --out down --ceiling-default 2.7 --json > down.json
./fp_roomscan --offline "$HOME/Downloads/one.fprs" --out one --json > one.json
```
Expected JSON: box has `ok:true`, `walls:4`, `detected_walls:4`, `volumes:2`, floor within 5 cm of 0,
ceiling within 5 cm of 2.6, coverage 100; the only expected box warnings concern skipped warm-up frames and
out-of-range vertices. `box/room.txt` includes TABLE and COUCH volume records.
L has six walls and the concave corner near (0,0), rather than a rectangle. Down includes exactly the warning
string `ceiling not seen` (other warnings are expected), with ceiling minus floor 2.7. One includes
`few frames; scan more viewpoints`. Every mesh should have at most 200,000 triangles. JSON is the scanner log;
there are no expected stderr status lines.
3. Verify sidecars, wall labels and the corrupt-input exit status on the Frame:
```sh
python3 - <<'PY'
import json, pathlib, struct
for name in ('box', 'l', 'down', 'one'):
report = json.loads(pathlib.Path(name + '.json').read_text())
data = pathlib.Path(name, 'room-mesh.bin').read_bytes()
magic, version, nv, ni = struct.unpack_from('<4sIII', data)
assert report['ok'] and magic == b'FPRM' and version == 1
assert ni % 3 == 0 and 0 < ni // 3 <= 200000
assert len(data) == 16 + nv * 12 + ni * 4
for line in pathlib.Path(name, 'room.txt').read_text().splitlines():
if line.startswith('wall '):
fields = line.split()
assert len(fields) in (8, 9)
if len(fields) == 9:
assert fields[-1] == 'INVISIBLE_WALL_FACE'
print(name, nv, ni // 3, report['warnings'])
pathlib.Path('broken.fprs').write_bytes(b'FPRS\x01\0\0\0\0')
PY
./fp_roomscan --offline broken.fprs --out broken --json
echo "$?"
test ! -e broken/room.txt
test ! -e broken/room-mesh.bin
```
Expected: `{"ok":false,"error":"truncated frame header"}`, exit status 2, no `broken` outputs.
4. Record actual RoomView dumps with the maintainer's recorder (FPRS v1, mesh-to-STAGE poses, stable universe).
Copy the recording to `~/Downloads/capture.fprs`. Look around slowly for about a minute, including floor,
ceiling, every wall, and furniture from several directions. With the **recorded universe id** assigned below:
```sh
scan_universe=0 # replace 0 with the recorder's actual decimal universe id
/usr/bin/time -v ./fp_roomscan --offline "$HOME/Downloads/capture.fprs" --out real --universe "$scan_universe" --json > real.json 2> real-timing.log
cat real.json real-timing.log
cat real/room.txt
```
Measure wall offsets/yaws, ceiling and table top physically and compare to the records (target 5 cm / 2°).
Check resident memory below 300 MB, sensible coverage/warnings, tabletop labels and no false wall/furniture
blobs from smeared edges. Repeat with `--max-edge 0.35 --grazing 0.2` into `real-stricter` if edges smear;
try `--voxel 0.08 --plane-distance 0.07` into `real-coarser` if depth is noisy. Keep both JSON logs and raw
dumps **outside git** for threshold calibration. These are proposed tuning probes, not established device fixes.
5. Repeat the synthetic 1,800-frame benchmark from the preceding section on the Frame, using the same command
and `/usr/bin/time -v`. Then test a ten-minute independent real capture. Target: about 60 s elapsed or less,
less than 600 MB RSS, successful JSON and bounded mesh size. Record CPU time as well as wall time so storage
overhead is visible. With difficult stereo edges compare `--min-views 8 --patch-residual 0.02` in a fresh output
directory; short captures cap view support automatically. Include the JSON's exclusion counters in triage;
skipped frames may occur anywhere in the capture. Keep all personal results outside git.
6. After merging plumbing/emulation, publish this staged room through that stream's room-install path and try
Demeter, then an MRUK discovery/mesh game. Check room loading, furniture alignment, and warning/fallback
behaviour in-headset; the scanner alone cannot verify the Meta API path. Do not replace `rooms/current` by hand
during these standalone checks.
Undo standalone checks: remove only the isolated `~/.local/share/frameport/roomscan-test` directory and the five
test files copied to `~/Downloads` (plus `capture.fprs` if no longer needed). No Steam settings, games, agent,
room universe folder, or current-room symlink were changed by these steps.
+245 -5
View File
@@ -6,10 +6,13 @@
// Budget Cuts Ultimate's cube-map overlay, GitHub #107). Only when the runtime refused one, a cube swapchain is served
// by the adapter instead: one GL cube-map texture or Vulkan cube-compatible image with the requested format,
// size and mip levels. Acquire/wait/release succeed at once. The app renders or copies into it as usual; layers
// that use it are dropped in xrEndFrame (the runtime couldn't show them anyway).
// are converted to six quad faces by cube_quads when no projection is present.
#define STANDIN_MAX 8
typedef struct {
XrSwapchain handle;
XrSession session;
XrSwapchainCreateInfo info;
uint32_t slice;
GLuint tex;
VkDevice device;
VkImage image;
@@ -43,7 +46,7 @@ static int standin_vk_create(const XrSwapchainCreateInfo *info, standin_swapchai
PFN_vkQueueWaitIdle wait_idle = (PFN_vkQueueWaitIdle)vk.GetDeviceProcAddr(vk.device, "vkQueueWaitIdle");
if (!destroy_pool || !wait_idle) goto done;
s->device = vk.device;
VkImageUsageFlags usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
VkImageUsageFlags usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
if (info->usageFlags & XR_SWAPCHAIN_USAGE_SAMPLED_BIT) usage |= VK_IMAGE_USAGE_SAMPLED_BIT;
if (info->usageFlags & XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT) usage |= VK_IMAGE_USAGE_TRANSFER_SRC_BIT;
if (info->usageFlags & XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT) usage |= VK_IMAGE_USAGE_TRANSFER_DST_BIT;
@@ -98,7 +101,7 @@ done:
}
// After the runtime refused a swapchain: serve a cube stand-in if the graphics API can supply it.
static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result) {
static XrResult standin_after_failure(XrSession session, const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result) {
if (XR_SUCCEEDED(result) || !cube_standin || !info || !out || info->faceCount != 6) return result;
if (info->width != info->height || !info->width || info->arraySize > 1) return result;
int gles = (p_eglCreateContext || emul_load_gl()) && p_eglGetCurrentContext() != EGL_NO_CONTEXT;
@@ -109,6 +112,9 @@ static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapc
if (slot >= 0) standins[slot].handle = (XrSwapchain)1; // reserved
pthread_mutex_unlock(&standin_lock);
if (slot < 0) { LOG("cube_standin: too many cube swapchains, the runtime's error stays"); return result; }
cube_outputs_destroy();
standins[slot].session = session;
standins[slot].info = *info;
if (!gles) {
if (!standin_vk_create(info, &standins[slot])) {
pthread_mutex_lock(&standin_lock);
@@ -121,7 +127,7 @@ static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapc
*out = standins[slot].handle = FAKE_HANDLE(XrSwapchain);
pthread_mutex_unlock(&standin_lock);
LOG("cube_standin: runtime refused a %ux%u cube swapchain (result=%d): served by FrameBridge "
"(Vulkan cube image, %u levels); its cube layers are dropped", info->width, info->height, result, info->mipCount);
"(Vulkan cube image, %u levels); cube_quads can display its layers", info->width, info->height, result, info->mipCount);
return XR_SUCCESS;
}
GLsizei levels = info->mipCount > 1 ? (GLsizei)info->mipCount : 1;
@@ -150,7 +156,7 @@ static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapc
pthread_mutex_unlock(&standin_lock);
*out = handle;
LOG("cube_standin: runtime refused a %ux%u cube swapchain (result=%d): served by FrameBridge (GL cube map %u, "
"%d levels); its cube layers are dropped", info->width, info->height, result, tex, (int)levels);
"%d levels); cube_quads can display its layers", info->width, info->height, result, tex, (int)levels);
return XR_SUCCESS;
}
@@ -172,6 +178,7 @@ static XrResult standin_enumerate(XrSwapchain handle, uint32_t capacity, uint32_
}
static void standin_destroy(XrSwapchain handle) {
cube_outputs_destroy();
pthread_mutex_lock(&standin_lock);
for (int i = 0; i < STANDIN_MAX; ++i)
if (standins[i].handle == handle) {
@@ -220,3 +227,236 @@ static XRAPI_ATTR XrResult XRAPI_CALL standin_get_swapchain_state(XrSwapchain sw
if (is_standin(swapchain)) return XR_ERROR_VALIDATION_FAILURE; // no state to report (apps treat it as "unsupported")
return real_get_swapchain_state ? real_get_swapchain_state(swapchain, state) : XR_ERROR_FUNCTION_UNSUPPORTED;
}
// Six runtime swapchains per size/format. Array slices keep different stand-ins independent,
// even when several cube layers of the same size appear in one frame.
static struct {
XrSession session;
uint32_t size, slices;
int64_t format;
int vulkan, failed, ready;
XrSwapchain face[6];
} cube_pools[STANDIN_MAX], *cube_out;
static void cube_pool_destroy(int pool) {
PFN_xrDestroySwapchain destroy = (PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain");
for (int f = 0; f < 6; ++f)
if (cube_pools[pool].face[f] && destroy) destroy(cube_pools[pool].face[f]);
memset(&cube_pools[pool], 0, sizeof(cube_pools[pool]));
}
static void cube_outputs_destroy(void) {
for (int i = 0; i < STANDIN_MAX; ++i) cube_pool_destroy(i);
cube_out = NULL;
}
// Keep a failed key so a refused allocation/copy is diagnosed once, then falls back without retrying each frame.
static void cube_output_disable(void) {
XrSession session = cube_out->session;
uint32_t size = cube_out->size;
int64_t format = cube_out->format;
int vulkan = cube_out->vulkan;
cube_pool_destroy((int)(cube_out - cube_pools));
cube_out->session = session;
cube_out->size = size;
cube_out->format = format;
cube_out->vulkan = vulkan;
cube_out->failed = 1;
}
static int cube_outputs_create(XrSession session, const standin_swapchain *s) {
int pool = -1, empty = -1;
for (int i = 0; i < STANDIN_MAX; ++i) {
if (!cube_pools[i].session && empty < 0) empty = i;
if (cube_pools[i].session == session && cube_pools[i].size == s->info.width &&
cube_pools[i].format == s->info.format && cube_pools[i].vulkan == !!s->image) pool = i;
}
if (pool < 0) pool = empty;
if (pool < 0) return 0;
cube_out = &cube_pools[pool];
if (cube_out->failed) return 0;
if (cube_out->face[5]) return 1;
cube_out->slices = 0;
for (int i = 0; i < STANDIN_MAX; ++i)
if (standins[i].handle && standins[i].session == session && standins[i].info.width == s->info.width &&
standins[i].info.format == s->info.format && !!standins[i].image == !!s->image)
standins[i].slice = cube_out->slices++;
cube_out->session = session;
cube_out->size = s->info.width;
cube_out->format = s->info.format;
cube_out->vulkan = !!s->image;
PFN_xrCreateSwapchain create = (PFN_xrCreateSwapchain)lookup(active_instance, "xrCreateSwapchain");
XrSwapchainCreateInfo ci = {XR_TYPE_SWAPCHAIN_CREATE_INFO, NULL, 0,
XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT,
s->info.format, 1, s->info.width, s->info.height, 1, cube_out->slices, 1};
for (int f = 0; f < 6; ++f) {
XrResult r = create ? create(session, &ci, &cube_out->face[f]) : XR_ERROR_FUNCTION_UNSUPPORTED;
if (XR_FAILED(r)) {
cube_output_disable();
LOG("cube_quads: face swapchain creation failed (result=%d); dropping cube layers", r);
return 0;
}
}
LOG("cube_quads: created six %ux%u face swapchains (%s)", s->info.width, s->info.height,
s->image ? "Vulkan" : "GLES");
return 1;
}
static int cube_copy_face(const standin_swapchain *s, int face, uint32_t index, uint32_t slice) {
PFN_xrEnumerateSwapchainImages enumerate = (PFN_xrEnumerateSwapchainImages)lookup(active_instance, "xrEnumerateSwapchainImages");
uint32_t n = 0;
if (!enumerate || XR_FAILED(enumerate(cube_out->face[face], 0, &n, NULL)) || index >= n) return 0;
int ok = 0;
if (s->image) {
XrSwapchainImageVulkanKHR *images = calloc(n, sizeof(*images));
if (!images) return 0;
for (uint32_t i = 0; i < n; ++i) images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR;
if (XR_SUCCEEDED(enumerate(cube_out->face[face], n, &n, (void *)images))) {
pthread_mutex_lock(&flip_lock);
PFN_vkQueueWaitIdle idle = (PFN_vkQueueWaitIdle)vk.GetDeviceProcAddr(vk.device, "vkQueueWaitIdle");
VkCommandBufferBeginInfo begin = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, NULL,
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, NULL};
if (idle && vk.ResetCommandBuffer(vk.cmd, 0) == VK_SUCCESS && vk.BeginCommandBuffer(vk.cmd, &begin) == VK_SUCCESS) {
VkImage dst = images[index].image;
barrier(s->image, face, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT);
barrier(dst, slice, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT);
int32_t size = (int32_t)s->info.width;
// Vulkan quad v=0 is the top; GLES quad t=0 is the bottom. Reverse rows for the same pose.
VkImageBlit blit = {{VK_IMAGE_ASPECT_COLOR_BIT, 0, (uint32_t)face, 1}, {{0, 0, 0}, {size, size, 1}},
{VK_IMAGE_ASPECT_COLOR_BIT, 0, slice, 1}, {{0, size, 0}, {size, 0, 1}}};
vk.CmdBlitImage(vk.cmd, s->image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, dst,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_NEAREST);
barrier(s->image, face, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT);
barrier(dst, slice, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_COLOR_ATTACHMENT_READ_BIT);
if (vk.EndCommandBuffer(vk.cmd) == VK_SUCCESS) {
VkSubmitInfo submit = {VK_STRUCTURE_TYPE_SUBMIT_INFO, NULL, 0, NULL, NULL, 1, &vk.cmd, 0, NULL};
ok = vk.QueueSubmit(vk.queue, 1, &submit, VK_NULL_HANDLE) == VK_SUCCESS && idle(vk.queue) == VK_SUCCESS;
}
}
pthread_mutex_unlock(&flip_lock);
}
free(images);
} else {
if (!p_eglGetCurrentContext || p_eglGetCurrentContext() == EGL_NO_CONTEXT) return 0;
emul_gles_image *images = calloc(n, sizeof(*images));
if (!images) return 0;
for (uint32_t i = 0; i < n; ++i) images[i].type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
if (XR_SUCCEEDED(enumerate(cube_out->face[face], n, &n, (void *)images))) {
for (int i = 0; i < 8 && p_glGetError() != GL_NO_ERROR; ++i) {}
GLint read, draw, scissor = p_glIsEnabled(GL_SCISSOR_TEST);
p_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read);
p_glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw);
GLuint fb[2];
p_glGenFramebuffers(2, fb);
p_glBindFramebuffer(GL_READ_FRAMEBUFFER, fb[0]);
p_glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + face, s->tex, 0);
p_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fb[1]);
if (cube_out->slices == 1)
p_glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, images[index].image, 0);
else p_glFramebufferTextureLayer(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, images[index].image, 0, slice);
ok = p_glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE &&
p_glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
if (ok) {
p_glDisable(GL_SCISSOR_TEST);
p_glBlitFramebuffer(0, 0, s->info.width, s->info.height, 0, 0, s->info.width, s->info.height,
GL_COLOR_BUFFER_BIT, GL_NEAREST);
ok = p_glGetError() == GL_NO_ERROR;
p_glFlush();
}
if (scissor) p_glEnable(GL_SCISSOR_TEST);
p_glBindFramebuffer(GL_READ_FRAMEBUFFER, read);
p_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, draw);
p_glDeleteFramebuffers(2, fb);
}
free(images);
}
return ok;
}
static void cube_outputs_begin(void) {
for (int i = 0; i < STANDIN_MAX; ++i) cube_pools[i].ready = 0;
}
static void cube_session_destroy(XrSession session) {
cube_outputs_destroy();
for (int i = 0; i < STANDIN_MAX; ++i)
if (standins[i].session == session && standins[i].handle) standin_destroy(standins[i].handle);
}
// xrEndSession may be followed by another xrBeginSession without destroying the session or app cube handles.
static XRAPI_ATTR XrResult XRAPI_CALL cube_end_session(XrSession session) {
cube_outputs_destroy();
PFN_xrEndSession end = (PFN_xrEndSession)lookup(active_instance, "xrEndSession");
return end ? end(session) : XR_ERROR_FUNCTION_UNSUPPORTED;
}
static int cube_layer_quads(XrSession session, const XrFrameEndInfo *info, uint32_t at, XrCompositionLayerQuad *out) {
const XrCompositionLayerCubeKHR *c = (const void *)info->layers[at];
int slot = standin_find(c->swapchain);
if (slot < 0 || c->imageArrayIndex != 0) return 0;
// Quads always cover projections on Frame, including alpha projections. Conservatively skip any mixed frame.
for (uint32_t i = 0; i < info->layerCount; ++i)
if (info->layers[i] && (info->layers[i]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION)) {
static int logged;
if (!logged++) LOG("cube_quads: skipping cube with projection; preserving scene visibility");
return 0;
}
standin_swapchain *s = &standins[slot];
if (s->session != session || !cube_outputs_create(session, s)) return 0;
PFN_xrAcquireSwapchainImage acquire = (PFN_xrAcquireSwapchainImage)lookup(active_instance, "xrAcquireSwapchainImage");
PFN_xrWaitSwapchainImage wait = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
PFN_xrReleaseSwapchainImage release = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
if (!acquire || !wait || !release) return 0;
if (!cube_out->ready) for (int f = 0; f < 6; ++f) {
uint32_t index;
XrSwapchainImageAcquireInfo ai = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO, NULL};
XrSwapchainImageWaitInfo wi = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO, NULL, XR_INFINITE_DURATION};
XrSwapchainImageReleaseInfo ri = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO, NULL};
if (XR_FAILED(acquire(cube_out->face[f], &ai, &index))) goto fail;
if (XR_FAILED(wait(cube_out->face[f], &wi))) goto fail;
int ok = 1;
for (int k = 0; k < STANDIN_MAX && ok; ++k) {
if (!standins[k].handle || standins[k].session != session || standins[k].info.width != s->info.width ||
standins[k].info.format != s->info.format || !!standins[k].image != !!s->image) continue;
int submitted = 0;
for (uint32_t l = 0; l < info->layerCount; ++l)
if (info->layers[l] && info->layers[l]->type == XR_TYPE_COMPOSITION_LAYER_CUBE_KHR &&
((const XrCompositionLayerCubeKHR *)info->layers[l])->swapchain == standins[k].handle) submitted = 1;
if (submitted) ok = cube_copy_face(&standins[k], f, index, standins[k].slice);
}
XrResult r = release(cube_out->face[f], &ri);
if (!ok || XR_FAILED(r)) goto fail;
}
cube_out->ready = 1;
XrVector3f centre = {0, 0, 0};
PFN_xrLocateViews locate = (PFN_xrLocateViews)lookup(active_instance, "xrLocateViews");
XrViewLocateInfo li = {XR_TYPE_VIEW_LOCATE_INFO, NULL, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, info->displayTime, c->space};
XrViewState state = {XR_TYPE_VIEW_STATE};
XrView views[2] = {{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
uint32_t nviews;
if (locate && XR_SUCCEEDED(locate(session, &li, &state, 2, &nviews, views)) && nviews == 2 &&
(state.viewStateFlags & XR_VIEW_STATE_POSITION_VALID_BIT))
centre = (XrVector3f){(views[0].pose.position.x + views[1].pose.position.x) / 2,
(views[0].pose.position.y + views[1].pose.position.y) / 2,
(views[0].pose.position.z + views[1].pose.position.z) / 2};
for (int f = 0; f < 6; ++f) {
out[f] = (XrCompositionLayerQuad){XR_TYPE_COMPOSITION_LAYER_QUAD, NULL, c->layerFlags, c->space,
c->eyeVisibility, {cube_out->face[f], {{0, 0}, {(int32_t)s->info.width, (int32_t)s->info.height}}, s->slice},
lm_cube_quad_pose(c->orientation, f, 50), {100, 100}};
out[f].pose.position.x += centre.x;
out[f].pose.position.y += centre.y;
out[f].pose.position.z += centre.z;
}
{
static int logged;
if (!logged++) LOG("cube_quads: submitting cube as six quads (half-size=50m, mip=0)");
}
return 1;
fail:
cube_output_disable();
LOG("cube_quads: face copy/acquire/wait/release failed; dropping cube layers");
return 0;
}
+66 -134
View File
@@ -17,7 +17,7 @@
// * drops composition layers that reference swapchains that failed to create (layer_fix);
// * answers xrGetDisplayRefreshRateFB with the measured display rate when the runtime lacks it or reports 0 Hz
// (session_fixes.c);
// * serves cube swapchains the runtime refuses as GL/Vulkan cube maps and drops their layers (cube_standin, cube_standin.c);
// * serves refused cube swapchains as GL/Vulkan cube maps and emulates cube-only frames with quads (cube_standin, cube_standin.c);
// * optionally requests mutable-format swapchain images (mutable_fix, off by default);
// * optionally swaps left/right images of stereo projection layers (swap_eyes, off by default);
// * emulates XR_FB_passthrough with XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND when the runtime lacks it
@@ -128,6 +128,7 @@ static int frame_open;
static int sc_acquires, sc_waits, sc_releases, sc_wait_fails; // per pacing period (layer_debug) // drop XrCompositionLayerColorScaleBiasKHR (the runtime's color pass)
static int controller_fix = 1;
static int swapchain_fix = 1;
static int cube_quads = 1;
static int cube_standin = 1; // cube swapchains the runtime refuses: served by the adapter (cube_standin.c)
static int rect_clamp = 1;
static int fb_color_space; // fb_color_space: offer XR_FB_color_space when the runtime lacks it (#336)
@@ -146,8 +147,6 @@ static int flip_quads = 0;
static int flip_emul_setting = 1;
static int cylinder_strips_on = 1; // show cylinder layers as flat quad strips (cylinder_strips)
static int scene_emul; // scene emulation setting (per game)
static int scene_hide_bounds; // per game: the app's STAGE bounds query answers "no play area" (Demeter)
static int scene_events_via_shim; // the shim delivers room events: the layer's xrPollEvent leaves them alone
static float scene_height = 2.5f; // scene emulation ceiling height (m)
static float scene_width, scene_depth; // optional room size override (m)
static int64_t xr_time_offset; // XrTime minus CLOCK_MONOTONIC ns, measured at xrWaitFrame
@@ -181,6 +180,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");
@@ -189,6 +189,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;
@@ -204,6 +205,7 @@ static void read_settings(const char *path) {
if (sscanf(line, "gl_error_clear=%f", &value) == 1) gl_error_clear = value != 0;
if (sscanf(line, "controller_fix=%f", &value) == 1) controller_fix = value != 0;
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
if (sscanf(line, "cube_quads=%f", &value) == 1) cube_quads = value != 0;
if (sscanf(line, "cube_standin=%f", &value) == 1) cube_standin = value != 0;
if (sscanf(line, "rect_clamp=%f", &value) == 1) rect_clamp = value != 0;
if (sscanf(line, "fb_color_space=%f", &value) == 1) fb_color_space = value != 0;
@@ -222,7 +224,6 @@ static void read_settings(const char *path) {
if (sscanf(line, "flip_emul=%f", &value) == 1) flip_emul_setting = value != 0;
if (sscanf(line, "cylinder_strips=%f", &value) == 1) cylinder_strips_on = value != 0;
if (sscanf(line, "scene_emul=%f", &value) == 1) scene_emul = value != 0;
if (sscanf(line, "scene_hide_bounds=%f", &value) == 1) scene_hide_bounds = value != 0;
if (sscanf(line, "scene_height=%f", &value) == 1 && value > 1.5f && value < 5.0f) scene_height = value;
if (sscanf(line, "scene_width=%f", &value) == 1 && value > 0.5f && value < 20.0f) scene_width = value;
if (sscanf(line, "scene_depth=%f", &value) == 1 && value > 0.5f && value < 20.0f) scene_depth = value;
@@ -365,7 +366,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;
@@ -387,9 +388,8 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
else if (!strcmp(ext, XR_FB_COLOR_SPACE_EXTENSION_NAME)) {
requested_color_space = 1;
} else if (scene_emul && is_scene_extension(ext)) {
if (!emulate_scene) LOG("emulating Meta scene/spatial-entity extensions from room file or guardian bounds");
if (!emulate_scene) LOG("emulating Meta scene/spatial-entity extensions from the guardian bounds");
emulate_scene = 1;
if (!strcmp(ext, "XR_META_spatial_entity_mesh")) scene_app_mesh = 1;
} else if (controller_models && render_models_available && !strcmp(ext, XR_FB_RENDER_MODEL_EXTENSION_NAME)) {
emulate_render_model = 1;
LOG("emulating XR_FB_render_model with Steam Frame controller models");
@@ -402,6 +402,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;
@@ -486,7 +487,14 @@ 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(); mx_batman_geometry_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();
mx_batman_geometry_initialize();
}
return result;
}
@@ -629,9 +637,11 @@ static int emul_is_virtual(XrSwapchain handle);
static void emul_virtual_destroy(XrSwapchain handle);
static XrResult emul_virtual_enumerate(XrSwapchain handle, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images);
static void emul_on_acquire(XrSwapchain handle, uint32_t index);
static XrResult standin_after_failure(const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result);
static XrResult standin_after_failure(XrSession session, const XrSwapchainCreateInfo *info, XrSwapchain *out, XrResult result);
static int is_standin(XrSwapchain handle);
static void standin_destroy(XrSwapchain handle);
static void cube_outputs_destroy(void);
static void cube_session_destroy(XrSession session);
static XrResult standin_enumerate(XrSwapchain handle, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images);
static int known_swapchain(XrSwapchain handle) {
@@ -735,7 +745,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
snap_on_create(*swapchain, &fixed);
return result;
}
if (!swapchain_fix) return standin_after_failure(&fixed, swapchain, result);
if (!swapchain_fix) return standin_after_failure(session, &fixed, swapchain, result);
// Frame's runtime rejects some GLES formats (GL_RGBA8) and MSAA swapchains (overport #71).
if (fixed.sampleCount > 1) {
@@ -758,7 +768,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
emul_on_create_swapchain(*swapchain, &fixed);
snap_on_create(*swapchain, &fixed);
}
return standin_after_failure(&fixed, swapchain, result);
return standin_after_failure(session, &fixed, swapchain, result);
}
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain swapchain) {
@@ -860,44 +870,15 @@ static int layer_usable(const XrCompositionLayerBaseHeader *layer) {
static uint64_t next_fake_handle = 0x7f000001;
#define FAKE_HANDLE(type) ((type)(uintptr_t)__atomic_fetch_add(&next_fake_handle, 1, __ATOMIC_RELAXED))
// The app's passthrough calls, logged (each kind at most 8 times) to see where an MR game stops (NOPE: black).
static void passthrough_log(const char *what, uint64_t handle, uint32_t flags, int purpose) {
static int counts[8];
static const char *const kinds[8] = {"create", "start", "pause", "destroy", "layer create", "layer resume",
"layer pause", "layer destroy"};
for (int k = 0; k < 8; ++k)
if (!strcmp(what, kinds[k]) && counts[k]++ < 8)
LOG("passthrough: %s 0x%llx flags=0x%x purpose=%d", what, (unsigned long long)handle, flags, purpose);
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_create_passthrough(XrSession s, const XrPassthroughCreateInfoFB *i, XrPassthroughFB *out) {
(void)s; if (!out) return XR_ERROR_VALIDATION_FAILURE; *out = FAKE_HANDLE(XrPassthroughFB);
passthrough_log("create", (uint64_t)(uintptr_t)*out, i ? (uint32_t)i->flags : 0, -1);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_start(XrPassthroughFB p) {
passthrough_log("start", (uint64_t)(uintptr_t)p, 0, -1); return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_pause(XrPassthroughFB p) {
passthrough_log("pause", (uint64_t)(uintptr_t)p, 0, -1); return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_handle(XrPassthroughFB p) {
passthrough_log("destroy", (uint64_t)(uintptr_t)p, 0, -1); return XR_SUCCESS;
(void)s; (void)i; if (!out) return XR_ERROR_VALIDATION_FAILURE; *out = FAKE_HANDLE(XrPassthroughFB); return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_handle(XrPassthroughFB p) { (void)p; return XR_SUCCESS; }
static XRAPI_ATTR XrResult XRAPI_CALL emu_create_passthrough_layer(XrSession s, const XrPassthroughLayerCreateInfoFB *i,
XrPassthroughLayerFB *out) {
(void)s; if (!out) return XR_ERROR_VALIDATION_FAILURE; *out = FAKE_HANDLE(XrPassthroughLayerFB);
passthrough_log("layer create", (uint64_t)(uintptr_t)*out, i ? (uint32_t)i->flags : 0, i ? (int)i->purpose : -1);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_layer_resume(XrPassthroughLayerFB l) {
passthrough_log("layer resume", (uint64_t)(uintptr_t)l, 0, -1); return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_layer_pause(XrPassthroughLayerFB l) {
passthrough_log("layer pause", (uint64_t)(uintptr_t)l, 0, -1); return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_layer_handle(XrPassthroughLayerFB l) {
passthrough_log("layer destroy", (uint64_t)(uintptr_t)l, 0, -1); return XR_SUCCESS;
(void)s; (void)i; if (!out) return XR_ERROR_VALIDATION_FAILURE; *out = FAKE_HANDLE(XrPassthroughLayerFB); return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_layer_handle(XrPassthroughLayerFB l) { (void)l; return XR_SUCCESS; }
static XRAPI_ATTR XrResult XRAPI_CALL emu_passthrough_layer_style(XrPassthroughLayerFB l, const XrPassthroughStyleFB *st) {
(void)l; (void)st; return XR_SUCCESS;
}
@@ -933,12 +914,12 @@ static PFN_xrVoidFunction passthrough_emulation(const char *name) {
static const struct { const char *name; PFN_xrVoidFunction fn; } table[] = {
{"xrCreatePassthroughFB", (PFN_xrVoidFunction)emu_create_passthrough},
{"xrDestroyPassthroughFB", (PFN_xrVoidFunction)emu_passthrough_handle},
{"xrPassthroughStartFB", (PFN_xrVoidFunction)emu_passthrough_start},
{"xrPassthroughPauseFB", (PFN_xrVoidFunction)emu_passthrough_pause},
{"xrPassthroughStartFB", (PFN_xrVoidFunction)emu_passthrough_handle},
{"xrPassthroughPauseFB", (PFN_xrVoidFunction)emu_passthrough_handle},
{"xrCreatePassthroughLayerFB", (PFN_xrVoidFunction)emu_create_passthrough_layer},
{"xrDestroyPassthroughLayerFB", (PFN_xrVoidFunction)emu_passthrough_layer_handle},
{"xrPassthroughLayerPauseFB", (PFN_xrVoidFunction)emu_passthrough_layer_pause},
{"xrPassthroughLayerResumeFB", (PFN_xrVoidFunction)emu_passthrough_layer_resume},
{"xrPassthroughLayerPauseFB", (PFN_xrVoidFunction)emu_passthrough_layer_handle},
{"xrPassthroughLayerResumeFB", (PFN_xrVoidFunction)emu_passthrough_layer_handle},
{"xrPassthroughLayerSetStyleFB", (PFN_xrVoidFunction)emu_passthrough_layer_style},
{"xrCreateGeometryInstanceFB", (PFN_xrVoidFunction)emu_create_geometry},
{"xrDestroyGeometryInstanceFB", (PFN_xrVoidFunction)emu_geometry_handle},
@@ -976,13 +957,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetSystemProperties(XrInstance instance, XrSyst
((XrSystemPassthroughPropertiesFB *)p)->supportsPassthrough = XR_TRUE;
if (p->type == XR_TYPE_SYSTEM_SPATIAL_ENTITY_PROPERTIES_FB && emulate_scene)
((XrSystemSpatialEntityPropertiesFB *)p)->supportsSpatialEntity = XR_TRUE;
// META scene capability chains used by current MRUK.
if (p->type == XR_TYPE_SYSTEM_SPACE_DISCOVERY_PROPERTIES_META && emulate_scene)
((XrSystemSpaceDiscoveryPropertiesMETA *)p)->supportsSpaceDiscovery = XR_TRUE;
if (p->type == XR_TYPE_SYSTEM_SPACE_PERSISTENCE_PROPERTIES_META && emulate_scene)
((XrSystemSpacePersistencePropertiesMETA *)p)->supportsSpacePersistence = XR_TRUE;
if (p->type == XR_TYPE_SYSTEM_BOUNDARY_VISIBILITY_PROPERTIES_META && emulate_scene)
((XrSystemBoundaryVisibilityPropertiesMETA *)p)->supportsBoundaryVisibility = XR_TRUE;
if (p->type == XR_TYPE_SYSTEM_PASSTHROUGH_PROPERTIES2_FB)
((XrSystemPassthroughProperties2FB *)p)->capabilities = XR_PASSTHROUGH_CAPABILITY_BIT_FB;
if (p->type == XR_TYPE_SYSTEM_RENDER_MODEL_PROPERTIES_FB && emulate_render_model)
@@ -1030,10 +1004,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpace(XrSpace space, XrSpace baseSpace, X
pose_time_note("xrLocateSpace", (uint64_t)(uintptr_t)space, (uint64_t)(uintptr_t)baseSpace, time);
if (emulate_scene && location) {
XrPosef pose; XrSpaceLocationFlags flags;
pthread_mutex_lock(&scene_lock);
int fake = locate_fake(space, baseSpace, time, &pose, &flags);
pthread_mutex_unlock(&scene_lock);
if (fake) {
if (locate_fake(space, baseSpace, time, &pose, &flags)) {
location->pose = pose;
location->locationFlags = flags;
return XR_SUCCESS;
@@ -1055,8 +1026,8 @@ static XrResult locate_spaces_common(const char *name, XrSession session, const
if (info) pose_time_note(name, info->spaceCount ? (uint64_t)(uintptr_t)info->spaces[0] : 0,
(uint64_t)(uintptr_t)info->baseSpace, info->time);
if (emulate_scene && info && locations) {
any_fake = is_fake_space(info->baseSpace);
for (uint32_t i = 0; i < info->spaceCount && !any_fake; ++i) any_fake = is_fake_space(info->spaces[i]);
any_fake = fake_space_index(info->baseSpace) >= 0;
for (uint32_t i = 0; i < info->spaceCount && !any_fake; ++i) any_fake = fake_space_index(info->spaces[i]) >= 0;
}
if (!any_fake) {
PFN_xrLocateSpaces fn = (PFN_xrLocateSpaces)lookup(active_instance, name);
@@ -1079,12 +1050,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_locate_spaces_khr(XrSession session, c
}
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace space) {
if (is_fake_space(space)) {
pthread_mutex_lock(&scene_lock);
int valid = fake_space_index(space) >= 0;
pthread_mutex_unlock(&scene_lock);
return valid ? XR_SUCCESS : XR_ERROR_HANDLE_INVALID; // entity spaces live for the whole session
}
if (fake_space_index(space) >= 0) return XR_SUCCESS; // entity spaces live for the whole session
PFN_xrDestroySpace fn = (PFN_xrDestroySpace)lookup(active_instance, "xrDestroySpace");
return fn ? fn(space) : XR_ERROR_FUNCTION_UNSUPPORTED;
}
@@ -1446,7 +1412,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrPollEvent(XrInstance instance, XrEventDataBuffe
mx_audio_initialize(); mx_batman_geometry_initialize();
PFN_xrPollEvent fn = (PFN_xrPollEvent)lookup(instance, "xrPollEvent");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
if (emulate_scene && event && !scene_events_via_shim) {
if (emulate_scene && event) {
pthread_mutex_lock(&scene_lock);
int got = pop_event(event);
pthread_mutex_unlock(&scene_lock);
@@ -1737,7 +1703,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
frames = 0; drift_sum = drift_max = 0;
}
}
if ((!layer_fix && !swap_eyes && !emulate_passthrough && !flip_quads && !flip_emul && !strip_depth && !fade_quad) || !info || !info->layerCount ||
if ((!cube_standin && !layer_fix && !swap_eyes && !emulate_passthrough && !flip_quads && !flip_emul && !strip_depth && !fade_quad) || !info || !info->layerCount ||
info->layerCount > 64)
return fn(session, info);
if (layer_debug) { // the submitted layer list (order, flags, eyes, chained structs) whenever its shape changes
@@ -1784,6 +1750,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
XrCompositionLayerQuad quads[64];
XrCompositionLayerQuad fade_out; // fade_quad: at most one fade layer per frame
int faded = 0;
cube_outputs_begin();
uint32_t count = 0, dropped = 0, swapped = 0, passthrough = 0;
const XrCompositionLayerBaseHeader *surface_projections[SURF_MAX];
int surface_projection_count = 0;
@@ -1870,6 +1837,14 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
++dropped; // an emulated video surface without a frame yet
continue;
}
if (cube_quads && layer->type == XR_TYPE_COMPOSITION_LAYER_CUBE_KHR &&
count + 6 + info->layerCount - i - 1 <= 16 && count + 6 <= 64 &&
cube_layer_quads(session, info, i, &quads[count])) {
for (int f = 0; f < 6; ++f) kept[count + f] = (const XrCompositionLayerBaseHeader *)&quads[count + f];
count += 6;
++swapped;
continue;
}
if (layer_uses_standin(layer)) { // a cube layer whose swapchain the runtime refused (cube_standin.c)
static int logged;
if (logged++ < 3) LOG("cube_standin: dropped a layer (type=%d) that shows a stand-in swapchain", layer->type);
@@ -2154,6 +2129,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
@@ -2168,7 +2144,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;
}
@@ -2256,56 +2247,6 @@ static XRAPI_ATTR XrResult XRAPI_CALL fb_hide_update_swapchain(XrSwapchain swapc
return fn ? fn(swapchain, state) : XR_ERROR_FUNCTION_UNSUPPORTED;
}
// Generic calls the shim routes here for the emulated room (OVRPort's dispatcher refuses fake handles' functions):
// only fake room spaces and FramePort's own room events are handled locally; every real call goes on to OVRPort's
// dispatcher, whose own work (headset identity, events, controller spaces) was skipped for every scene game before.
static PFN_xrVoidFunction dispatcher_proc(const char *name) {
static PFN_xrGetInstanceProcAddr gipa;
static int tried;
if (!tried) {
tried = 1;
void *h = dlopen("libopenxr_loader.so", RTLD_NOW | RTLD_NOLOAD); // OVRPort's dispatcher (xrshim's next)
if (h) gipa = (PFN_xrGetInstanceProcAddr)dlsym(h, "xrGetInstanceProcAddr");
LOG("scene: shim calls for real handles go to OVRPort's dispatcher (%s)", gipa ? "found" : "MISSING");
}
PFN_xrVoidFunction fn = NULL;
if (gipa && active_instance) gipa(active_instance, name, &fn);
return fn;
}
static XRAPI_ATTR XrResult XRAPI_CALL shim_locate_space(XrSpace space, XrSpace base, XrTime time, XrSpaceLocation *loc) {
PFN_xrLocateSpace next = is_fake_space(space) || is_fake_space(base) ? NULL
: (PFN_xrLocateSpace)dispatcher_proc("xrLocateSpace");
return next ? next(space, base, time, loc) : xrLocateSpace(space, base, time, loc);
}
static XrResult shim_locate_spaces_common(const char *name, XrSession session, const XrSpacesLocateInfo *info,
XrSpaceLocations *locations) {
int any_fake = info && is_fake_space(info->baseSpace);
for (uint32_t i = 0; info && i < info->spaceCount && !any_fake; ++i) any_fake = is_fake_space(info->spaces[i]);
PFN_xrLocateSpaces next = any_fake ? NULL : (PFN_xrLocateSpaces)dispatcher_proc(name);
return next ? next(session, info, locations) : locate_spaces_common(name, session, info, locations);
}
static XRAPI_ATTR XrResult XRAPI_CALL shim_locate_spaces(XrSession s, const XrSpacesLocateInfo *i, XrSpaceLocations *l) {
return shim_locate_spaces_common("xrLocateSpaces", s, i, l);
}
static XRAPI_ATTR XrResult XRAPI_CALL shim_locate_spaces_khr(XrSession s, const XrSpacesLocateInfo *i, XrSpaceLocations *l) {
return shim_locate_spaces_common("xrLocateSpacesKHR", s, i, l);
}
static XRAPI_ATTR XrResult XRAPI_CALL shim_destroy_space(XrSpace space) {
PFN_xrDestroySpace next = is_fake_space(space) ? NULL : (PFN_xrDestroySpace)dispatcher_proc("xrDestroySpace");
return next ? next(space) : xrDestroySpace(space);
}
static XRAPI_ATTR XrResult XRAPI_CALL shim_poll_event(XrInstance instance, XrEventDataBuffer *event) {
scene_events_via_shim = 1;
if (event) { // room events first: OVRPort's dispatcher might drop event types it doesn't know
pthread_mutex_lock(&scene_lock);
int got = pop_event(event);
pthread_mutex_unlock(&scene_lock);
if (got) return XR_SUCCESS;
}
PFN_xrPollEvent next = (PFN_xrPollEvent)dispatcher_proc("xrPollEvent");
return next ? next(instance, event) : xrPollEvent(instance, event);
}
// For native/xrshim: emulated functions that overport's dispatcher doesn't know (it never asks us for them).
__attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_proc(const char *name) {
pthread_once(&init_once, initialize);
@@ -2328,19 +2269,6 @@ __attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_
NATIVE_PROC(xrCreateVulkanDeviceKHR)
NATIVE_PROC(xrCreateVulkanInstanceKHR)
#undef NATIVE_PROC
}
// The shim bypasses OVRPort's fixed dispatch table for all scene calls, including consumers of fake spaces.
if (name && emulate_scene) {
PFN_xrVoidFunction scene = scene_emulation(name);
if (scene) return scene;
// xrGetSystemProperties stays with OVRPort (its headset identity; it calls down to our hook for the scene flags)
#define SCENE_PROC(fn, impl) if (!strcmp(name, #fn)) return (PFN_xrVoidFunction)impl;
SCENE_PROC(xrPollEvent, shim_poll_event)
SCENE_PROC(xrLocateSpace, shim_locate_space)
SCENE_PROC(xrLocateSpaces, shim_locate_spaces)
SCENE_PROC(xrLocateSpacesKHR, shim_locate_spaces_khr)
SCENE_PROC(xrDestroySpace, shim_destroy_space)
#undef SCENE_PROC
}
PFN_xrVoidFunction color = name ? color_space_emulation(name) : NULL;
return color ? color : (name ? render_model_emulation(name) : NULL);
@@ -2407,6 +2335,10 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
HOOK_AS(xrReleaseSwapchainImage, surf_composite_release)
HOOK_AS(xrWaitSwapchainImage, surf_composite_wait)
}
if (cube_standin) {
HOOK_AS(xrEndSession, cube_end_session)
HOOK_AS(xrDestroySession, hook_xrDestroySession)
}
if (equirect_emul) {
HOOK_AS(xrReleaseSwapchainImage, hook_xrReleaseSwapchainImage)
HOOK_AS(xrWaitSwapchainImage, hook_xrWaitSwapchainImage)
@@ -2439,7 +2371,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()) {
+4
View File
@@ -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).
+2 -1
View File
@@ -42,7 +42,7 @@ typedef struct { XrStructureType type; void *next; uint32_t image; } emul_gles_i
X(eglChooseConfig) X(eglCreatePbufferSurface) X(eglDestroySurface) X(eglGetCurrentContext) X(eglGetError) \
X(eglQueryString)
#define EMUL_GL_FUNCS(X) X(glFenceSync) X(glDeleteSync) X(glWaitSync) X(glClientWaitSync) X(glFlush) \
X(glGenFramebuffers) X(glDeleteFramebuffers) X(glBindFramebuffer) X(glFramebufferTexture2D) X(glCheckFramebufferStatus) X(glViewport) \
X(glFramebufferTextureLayer) X(glIsEnabled) X(glBlitFramebuffer) X(glGenFramebuffers) X(glDeleteFramebuffers) X(glBindFramebuffer) X(glFramebufferTexture2D) X(glCheckFramebufferStatus) X(glViewport) \
X(glUseProgram) X(glCreateShader) X(glShaderSource) X(glCompileShader) X(glGetShaderiv) X(glGetShaderInfoLog) \
X(glDeleteShader) X(glCreateProgram) X(glAttachShader) X(glLinkProgram) X(glGetProgramiv) X(glGetProgramInfoLog) \
X(glGetUniformLocation) X(glUniform1i) X(glUniform1f) X(glUniform3f) X(glUniform4f) X(glUniformMatrix3fv) \
@@ -1003,6 +1003,7 @@ static void emul_on_destroy_session(XrSession session) {
}
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrDestroySession(XrSession session) {
cube_session_destroy(session);
emul_on_destroy_session(session);
PFN_xrDestroySession fn = (PFN_xrDestroySession)lookup(active_instance, "xrDestroySession");
return fn ? fn(session) : XR_ERROR_FUNCTION_UNSUPPORTED;
+10
View File
@@ -88,6 +88,16 @@ static inline XrVector3f lm_glcube_dir(int face, float u, float v) {
}
}
// Cube image array order: +X,-X,+Y,-Y,+Z,-Z. Local +Z faces inward.
// Quad right/up match GL cube s/t; Vulkan copies reverse rows to compensate its top-left image origin.
static inline XrPosef lm_cube_quad_pose(XrQuaternionf orientation, int face, float distance) {
static const int faces[6] = {1, 3, 4, 5, 2, 0};
XrPosef p = lm_face_pose((XrPosef){orientation, {0, 0, 0}}, faces[face], distance);
if (face != 2 && face != 3)
p.orientation = lm_qmul(p.orientation, (XrQuaternionf){0, 0, 1, 0});
return p;
}
// GL's cube map lookup (OpenGL ES 3.0 spec, table 3.21): direction -> face and (s, t).
static inline int lm_glcube_lookup(XrVector3f d, float *s, float *t) {
float ax = fabsf(d.x), ay = fabsf(d.y), az = fabsf(d.z), sc, tc, ma;
+39
View File
@@ -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;
}
-115
View File
@@ -1,115 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by scene_emu.c. Persist only application anchors; scanned entities remain owned by the room file.
#include <sys/random.h>
#include <sys/stat.h>
#include <unistd.h>
#ifndef SCENE_EXTERNAL_ROOT
#define SCENE_EXTERNAL_ROOT "/sdcard/Android/data"
#endif
static int anchor_path(char *path, size_t size) {
char process[256] = {0};
FILE *f = fopen("/proc/self/cmdline", "r");
if (!f) return 0;
size_t n = fread(process, 1, sizeof(process)-1, f);
fclose(f);
if (!n) return 0;
char *colon = strchr(process, ':');
if (colon) *colon = 0;
// Only package-like process names: never let a command line become a path traversal.
if (!strchr(process, '.') || !strcmp(process, ".") || !strcmp(process, "..")) return 0;
for (const char *p = process; *p; ++p)
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') || *p == '.' || *p == '_')) return 0;
int len = snprintf(path, size, "%s/%s/files/framebridge-anchors.txt", SCENE_EXTERNAL_ROOT, process);
return len > 0 && (size_t)len < size;
}
static int anchor_uuid(XrUuidEXT *u) {
size_t have = 0;
while (have < sizeof(u->data)) {
ssize_t n = getrandom(u->data + have, sizeof(u->data) - have, 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
have += (size_t)n;
}
if (have < sizeof(u->data)) {
FILE *f = fopen("/dev/urandom", "rb");
if (!f) return 0;
have = fread(u->data, 1, sizeof(u->data), f);
fclose(f);
if (have != sizeof(u->data)) return 0;
}
u->data[6] = (uint8_t)((u->data[6] & 15) | 0x40);
u->data[8] = (uint8_t)((u->data[8] & 63) | 0x80);
for (int i = 0; i < entity_count; ++i) if (uuid_equal(u, &entities[i].uuid)) return 0;
return 1;
}
static int write_anchors(void) {
char path[4096], tmp[4128];
if (!anchor_path(path, sizeof(path))) return 0;
snprintf(tmp, sizeof(tmp), "%s.tmp-XXXXXX", path);
int fd = mkstemp(tmp);
if (fd < 0) return 0;
FILE *f = fdopen(fd, "w");
if (!f) { close(fd); unlink(tmp); return 0; }
int ok = 1;
for (int i = 0; i < entity_count; ++i) {
const scene_entity *e = &entities[i];
if (!e->is_anchor || !e->saved || e->erased) continue;
char uuid[33];
for (int k = 0; k < 16; ++k) snprintf(uuid + k*2, 3, "%02x", e->uuid.data[k]);
const XrPosef p = e->pose_in_stage;
if (fprintf(f, "anchor %s %.9g %.9g %.9g %.9g %.9g %.9g %.9g\n", uuid,
p.position.x, p.position.y, p.position.z, p.orientation.x, p.orientation.y, p.orientation.z, p.orientation.w) < 0) ok = 0;
}
if (fflush(f) || fsync(fd)) ok = 0;
if (fclose(f)) ok = 0;
if (ok && rename(tmp, path)) ok = 0;
if (!ok) { LOG("scene: anchor write failed (%d)", errno); unlink(tmp); }
return ok;
}
static void load_anchors(void) {
char path[4096];
if (!anchor_path(path, sizeof(path))) return;
FILE *f = fopen(path, "r");
if (!f) return;
char line[1024], *t[10];
unsigned loaded = 0;
while (fgets(line, sizeof(line), f)) {
if (!strchr(line, '\n') && !feof(f)) { int c; while ((c=fgetc(f)) != EOF && c != '\n') {} continue; }
XrUuidEXT uuid; float v[7];
int n = room_tokens(line, t, 10);
if (n != 9 || strcmp(t[0], "anchor") || !room_uuid(t[1], &uuid) || !room_values(t, 2, 7, v)) continue;
float norm = sqrtf(v[3]*v[3]+v[4]*v[4]+v[5]*v[5]+v[6]*v[6]);
if (norm < 0.0001f) continue;
int duplicate = 0;
for (int i = 0; i < entity_count; ++i) duplicate |= uuid_equal(&uuid, &entities[i].uuid);
if (duplicate) continue;
int i = add_entity((XrPosef){{v[3]/norm,v[4]/norm,v[5]/norm,v[6]/norm},{v[0],v[1],v[2]}}, NULL, 0, 0, 0, 1);
if (i < 0) break;
entities[i].uuid = uuid; entities[i].saved = 1; ++loaded;
}
fclose(f);
LOG("scene: loaded %u saved anchors", loaded);
}
// Batch writes are atomic both on disk and in memory. Failure never silently destroys the old anchor set.
static XrResult persist_spaces(uint32_t count, const XrSpace *spaces, uint32_t uuid_count, const XrUuidEXT *uuids, int erase) {
unsigned char selected[MAX_ENTITIES] = {0}, old_saved[MAX_ENTITIES], old_erased[MAX_ENTITIES];
for (uint32_t k = 0; k < count; ++k) {
int i = fake_space_index(spaces[k]);
if (i < 0) return XR_ERROR_HANDLE_INVALID;
if (entities[i].is_anchor) selected[i] = 1;
}
if (erase) for (int i = 0; i < entity_count; ++i)
if (entities[i].is_anchor && !entities[i].erased && uuid_matches(uuid_count, uuids, &entities[i])) selected[i] = 1;
int changed = 0;
for (int i = 0; i < entity_count; ++i) {
old_saved[i] = (unsigned char)entities[i].saved; old_erased[i] = (unsigned char)entities[i].erased;
if (selected[i]) { entities[i].saved = !erase; entities[i].erased = erase; changed = 1; }
}
if (changed && !write_anchors()) {
for (int i = 0; i < entity_count; ++i) { entities[i].saved = old_saved[i]; entities[i].erased = old_erased[i]; }
return XR_ERROR_RUNTIME_FAILURE;
}
if (changed) LOG("scene: anchors %s", erase ? "erased" : "saved");
return XR_SUCCESS;
}
+117 -367
View File
@@ -1,19 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by frame_adapter.c. Emulates Meta's scene / spatial-entity extensions (XR_FB_spatial_entity,
// _query, _storage, _container, XR_FB_scene, XR_FB_scene_capture) for mixed-reality apps on runtimes that
// lack them (Steam Frame). Room files use STAGE coordinates; absent/invalid files retain the guardian box.
// lack them (Steam Frame). The "room" is a box built from the runtime's STAGE bounds (the guardian /
// chaperone rectangle): floor, ceiling and four walls, reported as one room anchor with a layout.
// Entity spaces are fake handles; every function that consumes a space is intercepted for them.
static float scene_min_size = 1.5f; // smallest room edge if the guardian is tiny or unavailable
static int emulate_scene; // app requested the scene extensions and the runtime lacks them
static int scene_app_mesh; // app enabled XR_META_spatial_entity_mesh: only then is the global mesh an entity
// (an older SDK's scene loader fails on an anchor it can't place)
static const char *const scene_extensions[] = {
"XR_FB_spatial_entity", "XR_FB_spatial_entity_query", "XR_FB_spatial_entity_storage",
"XR_FB_spatial_entity_container", "XR_FB_scene", "XR_FB_scene_capture",
"XR_META_spatial_entity_mesh", "XR_META_spatial_entity_discovery", "XR_META_spatial_entity_persistence",
"XR_META_boundary_visibility", // MR games hide the guardian before starting (NOPE asks every frame)
};
#define SCENE_EXTENSION_COUNT (sizeof(scene_extensions) / sizeof(scene_extensions[0]))
@@ -45,58 +42,38 @@ static XrPosef pose_inv(XrPosef a) {
// ---------------------------------------------------------------- entities
enum { ENT_ROOM, ENT_FLOOR, ENT_CEILING, ENT_WALL0, ENT_WALL1, ENT_WALL2, ENT_WALL3, ENT_FIXED_COUNT };
#define MAX_ENTITIES 2048
#define MAX_OUTLINE 1024
#define MAX_ENTITIES 64
typedef struct {
XrSpace handle; // fake handle given to the app
XrUuidEXT uuid;
XrPosef pose_in_stage;
char label[256]; // comma-separated Meta semantic labels
const char *label; // semantic label, NULL if none
XrRect2Df bounds; // 2D bounds in the entity's local XY plane
XrRect3DfFB bounds3;
XrVector2f *boundary;
uint32_t boundary_count;
XrVector3f *vertices;
uint32_t *indices, vertex_count, index_count;
int has_bounds, has_bounds3, is_room, is_anchor, is_wall, erased, saved;
int has_bounds, is_room, is_anchor;
} scene_entity;
static pthread_mutex_t scene_lock = PTHREAD_MUTEX_INITIALIZER;
static scene_entity entities[MAX_ENTITIES];
static int entity_count, room_entity_count, wall_count, room_from_file;
static char room_source[32];
static uint64_t room_universe;
static int entity_count;
static XrSpace scene_stage = XR_NULL_HANDLE; // real STAGE space used to anchor everything
static XrSession scene_session = XR_NULL_HANDLE;
static uint64_t next_request = 0x5ce0000001ull;
// Fit the tag in uintptr_t as the adapter is also built for AArch32. Session generations prevent stale
// handles from aliasing new room entities when an engine recreates its session in the same process.
#if UINTPTR_MAX > 0xffffffffu
#define FAKE_SPACE_TAG ((uintptr_t)0x5ce0000000000000ull)
#define FAKE_SPACE_MASK ((uintptr_t)0xfff0000000000000ull)
#else
#define FAKE_SPACE_TAG ((uintptr_t)0x5ce00000u)
#define FAKE_SPACE_MASK ((uintptr_t)0xfff00000u)
#endif
static unsigned scene_generation;
static int is_fake_space(XrSpace space) { return ((uintptr_t)space & FAKE_SPACE_MASK) == FAKE_SPACE_TAG; }
#define FAKE_SPACE_TAG 0x5ce0000000000000ull
static int fake_space_index(XrSpace space) {
uintptr_t v = (uintptr_t)space;
if ((v & FAKE_SPACE_MASK) != FAKE_SPACE_TAG || ((v >> 12) & 255) != scene_generation) return -1;
int i = (int)(v & 4095);
return i < entity_count && !entities[i].erased ? i : -1;
uint64_t v = (uint64_t)(uintptr_t)space;
if ((v & 0xfff0000000000000ull) != FAKE_SPACE_TAG) return -1;
int i = (int)(v & 0xffff);
return i < entity_count ? i : -1;
}
static XrSpace fake_space(int index) { return (XrSpace)(FAKE_SPACE_TAG | ((uintptr_t)scene_generation << 12) | (uintptr_t)index); }
static XrSpace fake_space(int index) { return (XrSpace)(uintptr_t)(FAKE_SPACE_TAG | (uint64_t)index); }
static XrUuidEXT make_uuid(int index) {
XrUuidEXT u;
for (int i = 0; i < XR_UUID_SIZE_EXT; ++i) u.data[i] = (uint8_t)(0x5c ^ (i * 37) ^ (index * 101));
u.data[6] = (uint8_t)((u.data[6] & 0x0f) | 0x40); // version 4 style
u.data[15] = (uint8_t)index;
u.data[14] ^= (uint8_t)((unsigned)index >> 8);
return u;
}
static int uuid_equal(const XrUuidEXT *a, const XrUuidEXT *b) { return !memcmp(a->data, b->data, XR_UUID_SIZE_EXT); }
@@ -109,7 +86,7 @@ static int add_entity(XrPosef pose, const char *label, float w, float h, int is_
e->handle = fake_space(i);
e->uuid = make_uuid(i);
e->pose_in_stage = pose;
if (label) snprintf(e->label, sizeof(e->label), "%s", label);
e->label = label;
e->has_bounds = w > 0 && h > 0;
e->bounds = (XrRect2Df){{-w / 2, -h / 2}, {w, h}};
e->is_room = is_room;
@@ -117,23 +94,9 @@ static int add_entity(XrPosef pose, const char *label, float w, float h, int is_
return i;
}
static void reset_room(void) {
for (int i = 0; i < entity_count; ++i) {
free(entities[i].boundary);
free(entities[i].vertices);
free(entities[i].indices);
}
memset(entities, 0, sizeof(entities));
entity_count = room_entity_count = wall_count = room_from_file = 0;
room_universe = 0;
}
#include "scene_room.c"
// Build the room once the session exists: guardian rectangle -> floor/ceiling/walls.
static void build_room(void) {
if (entity_count) return;
if (load_room_file()) return;
float width = 0, depth = 0;
PFN_xrGetReferenceSpaceBoundsRect bounds_fn =
(PFN_xrGetReferenceSpaceBoundsRect)lookup(active_instance, "xrGetReferenceSpaceBoundsRect");
@@ -154,14 +117,10 @@ static void build_room(void) {
add_entity((XrPosef){{0, 0, 0, 1}, {0, 0, 0}}, NULL, 0, 0, 1, 0); // ENT_ROOM
add_entity((XrPosef){{-s, 0, 0, s}, {0, 0, 0}}, "FLOOR", width, depth, 0, 0); // +Z (normal) -> +Y
add_entity((XrPosef){{s, 0, 0, s}, {0, h, 0}}, "CEILING", width, depth, 0, 0); // +Z -> -Y
// Walls in loop order around the floor, as Meta's ROOM_LAYOUT lists them (Demeter walks them as an outline).
add_entity((XrPosef){{0, 0, 0, 1}, {0, h / 2, -depth / 2}}, "WALL_FACE", width, h, 0, 0); // faces +Z
add_entity((XrPosef){{0, -s, 0, s}, {width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces -X
add_entity((XrPosef){{0, 1, 0, 0}, {0, h / 2, depth / 2}}, "WALL_FACE", width, h, 0, 0); // faces -Z
add_entity((XrPosef){{0, s, 0, s}, {-width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces +X
wall_count = 4;
room_entity_count = entity_count;
for (int i = ENT_WALL0; i < entity_count; ++i) entities[i].is_wall = 1;
add_entity((XrPosef){{0, -s, 0, s}, {width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces -X
}
// Supported components per entity.
@@ -169,17 +128,12 @@ static int entity_components(const scene_entity *e, XrSpaceComponentTypeFB *out)
int n = 0;
out[n++] = XR_SPACE_COMPONENT_TYPE_LOCATABLE_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_STORABLE_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SHARABLE_FB; // Meta's runtime lists it; nothing is shared from here
if (e->is_room) {
out[n++] = XR_SPACE_COMPONENT_TYPE_ROOM_LAYOUT_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SPACE_CONTAINER_FB;
} else if (!e->is_anchor) {
if (e->has_bounds) {
out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB;
}
if (e->has_bounds3) out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_3D_FB;
if (e->vertex_count) out[n++] = XR_SPACE_COMPONENT_TYPE_TRIANGLE_MESH_META;
if (e->label[0]) out[n++] = XR_SPACE_COMPONENT_TYPE_SEMANTIC_LABELS_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SEMANTIC_LABELS_FB;
}
return n;
}
@@ -194,7 +148,6 @@ static int entity_has_component(const scene_entity *e, XrSpaceComponentTypeFB ty
#define MAX_EVENTS 64
static XrEventDataBuffer scene_events[MAX_EVENTS];
static int event_head, event_tail;
static int event_free(void) { return (event_head - event_tail - 1 + MAX_EVENTS) % MAX_EVENTS; }
static void push_event(const void *event, size_t size) {
if ((event_tail + 1) % MAX_EVENTS == event_head) return;
memset(&scene_events[event_tail], 0, sizeof(XrEventDataBuffer));
@@ -218,7 +171,6 @@ static int stage_pose(XrSpace space, XrTime time, XrPosef *pose, XrSpaceLocation
XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT | XR_SPACE_LOCATION_POSITION_TRACKED_BIT;
return 1;
}
if (is_fake_space(space)) return 0; // erased or from a prior session: never pass a fake handle to SteamVR
PFN_xrLocateSpace locate = (PFN_xrLocateSpace)lookup(active_instance, "xrLocateSpace");
if (!locate || !scene_stage) return 0;
XrSpaceLocation loc = {XR_TYPE_SPACE_LOCATION, NULL, 0, {{0, 0, 0, 1}, {0, 0, 0}}};
@@ -229,7 +181,7 @@ static int stage_pose(XrSpace space, XrTime time, XrPosef *pose, XrSpaceLocation
}
// Locate `space` in `base` when either is fake. Returns 0 if neither is fake (caller forwards).
static int locate_fake(XrSpace space, XrSpace base, XrTime time, XrPosef *pose, XrSpaceLocationFlags *flags) {
if (!is_fake_space(space) && !is_fake_space(base)) return 0;
if (fake_space_index(space) < 0 && fake_space_index(base) < 0) return 0;
XrPosef s, b;
XrSpaceLocationFlags fs = 0, fb = 0;
if (!stage_pose(space, time, &s, &fs) || !stage_pose(base, time, &b, &fb)) {
@@ -243,98 +195,99 @@ static int locate_fake(XrSpace space, XrSpace base, XrTime time, XrPosef *pose,
}
// ---------------------------------------------------------------- emulated entry points
// Results are snapshotted before their available event is queued, so callers on another thread cannot
// observe a half-stored filter. A bounded request history supports the count/data calls independently.
#define MAX_QUERIES 64
typedef struct {
XrAsyncRequestIdFB id;
uint32_t count;
XrSpaceQueryResultFB *results;
} scene_query;
static scene_query queries[MAX_QUERIES];
static unsigned query_next;
static scene_query *new_query(void) {
scene_query *q = &queries[query_next % MAX_QUERIES];
XrSpaceQueryResultFB *results = malloc((size_t)entity_count * sizeof(*results));
if (!results) return NULL;
++query_next;
free(q->results);
*q = (scene_query){next_request++, 0, results};
return q;
}
static scene_query *find_query(XrAsyncRequestIdFB id) {
for (int i = 0; i < MAX_QUERIES; ++i) if (queries[i].id == id) return &queries[i];
return NULL;
}
static int uuid_matches(uint32_t n, const XrUuidEXT *uuids, const scene_entity *e) {
for (uint32_t i = 0; i < n; ++i) if (uuid_equal(&uuids[i], &e->uuid)) return 1;
return 0;
}
#include "scene_anchors.c"
static int fb_filter_matches(const XrSpaceFilterInfoBaseHeaderFB *f, const scene_entity *e) {
if (!f) return 1;
if (f->type == XR_TYPE_SPACE_COMPONENT_FILTER_INFO_FB)
return entity_has_component(e, ((const XrSpaceComponentFilterInfoFB *)f)->componentType);
if (f->type == XR_TYPE_SPACE_UUID_FILTER_INFO_FB) {
const XrSpaceUuidFilterInfoFB *u = (const XrSpaceUuidFilterInfoFB *)f;
return u->uuids && uuid_matches(u->uuidCount, u->uuids, e);
}
return 1;
}
static void log_head_in_room(void); // scene_meta.c
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces_filtered(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
if (!info || !request || info->type != XR_TYPE_SPACE_QUERY_INFO_FB) return XR_ERROR_VALIDATION_FAILURE;
const XrSpaceQueryInfoFB *info_fb = (const XrSpaceQueryInfoFB *)info;
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
scene_session = session;
build_room();
if (event_free() < 2) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
scene_query *q = new_query();
if (!q) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_OUT_OF_MEMORY; }
for (int i = 0; i < entity_count && q->count < info_fb->maxResultCount; ++i) {
scene_entity *e = &entities[i];
if (!e->erased && fb_filter_matches(info_fb->filter, e))
q->results[q->count++] = (XrSpaceQueryResultFB){e->handle, e->uuid};
}
*request = q->id;
*request = next_request++;
XrEventDataSpaceQueryResultsAvailableFB avail = {XR_TYPE_EVENT_DATA_SPACE_QUERY_RESULTS_AVAILABLE_FB, NULL, *request};
XrEventDataSpaceQueryCompleteFB done = {XR_TYPE_EVENT_DATA_SPACE_QUERY_COMPLETE_FB, NULL, *request, XR_SUCCESS};
push_event(&avail, sizeof(avail)); push_event(&done, sizeof(done));
LOG("scene: query request=%llu results=%u", (unsigned long long)*request, q->count);
log_head_in_room();
push_event(&avail, sizeof(avail));
push_event(&done, sizeof(done));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
// Query filters are kept per request so retrieve can apply them.
typedef struct {
XrAsyncRequestIdFB id;
int has_component;
XrSpaceComponentTypeFB component;
uint32_t uuid_count;
XrUuidEXT uuids[64];
} query_filter;
static query_filter filters[16];
static int filter_next;
static void store_filter(XrAsyncRequestIdFB id, const XrSpaceQueryInfoFB *q) {
query_filter *f = &filters[filter_next++ % 16];
memset(f, 0, sizeof(*f));
f->id = id;
const XrSpaceFilterInfoBaseHeaderFB *flt = q->filter;
if (flt && flt->type == XR_TYPE_SPACE_COMPONENT_FILTER_INFO_FB) {
f->has_component = 1;
f->component = ((const XrSpaceComponentFilterInfoFB *)flt)->componentType;
} else if (flt && flt->type == XR_TYPE_SPACE_UUID_FILTER_INFO_FB) {
const XrSpaceUuidFilterInfoFB *u = (const XrSpaceUuidFilterInfoFB *)flt;
f->uuid_count = u->uuidCount < 64 ? u->uuidCount : 64;
for (uint32_t i = 0; i < f->uuid_count; ++i) f->uuids[i] = u->uuids[i];
}
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces_filtered(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
XrResult r = emu_query_spaces(session, info, request);
if (XR_SUCCEEDED(r) && info->type == XR_TYPE_SPACE_QUERY_INFO_FB) {
pthread_mutex_lock(&scene_lock);
store_filter(*request, (const XrSpaceQueryInfoFB *)info);
const XrSpaceQueryInfoFB *q = (const XrSpaceQueryInfoFB *)info;
LOG("scene: query request=%llu filter=%d", (unsigned long long)*request, q->filter ? q->filter->type : 0);
pthread_mutex_unlock(&scene_lock);
}
return r;
}
static int filter_matches(const query_filter *f, const scene_entity *e) {
if (!f) return 1;
if (f->has_component) return entity_has_component(e, f->component);
if (f->uuid_count) {
for (uint32_t i = 0; i < f->uuid_count; ++i) if (uuid_equal(&f->uuids[i], &e->uuid)) return 1;
return 0;
}
return 1;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_retrieve_results(XrSession session, XrAsyncRequestIdFB request,
XrSpaceQueryResultsFB *results) {
(void)session;
if (!results) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
scene_query *q = find_query(request);
XrResult r = XR_SUCCESS;
if (!q) r = XR_ERROR_VALIDATION_FAILURE;
else {
results->resultCountOutput = q->count;
if (results->resultCapacityInput && results->resultCapacityInput < q->count) r = XR_ERROR_SIZE_INSUFFICIENT;
else if (results->resultCapacityInput) {
if (!results->results) r = XR_ERROR_VALIDATION_FAILURE;
else memcpy(results->results, q->results, q->count * sizeof(*q->results));
const query_filter *f = NULL;
for (int i = 0; i < 16; ++i) if (filters[i].id == request) f = &filters[i];
uint32_t n = 0;
for (int i = 0; i < entity_count; ++i) {
if (!filter_matches(f, &entities[i])) continue;
if (results->resultCapacityInput && n < results->resultCapacityInput && results->results) {
results->results[n].space = entities[i].handle;
results->results[n].uuid = entities[i].uuid;
}
++n;
}
results->resultCountOutput = n;
pthread_mutex_unlock(&scene_lock);
return r;
return (results->resultCapacityInput && results->resultCapacityInput < n) ? XR_ERROR_SIZE_INSUFFICIENT : XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_uuid(XrSpace space, XrUuidEXT *uuid) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_uuid(XrSpace space, XrUuidEXT *uuid) {
int i = fake_space_index(space);
if (i < 0 || !uuid) return XR_ERROR_HANDLE_INVALID;
*uuid = entities[i].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count,
static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count,
XrSpaceComponentTypeFB *types) {
int i = fake_space_index(space);
if (i < 0 || !count) return XR_ERROR_HANDLE_INVALID;
@@ -343,33 +296,16 @@ static XRAPI_ATTR XrResult XRAPI_CALL scene_enumerate_components(XrSpace space,
*count = (uint32_t)n;
if (!capacity) return XR_SUCCESS;
if (capacity < (uint32_t)n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!types) return XR_ERROR_VALIDATION_FAILURE;
for (int k = 0; k < n; ++k) types[k] = list[k];
return XR_SUCCESS;
}
// Which entity refused which component (the first ones only): an app's scene loader gives up on the whole room over
// one of these, and OVRPlugin's own error line doesn't name the component (Demeter, 2026-10-16).
static void log_refused_component(const char *op, int i, XrSpaceComponentTypeFB type) {
static int logged;
if (logged >= 20) return;
++logged;
const scene_entity *e = &entities[i];
LOG("scene: %s component %d refused on %s (entity %d)", op, (int)type,
e->is_room ? "room" : e->is_anchor ? "app anchor" : e->label[0] ? e->label : "unlabelled", i);
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_set_component_status(XrSpace space, const XrSpaceComponentStatusSetInfoFB *info,
XrAsyncRequestIdFB *request) {
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(space);
if (i < 0 || !info || !request) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!entity_has_component(&entities[i], info->componentType)) {
log_refused_component("set", i, info->componentType);
pthread_mutex_unlock(&scene_lock);
return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
}
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
if (i < 0 || !info || !request) return XR_ERROR_HANDLE_INVALID;
if (!entity_has_component(&entities[i], info->componentType)) return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
pthread_mutex_lock(&scene_lock);
*request = next_request++;
XrEventDataSpaceSetStatusCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SET_STATUS_COMPLETE_FB, NULL, *request, XR_SUCCESS,
space, entities[i].uuid, info->componentType, info->enabled};
@@ -378,49 +314,44 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_set_component_status(XrSpace space, co
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_component_status(XrSpace space, XrSpaceComponentTypeFB type,
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_component_status(XrSpace space, XrSpaceComponentTypeFB type,
XrSpaceComponentStatusFB *status) {
int i = fake_space_index(space);
if (i < 0 || !status) return XR_ERROR_HANDLE_INVALID;
if (!entity_has_component(&entities[i], type)) {
log_refused_component("get", i, type);
return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
}
if (!entity_has_component(&entities[i], type)) return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
status->enabled = XR_TRUE;
status->changePending = XR_FALSE;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !container) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].is_room) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
uint32_t n = room_entity_count - 1; // everything except the room itself
uint32_t n = ENT_FIXED_COUNT - 1; // everything except the room itself
container->uuidCountOutput = n;
if (!container->uuidCapacityInput) return XR_SUCCESS;
if (container->uuidCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!container->uuids) return XR_ERROR_VALIDATION_FAILURE;
for (uint32_t k = 0; k < n; ++k) container->uuids[k] = entities[ENT_FLOOR + k].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !layout) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].is_room) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
layout->floorUuid = entities[ENT_FLOOR].uuid;
layout->ceilingUuid = entities[ENT_CEILING].uuid;
layout->wallUuidCountOutput = (uint32_t)wall_count;
layout->wallUuidCountOutput = 4;
if (!layout->wallUuidCapacityInput) return XR_SUCCESS;
if (layout->wallUuidCapacityInput < (uint32_t)wall_count) return XR_ERROR_SIZE_INSUFFICIENT;
if (!layout->wallUuids) return XR_ERROR_VALIDATION_FAILURE;
for (int k = 0; k < wall_count; ++k) layout->wallUuids[k] = entities[ENT_WALL0 + k].uuid;
if (layout->wallUuidCapacityInput < 4) return XR_ERROR_SIZE_INSUFFICIENT;
for (int k = 0; k < 4; ++k) layout->wallUuids[k] = entities[ENT_WALL0 + k].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !rect) return XR_ERROR_HANDLE_INVALID;
@@ -429,29 +360,19 @@ static XRAPI_ATTR XrResult XRAPI_CALL scene_get_bounding_box_2d(XrSession sessio
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_bounding_box_3d(XrSession session, XrSpace space, XrRect3DfFB *box) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !box) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].has_bounds3) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
*box = entities[i].bounds3;
return XR_SUCCESS;
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_3d(XrSession session, XrSpace space, XrRect3DfFB *box) {
(void)session; (void)space; (void)box;
return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !boundary) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].has_bounds) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
uint32_t n = entities[i].boundary_count ? entities[i].boundary_count : 4;
boundary->vertexCountOutput = n;
boundary->vertexCountOutput = 4;
if (!boundary->vertexCapacityInput) return XR_SUCCESS;
if (boundary->vertexCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!boundary->vertices) return XR_ERROR_VALIDATION_FAILURE;
if (entities[i].boundary_count) {
memcpy(boundary->vertices, entities[i].boundary, n * sizeof(*boundary->vertices));
return XR_SUCCESS;
}
if (boundary->vertexCapacityInput < 4) return XR_ERROR_SIZE_INSUFFICIENT;
const XrRect2Df b = entities[i].bounds;
boundary->vertices[0] = (XrVector2f){b.offset.x, b.offset.y};
boundary->vertices[1] = (XrVector2f){b.offset.x + b.extent.width, b.offset.y};
@@ -460,76 +381,16 @@ static XRAPI_ATTR XrResult XRAPI_CALL scene_get_boundary_2d(XrSession session, X
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_triangle_mesh(XrSpace space, const XrSpaceTriangleMeshGetInfoMETA *info,
XrSpaceTriangleMeshMETA *mesh) {
(void)info;
int i = fake_space_index(space);
if (i < 0 || !mesh) return XR_ERROR_HANDLE_INVALID;
const scene_entity *e = &entities[i];
if (!e->vertex_count) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
mesh->vertexCountOutput = e->vertex_count;
mesh->indexCountOutput = e->index_count;
if ((mesh->vertexCapacityInput && mesh->vertexCapacityInput < e->vertex_count) ||
(mesh->indexCapacityInput && mesh->indexCapacityInput < e->index_count)) return XR_ERROR_SIZE_INSUFFICIENT;
if ((mesh->vertexCapacityInput && !mesh->vertices) || (mesh->indexCapacityInput && !mesh->indices))
return XR_ERROR_VALIDATION_FAILURE;
if (mesh->vertexCapacityInput) memcpy(mesh->vertices, e->vertices, e->vertex_count * sizeof(*e->vertices));
if (mesh->indexCapacityInput) memcpy(mesh->indices, e->indices, e->index_count * sizeof(*e->indices));
return XR_SUCCESS;
}
// Labels as Meta's runtime hands them to an app (XR_FB_scene): an app that sends no XrSemanticLabelsSupportInfoFB
// knows only the first label set; one that does gets "OTHER" for labels missing from its recognizedLabels, WALL_FACE
// for invisible walls unless it accepts them, and DESK for TABLE unless it accepts that migration. Older Meta SDKs
// (Demeter, 2026-10-16) rejected a whole scanned room over one INVISIBLE_WALL_FACE and kept asking for Space Setup.
static const char *const scene_legacy_labels = "DESK,COUCH,FLOOR,CEILING,WALL_FACE,DOOR_FRAME,WINDOW_FRAME,OTHER";
static int label_listed(const char *list, const char *label, size_t len) {
for (const char *p = list; p && *p;) {
const char *end = strchr(p, ',');
size_t n = end ? (size_t)(end - p) : strlen(p);
if (n == len && !strncmp(p, label, len)) return 1;
p = end ? end + 1 : NULL;
}
return 0;
}
static void scene_app_labels(const char *label, const XrSemanticLabelsFB *labels, char *out, size_t size) {
const XrSemanticLabelsSupportInfoFB *support = NULL;
for (const XrBaseInStructure *n = labels->next; n; n = n->next)
if (n->type == XR_TYPE_SEMANTIC_LABELS_SUPPORT_INFO_FB) support = (const XrSemanticLabelsSupportInfoFB *)n;
const char *known = support ? support->recognizedLabels : scene_legacy_labels;
XrSemanticLabelsSupportFlagsFB flags = support ? support->flags : 0;
out[0] = 0;
for (const char *p = label; *p;) {
const char *end = strchr(p, ',');
size_t len = end ? (size_t)(end - p) : strlen(p);
char one[64];
snprintf(one, sizeof(one), "%.*s", (int)len, p);
if (!strcmp(one, "INVISIBLE_WALL_FACE") && !(flags & XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_INVISIBLE_WALL_FACE_BIT_FB))
snprintf(one, sizeof(one), "WALL_FACE");
if (!strcmp(one, "TABLE") && !(flags & XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_DESK_TO_TABLE_MIGRATION_BIT_FB))
snprintf(one, sizeof(one), "DESK");
if (known && *known && !label_listed(known, one, strlen(one))) snprintf(one, sizeof(one), "OTHER");
if (!label_listed(out, one, strlen(one)))
snprintf(out + strlen(out), size - strlen(out), "%s%s", out[0] ? "," : "", one);
p = end ? end + 1 : p + len;
}
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !labels) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].label[0]) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
char text[sizeof(entities[0].label)];
scene_app_labels(entities[i].label, labels, text, sizeof(text));
uint32_t n = (uint32_t)strlen(text) + 1;
if (!entities[i].label) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
uint32_t n = (uint32_t)strlen(entities[i].label) + 1;
labels->bufferCountOutput = n;
if (!labels->bufferCapacityInput) return XR_SUCCESS;
if (labels->bufferCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!labels->buffer) return XR_ERROR_VALIDATION_FAILURE;
memcpy(labels->buffer, text, n);
memcpy(labels->buffer, entities[i].label, n);
return XR_SUCCESS;
}
@@ -540,12 +401,11 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_request_scene_capture(XrSession sessio
pthread_mutex_lock(&scene_lock);
scene_session = session;
build_room();
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
*request = next_request++;
XrEventDataSceneCaptureCompleteFB e = {XR_TYPE_EVENT_DATA_SCENE_CAPTURE_COMPLETE_FB, NULL, *request, XR_SUCCESS};
push_event(&e, sizeof(e));
LOG("scene: capture requested -> %s", room_from_file ? "scanned room" : "guardian box");
pthread_mutex_unlock(&scene_lock);
LOG("scene: capture requested -> using guardian room");
return XR_SUCCESS;
}
@@ -557,16 +417,9 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_create_anchor(XrSession session, const
build_room();
XrPosef base = {{0, 0, 0, 1}, {0, 0, 0}};
XrSpaceLocationFlags flags = 0;
if (event_free() < 1) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
if (!stage_pose(info->space, info->time, &base, &flags) ||
!(flags & XR_SPACE_LOCATION_POSITION_VALID_BIT) || !(flags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT)) {
pthread_mutex_unlock(&scene_lock); return XR_ERROR_POSE_INVALID;
}
XrUuidEXT uuid;
if (!anchor_uuid(&uuid)) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_RUNTIME_FAILURE; }
stage_pose(info->space, info->time, &base, &flags);
int i = add_entity(pose_mul(base, info->poseInSpace), NULL, 0, 0, 0, 1);
*request = next_request++;
if (i >= 0) entities[i].uuid = uuid;
XrEventDataSpatialAnchorCreateCompleteFB e = {XR_TYPE_EVENT_DATA_SPATIAL_ANCHOR_CREATE_COMPLETE_FB, NULL, *request,
i >= 0 ? XR_SUCCESS : XR_ERROR_LIMIT_REACHED, i >= 0 ? entities[i].handle : XR_NULL_HANDLE,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}};
@@ -578,118 +431,31 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_create_anchor(XrSession session, const
static XRAPI_ATTR XrResult XRAPI_CALL emu_save_space(XrSession session, const XrSpaceSaveInfoFB *info, XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(info->space);
if (i < 0) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(1, &info->space, 0, NULL, 0);
pthread_mutex_lock(&scene_lock);
*request = next_request++;
XrEventDataSpaceSaveCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB, NULL, *request, r, info->space,
entities[i].uuid, info->location};
XrEventDataSpaceSaveCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB, NULL, *request, XR_SUCCESS, info->space,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}, info->location};
push_event(&e, sizeof(e));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_erase_space(XrSession session, const XrSpaceEraseInfoFB *info, XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(info->space);
if (i < 0) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(1, &info->space, 0, NULL, 1);
pthread_mutex_lock(&scene_lock);
*request = next_request++;
XrEventDataSpaceEraseCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB, NULL, *request, r, info->space,
entities[i].uuid, info->location};
XrEventDataSpaceEraseCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB, NULL, *request, XR_SUCCESS, info->space,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}, info->location};
push_event(&e, sizeof(e));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
// Reads share the mutation lock: engine worker threads may retrieve geometry while anchors are saved.
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_uuid(XrSpace space, XrUuidEXT *uuid) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_uuid(space, uuid);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count, XrSpaceComponentTypeFB *types) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_enumerate_components(space, capacity, count, types);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_component_status(XrSpace space, XrSpaceComponentTypeFB type, XrSpaceComponentStatusFB *status) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_component_status(space, type, status);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_container(session, space, container);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_room_layout(session, space, layout);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_bounding_box_2d(session, space, rect);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_3d(XrSession session, XrSpace space, XrRect3DfFB *box) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_bounding_box_3d(session, space, box);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_boundary_2d(session, space, boundary);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_triangle_mesh(XrSpace space, const XrSpaceTriangleMeshGetInfoMETA *info, XrSpaceTriangleMeshMETA *mesh) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_triangle_mesh(space, info, mesh);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_semantic_labels(session, space, labels);
pthread_mutex_unlock(&scene_lock);
return r;
}
// The app's STAGE bounds: the Frame reports a 1x1 m rect but OVRPlugin can't give its outline ("ovrpBoundary_Outer
// is not supported"), so Unity games that trust a configured boundary index an empty array (Demeter's
// RemoveObjectNotInGuardian). With an emulated room there is no play area for the app; build_room asks the runtime.
static XRAPI_ATTR XrResult XRAPI_CALL emu_stage_bounds(XrSession session, XrReferenceSpaceType type, XrExtent2Df *bounds) {
if (type == XR_REFERENCE_SPACE_TYPE_STAGE) {
if (!bounds) return XR_ERROR_VALIDATION_FAILURE;
static int logged;
if (!logged++) LOG("scene: STAGE bounds hidden from the app (no play-area outline on this runtime)");
*bounds = (XrExtent2Df){0, 0};
return XR_SPACE_BOUNDS_UNAVAILABLE;
}
PFN_xrGetReferenceSpaceBoundsRect fn =
(PFN_xrGetReferenceSpaceBoundsRect)lookup(active_instance, "xrGetReferenceSpaceBoundsRect");
return fn ? fn(session, type, bounds) : XR_ERROR_FUNCTION_UNSUPPORTED;
}
#include "scene_meta.c"
static PFN_xrVoidFunction scene_emulation(const char *name) {
if (!emulate_scene) return NULL;
if (!strcmp(name, "xrGetReferenceSpaceBoundsRect")) // only for games that trust the 1x1 m rect (NOPE stalled)
return scene_hide_bounds ? (PFN_xrVoidFunction)emu_stage_bounds : NULL;
static const struct { const char *name; PFN_xrVoidFunction fn; } table[] = {
{"xrQuerySpacesFB", (PFN_xrVoidFunction)emu_query_spaces_filtered},
{"xrRetrieveSpaceQueryResultsFB", (PFN_xrVoidFunction)emu_retrieve_results},
@@ -702,17 +468,11 @@ static PFN_xrVoidFunction scene_emulation(const char *name) {
{"xrGetSpaceBoundingBox2DFB", (PFN_xrVoidFunction)emu_get_bounding_box_2d},
{"xrGetSpaceBoundingBox3DFB", (PFN_xrVoidFunction)emu_get_bounding_box_3d},
{"xrGetSpaceBoundary2DFB", (PFN_xrVoidFunction)emu_get_boundary_2d},
{"xrGetSpaceTriangleMeshMETA", (PFN_xrVoidFunction)emu_get_triangle_mesh},
{"xrGetSpaceSemanticLabelsFB", (PFN_xrVoidFunction)emu_get_semantic_labels},
{"xrRequestSceneCaptureFB", (PFN_xrVoidFunction)emu_request_scene_capture},
{"xrCreateSpatialAnchorFB", (PFN_xrVoidFunction)emu_create_anchor},
{"xrSaveSpaceFB", (PFN_xrVoidFunction)emu_save_space},
{"xrEraseSpaceFB", (PFN_xrVoidFunction)emu_erase_space},
{"xrDiscoverSpacesMETA", (PFN_xrVoidFunction)emu_discover_spaces},
{"xrRetrieveSpaceDiscoveryResultsMETA", (PFN_xrVoidFunction)emu_retrieve_discovery},
{"xrSaveSpacesMETA", (PFN_xrVoidFunction)emu_save_spaces},
{"xrEraseSpacesMETA", (PFN_xrVoidFunction)emu_erase_spaces},
{"xrRequestBoundaryVisibilityMETA", (PFN_xrVoidFunction)emu_request_boundary_visibility},
};
for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); ++i)
if (!strcmp(name, table[i].name)) return table[i].fn;
@@ -725,16 +485,6 @@ static void scene_on_create_session(XrSession session) {
PFN_xrCreateReferenceSpace create = (PFN_xrCreateReferenceSpace)lookup(active_instance, "xrCreateReferenceSpace");
XrReferenceSpaceCreateInfo ci = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO, NULL, XR_REFERENCE_SPACE_TYPE_STAGE,
{{0, 0, 0, 1}, {0, 0, 0}}};
pthread_mutex_lock(&scene_lock);
reset_room();
scene_generation = (scene_generation + 1) & 255;
event_head = event_tail = 0;
for (int i = 0; i < MAX_QUERIES; ++i) { free(queries[i].results); memset(&queries[i], 0, sizeof(queries[i])); }
query_next = 0;
scene_session = session;
scene_stage = XR_NULL_HANDLE;
if (create && XR_SUCCEEDED(create(session, &ci, &scene_stage))) LOG("scene: emulation active (anchored to STAGE)");
build_room();
load_anchors();
pthread_mutex_unlock(&scene_lock);
}
-140
View File
@@ -1,140 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by scene_emu.c, using the cached OpenXR registry declarations (no private ABI copies).
static int meta_filter_matches(const XrSpaceDiscoveryInfoMETA *info, const scene_entity *e) {
for (uint32_t k = 0; k < info->filterCount; ++k) {
const XrSpaceFilterBaseHeaderMETA *f = info->filters[k];
if (!f) continue;
if (f->type == XR_TYPE_SPACE_FILTER_COMPONENT_META &&
!entity_has_component(e, ((const XrSpaceFilterComponentMETA *)f)->componentType)) return 0;
if (f->type == XR_TYPE_SPACE_FILTER_UUID_META) {
const XrSpaceFilterUuidMETA *u = (const XrSpaceFilterUuidMETA *)f;
if (!u->uuids || !uuid_matches(u->uuidCount, u->uuids, e)) return 0;
}
}
return 1;
}
// Diagnostics: MRUK takes the room whose floor outline contains the head as the "current room"; outside it, a game
// can ask for a room scan again and again (Pinball FX). Logs the head against the floor outline (3 times per session).
static void log_head_in_room(void) {
static int logged;
static XrSpace view = XR_NULL_HANDLE;
const scene_entity *f = &entities[ENT_FLOOR];
if (logged >= 3 || !scene_session || !scene_stage || !last_predicted_time || entity_count <= ENT_FLOOR ||
!f->has_bounds) return;
if (!view) {
PFN_xrCreateReferenceSpace create = (PFN_xrCreateReferenceSpace)lookup(active_instance, "xrCreateReferenceSpace");
XrReferenceSpaceCreateInfo ci = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO, NULL, XR_REFERENCE_SPACE_TYPE_VIEW,
{{0, 0, 0, 1}, {0, 0, 0}}};
if (!create || XR_FAILED(create(scene_session, &ci, &view))) return;
}
XrPosef head;
XrSpaceLocationFlags flags = 0;
if (!stage_pose(view, last_predicted_time, &head, &flags)) return;
XrVector3f p = pose_mul(pose_inv(f->pose_in_stage), head).position; // floor plane: x, y; its normal: z
XrVector2f rect[4], *v = f->boundary;
uint32_t n = f->boundary_count;
if (!n) {
const XrRect2Df b = f->bounds;
rect[0] = (XrVector2f){b.offset.x, b.offset.y};
rect[1] = (XrVector2f){b.offset.x + b.extent.width, b.offset.y};
rect[2] = (XrVector2f){b.offset.x + b.extent.width, b.offset.y + b.extent.height};
rect[3] = (XrVector2f){b.offset.x, b.offset.y + b.extent.height};
v = rect;
n = 4;
}
int inside = 0;
for (uint32_t a = 0, b = n - 1; a < n; b = a++)
if ((v[a].y > p.y) != (v[b].y > p.y) && p.x < (v[b].x - v[a].x) * (p.y - v[a].y) / (v[b].y - v[a].y) + v[a].x)
inside = !inside;
LOG("scene: head in STAGE %.2f,%.2f,%.2f (flags 0x%llx), floor-local %.2f,%.2f: %s the floor outline (%u points)",
head.position.x, head.position.y, head.position.z, (unsigned long long)flags, p.x, p.y,
inside ? "inside" : "OUTSIDE", n);
++logged;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_discover_spaces(XrSession session, const XrSpaceDiscoveryInfoMETA *info,
XrAsyncRequestIdFB *request) {
if (!info || !request || (info->filterCount && !info->filters)) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
scene_session = session;
build_room();
if (event_free() < 2) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
scene_query *q = new_query();
if (!q) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_OUT_OF_MEMORY; }
for (int i = 0; i < entity_count; ++i) {
scene_entity *e = &entities[i];
if (!e->erased && meta_filter_matches(info, e))
q->results[q->count++] = (XrSpaceQueryResultFB){e->handle, e->uuid};
}
*request = q->id;
XrEventDataSpaceDiscoveryResultsAvailableMETA avail = {XR_TYPE_EVENT_DATA_SPACE_DISCOVERY_RESULTS_AVAILABLE_META, NULL, *request};
XrEventDataSpaceDiscoveryCompleteMETA done = {XR_TYPE_EVENT_DATA_SPACE_DISCOVERY_COMPLETE_META, NULL, *request, XR_SUCCESS};
push_event(&avail, sizeof(avail)); push_event(&done, sizeof(done));
LOG("scene: discovery request=%llu results=%u", (unsigned long long)*request, q->count);
log_head_in_room();
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_retrieve_discovery(XrSession session, XrAsyncRequestIdFB request,
XrSpaceDiscoveryResultsMETA *results) {
(void)session;
if (!results) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
scene_query *q = find_query(request);
XrResult r = XR_SUCCESS;
if (!q) r = XR_ERROR_VALIDATION_FAILURE;
else {
results->resultCountOutput = q->count;
if (results->resultCapacityInput && results->resultCapacityInput < q->count) r = XR_ERROR_SIZE_INSUFFICIENT;
else if (results->resultCapacityInput) {
if (!results->results) r = XR_ERROR_VALIDATION_FAILURE;
else for (uint32_t i = 0; i < q->count; ++i)
results->results[i] = (XrSpaceDiscoveryResultMETA){q->results[i].space, q->results[i].uuid};
}
}
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_save_spaces(XrSession session, const XrSpacesSaveInfoMETA *info,
XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request || !info->spaceCount || !info->spaces) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(info->spaceCount, info->spaces, 0, NULL, 0);
*request = next_request++;
XrEventDataSpacesSaveResultMETA done = {XR_TYPE_EVENT_DATA_SPACES_SAVE_RESULT_META, NULL, *request, r};
push_event(&done, sizeof(done));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
// XR_META_boundary_visibility: the Frame shows no guardian over an app anyway, so a request always succeeds; the
// change event (once per new state, like Meta's runtime) is what MR games wait for before they start.
static XrBoundaryVisibilityMETA boundary_visibility = XR_BOUNDARY_VISIBILITY_NOT_SUPPRESSED_META;
static XRAPI_ATTR XrResult XRAPI_CALL emu_request_boundary_visibility(XrSession session, XrBoundaryVisibilityMETA v) {
(void)session;
if (v != XR_BOUNDARY_VISIBILITY_SUPPRESSED_META && v != XR_BOUNDARY_VISIBILITY_NOT_SUPPRESSED_META)
return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
if (v != boundary_visibility) {
boundary_visibility = v;
XrEventDataBoundaryVisibilityChangedMETA e = {XR_TYPE_EVENT_DATA_BOUNDARY_VISIBILITY_CHANGED_META, NULL, v};
push_event(&e, sizeof(e));
LOG("scene: boundary %s", v == XR_BOUNDARY_VISIBILITY_SUPPRESSED_META ? "suppressed" : "shown");
}
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_erase_spaces(XrSession session, const XrSpacesEraseInfoMETA *info,
XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request || (info->spaceCount && !info->spaces) || (info->uuidCount && !info->uuids))
return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(info->spaceCount, info->spaces, info->uuidCount, info->uuids, 1);
*request = next_request++;
XrEventDataSpacesEraseResultMETA done = {XR_TYPE_EVENT_DATA_SPACES_ERASE_RESULT_META, NULL, *request, r};
push_event(&done, sizeof(done));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
-268
View File
@@ -1,268 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by scene_emu.c. Bounded parsing keeps corrupt scan files from exhausting an Android game.
#include <errno.h>
#include <stdint.h>
static int room_uuid(const char *s, XrUuidEXT *u) {
if (strlen(s) != 32) return 0;
for (int i = 0; i < 16; ++i) {
unsigned n = 0;
for (int k = 0; k < 2; ++k) {
char c = s[i * 2 + k];
if (c >= '0' && c <= '9') n = n * 16 + (unsigned)(c - '0');
else if (c >= 'a' && c <= 'f') n = n * 16 + (unsigned)(c - 'a' + 10);
else return 0;
}
u->data[i] = (uint8_t)n;
}
return 1;
}
static int room_float(const char *s, float *v) {
char *end;
errno = 0;
*v = strtof(s, &end);
return *s && !*end && !errno && isfinite(*v) && fabsf(*v) <= 100000;
}
static int room_uint(const char *s, uint64_t *v) {
char *end;
if (*s < '0' || *s > '9') return 0;
errno = 0;
*v = strtoull(s, &end, 10);
return !*end && !errno;
}
static int room_tokens(char *s, char **t, int cap) {
char *comment = strchr(s, '#');
if (comment) *comment = 0;
int n = 0;
char *save;
for (char *p = strtok_r(s, " \t\r\n", &save); p; p = strtok_r(NULL, " \t\r\n", &save)) {
if (n == cap) return -1;
t[n++] = p;
}
return n;
}
static int room_values(char **t, int start, int count, float *v) {
for (int i = 0; i < count; ++i) if (!room_float(t[start + i], &v[i])) return 0;
return 1;
}
static int room_path(char *out, size_t size, const char *name) {
const char *config = getenv("FRAMEBRIDGE_CONFIG");
if (!config || !*config || strchr(name, '/') || strchr(name, '\\') || !strcmp(name, "..")) return 0;
const char *slash = strrchr(config, '/');
int n = slash ? snprintf(out, size, "%.*s/%s", (int)(slash - config), config, name)
: snprintf(out, size, "%s", name);
return n >= 0 && (size_t)n < size;
}
static uint32_t room_u32(const unsigned char *p) {
return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}
static int room_mesh(scene_entity *e, const char *name, uint64_t nv, uint64_t ni) {
// At most 200k triangles and 16 MiB of payload; validate every index before publishing the mesh.
if (!nv || nv > 600000 || !ni || ni > 600000 || ni % 3 || nv * 12 + ni * 4 > 16 * 1024 * 1024) return 0;
char path[4096];
if (!room_path(path, sizeof(path), name)) return 0;
FILE *f = fopen(path, "rb");
if (!f) return 0;
unsigned char header[16], word[4];
int ok = fread(header, 1, 16, f) == 16 && !memcmp(header, "FPRM", 4) && room_u32(header + 4) == 1 &&
room_u32(header + 8) == nv && room_u32(header + 12) == ni;
XrVector3f *verts = ok ? malloc((size_t)nv * sizeof(*verts)) : NULL;
uint32_t *indices = ok ? malloc((size_t)ni * sizeof(*indices)) : NULL;
ok = ok && verts && indices;
for (uint64_t i = 0; ok && i < nv; ++i) {
float xyz[3];
for (int k = 0; ok && k < 3; ++k) {
ok = fread(word, 1, 4, f) == 4;
uint32_t bits = ok ? room_u32(word) : 0;
memcpy(&xyz[k], &bits, 4);
ok = ok && isfinite(xyz[k]) && fabsf(xyz[k]) <= 100000;
}
if (ok) verts[i] = (XrVector3f){xyz[0], xyz[1], xyz[2]};
}
for (uint64_t i = 0; ok && i < ni; ++i) {
ok = fread(word, 1, 4, f) == 4;
indices[i] = ok ? room_u32(word) : 0;
ok = ok && indices[i] < nv;
}
ok = ok && fgetc(f) == EOF && !ferror(f);
fclose(f);
if (!ok) { free(verts); free(indices); return 0; }
e->vertices = verts; e->indices = indices;
e->vertex_count = (uint32_t)nv; e->index_count = (uint32_t)ni;
return 1;
}
static int room_set_uuid(int i, XrUuidEXT uuid) {
for (int k = 0; k < entity_count; ++k)
if (k != i && uuid_equal(&entities[k].uuid, &uuid)) return 0;
entities[i].uuid = uuid;
return 1;
}
static int room_wall(XrUuidEXT uuid, const float *v, const char *label) {
float dx = v[2] - v[0], dz = v[3] - v[1], length = hypotf(dx, dz);
if (length < 0.001f || v[5] <= v[4]) return 0;
float angle = atan2f(-dz, dx) / 2;
int i = add_entity((XrPosef){{0, sinf(angle), 0, cosf(angle)},
{(v[0] + v[2]) / 2, (v[4] + v[5]) / 2, (v[1] + v[3]) / 2}},
label, length, v[5] - v[4], 0, 0);
if (i < 0) return 0;
if (!room_set_uuid(i, uuid)) { --entity_count; return 0; }
entities[i].is_wall = 1;
return 1;
}
static int room_outline(scene_entity *e, const XrVector2f *points, int n, float cx, float cz, int ceiling) {
e->boundary = malloc((size_t)n * sizeof(*e->boundary));
if (!e->boundary) return 0;
e->boundary_count = (uint32_t)n;
float xmin = INFINITY, ymin = INFINITY, xmax = -INFINITY, ymax = -INFINITY;
for (int i = 0; i < n; ++i) {
XrVector2f p = {points[i].x - cx, (points[i].y - cz) * (ceiling ? 1 : -1)};
e->boundary[i] = p;
xmin = fminf(xmin, p.x); xmax = fmaxf(xmax, p.x);
ymin = fminf(ymin, p.y); ymax = fmaxf(ymax, p.y);
}
e->has_bounds = 1;
e->bounds = (XrRect2Df){{xmin, ymin}, {xmax - xmin, ymax - ymin}};
return 1;
}
static int load_room_file(void) {
char path[4096];
if (!room_path(path, sizeof(path), "room.txt")) return 0;
FILE *f = fopen(path, "r");
if (!f) return 0;
// Long enough for a 1024-point outline. Reject whole overlong lines, never interpret their tails.
char *line = malloc(65536);
char **t = malloc((2 * MAX_OUTLINE + 4) * sizeof(*t));
if (!line || !t) { free(line); free(t); fclose(f); return 0; }
int ok = fgets(line, 65536, f) != NULL;
int n = ok ? room_tokens(line, t, 2 * MAX_OUTLINE + 4) : 0;
if (n != 2 || strcmp(t[0], "frameport-room") || strcmp(t[1], "1")) {
LOG("scene: bad room magic/version -> guardian box");
free(line); free(t); fclose(f); return 0;
}
const float s = 0.70710678f;
add_entity((XrPosef){{0, 0, 0, 1}, {0, 0, 0}}, NULL, 0, 0, 1, 0);
add_entity((XrPosef){{-s, 0, 0, s}, {0, 0, 0}}, "FLOOR", 0, 0, 0, 0);
add_entity((XrPosef){{s, 0, 0, s}, {0, 2.5f, 0}}, "CEILING", 0, 0, 0, 0);
XrVector2f outline[MAX_OUTLINE];
int outline_n = 0, ceiling_set = 0, volumes = 0, planes = 0, triangles = 0;
unsigned lineno = 1;
snprintf(room_source, sizeof(room_source), "manual");
while (fgets(line, 65536, f)) {
++lineno;
if (!strchr(line, '\n') && !feof(f)) {
int c; while ((c = fgetc(f)) != EOF && c != '\n') {}
LOG("scene: bad room line %u (too long)", lineno); continue;
}
n = room_tokens(line, t, 2 * MAX_OUTLINE + 4);
if (!n) continue;
ok = n > 0;
float v[10]; XrUuidEXT uuid; uint64_t a, b;
if (n < 0) ok = 0;
else if (!strcmp(t[0], "source")) {
ok = n == 2 && (!strcmp(t[1], "scan") || !strcmp(t[1], "boundary") || !strcmp(t[1], "box") || !strcmp(t[1], "manual"));
if (ok) snprintf(room_source, sizeof(room_source), "%s", t[1]);
} else if (!strcmp(t[0], "universe")) {
ok = n == 2 && room_uint(t[1], &a);
if (ok) room_universe = a;
} else if (!strcmp(t[0], "room") || !strcmp(t[0], "floor") || !strcmp(t[0], "ceiling")) {
int i = !strcmp(t[0], "room") ? ENT_ROOM : !strcmp(t[0], "floor") ? ENT_FLOOR : ENT_CEILING;
ok = n == (i == ENT_ROOM ? 2 : 3) && room_uuid(t[1], &uuid) &&
(i == ENT_ROOM || room_float(t[2], v)) && room_set_uuid(i, uuid);
if (ok && i != ENT_ROOM) entities[i].pose_in_stage.position.y = v[0];
if (ok && i == ENT_CEILING) ceiling_set = 1;
} else if (!strcmp(t[0], "outline")) {
ok = n >= 2 && room_uint(t[1], &a) && a >= 3 && a <= MAX_OUTLINE && n == 2 + 2 * (int)a;
XrVector2f parsed[MAX_OUTLINE];
for (uint64_t k = 0; ok && k < a; ++k) ok = room_float(t[2 + k * 2], &parsed[k].x) && room_float(t[3 + k * 2], &parsed[k].y);
if (ok) { memcpy(outline, parsed, (size_t)a * sizeof(*outline)); outline_n = (int)a; }
} else if (!strcmp(t[0], "wall")) {
// optional 8th field: WALL_FACE (default) or INVISIBLE_WALL_FACE for an edge the scanner closed itself
const char *label = n == 9 ? t[8] : "WALL_FACE";
ok = (n == 8 || (n == 9 && (!strcmp(label, "WALL_FACE") || !strcmp(label, "INVISIBLE_WALL_FACE")))) &&
room_uuid(t[1], &uuid) && room_values(t, 2, 6, v) && room_wall(uuid, v, label);
} else if (!strcmp(t[0], "volume") || !strcmp(t[0], "plane")) {
int volume = !strcmp(t[0], "volume");
ok = n == (volume ? 10 : 12) && room_uuid(t[1], &uuid) && strlen(t[2]) < sizeof(entities[0].label) && room_values(t, 3, volume ? 7 : 9, v);
XrPosef pose = {{0, 0, 0, 1}, {0, 0, 0}};
float w = 0, h = 0;
if (ok && volume) {
float yaw = v[3] * 0.00872664626f;
pose = (XrPosef){quat_mul((XrQuaternionf){0, sinf(yaw), 0, cosf(yaw)}, (XrQuaternionf){-s, 0, 0, s}), {v[0], v[2], v[1]}};
w = v[4]; h = v[5]; ok = v[6] > 0;
} else if (ok) {
float norm = sqrtf(v[3]*v[3]+v[4]*v[4]+v[5]*v[5]+v[6]*v[6]);
ok = norm > 0.0001f;
if (ok) pose = (XrPosef){{v[3]/norm,v[4]/norm,v[5]/norm,v[6]/norm}, {v[0],v[1],v[2]}};
w = v[7]; h = v[8];
}
int i = ok && w > 0 && h > 0 ? add_entity(pose, t[2], w, h, 0, 0) : -1;
ok = i >= 0;
if (ok && !room_set_uuid(i, uuid)) { --entity_count; ok = 0; }
if (ok && volume) {
entities[i].has_bounds3 = 1;
entities[i].bounds3 = (XrRect3DfFB){{-w/2, -h/2, -v[6]}, {w, h, v[6]}};
++volumes;
} else if (ok) ++planes;
} else if (!strcmp(t[0], "mesh")) {
if (!scene_app_mesh) continue; // the app didn't enable XR_META_spatial_entity_mesh
ok = n == 5 && room_uuid(t[1], &uuid) && room_uint(t[3], &a) && room_uint(t[4], &b) && !triangles;
int i = ok ? add_entity((XrPosef){{0,0,0,1},{0,0,0}}, "GLOBAL_MESH", 0, 0, 0, 0) : -1;
ok = i >= 0 && room_set_uuid(i, uuid) && room_mesh(&entities[i], t[2], a, b);
if (ok) triangles = (int)b / 3;
else if (i >= 0) --entity_count;
}
if (!ok) LOG("scene: bad room line %u (skipped)", lineno);
}
ok = !ferror(f);
free(line); free(t); fclose(f);
// Layout walls must be contiguous and in file order, regardless of where other records occurred.
for (int i = ENT_WALL0; i < entity_count; ++i) if (entities[i].is_wall) {
scene_entity wall = entities[i];
memmove(&entities[ENT_WALL0 + wall_count + 1], &entities[ENT_WALL0 + wall_count],
(size_t)(i - ENT_WALL0 - wall_count) * sizeof(wall));
entities[ENT_WALL0 + wall_count++] = wall;
}
if (!outline_n && wall_count) {
float xmin = INFINITY, zmin = INFINITY, xmax = -INFINITY, zmax = -INFINITY;
for (int i = ENT_WALL0; i < ENT_WALL0 + wall_count; ++i) {
scene_entity *e = &entities[i];
XrVector3f d = quat_rotate(e->pose_in_stage.orientation, (XrVector3f){e->bounds.extent.width/2,0,0});
xmin = fminf(xmin, e->pose_in_stage.position.x - fabsf(d.x)); xmax = fmaxf(xmax, e->pose_in_stage.position.x + fabsf(d.x));
zmin = fminf(zmin, e->pose_in_stage.position.z - fabsf(d.z)); zmax = fmaxf(zmax, e->pose_in_stage.position.z + fabsf(d.z));
}
outline[0]=(XrVector2f){xmin,zmin}; outline[1]=(XrVector2f){xmax,zmin};
outline[2]=(XrVector2f){xmax,zmax}; outline[3]=(XrVector2f){xmin,zmax}; outline_n=4;
}
double area = 0, cx = 0, cz = 0;
for (int i = 0; i < outline_n; ++i) {
XrVector2f a = outline[i], b = outline[(i+1)%outline_n];
double cross = (double)a.x*b.y-(double)b.x*a.y;
area += cross; cx += (a.x+b.x)*cross; cz += (a.y+b.y)*cross;
}
if (!ok || outline_n < 3 || fabs(area) < 0.00001) { reset_room(); return 0; }
cx /= 3*area; cz /= 3*area;
if (!ceiling_set) entities[ENT_CEILING].pose_in_stage.position.y = entities[ENT_FLOOR].pose_in_stage.position.y + 2.5f;
for (int i = ENT_FLOOR; i <= ENT_CEILING; ++i) {
entities[i].pose_in_stage.position.x = (float)cx; entities[i].pose_in_stage.position.z = (float)cz;
if (!room_outline(&entities[i], outline, outline_n, (float)cx, (float)cz, i == ENT_CEILING)) { reset_room(); return 0; }
}
if (!wall_count) for (int i = 0; i < outline_n; ++i) {
XrVector2f a = outline[i], b = outline[(i+1)%outline_n];
float v[] = {a.x,a.y,b.x,b.y,entities[ENT_FLOOR].pose_in_stage.position.y,entities[ENT_CEILING].pose_in_stage.position.y};
if (!room_wall(make_uuid(entity_count), v, "WALL_FACE")) { reset_room(); return 0; }
++wall_count;
}
// Move generated walls ahead of furniture as well.
if (wall_count && !entities[ENT_WALL0].is_wall) {
scene_entity *walls = malloc((size_t)wall_count * sizeof(*walls));
if (!walls) { reset_room(); return 0; }
memcpy(walls, &entities[entity_count-wall_count], (size_t)wall_count*sizeof(*walls));
memmove(&entities[ENT_WALL0+wall_count], &entities[ENT_WALL0], (size_t)(entity_count-wall_count-ENT_WALL0)*sizeof(*walls));
memcpy(&entities[ENT_WALL0], walls, (size_t)wall_count*sizeof(*walls)); free(walls);
}
for (int i = 0; i < entity_count; ++i) entities[i].handle = fake_space(i);
room_entity_count = entity_count; room_from_file = 1;
LOG("scene: room from %s file (%d walls, %d volumes, %d planes, %d mesh triangles, universe %llu)",
room_source, wall_count, volumes, planes, triangles, (unsigned long long)room_universe);
return 1;
}
+121 -5
View File
@@ -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,118 @@ 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[] = {
// Quest's second button sits up and toward the other hand: left Y on d-pad right, right B on X
{"left", "x", {{"dpad_down", "dpad_left"}, {"dpad_down", NULL}}},
{"left", "y", {{"dpad_up", "dpad_right"}, {"dpad_right", NULL}}},
{"left", "menu", {{"view", NULL}, {"view", NULL}}},
{"right", "a", {{"a", "b"}, {"a", NULL}}},
{"right", "b", {{"x", "y"}, {"x", 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, &copy);
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 +557,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, &copy);
r = suggest_touch_set(instance, fn, to_string, &copy);
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 +606,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 +616,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 +637,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
+2 -15
View File
@@ -201,18 +201,6 @@ def build_venc(tc: Path):
f"-ffile-prefix-map={HERE}=native", "fp_venc.c", "convert.c", "sys.c", "-o", out / "fp_venc"], cwd=src)
def build_roomscan(tc: Path):
"""Offline room reconstruction (Linux aarch64). No libc, headers, generated tables or fetched dependencies."""
src = HERE / "roomscan"
out = ART / "linux-arm64-bin"
out.mkdir(parents=True, exist_ok=True)
run([tc / "bin/clang", "--target=aarch64-linux-gnu", "-ffreestanding", "-nostdlib", "-nostdlibinc", "-static",
"-fno-stack-protector", "-fno-builtin", "-ffp-contract=off", "-fno-math-errno", "-O2", "-Wall", "-Wextra", "-Werror",
"-fuse-ld=lld", "-Wl,--build-id=none", "-Wl,-z,max-page-size=65536", "-e", "_start",
f"-ffile-prefix-map={HERE}=native", "main.c", "fusion.c", "model.c", "mesh.c", "platform.c",
"-o", out / "fp_roomscan"], cwd=src)
OCULUSHMD_IMPORTS = ("CreateEventW", "CreateJobObjectW", "AssignProcessToJobObject", "QueryInformationJobObject",
"CreateProcessW", "ResumeThread", "WaitForSingleObject", "GetExitCodeProcess", "CloseHandle",
"Sleep", "GetCommandLineW", "GetLastError", "GetStdHandle", "WriteFile", "ExitProcess")
@@ -452,7 +440,7 @@ def write_sums():
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,roomscan,a32")
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,a32")
ap.add_argument("--ndk")
args = ap.parse_args()
parts = set(args.only.split(","))
@@ -468,10 +456,9 @@ def main():
"ovrpshim": lambda: build_ovrpshim(tc), "tlskeys": lambda: build_tlskeys(tc),
"luajit": lambda: build_luajit(tc),
"vrsettings": lambda: build_vrsettings(tc), "venc": lambda: build_venc(tc),
"roomscan": lambda: build_roomscan(tc),
"vroverlay_probe": lambda: build_vroverlay_probe(tc), "a32": lambda: build_a32(tc)}
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "luajit", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
"roomscan", "vroverlay_probe", "a32"):
"vroverlay_probe", "a32"):
if name in parts:
log(f"build {name}")
steps[name]()
-325
View File
@@ -1,325 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define MIN_TRIANGLE_TWICE_AREA 1e-8f
#define MIN_RANGE .1f
#define MAX_COORDINATE 10000.f
#define QUATERNION_NORM_TOLERANCE .1f
#define SAMPLE_SPACING_VOXELS .75f
#define MAX_SURFEL_WEIGHT 10.f
#define FEW_FRAMES 8
#define MAX_GRID_XZ 8.f
#define MAX_GRID_Y 4.f
#define GRID_PADDING_VOXELS 5.f
#define NORMAL_PATCH_SIZE 12
#define MIN_PATCH_POINTS 24
float root(float x)
{
if (x <= 0) return 0;
/* -fno-math-errno makes this one hardware sqrt, with no libc/libm symbol on either build. */
return __builtin_sqrtf(x);
}
Vec unit(Vec p) { float n = root(dot(p,p)); return n > 1e-12f ? mul(p,1/n) : vec(0,0,0); }
int flo(float x) { int i = (int)x; return x < (float)i ? i-1 : i; }
float sine(float x)
{
while (x > PI) x -= 2*PI;
while (x < -PI) x += 2*PI;
if (x > PI/2) x = PI-x;
if (x < -PI/2) x = -PI-x;
float q = x*x;
return x*(1-q/6+q*q/120-q*q*q/5040+q*q*q*q/362880);
}
float cosine(float x) { return sine(x+PI/2); }
float angle(float y, float x)
{
/* atan approximation, maximum error < .012 degrees. */
float a = mn(ab(x),ab(y)) / mx(mx(ab(x),ab(y)),1e-20f), q = a*a;
float r = a*(.999866f+q*(-.3302995f+q*(.180141f+q*(-.085133f+q*.0208351f))));
if (ab(y) > ab(x)) r = PI/2-r;
if (x < 0) r = PI-r;
return y < 0 ? -r : r;
}
int same(const char *a, const char *b)
{
while (*a && *a == *b) a++,b++;
return *a == *b;
}
u32 random_next(Scan *s) { s->rng ^= s->rng<<13; s->rng ^= s->rng>>17; s->rng ^= s->rng<<5; return s->rng; }
void warn(Scan *s, const char *m)
{
for (int i = 0; i < s->nwarnings; i++) if (same(s->warnings[i],m)) return;
if (s->nwarnings < 24) s->warnings[s->nwarnings++] = m;
}
static u32 u32le(const u8 *p) { return (u32)p[0]|(u32)p[1]<<8|(u32)p[2]<<16|(u32)p[3]<<24; }
static float f32le(const u8 *p) { union { u32 u; float f; } v = {u32le(p)}; return v.f; }
/* 1 = complete, 0 = clean EOF, -1 = I/O error or partial record. */
static int exact(int fd, void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = io_read(fd,(u8 *)p+done,n-done);
if (r < 0 || (!r && done)) return -1;
if (!r) return 0;
done += (usize)r;
}
return 1;
}
Vec grid_point(Scan *s, int x, int y, int z)
{
return add(s->origin,vec(x*s->opt.voxel,y*s->opt.voxel,z*s->opt.voxel));
}
int cell_index(Scan *s, Vec p)
{
Vec q = mul(sub(p,s->origin),1/s->opt.voxel);
int x = flo(q.x+.5f), y = flo(q.y+.5f), z = flo(q.z+.5f);
if (x < 0 || y < 0 || z < 0 || x >= s->nx || y >= s->ny || z >= s->nz) return -1;
return (y*s->nz+z)*s->nx+x;
}
static void sample(Scan *s, Vec p, Vec n, float w)
{
int i = cell_index(s,p);
if (i < 0) { s->clipped++; return; }
Cell *c = &s->cells[i];
/* Cap repeated views' influence without throwing away later measurements. */
float scale = c->weight > MAX_SURFEL_WEIGHT ? MAX_SURFEL_WEIGHT/c->weight : 1;
c->p = add(mul(c->p,scale),mul(p,w));
c->n = add(mul(c->n,scale),mul(n,w));
c->weight = c->weight*scale+w;
if(c->last_frame!=s->fuse_frame) { c->last_frame=s->fuse_frame;c->views++; }
}
static void triangle(Scan *s, Vec a, Vec b, Vec c, Vec eye, Vec patch_normal, int fuse)
{
Vec abv = sub(b,a), ac = sub(c,a), bc = sub(c,b);
float longest = root(mx(dot(abv,abv),mx(dot(ac,ac),dot(bc,bc))));
Vec p = mul(add(a,add(b,c)),1.f/3), n = cross(abv,ac);
float twice_area = root(dot(n,n));
Vec view = sub(eye,p);
float distance = root(dot(view,view));
if (twice_area < MIN_TRIANGLE_TWICE_AREA || longest > s->opt.max_edge || distance > s->opt.range || distance < MIN_RANGE) {
if (!fuse) s->rejected++;
return;
}
n = mul(n,1/twice_area);
if(dot(patch_normal,patch_normal)>.5f) n=patch_normal;
float facing = dot(n,view)/distance;
if (ab(facing) < s->opt.grazing) { if (!fuse) s->rejected++; return; }
if (facing < 0) n = mul(n,-1);
if (!fuse) {
s->accepted++;
s->seen++;
int slot = s->seen <= MAX_SAMPLES ? (int)s->seen-1 : (int)(random_next(s)%s->seen);
if (slot < MAX_SAMPLES) s->samples[slot] = p;
s->nsamples = (int)mn((float)s->seen,MAX_SAMPLES);
} else {
int steps = (int)(longest/(s->opt.voxel*SAMPLE_SPACING_VOXELS))+1;
float weight = twice_area*facing*facing / ((steps+1)*(steps+2)*.5f);
for (int j = 0; j <= steps; j++) for (int k = 0; k <= steps-j; k++) {
Vec q = add(a,add(mul(abv,(float)j/steps),mul(ac,(float)k/steps)));
sample(s,q,n,weight);
}
}
}
static Vec patch_normal(Vec *points, float *depths, int width, int x0, int y0, int x1, int y1,
float tolerance)
{
Vec mean=vec(0,0,0);int count=0;
/* Shift before accumulating the covariance: room positions can be large relative to patch residuals. */
Vec origin=points[y0*width+x0];
for(int y=y0;y<y1;y++) for(int x=x0;x<x1;x++) if(depths[y*width+x]>0) {
mean=add(mean,sub(points[y*width+x],origin));count++;
}
if(count<MIN_PATCH_POINTS) return vec(0,0,0);
mean=mul(mean,1.f/count);
float cov[3][3]={{0}},basis[3][3]={{1,0,0},{0,1,0},{0,0,1}};
for(int y=y0;y<y1;y++) for(int x=x0;x<x1;x++) if(depths[y*width+x]>0) {
Vec p=sub(sub(points[y*width+x],origin),mean);float a[3]={p.x,p.y,p.z};
for(int i=0;i<3;i++) for(int j=0;j<3;j++) cov[i][j]+=a[i]*a[j];
}
for(int sweep=0;sweep<8;sweep++) {
int p=0,q=1;
if(ab(cov[0][2])>ab(cov[p][q])) { p=0;q=2; }
if(ab(cov[1][2])>ab(cov[p][q])) { p=1;q=2; }
if(ab(cov[p][q])<1e-10f) break;
float phi=.5f*angle(2*cov[p][q],cov[q][q]-cov[p][p]),co=cosine(phi),si=sine(phi);
float aa=cov[p][p],bb=cov[q][q],abv=cov[p][q];
for(int k=0;k<3;k++) if(k!=p && k!=q) {
float a=cov[k][p],b=cov[k][q];
cov[k][p]=cov[p][k]=co*a-si*b;cov[k][q]=cov[q][k]=si*a+co*b;
}
cov[p][p]=co*co*aa-2*si*co*abv+si*si*bb;
cov[q][q]=si*si*aa+2*si*co*abv+co*co*bb;cov[p][q]=cov[q][p]=0;
for(int k=0;k<3;k++) {
float a=basis[k][p],b=basis[k][q];basis[k][p]=co*a-si*b;basis[k][q]=si*a+co*b;
}
}
int least=0;
for(int i=1;i<3;i++) if(cov[i][i]<cov[least][least]) least=i;
if(cov[least][least]/count>tolerance*tolerance) return vec(0,0,0);
return unit(vec(basis[0][least],basis[1][least],basis[2][least]));
}
static int grid_normals(Scan *s, Vec *points, float *depths, Vec *normals, u32 nv, u32 ni, const u8 *indices)
{
if(ni<6 || u32le(indices)!=0 || u32le(indices+8)!=1) return 0;
u32 width=u32le(indices+4);
if(width<3 || width>nv || nv%width || ni!=6*(width-1)*(nv/width-1)) return 0;
/* Only a complete regular fan uses patch normals. Other meshes retain ordinary triangle normals. */
u32 at=0;
for(u32 row=0;row+1<nv/width;row++) for(u32 col=0;col+1<width;col++) {
u32 a=row*width+col,expected[6]={a,a+width,a+1,a+1,a+width,a+width+1};
for(int k=0;k<6;k++) if(u32le(indices+4*at++)!=expected[k]) return 0;
}
int height=(int)(nv/width);
for(int y=0;y<height;y+=NORMAL_PATCH_SIZE) for(int x=0;x<(int)width;x+=NORMAL_PATCH_SIZE) {
int x1=(int)mn(x+NORMAL_PATCH_SIZE,(float)width),y1=(int)mn(y+NORMAL_PATCH_SIZE,height);
Vec n=patch_normal(points,depths,(int)width,x,y,x1,y1,s->opt.patch_residual);
for(int yy=y;yy<y1;yy++) for(int xx=x;xx<x1;xx++) normals[yy*width+xx]=n;
}
return 1;
}
const char *read_dump(Scan *s, int fuse)
{
int fd = io_open(s->opt.input,0);
if (fd < 0) return "cannot open scan dump";
u8 header[44];
const char *error = 0;
Vec *vertices = 0,*normals=0;
float *depths = 0;
u8 *indices = 0;
usize vs = 0, is = 0, ds = 0;
if (exact(fd,header,8) != 1 || header[0]!='F' || header[1]!='P' || header[2]!='R' || header[3]!='S' ||
u32le(header+4) != 1) { error = "invalid FPRS header"; goto done; }
unsigned frames = 0;
for (;;) {
int r = exact(fd,header,44);
if (r == 0) break;
if (r < 0) { error = "truncated frame header"; break; }
/* Tracking can start before poses are available. Consume the entire bounded record before skipping,
* so one NaN cannot hide a truncated payload or desynchronize the next frame. */
int invalid=(u32le(header+4)&0x7ff00000u) == 0x7ff00000u;
float pose[7];
for (int i = 0; i < 7; i++) {
pose[i] = f32le(header+8+i*4);
if (!finite(pose[i]) || ab(pose[i]) > MAX_COORDINATE) invalid=1;
}
float norm = 0;
for (int i = 3; i < 7; i++) norm += pose[i]*pose[i];
if (!finite(norm) || ab(norm-1) > QUATERNION_NORM_TOLERANCE) invalid=1;
if(!invalid) for (int i = 3; i < 7; i++) pose[i] /= root(norm);
u32 nv = u32le(header+36), ni = u32le(header+40);
if (nv > MAX_FRAME_VERTICES || ni > MAX_FRAME_INDICES || ni%3 || (!nv && ni)) {
error = "invalid frame counts"; break;
}
usize needv = (usize)nv*12, needi = (usize)ni*4;
if (needv > vs) {
io_free(vertices,vs);io_free(normals,vs);vs=needv;vertices=io_alloc(vs);normals=io_alloc(vs);
}
if (needi > is) { io_free(indices,is); is=needi; indices=io_alloc(is); }
if((usize)nv*4>ds) { io_free(depths,ds);ds=(usize)nv*4;depths=io_alloc(ds); }
if ((nv && (!vertices || !depths || !normals)) || (ni && !indices)) { error = "frame allocation failed"; break; }
if ((needv && exact(fd,vertices,needv)!=1) || (needi && exact(fd,indices,needi)!=1)) {
error = "truncated frame data"; break;
}
Vec eye = vec(pose[0],pose[1],pose[2]), q = vec(pose[3],pose[4],pose[5]);
u64 far=0;
for (u32 i = 0; i < nv; i++) {
u8 *p = (u8 *)&vertices[i];
Vec v = vec(f32le(p),f32le(p+4),f32le(p+8));
if (!finite(v.x) || !finite(v.y) || !finite(v.z) ||
ab(v.x)>MAX_COORDINATE || ab(v.y)>MAX_COORDINATE || ab(v.z)>MAX_COORDINATE) {
invalid=1;break;
}
depths[i]=root(dot(v,v));
if(depths[i]>s->opt.range || depths[i]<MIN_RANGE) { depths[i]=-1;far++;continue; }
Vec t = mul(cross(q,v),2);
vertices[i] = add(eye,add(v,add(mul(t,pose[6]),cross(q,t))));
}
int regular=!invalid && grid_normals(s,vertices,depths,normals,nv,ni,indices);
/* Structural errors are fatal even in a frame whose tracking/depth is unusable. */
s->fuse_frame=frames+1;
for (u32 i = 0; i < ni; i+=3) {
u32 a=u32le(indices+4*i), b=u32le(indices+4*(i+1)), c=u32le(indices+4*(i+2));
if (a>=nv || b>=nv || c>=nv) { error = "index outside vertex array"; break; }
if(invalid) continue;
if(depths[a]<0 || depths[b]<0 || depths[c]<0) { if(!fuse) s->rejected++;continue; }
float near=mn(depths[a],mn(depths[b],depths[c])),far_depth=mx(depths[a],mx(depths[b],depths[c]));
if(far_depth-near>s->opt.depth_jump+s->opt.depth_ratio*near) {
if(!fuse) { s->rejected++;s->discontinuities++; }continue;
}
Vec normal=regular ? normals[a] : vec(0,0,0);
if(regular && dot(normal,normal)<.5f) { if(!fuse) s->rejected++;continue; }
triangle(s,vertices[a],vertices[b],vertices[c],eye,normal,fuse);
}
if (error) break;
if(!fuse) { if(invalid) s->skipped_frames++;else s->far_vertices+=far; }
frames++;
}
if (!error && !frames) error = "scan contains no frames";
if (!error && !fuse) s->frames = frames;
done:
io_free(vertices,vs);
io_free(normals,vs);
io_free(indices,is);
io_free(depths,ds);
io_close(fd);
return error;
}
static float median(Scan *s, int axis)
{
/* In-place quickselect on the bounded reservoir; no dump-sized allocations. */
int lo=0, hi=s->nsamples-1, mid=s->nsamples/2;
while (lo < hi) {
float pivot = ((float *)&s->samples[(lo+hi)/2])[axis];
int i=lo,j=hi;
while (i<=j) {
while (((float *)&s->samples[i])[axis] < pivot) i++;
while (((float *)&s->samples[j])[axis] > pivot) j--;
if (i<=j) { Vec t=s->samples[i]; s->samples[i++]=s->samples[j]; s->samples[j--]=t; }
}
if (mid<=j) hi=j;
else if (mid>=i) lo=i;
else break;
}
return ((float *)&s->samples[mid])[axis];
}
const char *init_grid(Scan *s)
{
if (!s->nsamples) return "scan contains no usable triangles";
float x=median(s,0), y=median(s,1), z=median(s,2), v=s->opt.voxel;
Vec low=s->samples[0],high=low;
for(int i=1;i<s->nsamples;i++) {
Vec p=s->samples[i];
low=vec(mn(low.x,p.x),mn(low.y,p.y),mn(low.z,p.z));
high=vec(mx(high.x,p.x),mx(high.y,p.y),mx(high.z,p.z));
}
float pad=v*mx(GRID_PADDING_VOXELS,s->opt.truncation+2);
low=sub(low,vec(pad,pad,pad));high=add(high,vec(pad,pad,pad));
s->origin=vec(mx(flo(low.x/v)*v,flo((x-MAX_GRID_XZ/2)/v)*v),
mx(flo(low.y/v)*v,flo((y-MAX_GRID_Y/2)/v)*v),
mx(flo(low.z/v)*v,flo((z-MAX_GRID_XZ/2)/v)*v));
s->nx=(int)mn(-flo(-(high.x-s->origin.x)/v)+1,(int)(MAX_GRID_XZ/v)+1);
s->ny=(int)mn(-flo(-(high.y-s->origin.y)/v)+1,(int)(MAX_GRID_Y/v)+1);
s->nz=(int)mn(-flo(-(high.z-s->origin.z)/v)+1,(int)(MAX_GRID_XZ/v)+1);
usize count = (usize)s->nx*s->ny*s->nz;
if (count > MAX_CELLS) return "voxel grid exceeds memory budget; increase --voxel";
s->count = (int)count;
s->cells = io_alloc(count*sizeof(Cell));
return s->cells ? 0 : "grid allocation failed";
}
void finish_fusion(Scan *s)
{
/* A short look-around cannot expose each surface in many frames. Cap the target at ten percent of usable
* views (at least one); long recordings still require repeated evidence rather than transient depth. */
unsigned required=(unsigned)mn(s->opt.min_views,mx(1,(s->frames-s->skipped_frames)/10.f));
for (int i=0; i<s->count; i++) {
Cell *c=&s->cells[i];
if(c->views<required) { c->weight=0;continue; }
if (c->weight) { c->p=mul(c->p,1/c->weight); c->n=unit(c->n); }
}
if (s->clipped) warn(s,"scan clipped to 8 x 8 x 4 metre grid");
if (s->frames < FEW_FRAMES) warn(s,"few frames; scan more viewpoints");
if(s->skipped_frames) warn(s,"non-finite or invalid frames skipped");
if(s->far_vertices) warn(s,"out-of-range depth vertices skipped");
if (s->accepted < s->rejected) warn(s,"many triangles rejected; check depth quality");
}
-227
View File
@@ -1,227 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
typedef struct { char data[65536]; usize n; int ok; } Text;
static void text(Text *t, const char *p)
{
while(*p) {
if(t->n>=sizeof(t->data)) { t->ok=0;return; }
t->data[t->n++]=*p++;
}
}
static void integer(Text *t, u64 value)
{
char rev[24];int n=0;
do { rev[n++]=(char)('0'+value%10);value/=10; } while(value);
while(n) { char a[2]={rev[--n],0};text(t,a); }
}
static void decimal(Text *t, float value)
{
if(value<0) { text(t,"-");value=-value; }
u64 scaled=(u64)(value*10000.f+.5f);
integer(t,scaled/10000);text(t,".");
u64 fraction=scaled%10000;
if(fraction<1000) text(t,"0");
if(fraction<100) text(t,"0");
if(fraction<10) text(t,"0");
integer(t,fraction);
}
static void space_number(Text *t, float n) { text(t," ");decimal(t,n); }
static int uuid(Text *t)
{
u8 bytes[16];if(!io_random(bytes,sizeof(bytes))) return 0;
bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;
const char *hex="0123456789abcdef";
for(int i=0;i<16;i++) { char pair[3]={hex[bytes[i]>>4],hex[bytes[i]&15],0};text(t,pair); }
return 1;
}
static int record(Text *t, const char *name)
{
text(t,name);text(t," ");return uuid(t);
}
static int path(char *out, const char *dir, const char *name)
{
usize n=0;
while(*dir) { if(n>=3900) return 0;out[n++]=*dir++; }
if(n && out[n-1]!='/') out[n++]='/';
while(*name) { if(n>=4095) return 0;out[n++]=*name++; }
out[n]=0;return 1;
}
static void le32(u8 *out,u32 v) { for(int i=0;i<4;i++) out[i]=(u8)(v>>(8*i)); }
static const char *write_files(Scan *s)
{
Text t={.ok=1};text(&t,"frameport-room 1\nuniverse ");integer(&t,s->opt.universe);
text(&t,"\nsource scan\ntime ");integer(&t,(u64)(io_time(0)/1000000000));text(&t,"\n");
if(!record(&t,"room")) return "getrandom failed";
text(&t,"\n");if(!record(&t,"floor")) return "getrandom failed";
space_number(&t,s->floor);text(&t,"\n");if(!record(&t,"ceiling")) return "getrandom failed";
space_number(&t,s->ceiling);text(&t,"\noutline ");integer(&t,(u64)s->noutline);
for(int i=0;i<s->noutline;i++) { space_number(&t,s->outline[i].x);space_number(&t,s->outline[i].z); }
text(&t,"\n");
for(int i=0;i<s->noutline;i++) {
Pt a=s->outline[i],b=s->outline[(i+1)%s->noutline];
if(!record(&t,"wall")) return "getrandom failed";
space_number(&t,a.x);space_number(&t,a.z);space_number(&t,b.x);space_number(&t,b.z);
space_number(&t,s->floor);space_number(&t,s->ceiling);
if(s->invisible[i]) text(&t," INVISIBLE_WALL_FACE");
text(&t,"\n");
}
for(int i=0;i<s->nvolumes;i++) {
Volume *v=&s->volumes[i];
if(!record(&t,"volume")) return "getrandom failed";
text(&t," ");text(&t,v->label);space_number(&t,v->p.x);space_number(&t,v->p.z);
space_number(&t,v->p.y);space_number(&t,v->yaw);space_number(&t,v->w);space_number(&t,v->d);space_number(&t,v->h);
text(&t,"\n");
}
if(!record(&t,"mesh")) return "getrandom failed";
text(&t," room-mesh.bin ");integer(&t,(u64)s->nv);text(&t," ");integer(&t,(u64)s->ni);text(&t,"\n");
if(!t.ok) return "room text exceeds output budget";
Text rname={.ok=1},mname={.ok=1};
text(&rname,"room.txt.");text(&mname,"room-mesh.bin.");
if(!uuid(&rname) || !uuid(&mname)) return "getrandom failed";
text(&rname,".tmp");text(&mname,".tmp");rname.data[rname.n]=0;mname.data[mname.n]=0;
char room[4096],mesh[4096],rtmp[4096],mtmp[4096];
if(!path(room,s->opt.out,"room.txt") || !path(mesh,s->opt.out,"room-mesh.bin") ||
!path(rtmp,s->opt.out,rname.data) || !path(mtmp,s->opt.out,mname.data)) return "output path too long";
if(!io_mkdir(s->opt.out)) return "cannot create output directory";
int fd=io_open(rtmp,1),ok;
if(fd<0) return "cannot open room temporary file";
ok=io_write(fd,t.data,t.n);if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);return "cannot write room temporary file"; }
fd=io_open(mtmp,1);
if(fd<0) { io_remove(rtmp);return "cannot open mesh temporary file"; }
u8 header[16]={'F','P','R','M'};le32(header+4,1);le32(header+8,(u32)s->nv);le32(header+12,(u32)s->ni);
ok=io_write(fd,header,16);
/* Both supported targets are little-endian IEEE float32; parser always decodes explicitly. */
ok=ok && io_write(fd,s->vertices,(usize)s->nv*sizeof(Vec));
ok=ok && io_write(fd,s->indices,(usize)s->ni*sizeof(u32));
if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);io_remove(mtmp);return "cannot write mesh temporary file"; }
if(io_rename(mtmp,mesh)) { io_remove(rtmp);io_remove(mtmp);return "cannot publish mesh"; }
if(io_rename(rtmp,room)) { io_remove(rtmp);return "cannot publish room"; }
return 0;
}
static int error_json(const char *error)
{
Text t={.ok=1};text(&t,"{\"ok\":false,\"error\":\"");text(&t,error);text(&t,"\"}\n");
io_write(1,t.data,t.n);return 2;
}
static int parse_float(const char *p,float *out)
{
int digits=0;float n=0,scale=1;
while(*p>='0' && *p<='9') { n=n*10+*p++-'0';digits++;if(n>10000) return 0; }
if(*p=='.') { p++;while(*p>='0' && *p<='9') { scale*=.1f;n+=(*p++-'0')*scale;digits++; } }
if(!digits || *p || !finite(n)) return 0;
*out=n;return 1;
}
static int parse_universe(const char *p,u64 *out)
{
u64 n=0;if(!*p) return 0;
for(;*p;p++) {
if(*p<'0' || *p>'9') return 0;
unsigned digit=(unsigned)(*p-'0');
if(n>(~(u64)0-digit)/10) return 0;
n=n*10+digit;
}
*out=n;return 1;
}
static const char *options(Options *o,int argc,char **argv)
{
*o=(Options){.voxel=.05f,.ceiling_default=2.5f,.truncation=3,.max_edge=.6f,.grazing=.12f,.range=4.f,
.depth_jump=.25f,.depth_ratio=.12f,.min_views=5,.patch_residual=.03f,
.plane_distance=.045f,.min_plane_area=.45f,.wall_height=1.5f,.snap_degrees=5,.floor_band=.35f,
.ceiling_min=1.8f,.min_volume=.05f,.cluster_gap=.10f,.gap=.65f,.horizontal=.85f};
for(int i=1;i<argc;i++) {
const char *arg=argv[i];
if(same(arg,"--json")) continue;
if(i+1==argc) return "option requires a value";
const char *value=argv[++i];
if(same(arg,"--offline")) o->input=value;
else if(same(arg,"--out")) o->out=value;
else if(same(arg,"--hint")) o->hint=value;
else if(same(arg,"--universe")) { if(!parse_universe(value,&o->universe)) return "invalid universe"; }
else {
float *dest=0,low=0,high=100;
if(same(arg,"--voxel")) { dest=&o->voxel;low=.04f;high=.2f; }
else if(same(arg,"--ceiling-default")) { dest=&o->ceiling_default;low=1.8f;high=3.8f; }
else if(same(arg,"--truncation")) { dest=&o->truncation;low=1;high=5; }
else if(same(arg,"--max-edge")) { dest=&o->max_edge;low=.05f;high=1.5f; }
else if(same(arg,"--grazing")) { dest=&o->grazing;low=.01f;high=.9f; }
else if(same(arg,"--range")) { dest=&o->range;low=1;high=10; }
else if(same(arg,"--depth-jump")) { dest=&o->depth_jump;low=.02f;high=1; }
else if(same(arg,"--depth-ratio")) { dest=&o->depth_ratio;high=.5f; }
else if(same(arg,"--min-views")) { dest=&o->min_views;low=1;high=16; }
else if(same(arg,"--patch-residual")) { dest=&o->patch_residual;low=.005f;high=.1f; }
else if(same(arg,"--plane-distance")) { dest=&o->plane_distance;low=.01f;high=.15f; }
else if(same(arg,"--min-plane-area")) { dest=&o->min_plane_area;low=.1f;high=4; }
else if(same(arg,"--wall-height")) { dest=&o->wall_height;low=.5f;high=3; }
else if(same(arg,"--snap-degrees")) { dest=&o->snap_degrees;high=15; }
else if(same(arg,"--floor-band")) { dest=&o->floor_band;low=.05f;high=1; }
else if(same(arg,"--ceiling-min")) { dest=&o->ceiling_min;low=1.5f;high=3; }
else if(same(arg,"--min-volume")) { dest=&o->min_volume;low=.005f;high=1; }
else if(same(arg,"--cluster-gap")) { dest=&o->cluster_gap;low=.04f;high=.2f; }
else if(same(arg,"--gap")) { dest=&o->gap;low=.1f;high=1.5f; }
else if(same(arg,"--horizontal")) { dest=&o->horizontal;low=.6f;high=.99f; }
else return "unknown option";
if(!parse_float(value,dest) || *dest<low || *dest>high) return "option outside supported range";
}
}
return o->input && o->out ? 0 : "required: --offline dump.fprs --out directory";
}
static void duration(Text *t,i64 a,i64 b) { decimal(t,(float)(b-a)/1000000); }
static void summary(Scan *s)
{
Text t={.ok=1};text(&t,"{\"ok\":true,\"frames\":");integer(&t,s->frames);
text(&t,",\"skipped_frames\":");integer(&t,s->skipped_frames);
text(&t,",\"far_vertices\":");integer(&t,s->far_vertices);
text(&t,",\"discontinuity_triangles\":");integer(&t,s->discontinuities);
text(&t,",\"walls\":");integer(&t,(u64)s->noutline);
text(&t,",\"detected_walls\":");integer(&t,(u64)s->nplanes);
text(&t,",\"volumes\":");integer(&t,(u64)s->nvolumes);
text(&t,",\"floor_y\":");decimal(&t,s->floor);text(&t,",\"ceiling_y\":");decimal(&t,s->ceiling);
text(&t,",\"coverage_percent\":");decimal(&t,s->coverage);
text(&t,",\"hint_used\":");text(&t,s->hint_used ? "true" : "false");
text(&t,",\"triangles\":");integer(&t,(u64)s->ni/3);
text(&t,",\"accepted_triangles\":");integer(&t,s->accepted);
text(&t,",\"rejected_triangles\":");integer(&t,s->rejected);
text(&t,",\"warnings\":[");
for(int i=0;i<s->nwarnings;i++) { if(i) text(&t,",");text(&t,"\"");text(&t,s->warnings[i]);text(&t,"\""); }
text(&t,"],\"timings_ms\":{\"read\":");duration(&t,s->t0,s->tread);
text(&t,",\"fusion\":");duration(&t,s->tread,s->tfuse);
text(&t,",\"model\":");duration(&t,s->tfuse,s->tmodel);
text(&t,",\"mesh\":");duration(&t,s->tmodel,s->tmesh);
text(&t,",\"total\":");duration(&t,s->t0,io_time(1));text(&t,"}}\n");io_write(1,t.data,t.n);
}
int main(int argc,char **argv)
{
if(argc==2 && same(argv[1],"--help")) {
const char usage[]="fp_roomscan --offline dump.fprs --out directory [--universe N] [--voxel 0.05]\n"
" [--ceiling-default 2.5] [--hint outline.txt] [--json]\n"
"Tuning: --truncation --max-edge --grazing --range --depth-jump --depth-ratio --plane-distance --min-plane-area\n"
" --min-views --patch-residual --wall-height --snap-degrees --floor-band --ceiling-min\n"
" --min-volume --cluster-gap --gap --horizontal\n"
"Always prints one JSON summary; errors exit 2. See docs/mr/scan.md for units and ranges.\n";
io_write(1,usage,sizeof(usage)-1);return 0;
}
Scan *s=io_alloc(sizeof(Scan));
if(!s) return error_json("scan allocation failed");
s->rng=0x714ce3u;s->t0=io_time(1);
const char *error=options(&s->opt,argc,argv);
if(!error) error=read_dump(s,0);
s->tread=io_time(1);
if(!error) error=init_grid(s);
if(!error) error=read_dump(s,1);
if(!error) finish_fusion(s);
s->tfuse=io_time(1);
if(!error) error=make_model(s);
s->tmodel=io_time(1);
if(!error) error=make_mesh(s);
s->tmesh=io_time(1);
if(!error) error=write_files(s);
int result=0;
if(error) result=error_json(error);else summary(s);
io_free(s->cells,(usize)s->count*sizeof(Cell));
io_free(s->vertices,(usize)MAX_MESH_VERTICES*sizeof(Vec));
io_free(s->indices,(usize)MAX_TRIANGLES*3*sizeof(u32));
io_free(s,sizeof(Scan));return result;
}
-112
View File
@@ -1,112 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define TANGENT_RADIUS_SQUARED 2.25f
#define TANGENT_WEIGHT_RADIUS_SQUARED 2.5f
#define MAX_EXTRACTION_STRIDE 4
/* Marching cubes without a 256-case table: trace the zero contour on the six faces, then fan each closed loop.
* Ambiguous faces choose their diagonal from the face centre. Adjacent cubes therefore make the same choice. */
static const int corners[8][3]={{0,0,0},{1,0,0},{1,0,1},{0,0,1},{0,1,0},{1,1,0},{1,1,1},{0,1,1}};
static const int edges[12][2]={{0,1},{1,2},{2,3},{3,0},{4,5},{5,6},{6,7},{7,4},{0,4},{1,5},{2,6},{3,7}};
static const int faces[6][4]={{0,1,2,3},{4,5,6,7},{0,9,4,8},{1,10,5,9},{2,11,6,10},{3,8,7,11}};
static void tsdf(Scan *s)
{
float v=s->opt.voxel,trunc=v*s->opt.truncation;
int radius=(int)s->opt.truncation+2;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if (!c->weight) continue;
int x=i%s->nx,z=(i/s->nx)%s->nz,y=i/(s->nx*s->nz);
for (int dy=-radius;dy<=radius;dy++) for (int dz=-radius;dz<=radius;dz++) for (int dx=-radius;dx<=radius;dx++) {
int xx=x+dx,yy=y+dy,zz=z+dz;
if (xx<0 || yy<0 || zz<0 || xx>=s->nx || yy>=s->ny || zz>=s->nz) continue;
Vec delta=sub(grid_point(s,xx,yy,zz),c->p);
float d=dot(delta,c->n),tangent=dot(delta,delta)-d*d;
if (ab(d)>trunc || tangent>v*v*TANGENT_RADIUS_SQUARED) continue;
Cell *dest=&s->cells[(yy*s->nz+zz)*s->nx+xx];
float w=(1-tangent/(v*v*TANGENT_WEIGHT_RADIUS_SQUARED))*mn(c->weight/(v*v),1.f);
dest->sdf+=d*w;dest->sw+=w;
}
}
for (int i=0;i<s->count;i++) if(s->cells[i].sw) s->cells[i].sdf/=s->cells[i].sw;
}
static void connect(int links[12][2], int a, int b)
{
if(links[a][0]<0) links[a][0]=b;else links[a][1]=b;
if(links[b][0]<0) links[b][0]=a;else links[b][1]=a;
}
static int cube(Scan *s, int x, int y, int z, int stride)
{
float f[8];Vec p[8],normal=vec(0,0,0);
for(int k=0;k<8;k++) {
int xx=x+corners[k][0]*stride,yy=y+corners[k][1]*stride,zz=z+corners[k][2]*stride;
Cell *c=&s->cells[(yy*s->nz+zz)*s->nx+xx];
if (c->sw<=0) return 1; /* Unknown space never creates an invented surface. */
f[k]=c->sdf;p[k]=grid_point(s,xx,yy,zz);
}
Vec cuts[12];int crossing[12],links[12][2],visited[12]={0};
for(int e=0;e<12;e++) {
int a=edges[e][0],b=edges[e][1];
crossing[e]=(f[a]<0)!=(f[b]<0);links[e][0]=links[e][1]=-1;
if(crossing[e]) cuts[e]=add(p[a],mul(sub(p[b],p[a]),f[a]/(f[a]-f[b])));
}
/* Gradient points to positive TSDF: toward the observed free room, including furniture's exterior. */
normal.x=f[1]+f[2]+f[5]+f[6]-f[0]-f[3]-f[4]-f[7];
normal.y=f[4]+f[5]+f[6]+f[7]-f[0]-f[1]-f[2]-f[3];
normal.z=f[2]+f[3]+f[6]+f[7]-f[0]-f[1]-f[4]-f[5];
for(int face=0;face<6;face++) {
int hit[4],n=0;float centre=0;
for(int j=0;j<4;j++) {
int e=faces[face][j];
if(crossing[e]) hit[n++]=e;
centre+=f[edges[e][0]]+f[edges[e][1]];
}
if(n==2) connect(links,hit[0],hit[1]);
else if(n==4) {
/* The shared first edge's first corner fixes the phase on this face. */
int first=edges[hit[0]][0],second=edges[hit[0]][1];
int common=(edges[hit[1]][0]==second || edges[hit[1]][1]==second) ? second : first;
if((centre<0)==(f[common]<0)) {
connect(links,hit[0],hit[3]);connect(links,hit[1],hit[2]);
} else { connect(links,hit[0],hit[1]);connect(links,hit[2],hit[3]); }
}
}
for(int e=0;e<12;e++) if(crossing[e] && !visited[e]) {
int loop[12],n=0,prev=-1,cur=e;
while(cur>=0 && !visited[cur] && n<12) {
visited[cur]=1;loop[n++]=cur;
int next=links[cur][0]==prev ? links[cur][1] : links[cur][0];prev=cur;cur=next;
}
if(cur!=e) continue;
for(int k=1;k+1<n;k++) {
Vec a=cuts[loop[0]],b=cuts[loop[k]],c=cuts[loop[k+1]];
Vec norm=cross(sub(b,a),sub(c,a));
if(dot(norm,norm)<1e-14f) continue;
if(s->ni/3>=MAX_TRIANGLES) return 0;
if(dot(norm,normal)<0) { Vec t=b;b=c;c=t; }
s->vertices[s->nv]=a;s->vertices[s->nv+1]=b;s->vertices[s->nv+2]=c;
for(int j=0;j<3;j++) s->indices[s->ni++]=(u32)s->nv++;
}
}
return 1;
}
const char *make_mesh(Scan *s)
{
s->vertices=io_alloc((usize)MAX_MESH_VERTICES*sizeof(Vec));
s->indices=io_alloc((usize)MAX_TRIANGLES*3*sizeof(u32));
if(!s->vertices || !s->indices) return "mesh allocation failed";
tsdf(s);
for(int stride=1;stride<=MAX_EXTRACTION_STRIDE;stride++) {
s->nv=s->ni=0;int ok=1;
for(int y=0;y+stride<s->ny && ok;y+=stride)
for(int z=0;z+stride<s->nz && ok;z+=stride)
for(int x=0;x+stride<s->nx && ok;x+=stride) ok=cube(s,x,y,z,stride);
if(ok) {
if(stride>1) warn(s,"mesh decimated by coarsening extraction grid");
if(!s->ni) warn(s,"no mesh surface; scan denser views");
return 0;
}
}
return "mesh exceeds triangle budget";
}
-672
View File
@@ -1,672 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define RANSAC_TRIALS 96
#define NORMAL_AGREEMENT .90f
#define MIN_WALL_SPAN .60f
#define FURNITURE_MARGIN .12f
#define NEAR_WALL .35f
#define MIN_TOP_AREA .25f
#define BOX_TRIM .02f
#define VERTICAL_NORMAL_Y .35f
#define BOX_SIDE_NORMAL_Y .30f
#define BOX_EDGE_STRENGTH .40f
#define MIN_CLUSTER_SURFELS 12
#define MIN_BOX_EDGE_VOTES 12
#define MIN_OUTLINE_EXTENT 1.f
#define MIN_OUTLINE_TWICE_AREA .5f
#define FLOOR_INFER_DISTANCE .3f
#define TABLE_MIN_HEIGHT .55f
#define TABLE_MAX_HEIGHT 1.1f
#define COUCH_MIN_HEIGHT .35f
#define COUCH_MAX_HEIGHT .9f
#define COUCH_MIN_LENGTH 1.4f
#define BED_MIN_HEIGHT .3f
#define BED_MAX_HEIGHT .8f
#define BED_MIN_AREA 2.f
#define STORAGE_MIN_HEIGHT 1.1f
#define SCREEN_MAX_DEPTH .22f
#define SCREEN_MIN_WIDTH .4f
#define SCREEN_MAX_WIDTH 2.f
#define DEFAULT_CIRCLE_MIN_POINTS 8
#define DEFAULT_CIRCLE_RADIAL_CV .05f
#define LOW_COVERAGE_PERCENT 80.f
#define WALL_MERGE_DISTANCE .25f
#define WALL_MERGE_COSINE .97f
#define RELATIVE_WALL_AREA .20f
#define DOMINANT_WALL_TOLERANCE 12.f
#define ORTHOGONAL_SUPPORT_FRACTION .65f
#define CEILING_MIN_AREA .08f /* m^2 of fused down-facing cells at one height (fused surfaces are sparse: the
* owner's floor was 0.7 m^2 of cells, the ceiling 0.11 m^2 after a 2 s look up) */
#define CEILING_MIN_HEIGHT 2.0f /* m above the floor: lower ones are shelves, door frames, table undersides */
#define CEILING_MAX_HEIGHT 4.0f
#define BOX_AXIS_COS .96f /* walls within ~16 deg of the two room axes (smeared depth tilted a real
* wall 12 deg in the owner's scan) */
#define BOX_SNAP .5f /* m: a rectangle side snaps to a detected wall this close */
#define BOX_LOW_SHARE .02f /* surfaces beyond the 2nd/98th percentile along an axis are outliers */
#define WALL_BAND_TOP 2.4f /* m above the floor: walls are fitted below this (and below the ceiling) */
#define MIN_FURNITURE_TOP .25f
#define WALL_FURNITURE_CLEARANCE .15f
#define MIN_TOP_DENSITY .30f
static void consolidate_walls(Scan *s)
{
if(!s->nplanes) return;
Plane result[MAX_PLANES]={0};int count=0;
float largest=0;
for(int i=0;i<s->nplanes;i++) {
Plane p=s->planes[i];int merge=-1;
largest=mx(largest,p.area);
for(int j=0;j<count;j++) if(dot(p.n,result[j].n)>WALL_MERGE_COSINE &&
ab(p.d-result[j].d)<WALL_MERGE_DISTANCE) { merge=j;break; }
if(merge<0) result[count++]=p;
else {
Plane *r=&result[merge];
r->area+=p.area;r->bottom=mn(r->bottom,p.bottom);r->top=mx(r->top,p.top);
r->lo=mn(r->lo,p.lo);r->hi=mx(r->hi,p.hi);
}
}
float base=angle(result[0].n.z,result[0].n.x),aligned=0,total=0;
for(int i=0;i<count;i++) {
Plane *p=&result[i];float a=angle(p->n.z,p->n.x)-base;
float delta=ab(a-flo(a/(PI/2)+.5f)*(PI/2));
total+=p->area;
if(delta<DOMINANT_WALL_TOLERANCE*PI/180) aligned+=p->area;
}
int orthogonal=aligned>total*ORTHOGONAL_SUPPORT_FRACTION && s->opt.snap_degrees>0;
s->nplanes=0;
for(int i=0;i<count;i++) {
Plane p=result[i];float a=angle(p.n.z,p.n.x)-base;
float delta=ab(a-flo(a/(PI/2)+.5f)*(PI/2));
if(p.area<largest*RELATIVE_WALL_AREA) continue;
if(orthogonal && delta>DOMINANT_WALL_TOLERANCE*PI/180) continue;
s->planes[s->nplanes++]=p;
}
}
static float plane_height(Scan *s, int ceiling, int *found)
{
float hist[512]={0}, step=s->opt.voxel*.5f;
int bins=(int)(4/step)+2;
if (bins>512) bins=512;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if (!c->weight || (ceiling ? c->n.y > -s->opt.horizontal : c->n.y < s->opt.horizontal)) continue;
if (ceiling ? c->p.y<s->opt.ceiling_min : ab(c->p.y)>s->opt.floor_band) continue;
int b=flo((c->p.y-s->origin.y)/step);
if (b>=0 && b<bins) hist[b]+=1;
}
int best=0;
float score=0;
for (int b=1;b<bins-1;b++) {
float h=hist[b-1]+hist[b]+hist[b+1];
if (h>score) { score=h; best=b; }
}
float y=s->origin.y+(best+.5f)*step, total=0, sum=0,upward=0;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if(c->weight && c->n.y>s->opt.horizontal) upward++;
if (!c->weight || ab(c->p.y-y)>s->opt.plane_distance) continue;
if (ceiling ? c->n.y > -s->opt.horizontal : c->n.y < s->opt.horizontal) continue;
total++; sum+=c->p.y;
}
*found=(ceiling || score* s->opt.voxel*s->opt.voxel >= s->opt.min_plane_area) && total>0;
/* A ceiling is judged on its own area and height, not against the floor: players glance up for a second and look
* at the floor all the time, so a share-of-upward-surfaces rule rejected every real ceiling (owner's scans,
* 2026-10-16: down-facing surfaces at ~2.9 m in both recordings, "ceiling not seen"). */
(void)upward;
if(ceiling && (total*s->opt.voxel*s->opt.voxel<CEILING_MIN_AREA || y-s->floor<CEILING_MIN_HEIGHT ||
y-s->floor>CEILING_MAX_HEIGHT)) *found=0;
return *found ? sum/total : (ceiling ? s->floor+s->opt.ceiling_default : 0);
}
static int on_plane(Scan *s, Cell *c, Plane *p)
{
return ab(dot(c->p,p->n)-p->d)<s->opt.plane_distance && dot(c->n,p->n)>NORMAL_AGREEMENT;
}
static void refine(Scan *s, int *ids, int n, Plane *p)
{
Vec avg=vec(0,0,0), normal=vec(0,0,0);
float count=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) { avg=add(avg,c->p); normal=add(normal,c->n); count++; }
}
if (!count) return;
avg=mul(avg,1/count);
/* Fit positions, rather than averaging noisy per-triangle normals. */
float xx=0,zz=0,xz=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) {
float x=c->p.x-avg.x,z=c->p.z-avg.z;
xx+=x*x; zz+=z*z; xz+=x*z;
}
}
float yaw=.5f*angle(2*xz,xx-zz);
Vec nn=vec(-sine(yaw),0,cosine(yaw));
if (dot(nn,normal)<0) nn=mul(nn,-1);
p->n=nn; p->d=dot(nn,avg);
p->lo=1e10f; p->hi=-1e10f; p->bottom=1e10f; p->top=-1e10f;
count=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) {
float t=c->p.x*p->n.z-c->p.z*p->n.x;
p->lo=mn(p->lo,t); p->hi=mx(p->hi,t);
p->bottom=mn(p->bottom,c->p.y); p->top=mx(p->top,c->p.y); count++;
}
}
p->area=count*s->opt.voxel*s->opt.voxel;
}
static void walls(Scan *s, int *ids, int n)
{
/* Walls are fitted in a fixed band above the floor whatever the ceiling: Valve's smoothed depth bends where wall
* meets ceiling, and with a detected 2.9 m ceiling that band confused the fit (owner's scan, 2026-10-16). */
float top=mn(s->ceiling,s->floor+WALL_BAND_TOP)-FURNITURE_MARGIN;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
c->mark=ab(c->n.y)>VERTICAL_NORMAL_Y || c->p.y<s->floor+FURNITURE_MARGIN || c->p.y>top;
}
for (int attempt=0;attempt<MAX_PLANES*2 && s->nplanes<MAX_PLANES;attempt++) {
Plane best={0}; int best_count=0;
for (int trial=0;trial<RANSAC_TRIALS;trial++) {
Cell *seed=&s->cells[ids[random_next(s)%(unsigned)n]];
if (seed->mark) continue;
Plane p={0}; p.n=unit(vec(seed->n.x,0,seed->n.z)); p.d=dot(seed->p,p.n);
int count=0;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (!c->mark && on_plane(s,c,&p)) count++;
}
if (count>best_count) { best_count=count; best=p; }
}
if (best_count*s->opt.voxel*s->opt.voxel<s->opt.min_plane_area) break;
refine(s,ids,n,&best);
/* Height separates large furniture faces from room walls. */
int wall=best.area>=s->opt.min_plane_area && best.top-best.bottom>=s->opt.wall_height &&
best.hi-best.lo>=MIN_WALL_SPAN;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (!c->mark && on_plane(s,c,&best)) c->mark=wall ? 2 : 3;
}
if (wall) s->planes[s->nplanes++]=best;
/* Rejected candidates are consumed too, ensuring bounded progress. */
}
if (s->nplanes==MAX_PLANES) warn(s,"wall limit reached");
if (s->nplanes) {
float base=angle(s->planes[0].n.z,s->planes[0].n.x);
for (int k=0;k<s->nplanes;k++) {
Plane *p=&s->planes[k];
float a=angle(p->n.z,p->n.x), diff=a-base;
int quarter=flo(diff/(PI/2)+.5f);
float target=base+quarter*(PI/2);
if (ab(a-target)<s->opt.snap_degrees*PI/180) {
Vec nn=vec(cosine(target),0,sine(target));
float sum=0,count=0,lo=1e10f,hi=-1e10f;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (on_plane(s,c,p)) {
sum+=dot(c->p,nn); count++;
float t=c->p.x*nn.z-c->p.z*nn.x;
lo=mn(lo,t); hi=mx(hi,t);
}
}
if (count) { p->n=nn; p->d=sum/count; p->lo=lo; p->hi=hi; }
}
}
}
consolidate_walls(s);
}
static Pt endpoint(Plane *p, int end)
{
float t=end ? p->hi : p->lo;
return (Pt){p->n.x*p->d+p->n.z*t,p->n.z*p->d-p->n.x*t};
}
static float distance2(Pt a, Pt b) { float x=a.x-b.x,z=a.z-b.z; return x*x+z*z; }
static int intersect(Plane *a, Plane *b, Pt *p)
{
float det=a->n.x*b->n.z-a->n.z*b->n.x;
if (ab(det)<.1f) return 0;
p->x=(a->d*b->n.z-a->n.z*b->d)/det;
p->z=(a->n.x*b->d-a->d*b->n.x)/det;
return 1;
}
static void push_outline(Scan *s, Pt p, int invisible)
{
if (s->noutline && distance2(s->outline[s->noutline-1],p)<.0001f) return;
if (s->noutline<MAX_OUTLINE) {
s->outline[s->noutline]=p;
s->invisible[s->noutline++]=invisible;
}
}
static void rectangle(Scan *s, int *ids, int n)
{
float x0=1e10f,x1=-1e10f,z0=1e10f,z1=-1e10f;
for (int i=0;i<n;i++) {
Vec p=s->cells[ids[i]].p;
x0=mn(x0,p.x);x1=mx(x1,p.x);z0=mn(z0,p.z);z1=mx(z1,p.z);
}
if (x1-x0<MIN_OUTLINE_EXTENT) { float m=(x0+x1)*.5f; x0=m-MIN_OUTLINE_EXTENT/2; x1=m+MIN_OUTLINE_EXTENT/2; }
if (z1-z0<MIN_OUTLINE_EXTENT) { float m=(z0+z1)*.5f; z0=m-MIN_OUTLINE_EXTENT/2; z1=m+MIN_OUTLINE_EXTENT/2; }
push_outline(s,(Pt){x0,z0},1);push_outline(s,(Pt){x1,z0},1);
push_outline(s,(Pt){x1,z1},1);push_outline(s,(Pt){x0,z1},1);
warn(s,"incomplete walls; synthesized outline");
}
static int segment_cross(Pt a, Pt b, Pt c, Pt d)
{
float p=(b.x-a.x)*(c.z-a.z)-(b.z-a.z)*(c.x-a.x);
float q=(b.x-a.x)*(d.z-a.z)-(b.z-a.z)*(d.x-a.x);
float r=(d.x-c.x)*(a.z-c.z)-(d.z-c.z)*(a.x-c.x);
float t=(d.x-c.x)*(b.z-c.z)-(d.z-c.z)*(b.x-c.x);
return p*q<-.000001f && r*t<-.000001f;
}
static int valid_polygon(Scan *s)
{
float area=0;
for (int i=0;i<s->noutline;i++) {
Pt a=s->outline[i], b=s->outline[(i+1)%s->noutline];
area+=a.x*b.z-b.x*a.z;
for (int j=i+2;j<s->noutline;j++) {
if ((j+1)%s->noutline==i) continue;
if (segment_cross(a,b,s->outline[j],s->outline[(j+1)%s->noutline])) return 0;
}
}
return s->noutline>=3 && area>MIN_OUTLINE_TWICE_AREA;
}
static int convex_fallback(Scan *s)
{
if(s->nplanes<3 || s->nplanes>4) return 0;
/* A concave multi-wall room must use directed segments. A small set of enclosure planes can instead be
* clipped as inward half-planes when smeared endpoints prevent reliable corner ordering. */
Pt a[MAX_OUTLINE],b[MAX_OUTLINE];float x0=1e10f,x1=-1e10f,z0=1e10f,z1=-1e10f;
for(int i=0;i<s->nplanes;i++) for(int end=0;end<2;end++) {
Pt p=endpoint(&s->planes[i],end);x0=mn(x0,p.x);x1=mx(x1,p.x);z0=mn(z0,p.z);z1=mx(z1,p.z);
}
int n=4;a[0]=(Pt){x0,z0};a[1]=(Pt){x1,z0};a[2]=(Pt){x1,z1};a[3]=(Pt){x0,z1};
for(int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];int count=0;
for(int j=0;j<n;j++) {
Pt u=a[j],v=a[(j+1)%n];float du=p->n.x*u.x+p->n.z*u.z-p->d,dv=p->n.x*v.x+p->n.z*v.z-p->d;
if(du>=0) b[count++]=u;
if((du>=0)!=(dv>=0)) {
float t=du/(du-dv);b[count++]=(Pt){u.x+t*(v.x-u.x),u.z+t*(v.z-u.z)};
}
}
if(count<3 || count>=MAX_OUTLINE) return 0;
n=count;memcpy(a,b,(usize)n*sizeof(Pt));
}
s->noutline=n;
for(int j=0;j<n;j++) {
s->outline[j]=a[j];s->invisible[j]=1;Pt v=a[(j+1)%n];
for(int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];
if(ab(p->n.x*a[j].x+p->n.z*a[j].z-p->d)<s->opt.plane_distance &&
ab(p->n.x*v.x+p->n.z*v.z-p->d)<s->opt.plane_distance) s->invisible[j]=0;
}
}
if(!valid_polygon(s)) { s->noutline=0;return 0; }
warn(s,"wall endpoints uncertain; using plane intersections");return 1;
}
/* Value below which `share` of the projections fall (2 cm bins over +-8 m around the data). */
static float projection_percentile(Scan *s, int *ids, int n, Pt axis, float share)
{
static u32 hist[800];
memset(hist,0,sizeof(hist));
for (int i=0;i<n;i++) {
Vec p=s->cells[ids[i]].p;
int b=(int)((p.x*axis.x+p.z*axis.z+8)/.02f);
if (b>=0 && b<800) hist[b]++;
}
u64 total=0,run=0;
for (int b=0;b<800;b++) total+=hist[b];
for (int b=0;b<800;b++) { run+=hist[b]; if (run>=share*total) return b*.02f-8+.01f; }
return 8;
}
/* Most rooms are rectangles. When every detected wall runs along one of two perpendicular axes (2-4 walls), the
* outline is that rectangle: its sides at the 2nd/98th percentile of the surfaces along each axis, each snapped to a
* detected wall when one lies within BOX_SNAP. Directed wall chaining made a skewed quadrilateral of the owner's
* room from three smeared walls (2026-10-16). L-shaped rooms have more walls and keep the chaining below. */
static int orthogonal_box(Scan *s, int *ids, int n)
{
if (s->nplanes<2 || s->nplanes>4) return 0;
Vec u=s->planes[0].n; float len=root(u.x*u.x+u.z*u.z);
if (len<.5f) return 0;
Pt axis[2]={{u.x/len,u.z/len},{-u.z/len,u.x/len}};
float side[2][2]; int wall[2][2]={{0,0},{0,0}}, snapped=0;
for (int k=0;k<2;k++) {
side[k][0]=projection_percentile(s,ids,n,axis[k],BOX_LOW_SHARE);
side[k][1]=projection_percentile(s,ids,n,axis[k],1-BOX_LOW_SHARE);
}
for (int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];
float c0=p->n.x*axis[0].x+p->n.z*axis[0].z, c1=p->n.x*axis[1].x+p->n.z*axis[1].z;
int k=ab(c0)>=BOX_AXIS_COS ? 0 : ab(c1)>=BOX_AXIS_COS ? 1 : -1;
if (k<0) return 0; // a wall off both axes: not a rectangle
float offset=p->d*(k ? c1 : c0)/ab(k ? c1 : c0);
int end=ab(offset-side[k][0])<ab(offset-side[k][1]) ? 0 : 1;
if (ab(offset-side[k][end])<BOX_SNAP) { side[k][end]=offset; snapped+=!wall[k][end]; wall[k][end]=1; }
}
if (snapped<2 || side[0][1]-side[0][0]<MIN_OUTLINE_EXTENT || side[1][1]-side[1][0]<MIN_OUTLINE_EXTENT) return 0;
/* corners (s,t) -> world x = a0.x s + a1.x t, z = a0.z s + a1.z t; edge flags = whether its side is a wall */
int order[4][2]={{0,0},{1,0},{1,1},{0,1}}, edge[4][2]={{1,0},{0,1},{1,1},{0,0}}; // edge k lies on side[a][e]
float twice=0;
Pt c[4];
for (int j=0;j<4;j++) {
float a=side[0][order[j][0]], b=side[1][order[j][1]];
c[j]=(Pt){axis[0].x*a+axis[1].x*b, axis[0].z*a+axis[1].z*b};
}
for (int j=0;j<4;j++) twice+=c[j].x*c[(j+1)%4].z-c[(j+1)%4].x*c[j].z;
s->noutline=0;
if (twice>0) for (int j=0;j<4;j++) push_outline(s,c[j],!wall[edge[j][0]][edge[j][1]]);
else for (int j=0;j<4;j++) { // reversed: corner 0,3,2,1; edge from corner k to k-1 is the old edge k-1
int k=(4-j)%4, e=(k+3)%4;
push_outline(s,c[k],!wall[edge[e][0]][edge[e][1]]);
}
if (!valid_polygon(s)) { s->noutline=0; return 0; }
if (snapped<4) warn(s,"gaps closed with invisible walls");
return 1;
}
static void outline(Scan *s, int *ids, int n)
{
if (orthogonal_box(s,ids,n)) return;
if (s->nplanes<3) { rectangle(s,ids,n); return; }
int order[MAX_PLANES]={0};
s->planes[0].used=1;
for (int k=1;k<s->nplanes;k++) {
Pt end=endpoint(&s->planes[order[k-1]],1);
float best=1e10f; int which=-1;
for (int j=0;j<s->nplanes;j++) if (!s->planes[j].used) {
float d=distance2(end,endpoint(&s->planes[j],0));
if (d<best) { best=d;which=j; }
}
order[k]=which;s->planes[which].used=1;
}
Pt starts[MAX_PLANES], ends[MAX_PLANES];
for (int k=0;k<s->nplanes;k++) {
Plane *p=&s->planes[order[k]], *next=&s->planes[order[(k+1)%s->nplanes]];
ends[k]=endpoint(p,1); starts[(k+1)%s->nplanes]=endpoint(next,0);
Pt corner;
if (intersect(p,next,&corner) && distance2(corner,ends[k])<s->opt.gap*s->opt.gap &&
distance2(corner,starts[(k+1)%s->nplanes])<s->opt.gap*s->opt.gap)
ends[k]=starts[(k+1)%s->nplanes]=corner;
}
for (int k=0;k<s->nplanes;k++) {
push_outline(s,starts[k],0);
if (distance2(ends[k],starts[(k+1)%s->nplanes])>.0001f) {
push_outline(s,ends[k],1);warn(s,"gaps closed with invisible walls");
}
}
if (!valid_polygon(s)) {
s->noutline=0;
if(!convex_fallback(s)) { rectangle(s,ids,n);warn(s,"wall ordering ambiguous"); }
}
}
static float wall_distance(Scan *s, Vec p)
{
float best=1e10f;
for (int i=0;i<s->nplanes;i++) best=mn(best,ab(dot(s->planes[i].n,p)-s->planes[i].d));
return best;
}
static void trimmed_bounds(Scan *s, int *queue, int n, Vec axis, float *lo, float *hi)
{
int hist[512]={0};float start=*lo,range=*hi-start;
if(range<.001f) return;
for(int i=0;i<n;i++) {
float value=dot(s->cells[queue[i]].p,axis);
int bin=(int)clamp((value-start)/range*511,0,511);hist[bin]++;
}
int count=0,target=(int)(n*BOX_TRIM);
for(int b=0;b<512;b++) { count+=hist[b];if(count>target) { *lo=start+range*b/511;break; } }
count=0;
for(int b=511;b>=0;b--) { count+=hist[b];if(count>target) { *hi=start+range*(b+1)/511;break; } }
}
static float top_height(Scan *s, int *queue, int n, float low, float high)
{
int histogram[512]={0},bins=(int)mn((high-low)/(s->opt.voxel*.25f)+2,512);
float step=s->opt.voxel*.25f;
for(int i=0;i<n;i++) {
int bin=flo((s->cells[queue[i]].p.y-low)/step);
if(bin>=0 && bin<bins) histogram[bin]++;
}
int count=0;
for(int bin=0;bin<bins;bin++) {
count+=histogram[bin];
/* The median resists a softened lip without selecting one of several noisy sub-peaks as a new top. */
if(count>=n/2) return low+(bin+.5f)*step;
}
return (low+high)*.5f;
}
static void furniture(Scan *s, int *ids, int n, int *queue)
{
/* Tops remain separable when a softened sofa/table edge blends into its neighbouring wall. Vertical-only
* components are considered separately for screens; they cannot turn a whole wall into an OTHER volume. */
for(int mode=0;mode<2;mode++) {
float histogram[256]={0},levels[32];int nlevels=0;
if(!mode) {
for(int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if(c->n.y>s->opt.horizontal && c->p.y>s->floor+MIN_FURNITURE_TOP &&
c->p.y<s->ceiling-FURNITURE_MARGIN && wall_distance(s,c->p)>WALL_FURNITURE_CLEARANCE) {
int bin=flo((c->p.y-s->floor)/s->opt.voxel);
if(bin>=0 && bin<256) histogram[bin]++;
}
}
for(int k=0;k<32;k++) {
int best=0;float score=0;
for(int bin=1;bin<255;bin++) {
float count=histogram[bin-1]+histogram[bin]+histogram[bin+1];
if(count>score) { score=count;best=bin; }
}
if(score*s->opt.voxel*s->opt.voxel<MIN_TOP_AREA) break;
levels[nlevels++]=s->floor+(best+.5f)*s->opt.voxel;
int radius=(int)(s->opt.plane_distance*2/s->opt.voxel)+1;
for(int bin=best-radius;bin<=best+radius;bin++) if(bin>=0 && bin<256) histogram[bin]=0;
}
}
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
int normal=mode ? ab(c->n.y)<BOX_SIDE_NORMAL_Y : c->n.y>s->opt.horizontal;
c->mark=(normal && c->p.y>s->floor+MIN_FURNITURE_TOP && c->p.y<s->ceiling-FURNITURE_MARGIN &&
wall_distance(s,c->p)>WALL_FURNITURE_CLEARANCE) ? 1 : 0;
if(c->mark && !mode) {
c->mark=0;float best=s->opt.plane_distance*2;
for(int k=0;k<nlevels;k++) if(ab(c->p.y-levels[k])<best) {
best=ab(c->p.y-levels[k]);c->mark=k+1;
}
}
}
int radius=(int)(s->opt.cluster_gap/s->opt.voxel)+1;
for (int j=0;j<n;j++) {
int seed=ids[j];
if (s->cells[seed].mark<=0) continue;
int group=s->cells[seed].mark;
int head=0,tail=1;queue[0]=seed;s->cells[seed].mark=-1;
while (head<tail) {
int id=queue[head++],x=id%s->nx,z=(id/s->nx)%s->nz,y=id/(s->nx*s->nz);
for (int dy=-radius;dy<=radius;dy++) for (int dz=-radius;dz<=radius;dz++) for (int dx=-radius;dx<=radius;dx++) {
if (dx*dx+dy*dy+dz*dz>radius*radius) continue;
int xx=x+dx,yy=y+dy,zz=z+dz;
if (xx<0 || yy<0 || zz<0 || xx>=s->nx || yy>=s->ny || zz>=s->nz) continue;
int next=(yy*s->nz+zz)*s->nx+xx;
Cell *c=&s->cells[next];
if (c->mark==group) { c->mark=-1;queue[tail++]=next; }
}
}
if (tail<MIN_CLUSTER_SURFELS) continue;
Vec avg=vec(0,0,0);float top=-1e10f,bottom=1e10f;
for (int k=0;k<tail;k++) {
Vec p=s->cells[queue[k]].p;avg=add(avg,p);top=mx(top,p.y);bottom=mn(bottom,p.y);
}
avg=mul(avg,1.f/tail);
float xx=0,zz=0,xz=0,edge_x=0,edge_y=0,edge_count=0;
for (int k=0;k<tail;k++) {
Cell *c=&s->cells[queue[k]];
Vec p=sub(c->p,avg);xx+=p.x*p.x;zz+=p.z*p.z;xz+=p.x*p.z;
if(ab(c->n.y)<BOX_SIDE_NORMAL_Y) {
Vec norm=unit(vec(c->n.x,0,c->n.z));
float co2=norm.x*norm.x-norm.z*norm.z,si2=2*norm.x*norm.z;
edge_x+=co2*co2-si2*si2;edge_y+=2*co2*si2;edge_count++;
}
}
float yaw=.5f*angle(2*xz,xx-zz);
/* Opposing faces and perpendicular edges vote for the same fourth harmonic. Unlike PCA, this is not
* biased by one side being more visible. Non-box objects fall back to position covariance. */
if(edge_count>MIN_BOX_EDGE_VOTES && root(edge_x*edge_x+edge_y*edge_y)>edge_count*BOX_EDGE_STRENGTH)
yaw=.25f*angle(edge_y,edge_x);
if(!mode) {
float smallest=1e10f;
for(int degree=0;degree<90;degree++) {
float a=degree*PI/180,co=cosine(a),si=sine(a),u0=1e10f,u1=-1e10f,v0=1e10f,v1=-1e10f;
for(int k=0;k<tail;k++) {
Vec p=s->cells[queue[k]].p;float u=p.x*co+p.z*si,v=-p.x*si+p.z*co;
u0=mn(u0,u);u1=mx(u1,u);v0=mn(v0,v);v1=mx(v1,v);
}
float area=(u1-u0)*(v1-v0);
if(area<smallest) { smallest=area;yaw=a; }
}
}
float co=cosine(yaw),si=sine(yaw);
float u0=1e10f,u1=-1e10f,v0=1e10f,v1=-1e10f,top_count=0;
for (int k=0;k<tail;k++) {
Cell *c=&s->cells[queue[k]];Vec p=sub(c->p,avg);
float u=p.x*co+p.z*si,v=-p.x*si+p.z*co;
u0=mn(u0,u);u1=mx(u1,u);v0=mn(v0,v);v1=mx(v1,v);
if (c->n.y>s->opt.horizontal && c->p.y>top-s->opt.plane_distance*2) top_count++;
}
/* Stereo discontinuities leave a low-density halo even after long-edge rejection. Trim the outer
* two percent before measuring boxes; otherwise a table can become wide enough to look like a couch. */
float up=dot(avg,vec(co,0,si)),vp=dot(avg,vec(-si,0,co));
u0+=up;u1+=up;v0+=vp;v1+=vp;
trimmed_bounds(s,queue,tail,vec(co,0,si),&u0,&u1);
trimmed_bounds(s,queue,tail,vec(-si,0,co),&v0,&v1);
u0-=up;u1-=up;v0-=vp;v1-=vp;
trimmed_bounds(s,queue,tail,vec(0,1,0),&bottom,&top);
if(!mode) top=top_height(s,queue,tail,bottom,top);
float w=u1-u0,d=v1-v0,h=top-(bottom<s->floor+FLOOR_INFER_DISTANCE ? s->floor : mn(bottom,s->floor+FURNITURE_MARGIN));
if(d>w) { float t=w;w=d;d=t;yaw+=PI/2; }
while(yaw>PI/2) yaw-=PI;
if(!mode) h=top-s->floor;
if (w*d*h<s->opt.min_volume) continue;
float height=top-s->floor,area=w*d,top_area=(mode ? top_count : tail)*s->opt.voxel*s->opt.voxel;
if(!mode && (top_area<MIN_TOP_AREA || top_area<area*MIN_TOP_DENSITY)) continue;
const char *label="OTHER";
if (height>=BED_MIN_HEIGHT && height<=BED_MAX_HEIGHT && area>=BED_MIN_AREA) label="BED";
else if (height>=COUCH_MIN_HEIGHT && height<=COUCH_MAX_HEIGHT && mx(w,d)+2*s->opt.voxel>=COUCH_MIN_LENGTH) label="COUCH";
else if (height>=TABLE_MIN_HEIGHT && height<=TABLE_MAX_HEIGHT && top_area>=MIN_TOP_AREA) label="TABLE";
else if (mn(w,d)<SCREEN_MAX_DEPTH && mx(w,d)>=SCREEN_MIN_WIDTH && mx(w,d)<=SCREEN_MAX_WIDTH &&
wall_distance(s,avg)<NEAR_WALL) label="SCREEN";
else if (h>=STORAGE_MIN_HEIGHT && wall_distance(s,avg)<NEAR_WALL) label="STORAGE";
if(mode && !same(label,"SCREEN")) continue;
if(!mode && same(label,"OTHER") && height>TABLE_MAX_HEIGHT) continue;
if (s->nvolumes>=MAX_VOLUMES) { warn(s,"furniture limit reached");break; }
Vec centre=vec(avg.x+co*(u0+u1)*.5f-si*(v0+v1)*.5f,top,
avg.z+si*(u0+u1)*.5f+co*(v0+v1)*.5f);
/* ROOM_FORMAT yaw is an active right-handed rotation about +Y. */
s->volumes[s->nvolumes++]=(Volume){centre,-yaw*180/PI,w,d,h,label};
}
}
}
/* A hint is a fallback for missing walls, never a constraint on observed geometry. */
static const char *hint(Scan *s);
const char *make_model(Scan *s)
{
int found;
s->floor=plane_height(s,0,&found);
if (!found) warn(s,"floor not seen; using STAGE floor");
s->ceiling=plane_height(s,1,&found);
if (!found) warn(s,"ceiling not seen");
usize bytes=(usize)s->count*sizeof(int);
int *ids=io_alloc(bytes),*queue=io_alloc(bytes);
if (!ids || !queue) { io_free(ids,bytes);io_free(queue,bytes);return "model allocation failed"; }
int n=0;
for (int i=0;i<s->count;i++) if (s->cells[i].weight) ids[n++]=i;
if (!n) { io_free(ids,bytes);io_free(queue,bytes);return "no surfaces in reconstruction bounds"; }
walls(s,ids,n);outline(s,ids,n);
const char *error=hint(s);
if (!error) furniture(s,ids,n,queue);
float perimeter=0,observed=0;
for (int i=0;i<s->noutline;i++) {
float d=root(distance2(s->outline[i],s->outline[(i+1)%s->noutline]));perimeter+=d;
if (!s->invisible[i]) observed+=d;
}
s->coverage=perimeter ? 100*observed/perimeter : 0;
if (s->coverage<LOW_COVERAGE_PERCENT) warn(s,"low wall coverage; scan all walls");
io_free(ids,bytes);io_free(queue,bytes);
return error;
}
static int number(char **cursor, float *value)
{
char *p=*cursor;while (*p==' ' || *p=='\t' || *p=='\r') p++;
int sign=1,digits=0;if (*p=='-' || *p=='+') { if (*p=='-') sign=-1;p++; }
float v=0,scale=1;
while (*p>='0' && *p<='9') { v=v*10+*p++-'0';digits++;if(v>10000) return 0; }
if (*p=='.') { p++;while (*p>='0' && *p<='9') { scale*=.1f;v+=(*p++-'0')*scale;digits++; } }
if(*p=='e' || *p=='E') {
p++;int exponent=0,negative=0,edigits=0;
if(*p=='-' || *p=='+') { negative=*p=='-';p++; }
while(*p>='0' && *p<='9') { exponent=exponent*10+*p++-'0';edigits++;if(exponent>38) return 0; }
if(!edigits) return 0;
while(exponent--) v=negative ? v*.1f : v*10;
}
if (!digits || !finite(v) || v>10000) return 0;
*value=sign*v;*cursor=p;return 1;
}
static const char *hint(Scan *s)
{
if (!s->opt.hint) return 0;
int fd=io_open(s->opt.hint,0);
if (fd<0) return "cannot open outline hint";
char data[8192];usize size=0;
for (;;) {
long r=io_read(fd,data+size,sizeof(data)-1-size);
if (r<0 || (r>0 && size+(usize)r==sizeof(data)-1)) { io_close(fd);return "outline hint too large or unreadable"; }
if (!r) break;
size+=(usize)r;
}
io_close(fd);data[size]=0;
Pt pts[MAX_OUTLINE];int n=0;char *p=data;
while (*p) {
while (*p==' ' || *p=='\t' || *p=='\r' || *p=='\n') p++;
if (!*p) break;
if (*p=='#') { while (*p && *p!='\n') p++;continue; }
if (n==MAX_OUTLINE || !number(&p,&pts[n].x) || !number(&p,&pts[n].z)) return "invalid outline hint";
n++;while (*p==' ' || *p=='\t' || *p=='\r') p++;
if (*p && *p!='\n' && *p!='#') return "invalid outline hint";
if (*p=='#') while (*p && *p!='\n') p++;
}
if (n<3) return "outline hint needs at least three points";
Pt centre={0};for (int i=0;i<n;i++) { centre.x+=pts[i].x/n;centre.z+=pts[i].z/n; }
float mean=0,variance=0;
for (int i=0;i<n;i++) mean+=root(distance2(pts[i],centre))/n;
for (int i=0;i<n;i++) { float r=root(distance2(pts[i],centre))-mean;variance+=r*r/n; }
if (n>=DEFAULT_CIRCLE_MIN_POINTS && root(variance)<mean*DEFAULT_CIRCLE_RADIAL_CV) {
warn(s,"default circle hint ignored");return 0;
}
int missing=0;for(int i=0;i<s->noutline;i++) missing+=s->invisible[i];
if (!missing) return 0;
Pt saved[MAX_OUTLINE];int inv[MAX_OUTLINE],old=s->noutline;
memcpy(saved,s->outline,sizeof(saved));memcpy(inv,s->invisible,sizeof(inv));
float area=0;for(int i=0;i<n;i++) area+=pts[i].x*pts[(i+1)%n].z-pts[(i+1)%n].x*pts[i].z;
s->noutline=n;
for(int i=0;i<n;i++) { s->outline[i]=pts[area>0 ? i : n-1-i];s->invisible[i]=1; }
if (!valid_polygon(s)) {
memcpy(s->outline,saved,sizeof(saved));memcpy(s->invisible,inv,sizeof(inv));s->noutline=old;
return "invalid outline hint polygon";
}
/* Only use a hint compatible with every observed wall. Chaperone can be an inset play area. */
for(int i=0;i<s->nplanes;i++) {
Plane *w=&s->planes[i];int match=0;
for(int k=0;k<n;k++) {
Pt a=s->outline[k],b=s->outline[(k+1)%n];
if(ab(a.x*w->n.x+a.z*w->n.z-w->d)<s->opt.gap &&
ab(b.x*w->n.x+b.z*w->n.z-w->d)<s->opt.gap) { match=1;s->invisible[k]=0; }
}
if (!match) {
memcpy(s->outline,saved,sizeof(saved));memcpy(s->invisible,inv,sizeof(inv));s->noutline=old;
warn(s,"hint disagrees with observed walls; ignored");return 0;
}
}
s->hint_used=1;warn(s,"incomplete walls closed using outline hint");return 0;
}
-145
View File
@@ -1,145 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "platform.h"
#ifdef FP_HOSTED
#define _DEFAULT_SOURCE 1
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/random.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
int io_open(const char *p, int w) { return open(p, w ? O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW : O_RDONLY, 0600); }
long io_read(int fd, void *p, usize n)
{
long r;
do r = read(fd, p, n); while (r < 0 && errno == EINTR);
return r;
}
static long put(int fd, const void *p, usize n)
{
long r;
do r = write(fd, p, n); while (r < 0 && errno == EINTR);
return r;
}
int io_close(int fd) { return close(fd); }
int io_rename(const char *a, const char *b) { return rename(a, b); }
void io_remove(const char *p) { unlink(p); }
int io_mkdir(const char *p) { return mkdir(p, 0700) == 0 || errno == EEXIST; }
void *io_alloc(usize n)
{
void *p = mmap(0, n, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
return p == MAP_FAILED ? 0 : p;
}
void io_free(void *p, usize n) { if (p) munmap(p, n); }
int io_random(void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = getrandom((u8 *)p + done, n - done, 0);
if (r < 0 && errno == EINTR) continue;
if (r <= 0) return 0;
done += (usize)r;
}
return 1;
}
i64 io_time(int mono)
{
struct timespec t;
if (clock_gettime(mono ? CLOCK_MONOTONIC : CLOCK_REALTIME, &t)) return 0;
return (i64)t.tv_sec * 1000000000 + t.tv_nsec;
}
#else
/* asm-generic Linux syscall numbers: this target has no libc or UAPI dependency. */
static long call(long n, long a, long b, long c, long d, long e, long f)
{
register long x8 __asm__("x8") = n;
register long x0 __asm__("x0") = a;
register long x1 __asm__("x1") = b;
register long x2 __asm__("x2") = c;
register long x3 __asm__("x3") = d;
register long x4 __asm__("x4") = e;
register long x5 __asm__("x5") = f;
__asm__ volatile("svc #0" : "+r"(x0) : "r"(x8), "r"(x1), "r"(x2), "r"(x3), "r"(x4), "r"(x5) : "memory", "cc");
return x0;
}
int io_open(const char *p, int w) { return (int)call(56, -100, (long)p, w ? 1|64|128|131072 : 0, 0600, 0, 0); }
long io_read(int fd, void *p, usize n)
{
long r;
do r = call(63, fd, (long)p, (long)n, 0, 0, 0); while (r == -4);
return r;
}
static long put(int fd, const void *p, usize n)
{
long r;
do r = call(64, fd, (long)p, (long)n, 0, 0, 0); while (r == -4);
return r;
}
int io_close(int fd) { return (int)call(57, fd, 0, 0, 0, 0, 0); }
int io_rename(const char *a, const char *b) { return (int)call(38, -100, (long)a, -100, (long)b, 0, 0); }
void io_remove(const char *p) { call(35, -100, (long)p, 0, 0, 0, 0); }
int io_mkdir(const char *p)
{
long r = call(34, -100, (long)p, 0700, 0, 0, 0);
return r == 0 || r == -17;
}
void *io_alloc(usize n)
{
long p = call(222, 0, (long)n, 3, 0x22, -1, 0);
return (unsigned long)p > (unsigned long)-4096 ? 0 : (void *)p;
}
void io_free(void *p, usize n) { if (p) call(215, (long)p, (long)n, 0, 0, 0, 0); }
int io_random(void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = call(278, (long)((u8 *)p + done), (long)(n - done), 0, 0, 0, 0);
if (r == -4) continue;
if (r <= 0) return 0;
done += (usize)r;
}
return 1;
}
i64 io_time(int mono)
{
struct { long sec, ns; } t = {0, 0};
call(113, mono ? 1 : 0, (long)&t, 0, 0, 0, 0);
return (i64)t.sec * 1000000000 + t.ns;
}
void *memcpy(void *d, const void *s, usize n)
{
u8 *a = d;
const u8 *b = s;
while (n--) *a++ = *b++;
return d;
}
void *memset(void *d, int c, usize n)
{
u8 *a = d;
while (n--) *a++ = (u8)c;
return d;
}
extern int main(int argc, char **argv);
__attribute__((used, noreturn)) void start_c(long *sp)
{
int code = main((int)sp[0], (char **)(sp + 1));
for (;;) call(94, code, 0, 0, 0, 0, 0);
}
__asm__(".text\n.global _start\n.type _start,%function\n_start:\n"
"mov x29,#0\nmov x30,#0\nmov x0,sp\nbl start_c\nbrk #0\n");
#endif
int io_write(int fd, const void *p, usize n)
{
while (n) {
long r = put(fd, p, n);
if (r <= 0) return 0;
p = (const u8 *)p + r;
n -= (usize)r;
}
return 1;
}
-25
View File
@@ -1,25 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef FP_ROOMSCAN_PLATFORM_H
#define FP_ROOMSCAN_PLATFORM_H
/* The reconstruction sources use only this boundary, on Linux and in hosted tests. */
typedef __SIZE_TYPE__ usize;
typedef unsigned char u8;
typedef unsigned int u32;
typedef unsigned long long u64;
typedef long long i64;
int io_open(const char *path, int write);
long io_read(int fd, void *data, usize size);
int io_write(int fd, const void *data, usize size);
int io_close(int fd);
int io_rename(const char *old, const char *new);
void io_remove(const char *path);
int io_mkdir(const char *path);
void *io_alloc(usize size);
void io_free(void *data, usize size);
int io_random(void *data, usize size);
i64 io_time(int monotonic);
void *memcpy(void *dst, const void *src, usize size);
void *memset(void *dst, int value, usize size);
#endif
-93
View File
@@ -1,93 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef FP_ROOMSCAN_H
#define FP_ROOMSCAN_H
#include "platform.h"
/* Resource limits are independent of dump length. Two passes avoid retaining frames. */
#define MAX_FRAME_VERTICES 1000000u
#define MAX_FRAME_INDICES 3000000u
#define MAX_CELLS 4200000u
#define MAX_SAMPLES 32768
#define MAX_PLANES 32
#define MAX_OUTLINE 96
#define MAX_VOLUMES 128
#define MAX_TRIANGLES 200000
#define MAX_MESH_VERTICES 600000
#define PI 3.14159265358979323846f
typedef struct { float x, y, z; } Vec;
typedef struct { float x, z; } Pt;
typedef struct {
Vec p, n;
float weight, sdf, sw;
int mark;
unsigned views, last_frame;
} Cell;
typedef struct {
Vec n;
float d, lo, hi, bottom, top, area;
int used;
} Plane;
typedef struct { Vec p; float yaw, w, d, h; const char *label; } Volume;
typedef struct {
const char *input, *out, *hint;
u64 universe;
float voxel, ceiling_default, truncation, max_edge, grazing, range, depth_jump, depth_ratio, min_views;
float patch_residual;
float plane_distance, min_plane_area, wall_height, snap_degrees;
float floor_band, ceiling_min, min_volume, cluster_gap, gap, horizontal;
} Options;
typedef struct {
Options opt;
Cell *cells;
int nx, ny, nz, count;
Vec origin;
Vec samples[MAX_SAMPLES];
int nsamples;
u64 seen, accepted, rejected, clipped, far_vertices, discontinuities;
unsigned frames, skipped_frames, fuse_frame;
Plane planes[MAX_PLANES];
int nplanes;
Pt outline[MAX_OUTLINE];
int invisible[MAX_OUTLINE], noutline;
Volume volumes[MAX_VOLUMES];
int nvolumes;
float floor, ceiling, coverage;
const char *warnings[24];
int nwarnings, hint_used;
u32 rng;
Vec *vertices;
u32 *indices;
int nv, ni;
i64 t0, tread, tfuse, tmodel, tmesh;
} Scan;
static inline float ab(float x) { return x < 0 ? -x : x; }
static inline float mn(float a, float b) { return a < b ? a : b; }
static inline float mx(float a, float b) { return a > b ? a : b; }
static inline float clamp(float x, float a, float b) { return mn(mx(x, a), b); }
static inline Vec vec(float x, float y, float z) { return (Vec){x,y,z}; }
static inline Vec add(Vec a, Vec b) { return vec(a.x+b.x,a.y+b.y,a.z+b.z); }
static inline Vec sub(Vec a, Vec b) { return vec(a.x-b.x,a.y-b.y,a.z-b.z); }
static inline Vec mul(Vec a, float b) { return vec(a.x*b,a.y*b,a.z*b); }
static inline float dot(Vec a, Vec b) { return a.x*b.x+a.y*b.y+a.z*b.z; }
static inline Vec cross(Vec a, Vec b) { return vec(a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x); }
static inline int finite(float x) { return x == x && ab(x) < 1e20f; }
float root(float x);
float sine(float x);
float cosine(float x);
float angle(float y, float x);
Vec unit(Vec p);
int flo(float x);
u32 random_next(Scan *s);
void warn(Scan *s, const char *message);
int cell_index(Scan *s, Vec p);
Vec grid_point(Scan *s, int x, int y, int z);
const char *read_dump(Scan *s, int fuse);
const char *init_grid(Scan *s);
void finish_fusion(Scan *s);
const char *make_model(Scan *s);
const char *make_mesh(Scan *s);
int same(const char *a, const char *b);
#endif
-2
View File
@@ -7,8 +7,6 @@
// Meta's OVRPlugin finds xrGetInstanceProcAddr with dlopen("libopenxr_loader.so") + dlsym, so FramePort points that
// string in libOVRPlugin.so at this library instead (patches/frame/adapter.py). Names the adapter emulates are served
// by the adapter (framebridge_extension_proc); everything else goes to overport's xrGetInstanceProcAddr unchanged.
// The adapter also routes scene discovery/persistence/mesh and the core locate/event functions that consume
// emulated spaces. Keep this name-agnostic: another fixed table here would repeat the dispatcher's limitation.
//
// Haptics (GitHub #9, found by Klownicle in Lucky's Tale): overport's dispatcher converts an
// XrHapticAmplitudeEnvelopeVibrationFB with its nanosecond duration taken as seconds, so one vibration allocates an
+192
View File
@@ -0,0 +1,192 @@
#!/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"
# Frame controller face, own drawing after public photos (Wikimedia Commons "Steam Frame controllers.jpg",
# CC BY 4.0): offsets from the head's centre for a head of radius 100; `o` = the controller's outer side
# (-1 left, +1 right). Left: d-pad outer, View above it, stick inner, Steam below. Right: the mirror, with the
# face buttons as a diamond (Y top, X left, B right, A bottom) and Menu above them.
PANEL_W, PANEL_H = 520, 380
HEAD_R = 100
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 = (PANEL_W, PANEL_H) if compact else (2 * PANEL_W + 40, 2 * PANEL_H + 104)
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 Steam Frame controller button is labelled with the Quest Touch button it sends. "
"A dash means it sends nothing. 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 tone(touch):
if touch == "—":
return colors["TEXT_3"]
return colors["SECONDARY"] if touch in ("X", "A") else colors["ACCENT"]
def sends(x, y, touch, size=15):
text(x, y + size * 0.36, touch, size if len(touch) == 1 else size - 4, tone(touch), "middle", 700)
def touch_button(x, y, label):
parts.append(f'<circle cx="{x}" cy="{y}" r="25" fill="{colors["SURFACE_2"]}" '
f'stroke="{tone(label)}" stroke-width="1.5"/>')
sends(x, y, label, 19)
def controller(hx, hy, hand, mapped):
o = -1 if hand == "left" else 1
shell, edge = colors["BG"], colors["BORDER"]
# the handle hangs below the head, a little toward the inner side
s = -o * 8
parts.append(f'<path d="M{hx - 46 + s},{hy + 70} C{hx - 44 + s},{hy + 128} {hx - 34 + s},{hy + 148} '
f'{hx - 12 + s},{hy + 152} C{hx + 10 + s},{hy + 156} {hx + 30 + s},{hy + 140} '
f'{hx + 46 + s},{hy + 70} Z" fill="{shell}" stroke="{edge}" stroke-width="2"/>')
parts.append(f'<circle cx="{hx}" cy="{hy}" r="{HEAD_R}" fill="{shell}" stroke="{edge}" stroke-width="2"/>')
# thumbstick (inner side) and Steam button (not mapped: always Steam)
sx = hx - o * 34
parts.append(f'<circle cx="{sx}" cy="{hy + 18}" r="24" fill="{colors["SURFACE"]}" stroke="{edge}"/>')
parts.append(f'<circle cx="{sx}" cy="{hy + 18}" r="15" fill="{colors["SURFACE_2"]}" stroke="{edge}"/>')
parts.append(f'<circle cx="{hx + o * 14}" cy="{hy + 62}" r="10" fill="{colors["SURFACE_2"]}" '
f'stroke="{edge}"/>')
text(hx + o * 14, hy + 86, "Steam", 9, colors["TEXT_3"], "middle")
# View (left) / Menu (right) pill above the d-pad / face buttons
px, py = hx - o * 4, hy - 46
pill = mapped.get("view" if hand == "left" else "menu", "—")
parts.append(f'<rect x="{px - 13}" y="{py - 5}" width="26" height="10" rx="5" '
f'fill="{colors["SURFACE_2"]}" stroke="{tone(pill)}"/>')
# a colon, not an arrow: the app's SVG renderer has no arrow glyphs
text(px, py - 10, ("View" if hand == "left" else "Menu") + ": " + pill, 10, tone(pill), "middle", 600)
cx, cy = hx + o * 42, hy + 2
if hand == "left": # d-pad: a cross of four arms
parts.append(f'<path d="M{cx - 14},{cy - 44} h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" '
f'fill="{colors["SURFACE_2"]}" stroke="{edge}"/>')
for dx, dy, name in ((0, -29, "dpad_up"), (-29, 0, "dpad_left"), (29, 0, "dpad_right"),
(0, 29, "dpad_down")):
sends(cx + dx, cy + dy, mapped.get(name, "—"), 14)
else: # face buttons as a diamond, each with its printed letter
for dx, dy, name in ((0, -29, "y"), (-29, 0, "x"), (29, 0, "b"), (0, 29, "a")):
touch = mapped.get(name, "—")
parts.append(f'<circle cx="{cx + dx}" cy="{cy + dy}" r="14" fill="{colors["SURFACE_2"]}" '
f'stroke="{tone(touch)}" stroke-width="1.5"/>')
sends(cx + dx, cy + dy, touch, 14)
text(cx + dx + 15, cy + dy - 10, name.upper(), 9, colors["TEXT_3"], "middle")
def panel(x, y, key):
parts.append(f'<g transform="translate({x} {y})">')
parts.append(f'<rect x="0" y="0" width="{PANEL_W}" height="{PANEL_H}" 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)
if key is None:
text(20, 55, "Two face buttons per hand, plus Menu on the left", 13, colors["TEXT_2"])
else:
text(20, 55, "Steam Frame controllers: each button shows the Quest button it sends", 13,
colors["TEXT_2"])
for hand, hx in (("left", 140), ("right", 380)):
text(hx, 84 if key is None else 78, 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 # Quest's second button sits up and toward the other hand
touch_button(hx - inner, 206, low)
touch_button(hx + inner, 151, high)
if hand == "left":
parts.append(f'<rect x="{hx - 57}" y="{251}" width="114" height="32" rx="8" '
f'fill="{colors["SURFACE_2"]}"/>')
text(hx, 272, "Menu", 12, colors["TEXT_2"], "middle")
else:
controller(hx, 186, hand, inverse(data["modes"][key], hand))
footnote = ("Lower: X / A · Upper: Y / B" if key is None else
"Sticks, triggers and grips unchanged · bumpers send nothing")
text(PANEL_W / 2, PANEL_H - 14, footnote, 12, 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 · —: sends nothing", 14, colors["TEXT_2"])
for x, y, key in ((16, 72, None), (PANEL_W + 24, 72, "0"), (16, PANEL_H + 88, "1"),
(PANEL_W + 24, PANEL_H + 88, "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())
-105
View File
@@ -1,105 +0,0 @@
"""Render the in-headset room-scan prompts (SteamVR overlay images) into artifacts/roomscan/.
The Frame has no imaging library, so the agent shows pre-rendered PNGs through IVROverlay while it records the
passthrough depth mesh (agent `room_capture`). One image per state; "look around" comes in 13 variants (0-12 of 12
directions seen) so the progress ring can advance without drawing on the Frame. Colours = the Portal theme.
uv run python scripts/mr/render_prompts.py [--check]
"""
from __future__ import annotations
import argparse
import hashlib
import io
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "artifacts" / "roomscan"
W, H = 1024, 400
BG, SURFACE, TEXT, TEXT_2 = (13, 14, 18, 235), (29, 31, 39, 255), (236, 238, 243, 255), (167, 172, 186, 255)
ACCENT, SECONDARY, OK = (255, 138, 31, 255), (58, 168, 255, 255), (74, 222, 128, 255)
DIRECTIONS = 12
PROMPTS = {
"start": ("Turn on passthrough", "The scan runs while you can see your room:\n"
"open the quick access menu and switch passthrough on."),
"ceiling": ("Look up at the ceiling", "Hold it for a few seconds,\n"
"then look back down and keep turning."),
"floor": ("Look down at the floor", "Hold it for a few seconds,\nthen keep turning."),
"done": ("Room scanned", "You can take the headset off or start a mixed reality game."),
"failed": ("Scan stopped", "The headset's cameras sent no room view.\nStart the scan again from FramePort."),
}
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
for name in ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf",):
for base in ("/usr/share/fonts/truetype/dejavu", "C:/Windows/Fonts", "/Library/Fonts"):
p = Path(base) / name
if p.is_file():
return ImageFont.truetype(str(p), size)
return ImageFont.load_default(size=size)
def panel(title: str, body: str, seen: int | None = None, accent=ACCENT) -> Image.Image:
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle((4, 4, W - 4, H - 4), radius=48, fill=BG, outline=SURFACE, width=4)
d.rounded_rectangle((4, 4, 20, H - 4), radius=8, fill=accent) # portal edge
x = 64
if seen is not None: # progress ring: one segment per 30 degree direction seen
cx, cy, r = 150, H // 2, 92
for i in range(DIRECTIONS):
a0 = -90 + i * 360 / DIRECTIONS + 3
d.arc((cx - r, cy - r, cx + r, cy + r), a0, a0 + 360 / DIRECTIONS - 6,
fill=(SECONDARY if i < seen else SURFACE), width=26)
d.text((cx, cy), f"{seen}/{DIRECTIONS}", font=font(44, True), fill=TEXT, anchor="mm")
x = 290
d.text((x, 108), title, font=font(54, True), fill=TEXT, anchor="lm")
y = 180
for line in body.split("\n"):
d.text((x, y), line, font=font(32), fill=TEXT_2, anchor="lm")
y += 48
return img
def images() -> dict[str, bytes]:
out = {}
for key, (title, body) in PROMPTS.items():
out[key] = panel(title, body, accent=OK if key == "done" else ACCENT)
for n in range(DIRECTIONS + 1):
out[f"look_{n:02d}"] = panel("Look around the room", "Turn slowly until the ring is full:\n"
"every wall and your furniture.", seen=n, accent=SECONDARY)
data = {}
for key, img in out.items():
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
data[f"{key}.png"] = buf.getvalue()
return data
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--check", action="store_true", help="fail if artifacts/roomscan is out of date")
args = ap.parse_args()
data = images()
if args.check:
stale = [n for n, b in data.items() if not (OUT / n).is_file() or (OUT / n).read_bytes() != b]
if stale:
print("out of date:", ", ".join(stale))
return 1
return 0
OUT.mkdir(parents=True, exist_ok=True)
for name, b in data.items():
p = OUT / name
if not p.is_file() or p.read_bytes() != b:
p.write_bytes(b)
print(f"{len(data)} prompts in {OUT.relative_to(ROOT)} "
f"({sum(map(len, data.values())) // 1024} KiB, {hashlib.sha256(b''.join(data.values())).hexdigest()[:12]})")
return 0
if __name__ == "__main__":
sys.exit(main())
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env python3
"""Render deterministic, noisy head-centred depth meshes into the FPRS v1 contract (stdlib only)."""
from __future__ import annotations
import argparse
import math
import random
import struct
from pathlib import Path
BOX_OUTLINE = [(-2, -1.5), (2, -1.5), (2, 1.5), (-2, 1.5)]
L_OUTLINE = [(-2, -2), (2, -2), (2, 0), (0, 0), (0, 2), (-2, 2)]
TABLE = ((-.6, 0, -.25), (1.2, .75, .7))
COUCH = ((.6, 0, .95), (1.6, .65, .65))
ROOMVIEW_WIDTH, ROOMVIEW_HEIGHT = 132, 128
ROOMVIEW_NEAR, ROOMVIEW_FAR = .3, 4.0
def fan(width, height, roomview):
"""RoomView camera axes are +Z forward / -Y image up; poses convert them to STAGE."""
sx, sy = (1.96, 1.94) if roomview else (math.tan(math.radians(55)), math.tan(math.radians(38)))
for row in range(height):
for col in range(width):
x = (2 * col / (width - 1) - 1) * sx
y = (2 * row / (height - 1) - 1) * sy
if not roomview:
y = -y
length = math.sqrt(x * x + y * y + 1)
yield x / length, y / length, (1 if roomview else -1) / length
def smooth_depths(values, width, height, radius=3):
"""Separable box smoothing models soft stereo boundaries, without a NumPy/scipy dependency."""
result = list(values)
for vertical in (False, True):
source, result = result, [0.0] * len(values)
lines, length, step = (width, height, width) if vertical else (height, width, 1)
for line in range(lines):
start = line if vertical else line * width
prefix = [0.0]
for i in range(length):
prefix.append(prefix[-1] + source[start + i * step])
for i in range(length):
lo, hi = max(0, i - radius), min(length, i + radius + 1)
result[start + i * step] = (prefix[hi] - prefix[lo]) / (hi - lo)
return result
def inside(x, z, polygon):
odd = False
for (ax, az), (bx, bz) in zip(polygon, polygon[1:] + polygon[:1], strict=False):
if (az > z) != (bz > z) and x < (bx - ax) * (z - az) / (bz - az) + ax:
odd = not odd
return odd
def rotate(v, q):
x, y, z = v
qx, qy, qz, qw = q
tx, ty, tz = 2 * (qy * z - qz * y), 2 * (qz * x - qx * z), 2 * (qx * y - qy * x)
return (x + qw * tx + qy * tz - qz * ty,
y + qw * ty + qz * tx - qx * tz,
z + qw * tz + qx * ty - qy * tx)
def cast(eye, direction, polygon, ceiling, furniture, missing_wall):
ex, ey, ez = eye
dx, dy, dz = direction
nearest = 5.0
hit = False
for height in (0, ceiling):
if abs(dy) > 1e-8:
t = (height - ey) / dy
if .3 < t < nearest and inside(ex + t * dx, ez + t * dz, polygon):
nearest, hit = t, True
for index, ((ax, az), (bx, bz)) in enumerate(zip(polygon, polygon[1:] + polygon[:1], strict=False)):
if index == missing_wall:
continue
sx, sz = bx - ax, bz - az
det = dx * sz - dz * sx
if abs(det) < 1e-8:
continue
t = ((ax - ex) * sz - (az - ez) * sx) / det
u = ((ax - ex) * dz - (az - ez) * dx) / det
if .3 < t < nearest and 0 <= u <= 1 and 0 <= ey + t * dy <= ceiling:
nearest, hit = t, True
for (cx, bottom, cz), (width, height, depth) in furniture:
near, far = -1e20, 1e20
for pos, ray, lo, hi in zip(eye, direction,
(cx - width / 2, bottom, cz - depth / 2),
(cx + width / 2, bottom + height, cz + depth / 2), strict=False):
if abs(ray) < 1e-8:
if not lo <= pos <= hi:
far = -1e20
break
else:
a, b = (lo - pos) / ray, (hi - pos) / ray
near, far = max(near, min(a, b)), min(far, max(a, b))
if .3 < near < nearest and near <= far:
nearest, hit = near, True
return nearest if hit else None
def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise=.004,
holes=.025, smear=.012, floor_bias=.008, no_ceiling=False, seed=7,
yaw=0.0, offset=(0.0, 0.0, 0.0), missing_wall=-1, objects=True, profile="roomview",
invalid_start=None, smoothing=3):
"""Each frame has its own moving pose; no ground-truth room data is put in the dump."""
rng = random.Random(seed)
roomview = profile == "roomview"
width = width or (ROOMVIEW_WIDTH if roomview else 56)
height = height or (ROOMVIEW_HEIGHT if roomview else 40)
invalid_start = (2 if roomview else 0) if invalid_start is None else invalid_start
polygon = BOX_OUTLINE if shape == "box" else L_OUTLINE
ceiling = 2.6
furniture = [TABLE, COUCH] if objects and shape == "box" else []
rotation = (0, math.sin(yaw / 2), 0, math.cos(yaw / 2))
rays = list(fan(width, height, roomview))
with path.open("wb") as out:
out.write(struct.pack("<4sI", b"FPRS", 1))
for frame in range(frames):
heading = 2 * math.pi * frame / max(frames, 1)
pitch = -.85 if no_ceiling else (-.60, 0, .70)[frame % 3]
if roomview and no_ceiling:
pitch = -1.2
# The L's two arms need viewpoints on both sides of the re-entrant corner.
centre = (-.65, -.65) if shape == "l" else (0, 0)
eye = (centre[0] + .35 * math.cos(heading), 1.6 + .04 * math.sin(heading * 2),
centre[1] + .35 * math.sin(heading))
if roomview:
eye = eye[0], 1.3 + .4 * math.sin(heading), eye[2]
sy, cy = math.sin(heading / 2), math.cos(heading / 2)
sp, cp = math.sin(pitch / 2), math.cos(pitch / 2)
q = (cy * sp, sy * cp, -sy * sp, cy * cp)
if roomview:
q = q[3], q[2], -q[1], -q[0] # head rotation composed with camera's 180 degree X rotation
depths = [cast(eye, rotate(ray, q), polygon, ceiling, furniture, missing_wall) for ray in rays]
if roomview:
# Smooth both valid depth and inpainted far pixels. Low-frequency noise survives the filter,
# while isolated high-frequency speckle is small, as in a stereo patch fit.
coarse_width = (width + 11) // 12
coarse = [rng.gauss(0, .012) for _ in range(coarse_width * ((height + 11) // 12))]
depths = [(depth if depth is not None else 8 + rng.random() * 2)
+ coarse[(i // width // 12) * coarse_width + (i % width // 12)]
for i, depth in enumerate(depths)]
depths = smooth_depths(depths, width, height, smoothing)
vertices, valid = [], []
for index, (ray, depth) in enumerate(zip(rays, depths, strict=False)):
keep = depth is not None and rng.random() >= holes
valid.append(keep or roomview)
if roomview and not keep:
depth = 8 + rng.random() * 2
if depth is None:
depth = 0
else:
# Stereo edges blend two depths, producing stretched grazing triangles.
other = depths[index + 1] if (index + 1) % width and index + 1 < len(depths) else depth
if other is not None and abs(other - depth) > .3 and rng.random() < smear:
depth = (depth + other) / 2
world_ray = rotate(ray, q)
if eye[1] + depth * world_ray[1] < .15:
depth += floor_bias * rng.random()
depth += rng.gauss(0, noise)
vertices.extend(c * depth for c in ray)
indices = []
for row in range(height - 1):
for col in range(width - 1):
a = row * width + col
for triangle in ((a, a + width, a + 1), (a + 1, a + width, a + width + 1)):
if all(valid[i] for i in triangle):
indices.extend(triangle)
# Compose the global room transform with each independent mesh -> room pose.
pose_eye = tuple(a + b for a, b in zip(rotate(eye, rotation), offset, strict=False))
ax, ay, az, aw = rotation
bx, by, bz, bw = q
pose_q = (aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz)
if frame < invalid_start:
pose_eye = (float("nan"),) * 3
out.write(struct.pack("<d7fII", frame / (3 if roomview else 15),
*pose_eye, *pose_q, len(rays), len(indices)))
out.write(struct.pack(f"<{len(vertices)}f", *vertices))
out.write(struct.pack(f"<{len(indices)}I", *indices))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("out", type=Path)
ap.add_argument("--shape", choices=("box", "l"), default="box")
ap.add_argument("--frames", type=int, default=30)
ap.add_argument("--profile", choices=("roomview", "legacy"), default="roomview")
ap.add_argument("--width", type=int)
ap.add_argument("--height", type=int)
ap.add_argument("--invalid-start", type=int, help="untracked initial frames (RoomView default: 2)")
ap.add_argument("--smoothing", type=int, default=3, help="RoomView depth filter radius in pixels")
ap.add_argument("--noise", type=float, default=.004)
ap.add_argument("--holes", type=float, default=.025)
ap.add_argument("--smear", type=float, default=.012)
ap.add_argument("--floor-bias", type=float, default=.008)
ap.add_argument("--no-ceiling", action="store_true")
ap.add_argument("--no-objects", action="store_true")
ap.add_argument("--missing-wall", type=int, default=-1)
ap.add_argument("--yaw", type=float, default=0, help="global room yaw in degrees")
ap.add_argument("--seed", type=int, default=7)
args = ap.parse_args()
if args.frames < 1 or (args.width is not None and args.width < 3) or (args.height is not None and args.height < 3):
ap.error("positive frame count and a grid of at least 3 x 3 required")
render(args.out, shape=args.shape, frames=args.frames, width=args.width, height=args.height,
noise=args.noise, holes=args.holes, smear=args.smear, floor_bias=args.floor_bias,
no_ceiling=args.no_ceiling, seed=args.seed, yaw=math.radians(args.yaw),
missing_wall=args.missing_wall, objects=not args.no_objects, profile=args.profile,
invalid_start=args.invalid_start, smoothing=args.smoothing)
if __name__ == "__main__":
main()
+2 -22
View File
@@ -143,7 +143,8 @@ def main() -> int:
("settings-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc")),
("settings-folders", lambda a: a.page.run_task(a.settings_view.show_section, "folders")),
("settings-installing", lambda a: a.page.run_task(a.settings_view.show_section, "installing")),
("settings-data", lambda a: a.page.run_task(a.settings_view.show_section, "data"))]
("settings-data", lambda a: a.page.run_task(a.settings_view.show_section, "data")),
("settings-controllers", lambda a: a.page.run_task(a.settings_view.show_section, "controllers"))]
if game:
steps.insert(1, ("game", lambda a: a.open_game(game)))
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
@@ -545,27 +546,6 @@ def main() -> int:
app = FramePortApp(page)
if args.fake_frame:
attach_fake_frame(app)
# The Room card uses local fixtures too; --fake-frame never contacts a headset.
from frameport.mr.room import box_room, dumps, summary
room = box_room(4, 3)
room.source, room.universe = "scan", 7
room_answer = dict(rooms=[], universe=7, room=dict(universe=7, source="scan", summary=summary(room)),
room_text=dumps(room), aligned=True, default_circle=False, scanner_installed=True)
original_agent = app.target.frame.agent
def room_agent(command, **kwargs):
if command == "room_capture":
return {"error": "not implemented yet"}
if command == "room_delete":
room_answer.update(room=None, room_text=None, aligned=False)
if command == "room_from_boundary":
room.source = "boundary"
room_answer.update(room=dict(universe=7, source="boundary", summary=summary(room)),
room_text=dumps(room), aligned=True)
if command.startswith("room_"):
return dict(room_answer)
return original_agent(command, **kwargs)
app.target.frame.agent = room_agent
except Exception: # noqa: BLE001
ERRORS.append("startup: " + traceback.format_exc())
return
+11 -6
View File
@@ -34,6 +34,11 @@
"title": "Show Steam Frame controllers",
"group": "Game settings"
},
"adapter.cube_quads": {
"anchor": "adapter-cube_quads",
"title": "Show cube loading screens",
"group": "Game settings"
},
"adapter.cube_standin": {
"anchor": "adapter-cube_standin",
"title": "Stand in for cube layers",
@@ -229,6 +234,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",
@@ -261,7 +271,7 @@
},
"adapter.scene_emul": {
"anchor": "adapter-scene_emul",
"title": "Use room layout",
"title": "Pretend room layout",
"group": "Game settings"
},
"adapter.scene_height": {
@@ -269,11 +279,6 @@
"title": "Room height",
"group": "Game settings"
},
"adapter.scene_hide_bounds": {
"anchor": "adapter-scene_hide_bounds",
"title": "Hide play-area size",
"group": "Game settings"
},
"adapter.scene_width": {
"anchor": "adapter-scene_width",
"title": "Room width",
+28 -4
View File
@@ -91,7 +91,7 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
</Layout>
<script>
import { parseInstallParams, readManifest } from '../../scripts/install-link';
import { githubRelease, parseInstallParams, readManifest } from '../../scripts/install-link';
const page = document.querySelector<HTMLElement>('main.page')!;
const $ = <T extends HTMLElement>(sel: string) => page.querySelector<T>(sel)!;
@@ -114,7 +114,31 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
$('[data-name]').textContent = 'Cool Game';
}
// the manifest's own name and description, when its host allows reading it from a page (CORS)
// a GitHub release download can't be read from a page (no CORS): name the release from GitHub's API instead
const releaseInfo = () => {
const rel = githubRelease(parsed.target);
if (!rel) return;
const api = `https://api.github.com/repos/${encodeURIComponent(rel.owner)}/${encodeURIComponent(rel.repo)}`;
const opts: RequestInit = { credentials: 'omit', referrerPolicy: 'no-referrer' };
const release = rel.tag ? `${api}/releases/tags/${encodeURIComponent(rel.tag)}` : `${api}/releases/latest`;
const json = (url: string) => fetch(url, opts).then((r) => (r.ok ? r.json() : null));
Promise.all([json(release), json(api)])
.then(([r, repo]) => {
const name = (typeof r?.name === 'string' && r.name.trim()) || (typeof repo?.name === 'string' && repo.name) || '';
if (!name) return;
$('[data-name]').textContent = name.slice(0, 200);
document.title = `Install ${name.slice(0, 200)} with FramePort`;
$('[data-files]').textContent = `A release of ${rel.owner}/${rel.repo}`;
if (typeof repo?.description === 'string' && repo.description.trim()) {
$('[data-desc]').textContent = repo.description.trim().slice(0, 2000);
$('[data-desc]').hidden = false;
}
})
.catch(() => { /* the address alone is shown */ });
};
// the manifest's own name and description, when its host allows reading it from a page (CORS); GitHub file
// pages already became their raw.githubusercontent.com address in parseInstallParams
if (parsed.kind === 'manifest') {
const ctl = new AbortController();
setTimeout(() => ctl.abort(), 6000);
@@ -122,7 +146,7 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
.then((r) => (r.ok && Number(r.headers.get('content-length') ?? 0) <= 256 * 1024 ? r.json() : null))
.then((data) => {
const m = readManifest(data);
if (!m) return;
if (!m) { releaseInfo(); return; }
$('[data-name]').textContent = m.name;
document.title = `Install ${m.name} with FramePort`;
$('[data-files]').textContent = m.files.join(', ');
@@ -135,7 +159,7 @@ import { DOWNLOADS, downloadPage, url } from '../../data/site';
img.src = m.icon;
}
})
.catch(() => { /* the address alone is shown */ });
.catch(() => releaseInfo());
}
// hand the link to FramePort; if this page is still in front afterwards, FramePort probably isn't there
+28 -1
View File
@@ -2,7 +2,9 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { DEMO_MANIFEST, checkUrl, isDemo, parseInstallParams, readManifest, toFrameportLink } from './install-link.ts';
import {
DEMO_MANIFEST, checkUrl, githubRelease, isDemo, parseInstallParams, rawGithubUrl, readManifest, toFrameportLink,
} from './install-link.ts';
const MANIFEST = 'https://cdn.example.com/game.framedrop.json';
@@ -76,3 +78,28 @@ test('the published demo manifest previews as Cool Game', () => {
assert.equal(m?.icon, 'https://frameport.app/demo/cool-game.svg');
assert.deepEqual(m?.files, ['cool-game-arm64.apk']);
});
test('GitHub file pages become raw.githubusercontent.com addresses (also in the frameport:// link)', () => {
const raw = 'https://raw.githubusercontent.com/o/r/main/dir/game.json';
for (const page of ['https://github.com/o/r/blob/main/dir/game.json', 'https://github.com/o/r/raw/main/dir/game.json',
'https://www.github.com/o/r/blob/main/dir/game.json']) {
assert.equal(rawGithubUrl(page), raw);
}
const p = parseInstallParams(`?manifest=${encodeURIComponent('https://github.com/o/r/blob/v1.2/game.json')}`);
assert.equal(p.target, 'https://raw.githubusercontent.com/o/r/v1.2/game.json');
assert.equal(p.link, toFrameportLink('manifest', p.target));
// everything else is left alone: release downloads, raw addresses, other hosts
for (const other of ['https://github.com/o/r/releases/latest/download/game.json', raw,
'https://example.com/o/r/blob/main/game.json', 'http://github.com/o/r/blob/main/game.json']) {
assert.equal(rawGithubUrl(other), other);
}
});
test('GitHub release downloads are recognised (for the release name from the API)', () => {
assert.deepEqual(githubRelease('https://github.com/o/r/releases/latest/download/frameport-install.json'),
{ owner: 'o', repo: 'r', tag: null });
assert.deepEqual(githubRelease('https://github.com/o/r/releases/download/v0.1.0/frameport-install.json'),
{ owner: 'o', repo: 'r', tag: 'v0.1.0' });
assert.equal(githubRelease('https://github.com/o/r/blob/main/x.json'), null);
assert.equal(githubRelease('https://raw.githubusercontent.com/o/r/main/x.json'), null);
});
+35 -2
View File
@@ -68,6 +68,38 @@ export function checkUrl(text: string, what = 'link'): string | null {
return null;
}
/**
* A GitHub file page (github.com/<owner>/<repo>/blob/<ref>/<path>, or /raw/<ref>/<path>) as the file itself on
* raw.githubusercontent.com: a /blob/ address is an HTML page, and raw.githubusercontent.com is the GitHub address a
* web page may read (CORS). Other addresses come back unchanged. deeplink.raw_github_url does the same in the app.
*/
export function rawGithubUrl(text: string): string {
let url: URL;
try { url = new URL(text); } catch { return text; }
if (url.protocol !== 'https:' || !['github.com', 'www.github.com'].includes(url.hostname.toLowerCase())) return text;
const m = url.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/);
if (!m) return text;
return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}`;
}
export interface GithubRelease { owner: string; repo: string; tag: string | null }
/**
* A GitHub release download (github.com/<owner>/<repo>/releases/download/<tag>/<file> or
* …/releases/latest/download/<file>; tag null = latest), else null. Release downloads can't be read by a web page
* (no CORS), so the page shows the release's name from GitHub's API instead of the manifest's.
*/
export function githubRelease(text: string): GithubRelease | null {
let url: URL;
try { url = new URL(text); } catch { return null; }
if (url.protocol !== 'https:' || !['github.com', 'www.github.com'].includes(url.hostname.toLowerCase())) return null;
const latest = url.pathname.match(/^\/([^/]+)\/([^/]+)\/releases\/latest\/download\/[^/]+$/);
if (latest) return { owner: latest[1], repo: latest[2], tag: null };
const tagged = url.pathname.match(/^\/([^/]+)\/([^/]+)\/releases\/download\/([^/]+)\/[^/]+$/);
if (tagged) return { owner: tagged[1], repo: tagged[2], tag: decodeURIComponent(tagged[3]) };
return null;
}
export function toFrameportLink(kind: Kind, target: string): string {
return `frameport://install?${kind}=${encodeURIComponent(target)}`;
}
@@ -75,8 +107,9 @@ export function toFrameportLink(kind: Kind, target: string): string {
/** The page's query string → what to install, or why not. */
export function parseInstallParams(search: string): Parsed {
const q = new URLSearchParams(search);
const manifest = q.get('manifest')?.trim();
const file = q.get('url')?.trim();
const raw = (v: string | undefined) => (v ? rawGithubUrl(v) : v);
const manifest = raw(q.get('manifest')?.trim());
const file = raw(q.get('url')?.trim());
if (manifest) {
const error = checkUrl(manifest, 'manifest address');
if (error) return { ok: false, error };
+3 -17
View File
@@ -46,14 +46,7 @@ def _last_error(output: str) -> str:
"""The exception line of Cpp2IL's output (its stack trace isn't useful to a user)."""
lines = [ln.strip() for ln in output.splitlines() if ln.strip()]
msg = next((ln for ln in reversed(lines) if "Exception" in ln and not ln.startswith("at ")), None)
msg = msg or next((ln for ln in reversed(lines) if not ln.startswith("at ")), None)
return (msg or "no output")[:300]
def _runtime_failure(error: str) -> bool:
""".NET itself stopped before Cpp2IL read anything ("Process terminated", e.g. no libicu on the PC): not a fact
about the game, so it isn't cached. Older FramePort versions cached it as the stack line it ends with."""
return "Process terminated" in error or error.endswith("at Cpp2IL.Program.Main(System.String[])")
return (msg or (lines[-1] if lines else "no output"))[:300]
def _plain_version(version: str) -> str:
@@ -80,9 +73,7 @@ def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[s
nightly = False # Cpp2IL's development build: reads metadata (Unity 6.5+) and games the release crashes on
try:
cached = json.loads(cache_file.read_text())
if cached.get("error") and _runtime_failure(cached["error"]):
pass # the PC's .NET failed last time, not the game: run again
elif cached.get("error"): # Cpp2IL couldn't read this build: don't run it again per patch
if cached.get("error"): # Cpp2IL couldn't read this build: don't run it again per patch
if explicit or cached.get("nightly"):
raise RuntimeError(cached["error"])
nightly = True # only the release couldn't: the development build gets one try
@@ -100,8 +91,6 @@ def find_methods(lib: bytes, metadata: bytes, unity_version: str, wanted: dict[s
break
except _Unreadable as exc:
error = "Cpp2IL couldn't read this game's code: " + str(exc)
if _runtime_failure(error):
raise RuntimeError("Cpp2IL couldn't start on this PC: " + str(exc)) from None
if not explicit and not nightly: # e.g. BattleTalent (Unity 2021.3): the release crashes, nightly reads it
nightly, exe = True, tool.ensure_nightly()
continue
@@ -135,10 +124,7 @@ def _run(cpp2il: Path, lib: bytes, metadata: bytes, unity_version: str, wanted:
"--force-unity-version", _plain_version(unity_version),
"--use-processor", "attributeinjector", "--output-as", "diffable-cs",
"--output-to", str(t / "out")], capture_output=True, text=True, errors="replace",
timeout=timeout, cwd=tmp,
# .NET aborts at start without libicu (fresh WSL/Linux installs); Cpp2IL needs no
# culture data
env={**os.environ, "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT": "1"})
timeout=timeout, cwd=tmp)
root = t / "out" / "DiffableCs"
# a crash after Cpp2IL made the folder tree (exit 134, no .cs files: GitHub #293) isn't "no such classes"
if proc.returncode != 0 or not root.is_dir() or next(root.rglob("*.cs"), None) is None:
+13
View File
@@ -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"]
+5 -91
View File
@@ -322,8 +322,8 @@ def link_updates(install: bool = typer.Option(False, "--install", help="download
"the Frame (asks first unless --yes)"),
yes: bool = typer.Option(False, "--yes", "-y", help="don't ask before downloading"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Check games installed from install links for newer versions (manifests that name a `version`; the GUI checks
at start, every 6 h)."""
"""Check games installed from install links for newer versions (compared with the version the manifest named, else
the installed APK's own; the GUI checks at start, every 6 h, and on the game page's "Check for updates")."""
from . import deeplink
pkgs = pipeline.check_link_updates(force=True, reporter=printing_reporter())
@@ -332,13 +332,14 @@ def link_updates(install: bool = typer.Option(False, "--install", help="download
return
for pkg in pkgs:
g = library.game(pkg)
typer.echo(f"{pkg}: {(g.get('link') or {}).get('version') or '?'} -> {g['link_update']['version']}"
typer.echo(f"{pkg}: {pipeline.link_installed_version(g) or '?'} -> {g['link_update']['version']}"
f" ({g['link']['source']})")
if not install or not (yes or typer.confirm(f"Download and install {len(pkgs)} update(s)?")):
return
target = _target(frame)
for pkg in pkgs:
m = deeplink.check_update(library.game(pkg)["link"])
g = library.game(pkg)
m = deeplink.check_update(g["link"], pipeline.link_installed_version(g))
if not m:
continue
rep = printing_reporter(verbose=False)
@@ -811,93 +812,6 @@ def frame_move(package: str = typer.Argument(..., help="an installed game"),
typer.echo(f"moved to {st.get('base') or st.get('to')}")
# ---------------------------------------------------------------- mixed-reality room commands
room_app = typer.Typer(help="Store a room for mixed-reality games.", no_args_is_help=True)
frame_app.add_typer(room_app, name="room")
@room_app.command("status")
def room_status(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Room summaries, play-area alignment and scanner availability."""
from .install import rooms
typer.echo(json.dumps(rooms.status(_target(frame).frame), indent=2))
@room_app.command("from-boundary")
def room_from_boundary(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
height: float = typer.Option(2.5, help="ceiling height in metres")):
"""Use your SteamVR play area as a room (refuses Valve's default circle)."""
from .install import rooms
typer.echo(json.dumps(rooms.from_boundary(_target(frame).frame, height), indent=2))
@room_app.command("import")
def room_import(path: Path = typer.Argument(..., exists=True, dir_okay=False),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Import room.txt and its mesh sidecar."""
from .install import rooms
typer.echo(json.dumps(rooms.put(_target(frame).frame, path), indent=2))
@room_app.command("export")
def room_export(directory: Path = typer.Argument(...),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Download the selected room and mesh to a directory."""
from .install import rooms
typer.echo(str(rooms.export(_target(frame).frame, directory)))
@room_app.command("delete")
def room_delete(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
universe: Optional[int] = typer.Option(None, help="stored universe (default: selected room)")):
"""Delete a stored room; saved game anchors are kept."""
from .install import rooms
typer.echo(json.dumps(rooms.delete(_target(frame).frame, universe), indent=2))
@room_app.command("build")
def room_build(dump: Path = typer.Argument(..., exists=True, dir_okay=False),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
universe: Optional[int] = typer.Option(None, help="universe the dump was recorded in")):
"""Upload a recorded FPRS dump and run the bundled scanner on the Frame."""
from .install import rooms
typer.echo(json.dumps(rooms.build(_target(frame).frame, dump, universe), indent=2))
@room_app.command("scan")
def room_scan(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
seconds: float = typer.Option(90, help="how long to record once passthrough is on"),
wait: float = typer.Option(180, help="how long to wait for the player to turn passthrough on")):
"""Scan the room in the headset: put it on, turn on passthrough and follow the prompts."""
from .install import rooms
def progress(st):
typer.echo(f"{st.get('state')}: {st.get('directions', 0)}/12 directions, "
f"ceiling {'seen' if st.get('looked_up', 0) >= 5 else 'not yet'}", err=True)
typer.echo(json.dumps(rooms.scan(_target(frame).frame, seconds, wait, progress=progress), indent=2))
@room_app.command("show")
def room_show(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
output: Optional[Path] = typer.Option(None, help="SVG preview path")):
"""Print a room summary and write a top-down SVG preview."""
from .core.paths import user_data_dir
from .install import rooms
output = output or user_data_dir() / "rooms" / "preview.svg"
typer.echo(json.dumps(rooms.show(_target(frame).frame, output), indent=2))
typer.echo(str(output))
# ---------------------------------------------------------- end mixed-reality room commands
# ------------------------------------------------------------------------------------------ save backups (#352)
def _save_pkg(package: str) -> str:
"""A library game (package or part of its title), else a package name as it is (installed from another PC)."""
+64
View File
@@ -0,0 +1,64 @@
"""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"))
# Test Quest Controllers (github.com/spoopyghosty0/TestQuestControllers): a small app that shows every Quest controller
# input it receives on a 3D model, for trying the controller button modes. Installed through its install link.
TESTER_PACKAGE = "io.github.spoopyghosty0.testquestcontrollers"
TESTER_MANIFEST = "https://raw.githubusercontent.com/spoopyghosty0/TestQuestControllers/main/frameport-install.json"
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}
-31
View File
@@ -317,37 +317,6 @@ def _migrate(data: dict) -> bool:
r.setdefault("reasons", {}).setdefault(pid, s.reason)
done.append("quest_binary_fixes_v2")
changed = True
if "scene_emul_all" not in done:
# Optional MR modes and anchor-only games need scene APIs too. Keep explicitly edited recipes intact.
from ..patches.applicability import uses_scene
for g in (data.get("games") or {}).values():
a, r = g.get("analysis"), g.get("recipe")
if (not isinstance(a, dict) or not isinstance(r, dict) or r.get("source") == "user"
or g.get("kind") in ("linux", "rift")):
continue
if uses_scene(analysis_from_dict(a)):
r.setdefault("patches", {})["adapter.scene_emul"] = {"value": 1}
r.setdefault("reasons", {})["adapter.scene_emul"] = (
"Scene and spatial anchor APIs use the saved room on the Frame.")
done.append("scene_emul_all")
changed = True
if "scene_permissions" not in done:
# Games that got the room also need Meta's scene/anchor permissions declared, or Android never grants them
# and the game never asks for the room (NOPE, Pinball FX). Keep explicitly edited recipes intact.
from ..patches.applicability import uses_scene
for g in (data.get("games") or {}).values():
a, r = g.get("analysis"), g.get("recipe")
if (not isinstance(a, dict) or not isinstance(r, dict) or r.get("source") == "user"
or g.get("kind") in ("linux", "rift")):
continue
if (r.get("patches") or {}).get("adapter.scene_emul", {}).get("value") and uses_scene(analysis_from_dict(a)):
r["patches"].setdefault("frame.meta_permissions", {})
r.setdefault("reasons", {}).setdefault(
"frame.meta_permissions", "Meta's room and anchor permissions must be granted for the saved room.")
done.append("scene_permissions")
changed = True
if "flat_hide_navbar" not in done:
# Android apps without VR gain the default-on "Hide Android's navigation bar" patch
for g in (data.get("games") or {}).values():
+34 -10
View File
@@ -185,6 +185,23 @@ def demo_manifest() -> Manifest:
version="1.0.0", demo=True)
_GITHUB_FILE_PAGE = re.compile(r"^/([^/]+)/([^/]+)/(?:blob|raw)/(.+)$")
def raw_github_url(url: str) -> str:
"""A GitHub file page (github.com/<owner>/<repo>/blob/<ref>/<path>, or /raw/…) as the file itself on
raw.githubusercontent.com (a /blob/ address is an HTML page); anything else unchanged. The install page
(site/src/scripts/install-link.ts rawGithubUrl) does the same."""
try:
parts = urlsplit(url.strip())
except ValueError:
return url
if parts.scheme != "https" or (parts.hostname or "").lower() not in ("github.com", "www.github.com"):
return url
m = _GITHUB_FILE_PAGE.match(parts.path)
return f"https://raw.githubusercontent.com/{m[1]}/{m[2]}/{m[3]}" if m else url
def parse(text: str) -> InstallRequest:
"""framedrop://install?…, frameport://install?…, the https://framedropvr.com/install?… button link, a manifest
URL (…json) or a direct file URL."""
@@ -207,12 +224,12 @@ def parse(text: str) -> InstallRequest:
if manifest and is_demo(manifest):
return demo_request()
if manifest:
return InstallRequest(manifest_url=check_url(manifest, "manifest address"))
return InstallRequest(manifest_url=check_url(raw_github_url(manifest), "manifest address"))
if file:
return InstallRequest(file_url=check_url(file, "file address"))
return InstallRequest(file_url=check_url(raw_github_url(file), "file address"))
raise LinkError("The link doesn't say what to install (no manifest= or url=).")
if scheme in ("https", "http"):
url = check_url(text)
url = check_url(raw_github_url(text))
name = filename_of(url).lower()
if name.endswith(".json"):
return InstallRequest(manifest_url=url)
@@ -267,10 +284,17 @@ def is_newer_version(candidate: str, current: str) -> bool:
return bool(candidate and current) and compare_versions(candidate, current) > 0
def can_check_updates(link: dict | None) -> bool:
def can_check_updates(link: dict | None, current: str | None = None) -> bool:
"""Whether a library entry's `link` record can be checked for updates: a manifest fetched from the web (not a bare
file link, a dropped local file or the demo) that named a version."""
if not isinstance(link, dict) or not link.get("version") or link.get("direct"):
file link, a dropped local file or the demo) and a known installed version: the one the manifest named, else
`current` (the installed build's own version, for manifests that named none)."""
return can_check_link(link) and bool(link.get("version") or current)
def can_check_link(link: dict | None) -> bool:
"""Whether a library entry's `link` record is a manifest fetched from the web that can be fetched again (the
game page's "Check for updates"), whether or not it named a version."""
if not isinstance(link, dict) or link.get("direct"):
return False
source = link.get("source") or ""
if not source.lower().startswith(("https://", "http://")) or is_demo(source):
@@ -281,11 +305,11 @@ def can_check_updates(link: dict | None) -> bool:
return False
def check_update(link: dict) -> Manifest | None:
"""The remembered manifest fetched again: returned when it names a newer version than the one installed, else
None. Raises LinkError/network errors like fetch_manifest."""
def check_update(link: dict, current: str | None = None) -> Manifest | None:
"""The remembered manifest fetched again: returned when it names a newer version than the one installed (the
link's version, else `current`), else None. Raises LinkError/network errors like fetch_manifest."""
m = fetch_manifest(InstallRequest(manifest_url=link["source"]))
return m if is_newer_version(m.version, link.get("version") or "") else None
return m if is_newer_version(m.version, link.get("version") or current or "") else None
# ------------------------------------------------------------------------------------------------- manifest
+17 -2
View File
@@ -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
+4 -2
View File
@@ -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
+13 -19
View File
@@ -497,31 +497,25 @@ def ensure_helper(frame) -> bool:
if local is None:
return False
try:
sync_helper(frame, local, HELPER)
from ..frame.connection import sh_quote
remote = f"{frame.home}/{HELPER_REMOTE}"
want = hashlib.sha256(local.read_bytes()).hexdigest()
_, out, _ = frame.run(f"sha256sum {sh_quote(remote)} 2>/dev/null")
if out.split()[:1] == [want]:
return True
frame.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
tmp = f"{remote}.new-{os.getpid()}-{threading.get_ident()}" # own name: two starts at once don't collide
frame.put(local, tmp, resume=False)
code, _, err = frame.run(f"chmod 755 {sh_quote(tmp)} && mv -f {sh_quote(tmp)} {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
return True
except Exception as exc: # noqa: BLE001
log.warning("live view: couldn't put %s on the Frame (%s); using the software encoder", HELPER, exc)
return False
def sync_helper(frame, local: Path, name: str) -> str:
"""Install a bundled host helper by digest, atomically. Shared by live view and the room scanner."""
from ..frame.connection import sh_quote
remote = f"{frame.home}/.local/share/frameport/bin/{name}"
want = hashlib.sha256(local.read_bytes()).hexdigest()
_, out, _ = frame.run(f"sha256sum {sh_quote(remote)} 2>/dev/null")
if out.split()[:1] == [want]:
return remote
frame.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
tmp = f"{remote}.new-{os.getpid()}-{threading.get_ident()}" # own name: two starts at once don't collide
frame.put(local, tmp, resume=False)
code, _, err = frame.run(f"chmod 755 {sh_quote(tmp)} && mv -f {sh_quote(tmp)} {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
return remote
STOP_GRACE = 2.0 # s to wait for the script to end after stdin EOF before killing its process group
DROP_WARN = 0.05 # share of a stats window's frame slots that were late or skipped before the panel warns
-169
View File
@@ -1,169 +0,0 @@
"""Room storage and offline scanner operations on a connected Frame. No Flet."""
from __future__ import annotations
import tempfile
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from ..core.paths import artifacts_dir, user_data_dir
from ..frame.connection import sh_quote
from ..mr.room import dumps, parse, preview_svg, read_mesh
from .livestream import sync_helper
def status(frame) -> dict:
return frame.agent("room_status")
def delete(frame, universe: int | None = None) -> dict:
return frame.agent("room_delete", **({"universe": universe} if universe is not None else {}))
def from_boundary(frame, height: float = 2.5) -> dict:
return frame.agent("room_from_boundary", height=height)
def capture(frame, seconds: float = 90, wait: float = 180) -> dict:
"""Start a scan in the headset (the agent records the passthrough mesh, then builds the room)."""
ensure_scanner(frame)
ensure_prompts(frame)
result = frame.agent("room_capture", seconds=seconds, wait=wait)
if result.get("error"):
raise RuntimeError(result["error"])
return result
def capture_status(frame) -> dict:
return frame.agent("room_capture_status")
SCAN_DONE = ("done", "failed", "none")
def scan(frame, seconds: float = 90, wait: float = 180, progress=None, poll: float = 2.0,
sleep=time.sleep) -> dict:
"""capture() and wait for the result. progress(status) gets every status the agent reports (state waiting ->
recording -> building -> done|failed; directions seen, looked_up). Returns the final status; raises on failure."""
capture(frame, seconds, wait)
deadline = time.monotonic() + wait + seconds + 600 # + building time
while True:
st = capture_status(frame)
if progress:
progress(st)
if st.get("state") in SCAN_DONE:
break
if time.monotonic() > deadline:
raise RuntimeError("The room scan didn't finish")
sleep(poll)
if st.get("state") != "done":
raise RuntimeError(st.get("error") or "The room scan didn't finish")
return st
def ensure_prompts(frame) -> str:
"""The in-headset scan prompts (artifacts/roomscan/*.png) next to the scanner, synced by digest in one go."""
import hashlib
local = artifacts_dir() / "roomscan"
remote = f"{frame.home}/.local/share/frameport/bin/roomscan-prompts"
files = sorted(local.glob("*.png"))
if not files:
return remote
want = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in files}
_, out, _ = frame.run(f"mkdir -p {sh_quote(remote)} && cd {sh_quote(remote)} && sha256sum -- *.png 2>/dev/null")
have = {}
for line in out.splitlines():
digest, _, name = line.partition(" ")
have[name.strip()] = digest
for f in files:
if have.get(f.name) != want[f.name]:
frame.put(f, f"{remote}/{f.name}", resume=False)
return remote
@contextmanager
def _upload_dir(frame):
remote = f"{frame.home}/.local/share/frameport/rooms/.incoming-{uuid.uuid4().hex}"
code, _, err = frame.run(f"mkdir -p {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
try:
yield remote
finally:
frame.run(f"rm -rf -- {sh_quote(remote)}")
def put(frame, path: Path) -> dict:
path = Path(path)
room = parse(path.read_text(encoding="utf-8"))
mesh_path = None
if room.mesh:
mesh_path = path.parent / room.mesh.filename
mesh = read_mesh(mesh_path.read_bytes())
if (mesh.vertex_count, mesh.index_count) != (room.mesh.vertex_count, room.mesh.index_count):
raise ValueError("room mesh counts do not match room.txt")
staging = user_data_dir() / "rooms"
staging.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="import-", dir=staging) as local, _upload_dir(frame) as remote:
canonical = Path(local) / "room.txt"
canonical.write_text(dumps(room), encoding="utf-8")
frame.put(canonical, f"{remote}/room.txt", resume=False)
if mesh_path:
frame.put(mesh_path, f"{remote}/{room.mesh.filename}", resume=False)
return frame.agent("room_put", path=f"{remote}/room.txt")
def export(frame, directory: Path) -> Path:
st = status(frame)
if not st.get("room_text"):
raise ValueError("No room is stored on the Frame")
room = parse(st["room_text"])
directory = Path(directory)
mesh_data = None
if room.mesh:
remote = f"{frame.home}/.local/share/frameport/rooms/{room.universe}/{room.mesh.filename}"
with frame.sftp.open(remote, "rb") as f:
mesh_data = f.read(64 * 1024 * 1024)
mesh = read_mesh(mesh_data)
if (mesh.vertex_count, mesh.index_count) != (room.mesh.vertex_count, room.mesh.index_count):
raise ValueError("room changed during export; try again")
directory.mkdir(parents=True, exist_ok=True)
if mesh_data is not None:
(directory / room.mesh.filename).write_bytes(mesh_data)
path = directory / "room.txt"
path.write_text(dumps(room), encoding="utf-8")
return path
def ensure_scanner(frame) -> str:
local = artifacts_dir() / "linux-arm64-bin" / "fp_roomscan"
if not local.is_file():
raise RuntimeError("This build does not include the room scanner yet")
return sync_helper(frame, local, "fp_roomscan")
def build(frame, dump: Path, universe: int | None = None) -> dict:
dump = Path(dump)
with dump.open("rb") as f:
if f.read(8) != b"FPRS\x01\0\0\0":
raise ValueError("expected FPRS version 1 scan dump")
ensure_scanner(frame)
with _upload_dir(frame) as remote:
frame.put(dump, f"{remote}/scan.fprs", resume=False)
return frame.agent("room_build", dump=f"{remote}/scan.fprs",
**({"universe": universe} if universe is not None else {}))
def show(frame, path: Path) -> dict:
from ..mr.room import summary
st = status(frame)
if not st.get("room_text"):
raise ValueError("No room is stored on the Frame")
room = parse(st["room_text"])
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(preview_svg(room), encoding="utf-8")
return summary(room)
+36 -37
View File
@@ -32,7 +32,6 @@
"(top folder)": "",
"+ {base_count} standard patches": "",
", and your signing keys were saved to {backup}.": "",
", ceiling seen": "",
", {n} already there": [
"",
""
@@ -179,6 +178,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.": "",
@@ -223,7 +228,6 @@
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
"Browse this game's files.": "",
"Build page": "",
"Building the room…": "",
"Built for PCs (x86_64): runs through a translator, slower": "",
"Built for the Frame (arm64)": "",
"Cable connected.": "",
@@ -272,11 +276,14 @@
"Choose a folder with Quest, Android or PC VR games. FramePort finds them and gets them ready.": "",
"Choose backup folder…": "",
"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 save backup folder": "",
"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": "",
@@ -326,13 +333,16 @@
"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": "",
"Cool Game went through the portal. It was only a demo: nothing was downloaded or installed.": "",
"Copied": "",
"Copied. Save it as a .json file, change the colors and install it.": "",
"Copies cube-map loading screens into six panels around the viewer (GLES and Vulkan). Mixed projection frames are skipped because the Frame draws panels above the scene. Requires cube_standin.": "",
"Copies the game and its saves to the other drive, checks the copy, then removes the old one. The game must be closed. A game on a microSD card only starts while the card is in.": "",
"Copy": "",
"Copy all": "",
@@ -349,7 +359,6 @@
"Corrects specific shaders that hang the game on the Frame. Comes from a game's recipe.": "",
"Cortopia games: don't wait for Meta's org-scoped id": "",
"Cortopia Studios' games (e.g. Escaping Wonderland) ask Meta's platform for an org-scoped user id at start when on Wi-Fi, only to use it as their analytics id. OVRPort's platform library doesn't answer that request (\"An unknown error occurred. Request failed.\"), so the game waits forever on its loading screen. Makes the game take its own offline path for that one request (the analytics id stays empty); the licence check and the logged-in user before it are unchanged. Found with Cpp2IL in libil2cpp.so.": "",
"Couldn't check room data: {error}": "",
"Couldn't check the saves on the Frame: {error}": "",
"Couldn't check this PC": "",
"Couldn't connect: {exc}": "",
@@ -393,9 +402,8 @@
"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 (large)": "",
"Default controller buttons": "",
"Delete": "",
"Delete room": "",
"Delete room?": "",
"Delete {names}?": "",
"Delete {n} items…": "",
"Delete {n} screenshot?": [
@@ -525,6 +533,7 @@
"Eye-tracked": "",
"failed": "",
"Failed": "",
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (for example Demeter).": "",
"Fan": "",
"File": "",
"file": "",
@@ -567,7 +576,6 @@
"For games that freeze the Frame when a controller vibrates (for example Lucky's Tale). Needs a rebuild.": "",
"For games that judder or shimmer while you hold your head still (for example I Am Cat).": "",
"For games that pause or recenter when the headset briefly loses focus.": "",
"For games that reject the room because of the play area.": "",
"For games that show the headset's own controllers. Takes effect after a reinstall.": "",
"For games that show your room on their objects and darkness where your room should be.": "",
"For games that stop at start because the headset reports no colour space (for example OutRun Quest VR).": "",
@@ -692,13 +700,13 @@
"Games see this folder as {value}": "",
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning it on needs a rebuild (it adds a small library in front of OVRPort's loader).": "",
"Games that fade to black by dimming their picture with a layer color scale (XR_KHR_composition_layer_color_scale_bias, for example Batman: Arkham Shadow's loading screens) can show the level loading instead of black on the Frame. FrameBridge then draws the fade itself: a black quad in front of the eyes whose opacity follows the game's dimming. 2 also removes the game's own dimming (if both show, the fade is doubled).": "",
"Games will use a simple room until you save another room.": "",
"Games with the Hardware video decoding patch play their videos on the Frame's video hardware. Turn it off if videos misbehave; games pick it up at their next start. For one game only, put FRAMEPORT_NO_HW_VIDEO=1 before %command% in its Steam launch options.": "",
"Gear VR": "",
"Gear VR / Oculus Go (32-bit)": "",
"Generate OVRPort config": "",
"Generates no-op stubs for ovr_* functions the game imports but OVRPort's platform loader lacks (for example Espire 1/2, Wallace & Gromit, The Climb 2). Symptom: UnsatisfiedLinkError / 'cannot locate symbol ovr_...'. Online/store features stay unavailable.": "",
"Get FramePort ready": "",
"Get Test Quest Controllers": "",
"Gets Cortopia games (for example Escaping Wonderland) past their loading screen.": "",
"Getting FramePort ready": "",
"GiB": "",
@@ -744,7 +752,6 @@
"Hide its processes": "",
"Hide log": "",
"Hide patches": "",
"Hide play-area size": "",
"Hide Quest foveation": "",
"Hide Vulkan foveation": "",
"Hides a Quest-only way of saving GPU power that the Frame lacks. Leave it on.": "",
@@ -811,10 +818,11 @@
"Install {title} on {value}": "",
"installed": "",
"Installed": "",
"Installed from an install link ({host}), version {version}.": "",
"Installed from an install link ({host}).": "",
"Installed games ({len})": "",
"Installed the theme \"{name}\"": "",
"Installed theme file · {file}": "",
"Installed when you build a recorded scan": "",
"Installed {join}": "",
"Installed · a newer build is ready": "",
"Installed · launch test failed": "",
@@ -933,7 +941,6 @@
"Letters, Enter, Backspace, arrows, Esc and shortcuts reach the headset.": "",
"Library": "",
"Library folders": "",
"Lines up with the current play area": "",
"Link": "",
"Linux": "",
"Linux app": "",
@@ -998,9 +1005,10 @@
"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 use walls and furniture from a saved room. Scan your room in the headset, use your play area for walls only, or import a recorded scan.": "",
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
"Modem": "",
"Monitor": "",
"Monitor is using {pct}% of the Frame's CPU": "",
@@ -1060,7 +1068,6 @@
"No process matches.": "",
"no reason given": "",
"No release notes.": "",
"No room saved yet": "",
"No screenshots yet": "",
"No Unreal crash reporter": "",
"No updates": "",
@@ -1147,6 +1154,7 @@
"Open GitHub issue": "",
"Open in browser": "",
"Open save backups": "",
"Open Test Quest Controllers": "",
"Open the Monitor": "",
"Open {scheme}:// links with FramePort?": "",
"Open {title}": "",
@@ -1250,6 +1258,7 @@
"Power": "",
"Power ICs": "",
"Prepare {title}": "",
"Pretend room layout": "",
"Pretty smooth": "",
"Prevents a start-up crash in some Unreal games.": "",
"Previous": "",
@@ -1267,7 +1276,6 @@
"Provides stand-ins for Meta store functions the game expects, so it doesn't crash at start.": "",
"Provides stand-ins for Quest system calls some games make at start.": "",
"Put a password, link or note in the box and press Type it.": "",
"Put on the headset and turn on passthrough. The scan starts by itself.": "",
"Put the Frame to sleep": "",
"Put the Frame to sleep?": "",
"Puts back the saves {title} had before the last restore. The saves it has now become the copy you can undo to.": "",
@@ -1280,6 +1288,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. To see what each mode does, try the free Test Quest Controllers app (Settings → Controllers): it lights up every Quest button a game receives.": "",
"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.": "",
@@ -1316,7 +1326,6 @@
"Reduces shading away from the full-resolution area. Changes apply at the next game start.": "",
"Refresh": "",
"Refresh rate": "",
"Refresh room": "",
"Refresh store details": "",
"Reinstall anyway…": "",
"Reinstall on Frame": "",
@@ -1418,14 +1427,8 @@
"Revive {value} · {value2} backend": "",
"Revive: SteamVR (OpenVR) backend on PC": "",
"Rift": "",
"Room": "",
"Room depth": "",
"Room floor plan": "",
"Room height": "",
"Room positions belong to the play area where they were recorded. If SteamVR changes it, scan again or use the new boundary to line up virtual walls with your room.": "",
"Room scan saved": "",
"Room scanned": "",
"Room setup": "",
"Room width": "",
"Rotate panels 180°": "",
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.": "",
@@ -1443,7 +1446,6 @@
"Runs OVRPort and apksigner": "",
"Runs the game directly": "",
"Runs xrSyncActions one at a time with xrPollEvent and skips it for 250 ms after focus returns. For games that crash in the runtime's input code right after focus comes back (SIGSEGV in vrclient.so UpdateActionStateInternal / xrSyncActions, for example Myst).": "",
"Same play area": "",
"Save": "",
"Save as known-good recipe": "",
"Save backups to keep per game": "",
@@ -1463,13 +1465,8 @@
"Saves what the game draws every few seconds, for testing without the headset.": "",
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.": "",
"Scan a folder…": "",
"Scan room in the headset": "",
"Scan your room for mixed reality": "",
"Scan {name}": "",
"Scanner installed": "",
"Scanning: {directions} of 12 directions seen{up}": "",
"Scans your game folders again and adds new games. A game you moved into one of them gets its new place; games already in the library keep their name, recipe, tags and art. Settings → Library folders lists the folders.": "",
"Scene games get your saved room. Without one, they get a simple room from the play area.": "",
"Screenshot copied. Paste it anywhere.": "",
"Screenshot taken": "",
"Screenshots": "",
@@ -1489,10 +1486,7 @@
"Select all": "",
"Select all shown": "",
"Select games to install": "",
"Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved room, use a simple play-area box (for example Demeter).": "",
"Set by you.": "",
"Set up a play area in SteamVR first. Valve's default circle does not describe your room.": "",
"Set up a room in your current play area": "",
"Set up for FramePort": "",
"Set up with a USB cable": "",
"Set up your Frame": "",
@@ -1508,6 +1502,7 @@
"Show advanced settings": "",
"Show all patches ({len} don't apply to this game)": "",
"Show as VR or as a flat window": "",
"Show cube loading screens": "",
"Show curved menus and screens": "",
"Show details": "",
"Show hidden files": "",
@@ -1526,6 +1521,7 @@
"Show video panels": "",
"Showing {len} of {n}": "",
"Showing {shown} of {n}. Search to find others.": "",
"Shows 360° cube loading screens as six panels around you. Requires stand-in cube layers. Skipped while a 3D scene is submitted.": "",
"Shows 3D 360° pictures flat if they look doubled.": "",
"Shows Batman: Arkham Shadow's cutscenes; set by that game's recipe.": "",
"Shows each eye's picture to the other eye. For testing.": "",
@@ -1610,6 +1606,7 @@
"Steam doesn't sync Quest games' saves. This copies each installed Quest game's saves to this PC (caches and big downloaded content stay out). Restore one from the game's menu → Restore saves….": "",
"Steam for Windows not found": "",
"Steam Frame": "",
"Steam Frame controllers: each button shows the Quest Touch button it sends. A dash means it sends nothing.": "",
"Steam Frame patches": "",
"Steam Frame: installed on the Frame, with an entry in its Steam library. This PC: a Steam shortcut that starts the game through Revive, for a headset connected to this PC.": "",
"Steam game": "",
@@ -1620,6 +1617,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)": "",
@@ -1682,6 +1680,7 @@
"Test": "",
"Test Proton": "",
"Test Proton on the Frame": "",
"Test Quest Controllers is a small free app that shows every Quest controller input it receives on a 3D model of the controller. Install it, then try each controller button mode and see which Quest button each Frame button sends before you choose one for your games.": "",
"Text and layout size": "",
"That APK is a different app, not {title}.": "",
"That key can't be used: {error}": "",
@@ -1717,7 +1716,7 @@
"The Frame's battery is at {pct}% and not charging. Plug it into a strong charger — installs pause at {pause}%.": "",
"The Frame's battery is at {pct}%, so installs are paused. Plug it in and they continue.": "",
"The Frame's desktop password, needed only once so FramePort can add its key.": "",
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, GLES games) and drops its layers, so the game runs on without that layer.": "",
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself on GLES and Vulkan; cube_quads displays its loading screens.": "",
"The Frame's Steam couldn't start {title} (error {code}). Please report it.": "",
"The Frame's Steam didn't accept {title}, so it was added as \"Devkit Game: …\". Starting it now — put the headset on.": "",
"The Frame's Steam library still shows the old artwork.": "",
@@ -1745,7 +1744,6 @@
"The PC VR counterpart of OVRPort's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); without it the game runs as a flat window. On the Frame the launcher runs the game through FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive handle it there).": "",
"the previous version, kept as a backup": "",
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games (Gear VR, Oculus Go) run through FramePort's 32-bit bridge (experimental).": "",
"The scanner turns a recorded depth-mesh dump into a room. FramePort installs it when you build a recorded scan.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or turn off HTTPS scanning.": "",
"The store's name": "",
@@ -1803,6 +1801,7 @@
"Trust the new key": "",
"Try again": "",
"Try now": "",
"Try the modes first: Test Quest Controllers shows which Quest button each Frame button sends.": "",
"Try this setting": "",
"Turn it down if controller vibrations are too strong.": "",
"Turn off only for games you play with your hands instead of controllers.": "",
@@ -1930,8 +1929,7 @@
"Use controllers": "",
"Use FramePort": "",
"Use FramePort for these links": "",
"Use my play area": "",
"Use room layout": "",
"Use global default ({mode})": "",
"Use the default": "",
"Use the new recipe": "",
"Use the repack's launcher (bundled Revive)": "",
@@ -1940,12 +1938,11 @@
"Use this PC's keyboard on the Frame.": "",
"Use this program": "",
"Use your own artwork…": "",
"Use your SteamVR play area as walls, without furniture.": "",
"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.": "",
"Uses the store's name and icon for the game.": "",
"Using your play area; furniture is not included": "",
"Valve's Android container on the Frame. Quest games run in it, one container per game. It needs Developer Mode.": "",
"Valve's tool that runs Windows games. On the Frame it runs PC VR games; FramePort installs it when needed (Steam restarts once).": "",
"Vermillion shows a canvas being painted as a separate compositor layer, which the Frame draws in front of everything: the hand and brush disappear over the canvas while they still paint. Rewrites PaintableMesh.HasCompositorLayerAvailable and ShouldUseCompositorOverlay -> false in libil2cpp.so (found with Cpp2IL), so the game uses its own fallback and draws the canvas in the scene. The canvas may look a little less sharp.": "",
@@ -2027,7 +2024,6 @@
"Windows' firewall blocks the Frame on public networks. Set this network to Private in Windows' network settings.": "",
"Windows' firewall for WSL blocks the Frame. Show the setup command again and allow the change when Windows asks.": "",
"With emulated passthrough, honours the eye layer's XR_FB_composition_layer_alpha_blend, which the Frame's runtime ignores: Unreal games keep inverted alpha and ask for inverse blending, so the room showed where the game drew and black where it should show the room (for example Star Wars: Beyond Victory). The alpha is inverted in the game's image before it is composited (Vulkan).": "",
"With scene_emul, the game's play-area (STAGE bounds) query answers \"no play area\". The Frame reports a 1x1 m area whose outline OVRPlugin can't give, and some Unity MR games then fail their guardian check (for example Demeter). Off by default: other games stalled without a play area.": "",
"Without a Meta account, OVRPort's platform library makes up a new user id at every start (ovr_GetLoggedInUserID). Games that file saves under the user id (for example Walkabout Mini Golf) then start with an empty profile each time and seem to lose all progress. Gives the game a fixed id instead, different for each game and each FramePort user.": "",
"Without Steam's shader cache layer": "",
"Working": "",
@@ -2130,6 +2126,10 @@
"",
""
],
"{n} game needs Update on Frame first.": [
"",
""
],
"{n} game now uses the other version found there": [
"",
""
@@ -2231,7 +2231,6 @@
"{value} failed ({returncode})": "",
"{value} installed, but didn't start": "",
"{value} took too long — update by hand: {join}": "",
"{walls} walls · {area:.1f} m² · {height:.1f} m high": "",
"Čeština": "",
"Русский": "",
"Українська": "",
-1
View File
@@ -1 +0,0 @@
"""Portable room files shared by the CLI and GUI."""
-308
View File
@@ -1,308 +0,0 @@
"""FramePort room format v1, in SteamVR STAGE coordinates (metres, +Y up).
Unknown text records are ignored so a newer scanner can work with an older app.
Known records are validated before upload; mesh paths never escape the room folder.
"""
from __future__ import annotations
import math
import re
import struct
import time
import uuid
from collections import Counter
from dataclasses import dataclass, field
from html import escape
def _id() -> str:
return uuid.uuid4().hex
@dataclass
class Wall:
uuid: str
x0: float
z0: float
x1: float
z1: float
y0: float = 0
y1: float = 2.5
label: str = "WALL_FACE" # INVISIBLE_WALL_FACE: an edge the scanner closed without seeing a wall
WALL_LABELS = ("WALL_FACE", "INVISIBLE_WALL_FACE")
@dataclass
class Volume:
uuid: str
label: str
cx: float
cz: float
top_y: float
yaw_deg: float
w: float
d: float
h: float
@dataclass
class Plane:
uuid: str
label: str
px: float
py: float
pz: float
qx: float
qy: float
qz: float
qw: float
w: float
h: float
@dataclass
class Mesh:
uuid: str = field(default_factory=_id)
filename: str = "room-mesh.bin"
vertex_count: int = 0
index_count: int = 0
vertices: list[tuple[float, float, float]] = field(default_factory=list)
indices: list[int] = field(default_factory=list)
def __post_init__(self):
if self.vertices:
self.vertex_count = len(self.vertices)
if self.indices:
self.index_count = len(self.indices)
@dataclass
class Room:
universe: int = 0
source: str = "manual"
time: int = field(default_factory=lambda: int(time.time()))
uuid: str = field(default_factory=_id)
floor_uuid: str = field(default_factory=_id)
ceiling_uuid: str = field(default_factory=_id)
floor: float = 0
ceiling: float = 2.5
outline: list[tuple[float, float]] = field(default_factory=list)
walls: list[Wall] = field(default_factory=list)
volumes: list[Volume] = field(default_factory=list)
planes: list[Plane] = field(default_factory=list)
mesh: Mesh | None = None
play_area: list[tuple[float, float]] = field(default_factory=list)
def _area(points) -> float:
return sum(x * b - a * z for (x, z), (a, b) in zip(points, points[1:] + points[:1], strict=True)) / 2
def _uuid(value: str) -> str:
if not re.fullmatch(r"[0-9a-f]{32}", value):
raise ValueError("UUID must be 32 lowercase hex digits")
return value
def _numbers(values):
result = [float(v) for v in values]
if not all(math.isfinite(v) for v in result):
raise ValueError("coordinates must be finite")
return result
def _finish(room: Room) -> Room:
if not room.outline and room.walls:
xs = [x for w in room.walls for x in (w.x0, w.x1)]
zs = [z for w in room.walls for z in (w.z0, w.z1)]
room.outline = [(min(xs), min(zs)), (max(xs), min(zs)),
(max(xs), max(zs)), (min(xs), max(zs))]
if len(room.outline) < 3 or abs(_area(room.outline)) < 1e-8:
raise ValueError("room needs an outline with nonzero area or walls")
if _area(room.outline) < 0:
room.outline.reverse()
if room.ceiling <= room.floor:
raise ValueError("ceiling must be above floor")
if not room.walls:
room.walls = [Wall(_id(), *a, *b, room.floor, room.ceiling)
for a, b in zip(room.outline, room.outline[1:] + room.outline[:1], strict=True)]
edges = list(zip(room.outline, room.outline[1:] + room.outline[:1], strict=True))
for wall in room.walls:
if wall.y1 <= wall.y0 or (wall.x0, wall.z0) == (wall.x1, wall.z1):
raise ValueError("wall must have positive height and length")
if ((wall.x1, wall.z1), (wall.x0, wall.z0)) in edges:
wall.x0, wall.x1 = wall.x1, wall.x0
wall.z0, wall.z1 = wall.z1, wall.z0
ids = [room.uuid, room.floor_uuid, room.ceiling_uuid]
ids += [v.uuid for v in [*room.walls, *room.volumes, *room.planes]]
if room.mesh:
ids.append(room.mesh.uuid)
if len(ids) != len(set(ids)):
raise ValueError("entity UUIDs must be unique")
return room
def parse(text: str) -> Room:
"""Read v1 text, tolerating comments, blank lines and future record types."""
lines = [line.split("#", 1)[0].split() for line in text.splitlines()]
lines = [line for line in lines if line]
if not lines or lines[0] != ["frameport-room", "1"]:
raise ValueError("expected frameport-room 1")
room, ceiling_seen = Room(), False
for parts in lines[1:]:
kind, values = parts[0], parts[1:]
try:
if kind in ("universe", "time"):
if len(values) != 1 or not values[0].isdigit():
raise ValueError("expected a nonnegative integer")
n = int(values[0])
if kind == "universe" and n >= 2**64:
raise ValueError("universe exceeds u64")
setattr(room, kind, n)
elif kind == "source":
if len(values) != 1 or values[0] not in ("scan", "boundary", "box", "manual"):
raise ValueError("invalid source")
room.source = values[0]
elif kind == "room":
if len(values) != 1:
raise ValueError("expected UUID")
room.uuid = _uuid(values[0])
elif kind in ("floor", "ceiling"):
ident, height = values
setattr(room, kind + "_uuid", _uuid(ident))
setattr(room, kind, _numbers([height])[0])
ceiling_seen |= kind == "ceiling"
elif kind == "outline":
n = int(values[0])
coords = _numbers(values[1:])
if n < 3 or len(coords) != n * 2:
raise ValueError("outline count does not match coordinates")
room.outline = list(zip(coords[::2], coords[1::2], strict=True))
elif kind == "wall":
if len(values) not in (7, 8) or (len(values) == 8 and values[7] not in WALL_LABELS):
raise ValueError("expected UUID, 6 coordinates and an optional wall label")
room.walls.append(Wall(_uuid(values[0]), *_numbers(values[1:7]), *values[7:]))
elif kind in ("volume", "plane"):
count = 9 if kind == "volume" else 11
if len(values) != count or not re.fullmatch(r"[A-Z][A-Z0-9_]*", values[1]):
raise ValueError("invalid label or coordinate count")
obj = (Volume if kind == "volume" else Plane)(_uuid(values[0]), values[1], *_numbers(values[2:]))
if min(obj.w, obj.h, obj.d if kind == "volume" else obj.w) <= 0:
raise ValueError("dimensions must be positive")
if kind == "plane" and sum(q*q for q in (obj.qx, obj.qy, obj.qz, obj.qw)) < 1e-12:
raise ValueError("plane quaternion is zero")
(room.volumes if kind == "volume" else room.planes).append(obj)
elif kind == "mesh":
ident, filename, nv, ni = values
if not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]*", filename) or filename in ("room.txt", "scan.log"):
raise ValueError("mesh must be a sidecar filename")
nv, ni = int(nv), int(ni)
if nv < 0 or ni < 0 or ni % 3 or ni > 600000:
raise ValueError("invalid mesh counts")
room.mesh = Mesh(_uuid(ident), filename, nv, ni)
except (ValueError, IndexError, TypeError) as exc:
raise ValueError(f"{kind}: {exc}") from exc
if not ceiling_seen:
room.ceiling = room.floor + 2.5
return _finish(room)
def dumps(room: Room) -> str:
"""Write canonical text; validate programmatically constructed rooms too."""
_finish(room)
def row(*values):
return " ".join(format(v, ".9g") if isinstance(v, float) else str(v) for v in values)
rows = ["frameport-room 1", row("universe", room.universe), row("source", room.source), row("time", room.time),
row("room", room.uuid), row("floor", room.floor_uuid, room.floor),
row("ceiling", room.ceiling_uuid, room.ceiling),
row("outline", len(room.outline), *(v for p in room.outline for v in p))]
for w in room.walls: # the label only when it isn't the default (older readers know 7 fields)
rows.append(row("wall", w.uuid, w.x0, w.z0, w.x1, w.z1, w.y0, w.y1,
*([w.label] if w.label != "WALL_FACE" else [])))
for kind, objects in (("volume", room.volumes), ("plane", room.planes)):
rows.extend(row(kind, *vars(obj).values()) for obj in objects)
if room.mesh:
m = room.mesh
rows.append(row("mesh", m.uuid, m.filename, m.vertex_count, m.index_count))
text = "\n".join(rows) + "\n"
parse(text)
return text
def read_mesh(data: bytes) -> Mesh:
if len(data) < 16:
raise ValueError("truncated mesh header")
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
if magic != b"FPRM" or version != 1:
raise ValueError("expected FPRM version 1")
if ni % 3 or ni > 600000 or len(data) != 16 + nv * 12 + ni * 4:
raise ValueError("invalid mesh length or triangle count")
vertices = list(struct.iter_unpack("<fff", data[16:16 + nv * 12]))
indices = [v[0] for v in struct.iter_unpack("<I", data[16 + nv * 12:])]
if any(not math.isfinite(x) for p in vertices for x in p) or any(i >= nv for i in indices):
raise ValueError("invalid mesh coordinate or index")
return Mesh(vertex_count=nv, index_count=ni, vertices=vertices, indices=indices)
def write_mesh(mesh: Mesh) -> bytes:
data = struct.pack("<4sIII", b"FPRM", 1, len(mesh.vertices), len(mesh.indices))
data += b"".join(struct.pack("<fff", *p) for p in mesh.vertices)
data += b"".join(struct.pack("<I", i) for i in mesh.indices)
read_mesh(data)
return data
def from_boundary(outline_xz, height: float = 2.5) -> Room:
coords = [tuple(_numbers(p)) for p in outline_xz]
if any(len(p) != 2 for p in coords) or not math.isfinite(height) or height <= 0:
raise ValueError("invalid boundary or height")
return _finish(Room(source="boundary", ceiling=height, outline=coords))
def box_room(width: float, depth: float, height: float = 2.5) -> Room:
if not all(math.isfinite(v) and v > 0 for v in (width, depth, height)):
raise ValueError("room dimensions must be positive and finite")
w, d = width / 2, depth / 2
room = from_boundary([(-w, -d), (w, -d), (w, d), (-w, d)], height)
room.source = "box"
return room
def summary(room: Room) -> dict:
return {"walls": len(room.walls), "volumes": dict(Counter(v.label for v in room.volumes)),
"planes": len(room.planes), "floor_area": abs(_area(room.outline)),
"ceiling_height": room.ceiling - room.floor}
def preview_svg(room: Room) -> str:
"""Self-contained top-down preview; text labels are escaped, never interpreted as markup."""
points = room.outline + room.play_area
xmin, xmax = min(x for x, _ in points), max(x for x, _ in points)
zmin, zmax = min(z for _, z in points), max(z for _, z in points)
scale = 440 / max(xmax-xmin, zmax-zmin, .1)
def point(x, z):
return f"{30+(x-xmin)*scale:.2f},{30+(zmax-z)*scale:.2f}"
def polygon(poly):
return " ".join(point(x, z) for x, z in poly)
out = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" role="img">',
'<title>Room floor plan</title>',
f'<polygon points="{polygon(room.outline)}" fill="#17232f" stroke="#f49a42" stroke-width="3"/>']
for wall in room.walls:
x0, y0 = point(wall.x0, wall.z0).split(",")
x1, y1 = point(wall.x1, wall.z1).split(",")
out.append(f'<line x1="{x0}" y1="{y0}" x2="{x1}" y2="{y1}" stroke="#f49a42" stroke-width="3"/>')
for v in room.volumes:
angle = math.radians(v.yaw_deg)
c, s = math.cos(angle), math.sin(angle)
corners = [(v.cx+c*x+s*z, v.cz-s*x+c*z)
for x, z in [(-v.w/2, -v.d/2), (v.w/2, -v.d/2), (v.w/2, v.d/2), (-v.w/2, v.d/2)]]
x, y = point(v.cx, v.cz).split(",")
out += [f'<polygon points="{polygon(corners)}" fill="#275772" stroke="#65bcec"/>',
f'<text x="{x}" y="{y}" text-anchor="middle" fill="#ffffff" font-size="12">{escape(v.label)}</text>']
if room.play_area:
out.append(f'<polygon points="{polygon(room.play_area)}" fill="none" stroke="#65bcec" stroke-dasharray="6 4"/>')
return "\n".join(out + ["</svg>"])
-1
View File
@@ -34,7 +34,6 @@ def meta_audio_libs(a: Analysis) -> list[str]:
def uses_scene(a: Analysis) -> bool:
"""Scene layouts and spatial anchors both need the Meta scene API on the Frame."""
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
return any(p.endswith(("USE_SCENE", "USE_ANCHOR_API")) for p in perms)
+2 -2
View File
@@ -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()
+2 -2
View File
@@ -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
@@ -75,14 +75,11 @@ class MetaPermissions(Patch):
order = 22
def detect(self, a):
from ..applicability import needs_scene, uses_scene
from ..applicability import needs_scene
if needs_scene(a):
return Suggestion(True, "Mixed-reality game that needs the room model: its 'use spatial data' "
"permission must be granted (for example Demeter).")
if uses_scene(a): # gets the room (scene_emul) too: without the permission it never asks for it
return Suggestion(True, "Uses Meta's room or anchor APIs: their permissions must be granted, or the "
"game never asks for the room (for example NOPE's MR mode).")
scene = [p for p in a.meta_permissions if any(k in p for k in ("SCENE", "ANCHOR", "SPATIAL", "BOUNDARY"))]
if scene:
names = ", ".join(p.rsplit(".", 1)[-1] for p in scene[:3])
+41 -25
View File
@@ -51,12 +51,20 @@ 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_quads", "int", 1, "Show cube layers",
"Copies cube-map loading screens into six panels around the viewer (GLES and Vulkan). Mixed projection frames "
"are skipped because the Frame draws panels above the scene. Requires cube_standin."),
("cube_standin", "int", 1, "Stand-in cube swapchains",
"The Frame's runtime has no cube-map layers and refuses their swapchains; OVRPlugin then crashes when it submits "
"the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself (a GL cube map, "
"GLES games) and drops its layers, so the game runs on without that layer."),
"the next frame (for example Budget Cuts Ultimate). FrameBridge serves such a swapchain itself on GLES and "
"Vulkan; cube_quads displays its loading screens."),
("surface_native", "int", 0, "Native video surfaces (Batman renderer)",
"Plays the game's Android video surface through GPU-shared buffers and composes it as a stereo panorama (Batman: "
"Arkham Shadow's cutscenes, see docs/SURFACE_VIDEO.md). Specific to that game's renderer: set by its recipe, not "
@@ -150,17 +158,13 @@ SETTINGS = [
"that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s "
"(compare both in one session)."),
("scene_emul", "int", 0, "Emulate Meta scene (room)",
"Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved "
"room, use a simple play-area box (for example Demeter)."),
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality "
"games that build their level from the room (for example Demeter)."),
("controller_models", "int", 0, "Steam Frame controller models",
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get "
"the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and "
"are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning "
"it on needs a rebuild (it adds a small library in front of OVRPort's loader)."),
("scene_hide_bounds", "int", 0, "Hide the play-area size from the game",
"With scene_emul, the game's play-area (STAGE bounds) query answers \"no play area\". The Frame reports a 1x1 m "
"area whose outline OVRPlugin can't give, and some Unity MR games then fail their guardian check (for example "
"Demeter). Off by default: other games stalled without a play area."),
("scene_height", "float", 2.5, "Emulated room height (m)", "Ceiling height for scene_emul."),
("scene_width", "float", 0.0, "Emulated room width (m)",
"Override the guardian width (0 = use guardian, min 1.5 m)."),
@@ -331,6 +335,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")),
@@ -401,11 +409,9 @@ UI: dict[str, dict] = {
depends="passthrough_emul",
help="For games that show your room on their objects and darkness where your room "
"should be.", control=("switch",)),
"scene_emul": dict(group="room", level="common", label="Use room layout",
help="Scene games get your saved room. Without one, they get a simple room from the play area.",
"scene_emul": dict(group="room", level="common", label="Pretend room layout",
help="Mixed-reality games that build their level from your room get a simple room with walls.",
control=("switch",)),
"scene_hide_bounds": dict(group="room", level="advanced", label="Hide play-area size", depends="scene_emul",
help="For games that reject the room because of the play area.", control=("switch",)),
"scene_height": dict(group="room", level="advanced", label="Room height", depends="scene_emul",
help="Ceiling height of the pretend room.", control=("slider", 2.0, 4.0, 0.1, "{:.1f} m")),
"scene_width": dict(group="room", level="advanced", label="Room width", depends="scene_emul",
@@ -511,6 +517,9 @@ UI: dict[str, dict] = {
"ovrp_hold_physics": dict(group="troubleshooting", level="advanced", label="Steadier hands (older Unity)",
help="For older Unity games whose hands trail the controllers (for example Sniper Elite "
"VR). In others the hands lag.", control=("switch",)),
"cube_quads": dict(group="troubleshooting", level="advanced", label="Show cube loading screens",
help="Shows 360° cube loading screens as six panels around you. Requires stand-in cube layers. "
"Skipped while a 3D scene is submitted.", control=("switch",)),
"cube_standin": dict(group="troubleshooting", level="advanced", label="Stand in for cube layers",
help="Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts "
"Ultimate). Leave it on.", control=("switch",)),
@@ -528,10 +537,10 @@ UI: dict[str, dict] = {
# settings that need the xrshim (frame/adapter.py: OVRPlugin's own arm64 OpenXR lookups routed to the adapter), so
# they don't work for 32-bit games on the bridge. surface_native: Batman's native video renderer hooks Vulkan
# functions OVRPort's dispatcher doesn't know. scene_emul: the dispatcher refuses every scene/anchor function
# ("Unknown proc addr: xrQuerySpacesFB", Pinball FX VR 2026-10-16), so the emulated room only reaches the game through
# the shim (Demeter/NOPE only got it because haptic_fix happened to be on)
XRSHIM_SETTINGS = ("hide_fb_foveation", "controller_models", "haptic_fix", "surface_native", "scene_emul")
# functions OVRPort's dispatcher doesn't know
XRSHIM_SETTINGS = ("hide_fb_foveation", "controller_models", "haptic_fix", "surface_native")
# shown with an "experimental" pill (Game settings, game page, CLI): not proven in the headset on many games yet
EXPERIMENTAL_SETTINGS = ("quest_buttons",)
class AdapterSetting(Patch):
@@ -547,6 +556,7 @@ class AdapterSetting(Patch):
self.default = default
self.revision = REVISIONS.get(key, 1)
self.a32 = key not in XRSHIM_SETTINGS
self.experimental = key in EXPERIMENTAL_SETTINGS
@property
def summary(self) -> str:
@@ -554,7 +564,7 @@ class AdapterSetting(Patch):
return UI.get(self.key, {}).get("help") or super().summary
def detect(self, a):
from .applicability import uses_equirect_layers, uses_render_models, uses_scene
from .applicability import needs_scene, uses_equirect_layers, uses_render_models, uses_room_model
if self.key == "controller_fix" and a.extra.get("hand_tracking_only"):
return Suggestion(True, "Hand tracking is required by the game: pass hands through instead of reporting "
@@ -590,9 +600,15 @@ class AdapterSetting(Patch):
# OVRPort's patch_disable_space_warp doesn't reach UE5). Off until confirmed in more games.
return Suggestion(False, "Unreal Engine 5 game with Meta's space warp: turn on if the picture flickers or "
"smears (for example Into The Radius 2).", {"value": 1})
if self.key == "scene_emul" and uses_scene(a):
return Suggestion(True, "The game uses Meta's scene or spatial anchor APIs: serve the saved room on "
"the Frame, or a simple play-area room until one is scanned.", {"value": 1})
if self.key == "scene_emul" and needs_scene(a):
return Suggestion(True, "Mixed-reality game that builds its level from the room model: emulate a "
"guardian-sized room (for example Demeter).", {"value": 1})
if self.key == "scene_emul" and uses_room_model(a):
# also VR games that only ask for the room (VR HOT's room setup retried forever without it)
return Suggestion(True, "The game asks Meta's system for your room layout (scene), which the Frame "
"doesn't have: emulate a room from the play area (for example VR HOT's room "
"setup).",
{"value": 1})
return None
def applies(self, a):
@@ -603,7 +619,6 @@ class AdapterSetting(Patch):
rules = {
"scene_emul": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"scene_height": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"scene_hide_bounds": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"scene_width": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"scene_depth": lambda a: ap.uses_scene(a) or a.extra.get("mr_only"),
"passthrough_emul": lambda a: (a.extra.get("mr_only")
@@ -650,10 +665,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]
@@ -855,8 +871,8 @@ class Foveation(Patch):
id = "device.foveation"
title = "Foveated rendering (Valve)"
description = ("Reduces shading away from the full-resolution area, following each eye's gaze. "
"Default keeps the runtime profile unchanged. Low has the largest full-resolution center; medium and high "
"use smaller centers and stronger peripheral detail reduction. Larger areas "
"Default keeps the runtime profile unchanged. Low has the largest full-resolution center; "
"medium and high use smaller centers and stronger peripheral detail reduction. Larger areas "
"retain more detail but save less GPU work. Fixed stops the area following your eyes. "
"The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. "
"Tested on RE4, Deadpool and Asgard's Wrath 2. "
+40 -16
View File
@@ -721,6 +721,20 @@ def add_windows_exe(exe: Path | str, reporter: Reporter | None = None) -> dict:
return library.upsert_game(entry["package"], exe_confirmed=True)
def _linked_game(path: Path, report: ScanReport, reporter: Reporter | None) -> dict | None:
"""The library entry a downloaded APK belongs to when the scan found it as a game the library already has (an
install-link update: the older download is gone, so the scan took the new one as "moved"/"updated" and returned
nothing). If the scan left the library's copy (still there: "other copy"/"other version"), the link wins: the
entry switches to the download like "Update with a newer APK…"."""
for item in report.moved + report.updated:
if _same_path(item["path"], path):
return library.game(item["package"])
for item in report.other_version + report.other_copy:
if _same_path(item["path"], path):
return replace_apk(item["package"], path, reporter)["entry"]
return None
def add_from_link(manifest, path: Path, reporter: Reporter | None = None, icon: Path | None = None) -> dict:
"""Add a build downloaded from an install link (deeplink.download) to the library: an APK (with any OBB files
next to it), a Linux build or a Windows program. A manifest's name becomes the title (FrameDrop: "name is what
@@ -729,10 +743,11 @@ def add_from_link(manifest, path: Path, reporter: Reporter | None = None, icon:
kind = deeplink.classify(path.name)
if kind == deeplink.APK:
added = add_path(path, reporter, art=True)
if not added:
report = ScanReport()
added = add_path(path, reporter, art=True, report=report)
entry = added[0] if added else _linked_game(path, report, reporter)
if entry is None:
raise ValueError(f"{path.name} couldn't be read as an Android app")
entry = added[0]
elif kind == deeplink.LINUX:
entry = add_linux_app(path, reporter)
elif kind == deeplink.EXE:
@@ -771,6 +786,18 @@ def add_from_link(manifest, path: Path, reporter: Reporter | None = None, icon:
LINK_CHECK_HOURS = 6
def link_installed_version(g: dict) -> str:
"""The version of a game from an install link that updates are compared against: the one its manifest named,
else the APK's own versionName from the analysis (manifests that named no version, e.g. a first release), else
"". No file access: the game page calls it while drawing."""
version = (g.get("link") or {}).get("version")
if version:
return str(version)
if is_rift(g) or is_linux(g):
return ""
return str((g.get("analysis") or {}).get("version") or "")
def check_link_updates(force: bool = False, reporter: Reporter | None = None) -> list[str]:
"""Fetch the manifest of every game installed from an install link whose manifest named a version again, and mark
the ones with a newer version (`link_update` = {"version", "checked"}; cleared when the manifest no longer offers
@@ -785,16 +812,16 @@ def check_link_updates(force: bool = False, reporter: Reporter | None = None) ->
library.set_setting("links.last_update_check", time.time())
found = {}
for g in library.games():
link = g.get("link")
if not deeplink.can_check_updates(link):
link, current = g.get("link"), link_installed_version(g)
if not deeplink.can_check_updates(link, current):
continue
try:
m = deeplink.check_update(link)
m = deeplink.check_update(link, current)
except Exception as exc: # noqa: BLE001 - offline, moved manifest…: try again next time
if reporter:
reporter.log(f"{g['package']}: update check failed: {exc}")
continue
found[g["package"]] = {"version": m.version, "checked": time.time()} if m else None
found[g["package"]] = {"version": m.version, "checked": time.time(), "current": current} if m else None
with library.edit() as data:
for pkg, offer in found.items():
if pkg not in data["games"]:
@@ -1275,19 +1302,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 +1792,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
-6
View File
@@ -6,7 +6,6 @@ from __future__ import annotations
from ..core.models import Analysis, Recipe
from ..patches import base
from ..patches.applicability import uses_scene
from . import catalog
from .catalog import TOGGLED_DEVICE
@@ -62,11 +61,6 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
for key, value in entry.adapter.items():
recipe.patches[f"adapter.{key}"] = {"value": value}
recipe.reasons[f"adapter.{key}"] = why
if ((recipe.patches.get("adapter.scene_emul") or {}).get("value") and uses_scene(analysis)
and "frame.meta_permissions" not in entry.frame_remove):
# the room needs Meta's scene/anchor permissions granted, or the game never asks for it (NOPE)
recipe.patches.setdefault("frame.meta_permissions", {})
recipe.reasons.setdefault("frame.meta_permissions", "Meta's room and anchor permissions for the saved room.")
if entry.device_files:
recipe.patches["device.files"] = {"files": dict(entry.device_files)}
recipe.reasons["device.files"] = why
+52 -6
View File
@@ -18,7 +18,7 @@ from pathlib import Path
import flet as ft
from .. import pipeline
from .. import deeplink, pipeline
from ..core import applog, library
from ..errors import explain
from ..frame import monitor as M
@@ -121,6 +121,7 @@ class FramePortApp:
def __init__(self, page: ft.Page):
from .views.library import load_filters
pop_dialog_skips_toasts()
self.page = page
self.target = None # FrameLeptonTarget when connected
self.frame_info: dict | None = None
@@ -1184,6 +1185,7 @@ class FramePortApp:
"update_app": lambda e: self.update_app_only(pkg),
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
"check_link": lambda e: self._update_from_link([pkg]),
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
"remove": lambda e: self.remove_from_library(pkg),
@@ -1205,7 +1207,7 @@ class FramePortApp:
selectable=lv is not None,
media_button=C.is_media_player(g) and not rift,
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
new_version=rift or linux)
new_version=rift or linux, link=deeplink.can_check_link(g.get("link")))
return menu_sections(g, st, handlers.__getitem__)
def open_game_menu(self, pkg: str) -> None:
@@ -2393,6 +2395,12 @@ class FramePortApp:
self.toast(tr("Game recipes are up to date"))
return n
def _update_from_link(self, pkgs: list[str]) -> None:
"""Ask the install links of these games for a newer version now (the game menu's "Check for updates")."""
from .views.link_dialog import update_from_link
update_from_link(self, pkgs)
def _check_link_updates(self, force: bool = False) -> list[str]:
"""Games installed from an install link whose manifest names a newer version now (pipeline.
check_link_updates; at most every 6 h): the Library bar and the game page offer the update."""
@@ -2401,13 +2409,19 @@ class FramePortApp:
new = [p for p in found if p not in before]
if set(found) != before:
self.refresh_view()
from .views.link_dialog import update_from_link
# the toast's own button: without one it said an update exists but offered no way to get it
update = lambda pkgs: (lambda e: update_from_link(self, pkgs)) # noqa: E731
if new:
self.toast(tr_n("An update is available for {name}", "Updates are available for {n} games",
len(new), name=self._title(new[0])))
len(new), name=self._title(new[0])), action=tr("Update"), on_action=update(new))
elif force: # Settings → Check now
self.toast(tr("Games from install links are up to date") if not found else
tr_n("{n} update is waiting in the Library", "{n} updates are waiting in the Library",
len(found)))
if not found:
self.toast(tr("Games from install links are up to date"))
else:
self.toast(tr_n("{n} update is waiting in the Library", "{n} updates are waiting in the Library",
len(found)), action=tr("Update"), on_action=update(found))
if force and self.route[0] == "settings":
self.render() # the "last checked" line
return found
@@ -3178,6 +3192,38 @@ def serialize_flet_updates() -> None:
applog.log.warning("Flet's property writes couldn't be serialized (another Flet version?): %s", exc)
def pop_dialog_skips_toasts() -> None:
"""Make page.pop_dialog() close the topmost open pop-up, never a toast.
Flet 1.0 keeps toasts (SnackBars) in the same stack as dialogs and pop_dialog() closes the topmost open entry, so
a toast that appeared while a pop-up was open was closed instead of the pop-up: an install link's question stayed
on screen after "Download and install" (its job's "waiting for your answer" toast came just after it) while the
download ran. Toasts close themselves or with their X. Hooks Flet internals like serialize_flet_updates: skipped
(logged) if a Flet version moves them."""
try:
from flet.controls.base_page import BasePage
original_pop = BasePage.pop_dialog
if getattr(original_pop, "_skips_toasts", False):
return
def pop_dialog(self):
try:
shown = self._dialogs.controls # noqa: SLF001
except AttributeError:
return original_pop(self)
dialog = next((d for d in reversed(shown) if d.open and not isinstance(d, ft.SnackBar)), None)
if dialog is None:
return None
dialog.open = False
dialog.update()
return dialog
pop_dialog._skips_toasts = True
BasePage.pop_dialog = pop_dialog
except (ImportError, AttributeError) as exc:
applog.log.warning("pop_dialog() couldn't be kept off toasts (another Flet version?): %s", exc)
def main(argv=None):
import sys
+5 -2
View File
@@ -687,8 +687,11 @@ def card(content: ft.Control, padding: int | None = None, bgcolor: str | None =
def section(heading: str, *controls: ft.Control, subtitle: str | None = None,
action: ft.Control | None = None, help: str | None = None) -> ft.Column:
head = ft.Row([ft.Column([with_help(h2(heading), help)] + ([meta(subtitle)] if subtitle else []), spacing=T.px(2),
action: ft.Control | None = None, help: str | None = None, badge: ft.Control | None = None) -> ft.Column:
title = with_help(h2(heading), help)
if badge: # e.g. an "experimental" pill, right after the heading
title = ft.Row([title, badge], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
head = ft.Row([ft.Column([title] + ([meta(subtitle)] if subtitle else []), spacing=T.px(2),
expand=True)]
+ ([action] if action else []), vertical_alignment=ft.CrossAxisAlignment.CENTER)
return ft.Column([head, *controls], spacing=T.S3)
+22
View File
@@ -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(322), fit=ft.BoxFit.CONTAIN,
semantics_label=tr("Steam Frame controllers: each button shows the Quest Touch button it sends. "
"A dash means it sends nothing."))
+9 -6
View File
@@ -19,12 +19,6 @@ class _Translated(dict):
HELP: dict[str, str] = _Translated({
"mr_room": "Mixed-reality games use walls and furniture from a saved room. Scan your room in the headset, use "
"your play area for walls only, or import a recorded scan.",
"mr_alignment": "Room positions belong to the play area where they were recorded. If SteamVR changes it, "
"scan again or use the new boundary to line up virtual walls with your room.",
"mr_scanner": "The scanner turns a recorded depth-mesh dump into a room. FramePort installs it when you "
"build a recorded scan.",
"steam_library": "Now waits until Steam can restart safely. Later saves shortcuts for the next Steam start. "
"Off uses FramePort Play without Steam per-game integration; existing shortcuts stay.",
# ---- game status / library
@@ -154,6 +148,15 @@ 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. To see what "
"each mode does, try the free Test Quest Controllers app (Settings → Controllers): it lights up "
"every Quest button a game receives.",
"controller_tester": "Test Quest Controllers is a small free app that shows every Quest controller input it "
"receives on a 3D model of the controller. Install it, then try each controller button mode "
"and see which Quest button each Frame button sends before you choose one for your games.",
"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,48 @@
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="380" viewBox="0 0 520 380" 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 Steam Frame controller button is labelled with the Quest Touch button it sends. A dash means it sends nothing. Left Touch buttons are X and Y; right are A and B.</desc>
<rect width="520" height="380" 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="380" 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">Steam Frame controllers: each button shows the Quest button it sends</text>
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
<text x="98" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="69" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="127" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="422" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="393" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="451" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" width="520" height="380" viewBox="0 0 520 380" 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 + outer = X/A, top + inner = Y/B, as on Quest controllers. Each Steam Frame controller button is labelled with the Quest Touch button it sends. A dash means it sends nothing. Left Touch buttons are X and Y; right are A and B.</desc>
<rect width="520" height="380" 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="380" 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">Steam Frame controllers: each button shows the Quest button it sends</text>
<text x="140" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Left</text>
<path d="M102,256 C104,314 114,334 136,338 C158,342 178,326 194,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="140" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="174" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="174" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="126" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="126" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="131" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#FF8A1F"/>
<text x="144" y="130" fill="#FF8A1F" font-size="10" font-weight="600" text-anchor="middle">View: Menu</text>
<path d="M84,144 h28 v30 h30 v28 h-30 v30 h-28 v-30 h-30 v-28 h30 Z" fill="#1D1F27" stroke="#2A2D37"/>
<text x="98" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="69" y="193.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
<text x="127" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">Y</text>
<text x="98" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">X</text>
<text x="380" y="78" fill="#A7ABB7" font-size="13" font-weight="400" text-anchor="middle">Right</text>
<path d="M326,256 C328,314 338,334 360,338 C382,342 402,326 418,256 Z" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="380" cy="186" r="100" fill="#0D0E12" stroke="#2A2D37" stroke-width="2"/>
<circle cx="346" cy="204" r="24" fill="#16181E" stroke="#2A2D37"/>
<circle cx="346" cy="204" r="15" fill="#1D1F27" stroke="#2A2D37"/>
<circle cx="394" cy="248" r="10" fill="#1D1F27" stroke="#2A2D37"/>
<text x="394" y="272" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Steam</text>
<rect x="363" y="135" width="26" height="10" rx="5" fill="#1D1F27" stroke="#868A96"/>
<text x="376" y="130" fill="#868A96" font-size="10" font-weight="600" text-anchor="middle">Menu: —</text>
<circle cx="422" cy="159" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="422" y="164.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="437" y="149" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">Y</text>
<circle cx="393" cy="188" r="14" fill="#1D1F27" stroke="#FF8A1F" stroke-width="1.5"/>
<text x="393" y="193.04" fill="#FF8A1F" font-size="14" font-weight="700" text-anchor="middle">B</text>
<text x="408" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">X</text>
<circle cx="451" cy="188" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="451" y="193.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
<text x="466" y="178" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">B</text>
<circle cx="422" cy="217" r="14" fill="#1D1F27" stroke="#3AA8FF" stroke-width="1.5"/>
<text x="422" y="222.04" fill="#3AA8FF" font-size="14" font-weight="700" text-anchor="middle">A</text>
<text x="437" y="207" fill="#868A96" font-size="9" font-weight="400" text-anchor="middle">A</text>
<text x="260.0" y="366" fill="#A7ABB7" font-size="12" font-weight="400" text-anchor="middle">Sticks, triggers and grips unchanged · bumpers send nothing</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more