Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8899411751 | ||
|
|
e24b4c963d | ||
|
|
d02ff3c9aa | ||
|
|
107215525f | ||
|
|
a32e48367a | ||
|
|
95a226d54f | ||
|
|
6a6b6be5b5 | ||
|
|
01ad5e6b97 | ||
|
|
33d306a11e | ||
|
|
8e463a55de | ||
|
|
484293ba43 | ||
|
|
faca9bf396 | ||
|
|
e0a9b5f7cb | ||
|
|
3e906d55cf | ||
|
|
5e668f33d3 | ||
|
|
feb4658dde | ||
|
|
4ed7929b1c | ||
|
|
61a6b3e2b2 | ||
|
|
78fc926a48 | ||
|
|
503e64367c | ||
|
|
3deb279270 | ||
|
|
0df1212bfe | ||
|
|
d6064cf51b | ||
|
|
0c4a4789b5 | ||
|
|
5c1a394ae0 | ||
|
|
02c72f6707 | ||
|
|
e2ef06375e | ||
|
|
e9bda15a87 | ||
|
|
fcd8432184 | ||
|
|
73ad29c7c5 | ||
|
|
dc06b1cb40 | ||
|
|
9c41acee58 | ||
|
|
ab53f3447f | ||
|
|
229ceb9d8e | ||
|
|
4638a8bc61 | ||
|
|
487e0781a7 | ||
|
|
ebb9255e59 | ||
|
|
578da5642e | ||
|
|
ebc9017980 | ||
|
|
21534589ec | ||
|
|
ef37ea5b48 | ||
|
|
d9ae479977 | ||
|
|
e90df7b278 | ||
|
|
25d138909d | ||
|
|
34dfc6f508 | ||
|
|
f3b8ece9a6 | ||
|
|
631e4bca5a | ||
|
|
2c50578f2f | ||
|
|
ce8b7b1e02 | ||
|
|
dac01dfccf | ||
|
|
aca93db6c9 | ||
|
|
f5a268853d | ||
|
|
f895a2f62a | ||
|
|
53b79a4eda | ||
|
|
0ec26ffd0b | ||
|
|
cf98f95ed2 | ||
|
|
8cb27832be | ||
|
|
e4e1a42cde | ||
|
|
4bf48d3f51 |
No files matched your search
@@ -251,7 +251,11 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
|
||||
restore = untrusted input (staged + checked first), rollback copy `<base>/saves-before-restore.tar.gz` (undo =
|
||||
`rollback: true`). PC `install/saves.py` (`<data>/save-backups/<pkg>/<ts>.tar.gz` + `.json`, keep `saves.keep`
|
||||
= 10), GUI `ui/views/saves_dialog.py` (game menu section "Saves", Frame page "Back up all saves", uninstall's
|
||||
"back up before deleting"), CLI `frameport saves backup|list|restore|undo`.
|
||||
"back up before deleting"), CLI `frameport saves backup|list|restore|undo`. Agent v101: PC folder `saves.folder`
|
||||
(Settings → Data; old folders stay listed in `saves.previous_folders`), automatic snapshots `saves.auto` (default
|
||||
off; flag `~/.local/share/frameport/save-snapshots/enabled`, Quest launchers run `_snapshot_close` in the EXIT
|
||||
trap under the launch lock, newest 3 per game on the Frame; `install/saves.sync_snapshots` imports them as
|
||||
"automatic" once, ledger `<data>/save-snapshots-synced.json`). Device-checked 2026-10-17 with BlazeRush.
|
||||
- `patches/` — **the unit of modularity**. `base.py` (Patch interface, registry), `overport.py` (overport CLI patch ids,
|
||||
discovered dynamically via `overport patches`), `frame/*.py` (one module per Frame fix), `settings.py` (FrameBridge
|
||||
adapter keys + device files as patches). Add a patch = add a module that calls `register(...)`.
|
||||
@@ -451,7 +455,9 @@ 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).
|
||||
- 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.
|
||||
- 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).
|
||||
@@ -585,6 +591,7 @@ 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
|
||||
|
||||
@@ -8,16 +8,16 @@ d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/li
|
||||
321da502e0f8f46f0880aad39fe6b84bdef88f925164568814cf704a1b31a577 ./arm64-v8a/libfp_ovrp.so
|
||||
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
|
||||
0167c2fde5dfde62897f9d85afc4fc8e97784e988b5b8df6cea8c407240b3424 ./arm64-v8a/libfp_tkeys.so
|
||||
468b97ee3c36481d939a87425b15880c7a801d16732350e1e2832b385d63f20a ./arm64-v8a/libfp_vk.so
|
||||
e32f9b3222bfe2e58c193db809ac2cf54086df36fd2ec6651aa2e58308f6b39d ./arm64-v8a/libfp_vk.so
|
||||
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
|
||||
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
|
||||
36400eae4ba3807bcfcd54463daa217347032b07218792faa448d8cbbd086a0e ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
2d1726aaa8ee0ce788df1b8727bd55ab72efae749e7652f38f67e5e317b74b2f ./arm64-v8a/libopenxr_loader_generic.so
|
||||
4b79892e0d8774129e88647b306d4768e9fafdfa670a6f7aca8e09cf6434ed6d ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
eba4e9ed8563fa0de00d88bb85f960e79f13a6f60eddd437b203c8d3b7d308af ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
cc7b6ab8232d0d943675f9adc587fa4165419ab1334bf8ffac979d590b5f6fc7 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
@@ -27,6 +27,25 @@ 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
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -1,9 +1,9 @@
|
||||
package: ScaredBySquares.Game
|
||||
title: Scared By Squares
|
||||
status: unknown
|
||||
status: works
|
||||
notes: 'Showed black and dropped back to the home environment while the game kept running (sound only, GitHub 323).
|
||||
Fixed in FramePort 1.2.1 and checked on the Frame without the headset (the game runs at 72 fps and SteamVR keeps it
|
||||
as the running game); not yet confirmed in the headset. Rebuild and reinstall the game after updating FramePort.'
|
||||
Fixed in FramePort 1.2.1 (confirmed in the headset by the reporter). Rebuild and reinstall the game after updating
|
||||
FramePort.'
|
||||
details: 'The game (Unreal 4.27, OpenXR) creates two controller actions named "turn" (an action mapping and an axis
|
||||
mapping called Turn). The Frame''s runtime accepts the duplicate, then fails to load the input manifest it builds
|
||||
from the actions ("SetXrInputConfig failed. Failed to load manifest") and treats the game as closed. FrameBridge''s
|
||||
@@ -14,6 +14,9 @@ xr: OpenXR
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
- frame.unreal_skip_obb_check
|
||||
updated: '2026-10-16'
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
issue: 323
|
||||
updated: '2026-10-17'
|
||||
min_app: 1.2.1
|
||||
source_hint: Scared By Squares
|
||||
@@ -0,0 +1,23 @@
|
||||
package: com.Appnori.SummerSports
|
||||
title: Summer Sports
|
||||
status: works
|
||||
tested_version: 0.5.1
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.unity_runtime_msaa_off
|
||||
- frame.unity_gl_shim
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: 582adf5a538eb9565921cc0384e61bf113ab9215c1ad462fade82d06ad3302fa
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 395
|
||||
source_hint: All in One summer Sports VR
|
||||
@@ -0,0 +1,23 @@
|
||||
package: com.BolverkGamesFlat2VR.SurvivingMarsPioneer
|
||||
title: 'Surviving Mars: Pioneer'
|
||||
status: works
|
||||
tested_version: 1.0.14
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.hw_video_decode
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scale: 2.0
|
||||
verified:
|
||||
date: '2026-10-18'
|
||||
known_good_sha256: 3a9ff281417c219736ee10969e6ce4f7d8395472cb60718db8839d65edac4777
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 399
|
||||
source_hint: Surviving Mars Pioneer
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.CarbonStudio.TheWizards
|
||||
title: The Wizards
|
||||
status: works
|
||||
tested_version: '1.11'
|
||||
engine: Unreal
|
||||
xr: VrApi
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
frame:
|
||||
- frame.unreal_thumb_touch
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
pose_time_fix: 1
|
||||
scale: 2.0
|
||||
verified:
|
||||
date: '2026-10-18'
|
||||
known_good_sha256: c1d979d746c2be941e150e948aec755d85034de829abfe30e5bf0761a49135ef
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 397
|
||||
source_hint: the wizards
|
||||
@@ -0,0 +1,25 @@
|
||||
package: com.CortopiaStudios.Spectre
|
||||
title: Spectre
|
||||
status: works
|
||||
notes: Works since 1.2.1 (the loading screen waited for a Meta analytics request).
|
||||
tested_version: 1.0.1.1354
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
- frame.hw_video_decode
|
||||
- frame.cortopia_org_id
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scale: 2.0
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: a37a5400ef58906b575f6da1649b62ac7ce024eab831fae105457c1d5d12c2de
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 390
|
||||
source_hint: Wonder
|
||||
@@ -0,0 +1,20 @@
|
||||
package: com.Meta4.TFBeyondReality
|
||||
title: 'Transformers Beyond Reality: Redux'
|
||||
status: unknown
|
||||
notes: Starts and runs at 72 fps in headless launch tests (2026-10-17); not yet checked in the headset. Steam's shader
|
||||
cache layer is left out and a few shaders the Frame's driver refuses are skipped, so some objects may be missing.
|
||||
details: Unreal Engine game (Binned3 allocator, GitHub issue 349). Crash 1 - the allocator's 76 GiB up-front
|
||||
reservation is refused by the Frame's kernel (frame.unreal_noreserve, suggested by analysis). Crash 2 - Unreal's
|
||||
"Shader compilation failures are Fatal." With Lepton's Fossilize layer loaded every graphics pipeline failed with
|
||||
VK_ERROR_OUT_OF_HOST_MEMORY, even a constant-colour one (not the pipeline cache, not the CPU allocator, not Valve's
|
||||
FDM/RPO layers); vk_no_fossilize takes the layer out of the game's instance. A few pipelines with the game's
|
||||
multiview vertex shaders then still fail in Turnip; vk_pipeline_fallback creates them with blank shaders.
|
||||
tested_version: 1.495.34291
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
adapter:
|
||||
vk_no_fossilize: 1
|
||||
vk_pipeline_fallback: 1
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
issue: 349
|
||||
@@ -12,6 +12,7 @@ overport_extra:
|
||||
- patch_force_passthrough
|
||||
adapter:
|
||||
scene_emul: 1
|
||||
scene_hide_bounds: 1
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
@@ -22,4 +23,4 @@ verified:
|
||||
frame:
|
||||
- frame.meta_permissions
|
||||
- frame.unity_text_input
|
||||
updated: '2026-10-15'
|
||||
updated: '2026-10-17'
|
||||
@@ -0,0 +1,21 @@
|
||||
package: com.WenklyStudio.SurvivalNation
|
||||
title: Survival Nation
|
||||
status: works
|
||||
tested_version: 1.14.1
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
scale: 1.8
|
||||
verified:
|
||||
date: '2026-10-18'
|
||||
known_good_sha256: 7903dfee1c9c36d19e1c6c78a1f0ede1f0b25ce6a6065811997297e701009250
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 401
|
||||
source_hint: survival nation
|
||||
@@ -0,0 +1,24 @@
|
||||
package: com.cosmorama.tabletroopers
|
||||
title: Table Troopers
|
||||
status: works
|
||||
tested_version: 2026.40.4
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.gms_platform_version
|
||||
- frame.photon_voice_audio_route
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
scale: 2.0
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: b0a4b0e29855277712b580bd5507721fff137bde77b1911b34cca89c627feef1
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 391
|
||||
source_hint: Troopers
|
||||
@@ -0,0 +1,22 @@
|
||||
package: com.el.blackholepooldemo
|
||||
title: Black Hole Pool
|
||||
status: works
|
||||
tested_version: 5.3.2
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.vivox_audio_route
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
adapter:
|
||||
haptic_fix: 1
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
known_good_sha256: 17c23df3813324250ec1792d8dc0488dadf0df0a7ccbe33157d88f427889a92e
|
||||
app: 1.2.1
|
||||
overport_cli: 1.2.5
|
||||
frame_build: '20261007.6125817'
|
||||
agent: 96
|
||||
issue: 396
|
||||
source_hint: Black Hole Pool
|
||||
@@ -5,8 +5,10 @@ source_hint: NOPE CHALLENGE
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Meta XR Audio telemetry patch + nodebug. MR "Reality Breach" mode unavailable.
|
||||
details: The MR 'Reality Breach' mode needs passthrough; standard VR mode used.
|
||||
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
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
frame:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package: com.rrrgames.ThiefSimVRQuest
|
||||
title: Thief Simulator
|
||||
status: unsupported
|
||||
notes: Quits at start with the game's own "installed from unknown source" message. Lepton installs the game itself at
|
||||
every start (there is no Meta store app on the Frame), so the game never sees the Meta store as its source.
|
||||
tested_version: 1.1.7.3
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-17'
|
||||
issue: 368
|
||||
updated: '2026-10-17'
|
||||
@@ -1,13 +1,14 @@
|
||||
package: com.zenstudios.PFXVRQuest
|
||||
title: Pinball FX VR
|
||||
status: issues
|
||||
notes: Plays; mixed reality mode not working yet. Space warp off (fewer stutters).
|
||||
notes: "Plays; space warp off (fewer stutters). MR mode: needs a scanned room."
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package: org.primedgun.v2
|
||||
title: PrimedGun v2
|
||||
status: unknown
|
||||
notes: 'Without the Vulkan library rename: the game builds the library name from two pieces, so the renamed name didn''t
|
||||
exist ("libfp_vkn.so"), its renderer fell back to OpenGL and VR couldn''t start (GitHub #254). Not yet confirmed in
|
||||
the headset.'
|
||||
tested_version: 2.0.0-alpha.4
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
frame_remove:
|
||||
- frame.vk_sanitize
|
||||
updated: '2026-10-18'
|
||||
source_hint: PrimedGun v2 Quest APK (github.com/iChris4/MetroidPrimePortVR)
|
||||
@@ -6,6 +6,16 @@
|
||||
# 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
|
||||
@@ -313,6 +323,16 @@ signatures:
|
||||
diagnosis: The game needs Meta's colour-space extension, which the Frame's runtime doesn't offer, and quits before
|
||||
it starts VR (for example OutRun Quest VR). FrameBridge can offer it.
|
||||
suggest: [adapter.fb_color_space]
|
||||
- id: vulkan-pipeline-refused
|
||||
# GitHub #349: with Lepton's Fossilize layer loaded every Transformers pipeline failed with -1; without it a few
|
||||
# multiview vertex shaders still fail in Turnip. Unreal ends at the first one.
|
||||
pattern: '(Shader compilation failures are Fatal|vk shim: vkCreateGraphicsPipelines failed: -\d+)'
|
||||
severity: fatal
|
||||
diagnosis: The Frame's graphics driver refused to build some of the game's shaders, and Unreal ends the game at the
|
||||
first one (for example Transformers Beyond Reality). Try leaving out Steam's shader cache layer, and skipping the
|
||||
shaders the driver still refuses (they then draw nothing).
|
||||
suggest: [adapter.vk_no_fossilize, adapter.vk_pipeline_fallback]
|
||||
supersedes: [native-crash, force-quit]
|
||||
- id: graphics-requirements-missing
|
||||
pattern: '(XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING|Failed to create XR session: -50\b|xrCreateSession[^\n]*-50\b)'
|
||||
severity: fatal
|
||||
@@ -523,6 +543,12 @@ signatures:
|
||||
diagnosis: The game crashed while leaving VR, inside Valve's foveation layer when it destroys a swapchain (for example Lambda1VR). This can happen after manually quitting; it is not a startup crash and does not explain distorted geometry seen before quitting.
|
||||
suggest: []
|
||||
supersedes: [native-crash]
|
||||
- id: batman-audio-geometry-allocation
|
||||
pattern: '(?s)\A(?=.*com\.camouflaj\.manta)(?=.*Trying to allocate: [1-9][0-9]{10,}B)(?=.*MetaXRAudioUnityMemoryAllocator\.cpp)'
|
||||
severity: fatal
|
||||
diagnosis: "Batman's Meta XR Audio geometry loader requested tens of gigabytes while loading a scene. Its background task can outlive the UnityWebRequest that owns the input buffer. Update Batman on Frame with the current FramePort: the adapter retains a private copy until parsing finishes, for the verified Batman build. This is a game allocation crash, not evidence of a headset thermal shutdown."
|
||||
suggest: [frame.adapter]
|
||||
supersedes: [native-crash]
|
||||
- id: native-crash
|
||||
pattern: 'Fatal signal (\d+) \(SIG[A-Z]+\)'
|
||||
severity: fatal
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Batman audio geometry input lifetime
|
||||
|
||||
Batman: Arkham Shadow 1.4.1 could abort while loading a save or streaming the area
|
||||
around a real-time cutscene. Unity reported requests for 26,388,238,103 or
|
||||
147,520,448,640 bytes through `MetaXRAudioUnityMemoryAllocator.cpp`, followed by
|
||||
`SIGTRAP` on an IL2CPP worker. The first captured stack reached
|
||||
`GeometricAudioMeshPreprocessor::readMeshInfo` through
|
||||
`ovrAudio_AudioGeometryReadMeshMemory`.
|
||||
|
||||
The serialized asset on disk was intact. In one failed run its vertex count was
|
||||
read as 1 instead of 17,251. The managed-code trace showed a lifetime risk:
|
||||
`LoadGeometryAsync` obtains a `NativeArray<byte>.ReadOnly` from a
|
||||
`UnityWebRequest` download handler, calls async-void `LoadGeometryFromMemory`,
|
||||
then returns. The background task retains the pointer/length pair, but not the
|
||||
request owning those bytes. Its `GC.KeepAlive` boxes the read-only view rather
|
||||
than retaining the request. The trace and device test support premature source
|
||||
reuse as the cause; no hardware watch established the exact freeing instruction.
|
||||
|
||||
## Repair and isolation
|
||||
|
||||
`audio_geometry_owned.h` copies the input synchronously at
|
||||
`LoadGeometryFromMemory`, before `Task.Run` can outlive the request. A mutex
|
||||
protects the list of copies. The original worker still parses and processes the
|
||||
audio geometry; its completion wrapper removes and frees the corresponding copy.
|
||||
This also handles a worker finishing before the queuing call returns. NativeArray
|
||||
length/padding bits and IL2CPP's hidden method-info arguments are preserved.
|
||||
Null/invalid lengths and allocation failure retain the original call. A task
|
||||
that never runs retains its copy until process exit; there is no timed expiry
|
||||
that could free data beneath a late worker.
|
||||
|
||||
`batman_audio_geometry.h` enables the repair only in `com.camouflaj.manta`, on
|
||||
AArch64, after the existing audio metadata initializer verifies Meta XR Audio
|
||||
Wwise build `e1619e7fb839badf0ea5ecc22d9f028d0b17c34d`. It additionally requires
|
||||
IL2CPP build `1d48a41d7e9011bef5fae0c23c404c62a4e7a73c` and exact 16-byte entry
|
||||
sequences at both managed hooks. Unknown builds are left untouched. The live
|
||||
IL2CPP addresses are `0x2e61bb4` (queue) and `0x2e63a28` (worker); text file
|
||||
offsets in this build are 0x1000 lower. These offsets must not be reused for
|
||||
another game or build.
|
||||
|
||||
Both geometry hooks and the metadata hooks use `audio_detour.h`. After the
|
||||
16-byte entry guard, only the first, naturally aligned instruction is replaced:
|
||||
a release atomic store publishes an AArch64 `B` to a nearby stub. The stub's
|
||||
absolute jump and the trampoline (one verified non-PC-relative instruction,
|
||||
then a direct branch to entry+4) are complete, instruction-cache flushed and RX
|
||||
before publication. The original-call pointer is published first. Remaining
|
||||
entry instructions are unchanged, so workers already past the entry can finish
|
||||
normally. `/proc/self/maps` supplies free gaps, clipped to the ranges of both
|
||||
branches. Page-aligned candidates are tried outward from the target (lower
|
||||
address first on equal distances) with `MAP_FIXED_NOREPLACE`; EEXIST/EINVAL
|
||||
advance to the next candidate. Existing mappings are never replaced, and a
|
||||
kernel that ignores the flag and relocates a mapping has that result unmapped.
|
||||
If no candidate works, that hook is skipped without changing its entry. No ELF
|
||||
page-slack fallback is used: zero bytes alone cannot establish that padding is
|
||||
outside all section content, and the loaded program headers do not prove this.
|
||||
|
||||
Every mprotect result is checked. Failures before entry publication free the
|
||||
mapping. If restoring RX after publication fails, the entry word is restored,
|
||||
its instruction cache is flushed, and restoring RX is attempted again. The
|
||||
mapping and original-call pointer must then remain valid until process exit:
|
||||
a worker may already have followed the published branch. Freeing them without
|
||||
quiescing those threads would introduce a use-after-unmap. A persistent kernel
|
||||
mprotect failure can leave the entry page RWX. Each hook failure logs its target
|
||||
and failing operation once; metadata installation is attempted once per verified
|
||||
library. Allocation failure reports the closest eligible free page, last
|
||||
attempted address, actual mmap result, errno and attempt count. Null closest and
|
||||
zero attempts mean no eligible free gap. Protection failures identify stub RX,
|
||||
entry RWX, or entry RX, including the rollback RX errno (zero means it succeeded).
|
||||
This exceptional rollback cannot provide immediate reclamation or guarantee RX
|
||||
when the kernel refuses it. The previous hint-based publication build failed installation on the Steam
|
||||
Frame; this gap-search replacement has host validation only.
|
||||
|
||||
This is separate from the existing [audio metadata retirement repair](AUDIO_METADATA.md).
|
||||
It does not change the decoder, acoustic processing, tracking, foveation,
|
||||
resolution, refresh rate, or other games' recipes. Temporary input dumps, native
|
||||
parser bypasses and redundant late copies used during investigation are not
|
||||
included. Production logs are limited to installation and the first four
|
||||
copy/completion pairs. No game assets are included in the repository.
|
||||
|
||||
The `frame.adapter` package revision increases only for Batman. Existing Batman
|
||||
installs therefore offer **Update on Frame**; other games are not marked outdated
|
||||
by this repair. Updating/rebuilding Batman installs the new adapter through the
|
||||
normal path; no launcher script or additional option is needed.
|
||||
|
||||
## Validation
|
||||
|
||||
- Native regressions cover deferred and inline completion, source destruction
|
||||
before worker execution, concurrent loads, cleanup, allocation failure,
|
||||
argument preservation, and rejection of unknown build IDs/function entries.
|
||||
Host C tests also cover branch-range endpoints, stub/trampoline encoding,
|
||||
publication order, synthetic maps with in-range/out-of-range/absent gaps and
|
||||
adjacent mappings, outward placement order, EEXIST/EINVAL fallback, rejected
|
||||
relocated mmap results and mprotect failure cleanup/rollback.
|
||||
- On the headset, the affected save loaded successfully with the repair. Logs
|
||||
showed private copies of 2,599,300, 159,020 and 628,088 bytes made before dispatch;
|
||||
each original parser completed and each copy was released. Another 180,120-byte
|
||||
geometry input also completed normally.
|
||||
- The game continued for over seven minutes after that load with zero allocation
|
||||
failures, rejected meshes or fatal signals. Acoustic geometry was loaded, not
|
||||
disabled. That device test used the ownership repair with diagnostic guards;
|
||||
none of the guards rejected a mesh. The production repair omits those guards.
|
||||
- The rebuilt latest adapter passes the repository checks recorded in the PR;
|
||||
the device result above is for the experimental build tested before integration.
|
||||
|
||||
This does not establish long-term stability or resolve the separately observed
|
||||
whole-headset power-offs. Those require their own power/reset investigation.
|
||||
@@ -288,7 +288,7 @@ Steam placeholder set (no Steam art at all) → fixed in artwork/steam.py.
|
||||
|
||||
## PR #34: Meta XR Audio metadata (Batman)
|
||||
|
||||
**PR #34 (merged 2026-10-04, Lucas-Mathieu):** `native/adapter/audio_metadata.h` patches Meta XR Audio Wwise (only build ID e1619e7f…, Batman: Arkham Shadow 1.4.1) so queued audio metadata isn't freed while the current audio frame references it (smoke-bomb crash). Verified headless: installs ("metadata reclamation follows …"), 72 fps; we added a once-per-second scan limit. Outside PRs: review source, rebuild `artifacts/` ourselves, merge locally (contributor's commit kept), push main.
|
||||
**PR #34 (merged 2026-10-04, Lucas-Mathieu):** `native/adapter/audio_metadata.h` patches Meta XR Audio Wwise (only build ID e1619e7f…, Batman: Arkham Shadow 1.4.1) so queued audio metadata isn't freed while the current audio frame references it (smoke-bomb crash). The shared detour now publishes one aligned AArch64 branch to a range-checked nearby stub, with a one-instruction trampoline; protection failures roll back (published mappings must remain alive for in-flight threads). See [AUDIO_GEOMETRY.md](AUDIO_GEOMETRY.md) for failure limits and host validation; this publication change has not been device-tested. Original repair verified headless: installs ("metadata reclamation follows …"), 72 fps; we added a once-per-second scan limit. Outside PRs: review source, rebuild `artifacts/` ourselves, merge locally (contributor's commit kept), push main.
|
||||
|
||||
## Heuristics eval (2026-10-04)
|
||||
|
||||
@@ -718,3 +718,42 @@ 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
|
||||
are Fatal." (2026-10-17, headless on the dev Frame). `vk_pipeline_log=1` showed every graphics pipeline failing in
|
||||
pairs: the precache attempt with `VK_PIPELINE_CREATE_FAIL_ON_PIPELINE_COMPILE_REQUIRED_BIT` (flags 0x100) answers
|
||||
`1000297000` (= VK_PIPELINE_COMPILE_REQUIRED, normal), the real compile answers `-1` (VK_ERROR_OUT_OF_HOST_MEMORY)
|
||||
in the same millisecond, and the shim never logged a single success. The dumped SPIR-V was valid (spirv-val), one
|
||||
module a constant-colour fragment shader. Ruled out in one run each: the app's VkPipelineCache, its
|
||||
VkAllocationCallbacks (retry without either/both), Valve's VALVE_rpo/VALVE_fdm_injection (`device.foveation: off`).
|
||||
Khronos' validation layer added a crash of its own (null deref in its vkCreateRenderPass2 on the game's create info,
|
||||
the shim changed nothing there): don't use it for this game. Removing `VK_LAYER_fossilize` from GraphicsEnv's debug
|
||||
layer list (`android::GraphicsEnv::setDebugLayers`, as `native/zinkfix` adds its layer) made the pipelines compile:
|
||||
360 modules instead of 10, frames submitted. Two Fossilize instances are still created by other threads before the
|
||||
engine loads the shim; they aren't the renderer's. About six pipelines with the game's multiview vertex shader
|
||||
(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).
|
||||
@@ -202,13 +202,24 @@ Saves stay on the Frame when you update or reinstall a game.
|
||||
|
||||
## Can I back up a game's saves?
|
||||
|
||||
Yes. Steam doesn't sync Quest games' saves, so FramePort copies them to your PC: the game's menu (**…** on its page) →
|
||||
**Saves** → **Back up saves**, or **Back up all saves** on the Steam Frame page (Installed games). Close the game
|
||||
Yes. Steam doesn't sync Quest games' saves, so FramePort copies them to your PC: **Back up saves** next to Play on
|
||||
the installed game's page, or **Back up all saves** on the Steam Frame page (Installed games). Close the game
|
||||
first. A backup holds the game's own storage (`/data/data/<package>` and `/sdcard/Android/data/<package>`), without
|
||||
caches, shader caches and large downloaded content (videos, OBBs, files over 64 MB); the job says when something was
|
||||
left out. FramePort keeps the 10 newest backups per game (Settings → Data). They're in FramePort's data folder under
|
||||
`save-backups/<package>/` (**Open backups folder** in the game's menu).
|
||||
|
||||
In **Settings → Data**, **Choose backup folder…** selects a different folder, including a local Drive or Nextcloud
|
||||
sync folder. FramePort checks that it can write there. **Use the default** resets the location. Existing backups stay
|
||||
where they are and remain listed for restoring; retention only removes old backups in the currently selected folder.
|
||||
FramePort writes local files; your cloud client handles uploading them.
|
||||
|
||||
**Back up saves when a game closes** (Settings → Data, off by default) keeps the newest three changed snapshots per
|
||||
game on the Frame, even while FramePort is closed. Connect once after changing this setting to apply it to the Frame.
|
||||
New snapshots are copied to your backup folder when FramePort connects or polls the connection, marked **automatic**,
|
||||
and count toward the same backup limit. Unchanged saves are skipped. A snapshot captures what the game wrote to disk;
|
||||
it cannot recover progress a forcibly closed game never saved.
|
||||
|
||||
**Restore saves…** puts one back: it replaces the game's saves on the Frame. The Frame keeps a copy of the saves it
|
||||
had, so **Undo last restore** in the same window takes you back. When you uninstall a game and tick *Also delete its
|
||||
saves*, FramePort backs them up first. Command line: `frameport saves backup|list|restore|undo <game>`.
|
||||
|
||||
@@ -66,7 +66,31 @@
|
||||
`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.
|
||||
|
||||
## Save backups (Quest games, agent v95, discussion #352)
|
||||
## 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
|
||||
`external/Android/data/<pkg>` (= `/sdcard/Android/data/<pkg>`: Unity's `files/`, Unreal's external `Saved`). On the
|
||||
@@ -91,6 +115,23 @@
|
||||
files owned by mapped ids are readable and keep their ids, take the game's launch lock (`<base>/.launch.lock`, a
|
||||
Play meanwhile is ignored as a double launch) and refuse while its container runs. `saves_info` sizes a backup and
|
||||
reports the rollback copy, `saves_rollback_delete` removes it.
|
||||
- PC destination: `saves.folder` (empty = `<data>/save-backups`); Settings → Data has an awaited folder picker,
|
||||
a default reset and a write probe. Old destinations (`saves.previous_folders`) stay listed; switching never moves
|
||||
or deletes files. `saves.keep` defaults to 10 and prunes only the selected destination.
|
||||
- `saves.auto` defaults off. Connection and 30 s poll hooks sync its value using `save_snapshot_settings` (flag
|
||||
`~/.local/share/frameport/save-snapshots/enabled`). Updated and newly written Quest launchers call `_snapshot_close`
|
||||
after stopping the container, inside the EXIT trap, with the launch lock still held on fd 9. The worker verifies
|
||||
that fd against `<base>/.launch.lock` and passes it through `podman unshare`; another launch cannot run meanwhile.
|
||||
`timeout -k 5 90` bounds the whole worker. PC VR/Linux launchers have no snapshot step.
|
||||
- Snapshots use the same archive builder, exclusions and 64 MiB/file, 4 GiB/total limits as manual backups.
|
||||
File paths/sizes/content hashes form a fingerprint; unchanged saves skip compression. Complete snapshots are
|
||||
atomically published under `~/.local/share/frameport/save-snapshots/<pkg>/<ns>-<sha256>.tar.gz`, with fingerprint
|
||||
sidecars. The newest three per game are retained; interrupted `.part` files are overwritten on the next attempt.
|
||||
- `save_snapshots` lists immutable snapshots oldest first per game; `_fetch_save_snapshot '<json>'` streams one
|
||||
(archive stdout, result JSON stderr). PC `install/saves.sync_snapshots` runs in the background, verifies the archive
|
||||
hash and package, imports it as a normal backup marked `automatic`, and applies `saves.keep`. A local
|
||||
`<data>/save-snapshots-synced.json` digest ledger prevents reimporting snapshots after pruning or deleting backups.
|
||||
Failures leave snapshots on the Frame for the next poll. This path does no disk/SSH work in rendering.
|
||||
|
||||
## OpenXR runtime (as seen by games through overport's loader)
|
||||
- Instance extensions present include KHR_android_create_instance (must be enabled; the adapter adds it),
|
||||
@@ -398,14 +439,57 @@ over Quest games.
|
||||
|
||||
Under a Quest game's **Game settings → Picture**, choose **Foveated rendering → Eye-tracked**, then **Save**.
|
||||
Changes apply at the next game start.
|
||||
The full-resolution area selector defaults to **Default (30% radius)**: no radius override, preserving the
|
||||
runtime profile used in the headset experiments. Larger radii retain more detail and save less shading work.
|
||||
Custom radii set `FOVE_INNER`; `FOVE_OUTER` is twice that value, matching the runtime's profile proportions.
|
||||
The inspected runtime's built-in inner/outer pairs are 0.30/0.60, 0.25/0.50 and 0.20/0.40.
|
||||
The shared-texture injection path selects profile 1: 0.30 inner radius and 0.60 outer radius.
|
||||
The area selector now uses Valve's supported `FOVE_LEVEL` presets (GitHub #394; binary analysis
|
||||
2026-10-17). Default leaves the runtime choice alone; on the shared injection path this is `lo`.
|
||||
The old `FOVE_INNER`/`FOVE_OUTER` radius exports had no effect: Lepton does not pass them through,
|
||||
and the layer does not read them. The previously documented 0.30/0.60, 0.25/0.50, 0.20/0.40
|
||||
radius pairs do **not** describe the generated shared-injection density map.
|
||||
|
||||
The inspected arm64 `libVkLayer_VALVE_fdm_injection.so` has these semantics (virtual addresses):
|
||||
- `init_fdm_fove_level` at `0x1cdf4` calls the cached option reader, then
|
||||
`debug_parse_flags_option` (`0x21d74`) with table `0x27e70`: named tokens `lo` → 1,
|
||||
`med` → 2, `hi` → 3. It does not call atoi/strtol. Unset, empty, unknown and numeric
|
||||
strings parse to zero and retain the current profile (initialized to 1 at `0x1ce74`).
|
||||
Tokens are case-sensitive and separated by non-alphanumeric/non-underscore characters;
|
||||
matching values are ORed, so combinations can alias another profile. `all` also yields 3;
|
||||
`help` prints the table and returns zero. Use a single preset token.
|
||||
- Profile selection at `0x1918c–0x19364` uses normalized **rectangles**, not circular radii.
|
||||
`lo` has a full-density center rectangle `(x=.364, y=.367, width=.295, height=.286)`
|
||||
(constant at `0xe440`), then density byte `0x76` outside. `med` uses rectangles from
|
||||
`0xe988`: sizes `.250×.245`, `.295×.286`, `.386×.367`, with density bytes
|
||||
`0xff`, `0xa8`, `0x76` and `0x50` outside. `hi` uses `0xe9b8`: sizes
|
||||
`.246×.211`, `.281×.246`, `.351×.316`, `.386×.351`, with bytes
|
||||
`0xff`, `0xa8`, `0x76`, `0x50`, then `0x38` outside. Each byte is written to both
|
||||
R8G8 density channels. All three retain a full-density center (`0xff`); medium/high make it smaller
|
||||
and reduce peripheral density more. Coordinates are sampled on the density-map grid (`0x19378` onward).
|
||||
- `FDM_DEBUG` flags `lo`/`med`/`hi` also select presets (high takes precedence), but an
|
||||
explicit recognized `FOVE_LEVEL` overrides them (`0x1ce14`). `enable` gates injection;
|
||||
`disable_offsets` fixes the gaze position (`0x1b6c8`), and `yflip` flips the vertical
|
||||
offset (`0x1b9b8`). These three flags do not resize the profile.
|
||||
- `FDM_SWAPCHAIN_SIZE` is parsed by `atoi` (`0x1c518`), clamped to nonnegative, and
|
||||
seeds a known swapchain size (`0x1ccf8`). It is a size/matching hint, not a radius.
|
||||
The synthetic map is square with side `ceil(size/32)` (`0x19048–0x19088`); image
|
||||
association also matches this tile size (`0x16c78`). Changing render resolution changes
|
||||
the pixel extent, tile quantization and potentially the matched render target/map. This can
|
||||
change the visible diagnostic area without making the ignored radius exports effective;
|
||||
a matched device capture is needed to establish which path a particular game used.
|
||||
|
||||
Per-game library preferences now store `level`. Legacy radius choices are translated on read
|
||||
(and written as `level` on Save): nearest old nominal .30 → lo, .25 → med, .20 → hi,
|
||||
with larger values clamped by nearest choice; zero or unknown/invalid values → Default.
|
||||
This preserves approximate intent, not a claim that these presets implement those radii.
|
||||
Explicit `level` wins. Obsolete radius exports are removed from managed launchers.
|
||||
|
||||
For device verification, hold game scene, render resolution and gaze constant; compare explicit
|
||||
`lo`, `med`, `hi` across fresh game starts (Default should match lo absent another runtime override).
|
||||
First use Fixed for a stable comparison, then Eye-tracked to check gaze following. Enable
|
||||
**Darken coarse fragments (test)** and adapter `snapshot=N` to capture submitted eye images headlessly
|
||||
every N seconds (GLES only, left eye at quarter size); compare the undarkened center and peripheral bands. Existing adapter
|
||||
builds need diagnostic support installed. Turn the test and snapshots off before benchmarking.
|
||||
Agent `FOVEATION_ENV_KEYS` must include `"FOVE_LEVEL"` for launcher updates via `set_settings`.
|
||||
|
||||
`device.foveation` enables Valve's shared Vulkan injection and suppresses competing native foveation through
|
||||
`foveation_fix=1`. Game settings stores mode, radius and test selection as a per-game library preference, outside shared recipes. This remains
|
||||
`foveation_fix=1`. Game settings stores mode, profile and test selection as a per-game library preference, outside shared recipes. This remains
|
||||
experimental: complete images were confirmed in RE4, Batman, Deadpool, The Climb 2 and Asgard's Wrath 2,
|
||||
not all Quest games or renderers. RE4 and Batman checks include title/calibration scenes; these are not
|
||||
equivalent to extended gameplay coverage. Asgard gameplay was visually confirmed, without an on/off benchmark.
|
||||
@@ -432,7 +516,7 @@ in Climb the same library also carries the compatibility correction.
|
||||
|
||||
High-resolution Deadpool samples crossed from about 45 to 90 FPS with injection enabled, but temperatures and
|
||||
power rose as it rendered twice as many frames. Foveation does not guarantee lower temperature at uncapped FPS.
|
||||
The new configurable radii have configuration/build coverage; each radius has not been separately play-tested.
|
||||
Preset export/migration has configuration coverage; these preset comparisons have not been device-tested here.
|
||||
|
||||
## Monitoring sources (probed 2026-10-07, SteamOS 0.4.3, kernel 6.18; used by the Monitor tab, agent `_monitor`)
|
||||
|
||||
@@ -459,7 +543,7 @@ game processes without one), temperatures every 2 s, the CPU-cluster power rails
|
||||
|
||||
### Foveation user preferences (discussion #365)
|
||||
|
||||
Game settings → Picture provides Default / Eye-tracked / Fixed / Off, radius, and an advanced diagnostic switch.
|
||||
Game settings → Picture provides Default / Eye-tracked / Fixed / Off, a runtime profile, and an advanced diagnostic switch.
|
||||
`set_settings` (agent 98) atomically rewrites just the managed launcher exports and records them in deployment.json;
|
||||
no APK rebuild, reinstall or live device intervention is needed. Changes apply at the next start. Default restores
|
||||
recipe/catalog launcher defaults; explicit preferences win. One-time library migration extracts old recipe choices
|
||||
|
||||
@@ -26,6 +26,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Beat Saber | PC VR | ✅ Works | |
|
||||
| Beat Saber (co-existence build) | Quest | ✅ Works | |
|
||||
| Black Hole Pool | Quest | ✅ Works | |
|
||||
| Blade & Sorcery: Nomad | Quest | ✅ Works | |
|
||||
| BlazeRush | Quest | ✅ Works | |
|
||||
| BodyCombat | Quest | ✅ Works | |
|
||||
@@ -117,6 +118,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Riven | Quest | ✅ Works | |
|
||||
| Robo Recall | Quest | ✅ Works | |
|
||||
| RUINSMAGUS | Quest | ✅ Works | |
|
||||
| Scared By Squares | Quest | ✅ Works | |
|
||||
| Sclerosis | Quest | ✅ Works | |
|
||||
| Shadow Point | Quest | ✅ Works | |
|
||||
| Shoot The Zombirds VR | Quest | ✅ Works | |
|
||||
@@ -126,14 +128,19 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Sniper Elite VR | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
| Spectre | Quest | ✅ Works | |
|
||||
| Sports Scramble (Santa Cruz) | Quest | ✅ Works | |
|
||||
| Star Wars Pinball VR | Quest | ✅ Works | |
|
||||
| Star Wars: Tales from the Galaxy's Edge | Quest | ✅ Works | |
|
||||
| Stremio | Quest | ✅ Works | |
|
||||
| Summer Sports | Quest | ✅ Works | |
|
||||
| Superfly | Quest | ✅ Works | |
|
||||
| SUPERHOT VR | PC VR | ✅ Works | |
|
||||
| SUPERHOT VR | Quest | ✅ Works | |
|
||||
| Survival Nation | Quest | ✅ Works | |
|
||||
| Surviving Mars: Pioneer | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| Table Troopers | Quest | ✅ Works | |
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
@@ -144,6 +151,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| The Room VR | Quest | ✅ Works | |
|
||||
| The Tale of Onogoro | Quest | ✅ Works | |
|
||||
| The Walking Dead: Saints & Sinners | Quest | ✅ Works | |
|
||||
| The Wizards | Quest | ✅ Works | |
|
||||
| Time Crisis II VR (Test) | Quest | ✅ Works | |
|
||||
| Time Crisis VR (Experimental) | Quest | ✅ Works | |
|
||||
| Totally Baseball | Quest | ✅ Works | |
|
||||
@@ -204,3 +212,4 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
|
||||
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
|
||||
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
|
||||
| Thief Simulator | Quest | ❌ Doesn't run | Quits at start with the game's own "installed from unknown source" message. |
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 182 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 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.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -12,7 +12,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (61)
|
||||
- [Game settings](#group-adapter) (73)
|
||||
- [Game settings](#group-adapter) (76)
|
||||
- [Files and settings on the Frame](#group-device) (10)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
@@ -39,7 +39,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Patch Oculus detection for Unity](#patch_oculus_unity) | `patch_oculus_unity` | Patches from OVRPort | default |
|
||||
| [Patch Oculus detection for Unreal](#patch_oculus_unreal) | `patch_oculus_unreal` | Patches from OVRPort | default |
|
||||
| [Remove localized app names](#patch_remove_localized_names) | `patch_remove_localized_names` | Patches from OVRPort | default |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 45 |
|
||||
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 46 |
|
||||
| [Remove uses-library](#patch_remove_uses_library) | `patch_remove_uses_library` | Patches from OVRPort | default |
|
||||
| [Remove VrApi library](#patch_remove_vrapi) | `patch_remove_vrapi` | Patches from OVRPort | |
|
||||
| [Replace application label and icon](#patch_replace_icon_label) | `patch_replace_icon_label` | Patches from OVRPort | default |
|
||||
@@ -52,13 +52,13 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
|
||||
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | 6 |
|
||||
| [BlazeRush: controllers without Meta avatars, own shader cache](#frame-blazerush) | `frame.blazerush` | Steam Frame patches | 1 |
|
||||
| [Cortopia games: don't wait for Meta's org-scoped id](#frame-cortopia_org_id) | `frame.cortopia_org_id` | Steam Frame patches | |
|
||||
| [Cortopia games: don't wait for Meta's org-scoped id](#frame-cortopia_org_id) | `frame.cortopia_org_id` | Steam Frame patches | 1 |
|
||||
| [Epic Online Services: stop the keychain retry loop](#frame-eos_keychain_loop) | `frame.eos_keychain_loop` | Steam Frame patches | |
|
||||
| [OpenGL ES: multiview shaders on flat panels (HUD, menus)](#frame-gl_multiview_fbo) | `frame.gl_multiview_fbo` | Steam Frame patches | 1 |
|
||||
| [GL shim (Mesa GLSL compatibility)](#frame-gl_shim) | `frame.gl_shim` | Steam Frame patches | 2 |
|
||||
| [Play services: Android 11 version checks](#frame-gms_platform_version) | `frame.gms_platform_version` | Steam Frame patches | |
|
||||
| [Play services: Android 11 version checks](#frame-gms_platform_version) | `frame.gms_platform_version` | Steam Frame patches | 1 |
|
||||
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | 1 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 31 |
|
||||
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 33 |
|
||||
| [Language packs from the game's files](#frame-langpacks) | `frame.langpacks` | Steam Frame patches | |
|
||||
| [Launcher category (INFO → LAUNCHER)](#frame-launcher) | `frame.launcher` | Steam Frame patches | default |
|
||||
| [GL wrapper: OpenGL ES depth formats (Minecraft)](#frame-ltw_depth) | `frame.ltw_depth` | Steam Frame patches | default |
|
||||
@@ -73,20 +73,20 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
|
||||
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
|
||||
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
|
||||
| [Photon Voice: skip Android 12 audio routing](#frame-photon_voice_audio_route) | `frame.photon_voice_audio_route` | Steam Frame patches | |
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 5 |
|
||||
| [Photon Voice: skip Android 12 audio routing](#frame-photon_voice_audio_route) | `frame.photon_voice_audio_route` | Steam Frame patches | 1 |
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 6 |
|
||||
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
|
||||
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
|
||||
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
|
||||
| [Team Beef ports: treat the Frame as a Meta headset](#frame-tbxr_vendor) | `frame.tbxr_vendor` | Steam Frame patches | 1 |
|
||||
| [Unity App UI: no Android 12 vibrator](#frame-unity_appui_vibrator) | `frame.unity_appui_vibrator` | Steam Frame patches | |
|
||||
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 25 |
|
||||
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 26 |
|
||||
| [Unity: render each eye separately (no multiview)](#frame-unity_multipass) | `frame.unity_multipass` | Steam Frame patches | 2 |
|
||||
| [Unity: disable MSAA](#frame-unity_no_msaa) | `frame.unity_no_msaa` | Steam Frame patches | 10 |
|
||||
| [Unity: skip OVROverlay layers (fades, splash screens)](#frame-unity_no_overlay_copy) | `frame.unity_no_overlay_copy` | Steam Frame patches | 2 |
|
||||
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 14 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 31 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 111 |
|
||||
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 32 |
|
||||
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 117 |
|
||||
| [Unity: share thread keys (games that run out of them)](#frame-unity_tls_key_pool) | `frame.unity_tls_key_pool` | Steam Frame patches | 1 |
|
||||
| [Unity: headset always counts as worn](#frame-unity_user_presence) | `frame.unity_user_presence` | Steam Frame patches | 1 |
|
||||
| [GL shim for Unreal (no multisampled render-to-texture)](#frame-unreal_gl_shim) | `frame.unreal_gl_shim` | Steam Frame patches | 1 |
|
||||
@@ -98,10 +98,10 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 9 |
|
||||
| [Unreal: skip the OBB delivery check](#frame-unreal_skip_obb_delivery) | `frame.unreal_skip_obb_delivery` | Steam Frame patches | 1 |
|
||||
| [Unreal: bypass the stalled loading splash](#frame-unreal_splash_bypass) | `frame.unreal_splash_bypass` | Steam Frame patches | 1 |
|
||||
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 7 |
|
||||
| [Unreal: thumb follows the capacitive touches](#frame-unreal_thumb_touch) | `frame.unreal_thumb_touch` | Steam Frame patches | 8 |
|
||||
| [USB MIDI: don't poll for USB devices](#frame-usb_midi_watcher) | `frame.usb_midi_watcher` | Steam Frame patches | |
|
||||
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 2 |
|
||||
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 3 |
|
||||
| [Vulkan: drop invalid render-pass pointers](#frame-vk_sanitize) | `frame.vk_sanitize` | Steam Frame patches | default · 1 |
|
||||
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 4 |
|
||||
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
|
||||
@@ -136,7 +136,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [GL shim: high-precision vertex shaders](#adapter-gl_highp_markers) | `adapter.gl_highp_markers` | Game settings | 1 |
|
||||
| [GL shim: integer vertex inputs](#adapter-gl_int_attribs) | `adapter.gl_int_attribs` | Game settings | 1 |
|
||||
| [Flat panel diagnostics (log)](#adapter-gl_mv_debug) | `adapter.gl_mv_debug` | Game settings | |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 96 |
|
||||
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 102 |
|
||||
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
|
||||
| [Turn off Quest Vulkan foveation](#adapter-hide_fb_foveation) | `adapter.hide_fb_foveation` | Game settings | |
|
||||
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 5 |
|
||||
@@ -149,17 +149,18 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [See your room](#adapter-passthrough_emul) | `adapter.passthrough_emul` | Game settings | |
|
||||
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 2 |
|
||||
| [Pose timing diagnostics (log)](#adapter-pose_debug) | `adapter.pose_debug` | Game settings | |
|
||||
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 15 |
|
||||
| [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 | |
|
||||
| [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 | 9 |
|
||||
| [Render resolution (supersampling)](#adapter-scale) | `adapter.scale` | Game settings | 14 |
|
||||
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
|
||||
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 12 |
|
||||
| [Use room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 14 |
|
||||
| [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 |
|
||||
@@ -171,6 +172,8 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Retry refused picture formats](#adapter-swapchain_fix) | `adapter.swapchain_fix` | Game settings | |
|
||||
| [Steady controller input after focus](#adapter-sync_guard) | `adapter.sync_guard` | Game settings | 3 |
|
||||
| [Hide Vulkan foveation](#adapter-vk_hide_fdm) | `adapter.vk_hide_fdm` | Game settings | 1 |
|
||||
| [Without Steam's shader cache layer](#adapter-vk_no_fossilize) | `adapter.vk_no_fossilize` | Game settings | 1 |
|
||||
| [Skip refused graphics](#adapter-vk_pipeline_fallback) | `adapter.vk_pipeline_fallback` | Game settings | 1 |
|
||||
| [Vulkan pipeline failures (log)](#adapter-vk_pipeline_log) | `adapter.vk_pipeline_log` | Game settings | |
|
||||
| [Stop objects popping in and out](#adapter-vk_query_slots) | `adapter.vk_query_slots` | Game settings | |
|
||||
| [Vulkan shader dump (diagnostics)](#adapter-vk_shader_dump) | `adapter.vk_shader_dump` | Game settings | |
|
||||
@@ -188,7 +191,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Game language](#device-language) | `device.language` | Files and settings on the Frame | |
|
||||
| [Extra Lepton settings](#device-lepton_env) | `device.lepton_env` | Files and settings on the Frame | 3 |
|
||||
| [Controllers as a gamepad (Steam Input)](#device-steam_gamepad) | `device.steam_gamepad` | Files and settings on the Frame | |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 114 |
|
||||
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 120 |
|
||||
| [Launch options (SteamVR or OpenXR)](#pcvr-launch_args) | `pcvr.launch_args` | PC VR (Proton and Revive) | 2 |
|
||||
| [Provide the Oculus runtime from Revive (Frame)](#pcvr-libovr_redirect) | `pcvr.libovr_redirect` | PC VR (Proton and Revive) | |
|
||||
| [No Unreal crash reporter](#pcvr-no_crash_reporter) | `pcvr.no_crash_reporter` | PC VR (Proton and Revive) | |
|
||||
@@ -433,7 +436,7 @@ Stops some Unreal games from closing themselves right after starting.
|
||||
- `patch_remove_unreal_force_quit`
|
||||
- **Changes:** Changes the APK (during the OVRPort conversion).
|
||||
- **Offered (off):** Built as the alternate APK for Unreal games (use it if the game quits itself).
|
||||
- **Catalog games (45):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 37 more.
|
||||
- **Catalog games (46):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 38 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `force-quit` (error): The game quit itself right after starting.
|
||||
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
|
||||
@@ -524,6 +527,7 @@ FramePort's layer between the game and the Steam Frame: fills in what the Frame
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Required for every OpenXR/overport build on the Frame.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `batman-audio-geometry-allocation` (fatal): Batman's Meta XR Audio geometry loader requested tens of gigabytes while loading a scene.
|
||||
- `cube-swapchain-refused` (fatal): The game asked for a cube-map swapchain (for example an OVROverlay skybox or loading cube), which the Frame's runtime doesn't have.
|
||||
- `cylinder-menu-layers-rejected` (info): The runtime rejected a frame's layers once (XR_ERROR_LAYER_INVALID).
|
||||
- `graphics-requirements-missing` (fatal): The game can't start VR on the Frame; rebuild it with the current FramePort.
|
||||
@@ -606,6 +610,7 @@ Gets Cortopia games (for example Escaping Wonderland) past their loading screen.
|
||||
- `frame.cortopia_org_id`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Cortopia's Meta platform start-up waits for an org-scoped user id that OVRPort's platform library never answers (stuck loading screen).
|
||||
- **Catalog games (1):** Spectre.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `cortopia-org-id-wait` (error): The game waits forever on its loading screen for an answer from Meta's platform (an org-scoped user id, used as its analytics id) that OVRPort's platform library doesn't give.
|
||||
- **Source:** [patches/frame/cortopia_org_id.py](../src/frameport/patches/frame/cortopia_org_id.py)
|
||||
@@ -659,6 +664,7 @@ Lets games with Google Play services code start on the Frame (they'd crash at st
|
||||
- `frame.gms_platform_version` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Google Play services' Android 12 checks were built in as 'always true'; on Lepton (Android 11) the game would crash at start.
|
||||
- **Catalog games (1):** Table Troopers.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `gms-android12-call` (fatal): Google Play services' code in the game called an Android 12 method (the game was built for Android 12 and newer, so its version checks were built in as "always true") and Lepton runs Android 11; the game crashes on its GoogleApiHandler thread at start (for example Titan Isles).
|
||||
- **Source:** [patches/frame/gms_platform_version.py](../src/frameport/patches/frame/gms_platform_version.py)
|
||||
@@ -686,7 +692,7 @@ Plays the game's videos on the Frame's video hardware, so 4K and 8K video runs s
|
||||
- `frame.hw_video_decode` · also for 2D apps
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** The game plays video through Android's decoders (MediaCodec, ExoPlayer or VLC): decode it on the Frame's hardware.
|
||||
- **Catalog games (31):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, BattleTalent, Breathedge VR, Dance Central, Exploding Kittens VR and 23 more.
|
||||
- **Catalog games (33):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, BattleTalent, Breathedge VR, Dance Central, Exploding Kittens VR and 25 more.
|
||||
- **Source:** [patches/frame/hw_video_decode.py](../src/frameport/patches/frame/hw_video_decode.py)
|
||||
|
||||
> 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.
|
||||
@@ -748,6 +754,7 @@ 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)
|
||||
|
||||
@@ -881,6 +888,7 @@ Stops games with Photon Voice chat from crashing on the Frame when voice chat st
|
||||
- `frame.photon_voice_audio_route` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Photon Voice calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes when voice chat starts.
|
||||
- **Catalog games (1):** Table Troopers.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `photon-voice-api31` (fatal): The game's Photon Voice microphone plugin calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes when voice chat starts (for example Table Troopers at the tutorial).
|
||||
- **Source:** [patches/frame/photon_voice_audio_route.py](../src/frameport/patches/frame/photon_voice_audio_route.py)
|
||||
@@ -894,7 +902,7 @@ Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboar
|
||||
- `frame.sdl_clipboard` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** SDL app: it crashes at start in Lepton, which has no clipboard service.
|
||||
- **Catalog games (5):** Crisis Zone VR, Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
|
||||
- **Catalog games (6):** Crisis Zone VR, Majora's Mask VR, PrimedGun v2, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `sdl-no-clipboard` (fatal): The app crashed at start because the Frame's Android has no clipboard.
|
||||
- **Source:** [patches/frame/sdl_clipboard.py](../src/frameport/patches/frame/sdl_clipboard.py)
|
||||
@@ -973,7 +981,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
- `frame.unity_gl_shim`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
|
||||
- **Catalog games (25):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 17 more.
|
||||
- **Catalog games (26):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 18 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
|
||||
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
|
||||
@@ -1042,7 +1050,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
|
||||
- `frame.unity_runtime_msaa_off` · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
|
||||
- **Catalog games (31):** Audica, BattleSisters, BattleTalent, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 23 more.
|
||||
- **Catalog games (32):** Audica, BattleSisters, BattleTalent, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 24 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
|
||||
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
|
||||
@@ -1058,7 +1066,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
- `frame.unity_text_input` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
|
||||
- **Catalog games (111):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 103 more.
|
||||
- **Catalog games (117):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 109 more.
|
||||
- **Source:** [patches/frame/unity_text_input.py](../src/frameport/patches/frame/unity_text_input.py)
|
||||
|
||||
> Unity's TMP_InputField / InputField wait for Android's on-screen keyboard and close themselves a frame later when there is none (Lepton has no on-screen keyboard; Meta's system keyboard does this on a Quest), so a selected text field only flashes a caret. Rewrites TouchScreenKeyboardShouldBeUsed -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1216,7 +1224,7 @@ Makes the hands' thumbs follow what your thumb touches (they always pointed up:
|
||||
- `frame.unreal_thumb_touch`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Catalog games (7):** Phantom: Covert Ops, Robo Recall, Star Wars: Tales from the Galaxy's Edge, Time Stall, Vader Immortal: Episode I, Vader Immortal: Episode II, Vader Immortal: Episode III.
|
||||
- **Catalog games (8):** Phantom: Covert Ops, Robo Recall, Star Wars: Tales from the Galaxy's Edge, The Wizards, Time Stall, Vader Immortal: Episode I, Vader Immortal: Episode II, Vader Immortal: Episode III.
|
||||
- **Source:** [patches/frame/unreal_thumb_touch.py](../src/frameport/patches/frame/unreal_thumb_touch.py)
|
||||
|
||||
> Unreal 4's Oculus input sets the ThumbUp axis (games animate the hand's thumb with it) from Meta's near-touch sensing, which the Frame never reports: the thumb always points up. Uses the touches instead (left X/Y/stick/thumb rest, right A/B/stick/thumb rest). Changes three instructions in OculusInput::SendControllerEvents, only where the code matches exactly (UE4 Oculus games of one engine generation, for example Vader Immortal, Robo Recall). Found by Klownicle (GitHub #49).
|
||||
@@ -1253,7 +1261,7 @@ Lets games with Vivox voice chat start on the Frame (they'd crash on an Android
|
||||
- `frame.vivox_audio_route` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Vivox voice chat: it calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes.
|
||||
- **Catalog games (2):** Dungeons of Eternity, Real VR Fishing.
|
||||
- **Catalog games (3):** Black Hole Pool, Dungeons of Eternity, Real VR Fishing.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `vivox-api31` (fatal): The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR).
|
||||
- **Source:** [patches/frame/vivox_audio_route.py](../src/frameport/patches/frame/vivox_audio_route.py)
|
||||
@@ -1772,7 +1780,7 @@ For games that freeze the Frame when a controller vibrates (for example Lucky's
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Fix freezes when controllers vibrate" (advanced).
|
||||
- **Catalog games (96):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, BattleTalent and 88 more.
|
||||
- **Catalog games (102):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, BattleTalent and 94 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> 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.
|
||||
@@ -1966,7 +1974,7 @@ For games whose hands trail the controllers when you move them (for example Batt
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Controllers → "Fix hands lagging behind controllers" (advanced).
|
||||
- **Catalog games (15):** Battlemarked, BattleSisters, Beat Saber, Dance Central, Dungeons of Eternity, Mini Motor Racing X, OrbusVR, Phantom: Covert Ops and 7 more.
|
||||
- **Catalog games (16):** Battlemarked, BattleSisters, Beat Saber, Dance Central, Dungeons of Eternity, Mini Motor Racing X, OrbusVR, Phantom: Covert Ops and 8 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Poses a game asks for at a CLOCK_MONOTONIC time, or more than 0.5 s before the frame's display time, are located at the same moment in the runtime's time (or now). Meta's OVRPlugin passes the monotonic clock on as the OpenXR time, which on the Frame runs seconds behind the runtime's clock, so its "now" poses (Unity's physics step, controller poses) came from the past: hands trailing the controllers (for example BattleSisters, Sniper Elite VR).
|
||||
@@ -2066,7 +2074,7 @@ Changes the game's render resolution. Higher is sharper but slower; 1.0× is the
|
||||
- **Options:**
|
||||
- Value: float, default `1.0` (always written).
|
||||
- Game settings → Picture → "Render resolution (supersampling)": slider 0.5–2 in steps of 0.1.
|
||||
- **Catalog games (9):** 4XVR Video Player, BlazeRush, CrazyTaxiVR, cubism, FGO VR, I Expect You To Die 2, The 7th Guest VR, Vampire: The Masquerade - Justice and 1 more.
|
||||
- **Catalog games (14):** 4XVR Video Player, BlazeRush, CrazyTaxiVR, cubism, FGO VR, I Expect You To Die 2, Spectre, Survival Nation and 6 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels.
|
||||
@@ -2085,23 +2093,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>Pretend room layout
|
||||
### <a id="adapter-scene_emul"></a>Use room layout
|
||||
|
||||
Mixed-reality games that build their level from your room get a simple room with walls.
|
||||
Scene games get your saved room. Without one, they get a simple room from the play area.
|
||||
|
||||
- `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`:** 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).
|
||||
- **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.
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- 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.
|
||||
- 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.
|
||||
- **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)
|
||||
|
||||
> 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).
|
||||
> 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).
|
||||
|
||||
### <a id="adapter-scene_height"></a>Room height
|
||||
|
||||
@@ -2117,6 +2125,21 @@ 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.
|
||||
@@ -2282,6 +2305,40 @@ For Unreal Engine 5 games that crash on the Frame (for example Metro Awakening).
|
||||
|
||||
> Vulkan shim (frame.vk_sanitize): the game doesn't see VK_EXT_fragment_density_map(2). Unreal Engine 5 turns on fragment-density-map foveation when the driver offers it and expects the density map from the headset, which the Frame doesn't provide: image views and barriers for a missing image, then a crash in the Frame's Vulkan driver (for example Metro Awakening). Valve's own foveation layer isn't affected.
|
||||
|
||||
### <a id="adapter-vk_no_fossilize"></a>Without Steam's shader cache layer
|
||||
|
||||
Some games can't create any graphics with it (for example Transformers Beyond Reality).
|
||||
|
||||
- `adapter.vk_no_fossilize`
|
||||
- **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 → Troubleshooting → "Without Steam's shader cache layer" (advanced).
|
||||
- **Catalog games (1):** Transformers Beyond Reality: Redux.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `vulkan-pipeline-refused` (fatal): The Frame's graphics driver refused to build some of the game's shaders, and Unreal ends the game at the first one (for example Transformers Beyond Reality).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Vulkan shim (frame.vk_sanitize): takes Lepton's Fossilize shader-cache layer out of the game's Vulkan layers (Android's debug layer list) when the engine loads its Vulkan library. With Fossilize loaded every graphics pipeline of Transformers Beyond Reality failed (VK_ERROR_OUT_OF_HOST_MEMORY), which Unreal treats as fatal (GitHub #349). Lepton has no switch for it; instances created before the engine loads the shim keep it.
|
||||
|
||||
### <a id="adapter-vk_pipeline_fallback"></a>Skip refused graphics
|
||||
|
||||
When the Frame's driver refuses one of the game's shaders, that object is left out instead of the game ending.
|
||||
|
||||
- `adapter.vk_pipeline_fallback`
|
||||
- **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 → Troubleshooting → "Skip refused graphics" (advanced).
|
||||
- **Catalog games (1):** Transformers Beyond Reality: Redux.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `vulkan-pipeline-refused` (fatal): The Frame's graphics driver refused to build some of the game's shaders, and Unreal ends the game at the first one (for example Transformers Beyond Reality).
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Vulkan shim (frame.vk_sanitize): a graphics pipeline the Frame's driver refuses is created again with a fragment shader that writes zero, then with a vertex shader that only sets the position as well, so the draw shows nothing instead of Unreal ending with "Shader compilation failures are Fatal." (for example a few multiview vertex shaders in Transformers Beyond Reality, GitHub #349). Logged as "retried with a blank".
|
||||
|
||||
### <a id="adapter-vk_pipeline_log"></a>Vulkan pipeline failures (log)
|
||||
|
||||
Logs graphics pipelines the Frame's driver refuses. No effect on the game.
|
||||
@@ -2456,12 +2513,12 @@ Render less detail outside your gaze; adjust the full-resolution area or darken
|
||||
- **Options:**
|
||||
- `support`: bool, default `False` — Internal APK compatibility support
|
||||
- `mode`: str, default empty — Catalog default: tracked, fixed or off (empty = Valve's default)
|
||||
- `radius`: float, default `0.0`, 0–0.45 — Full-resolution radius; 0 keeps the current runtime default
|
||||
- `level`: str, default empty — Runtime profile: lo, med, hi (empty = Default)
|
||||
- `test`: bool, default `False` — Darken lower-resolution regions (diagnostic)
|
||||
- **Catalog games (5):** Ocean Rift, Quantum Void, Tea For God, The 7th Guest VR, Vampire: The Masquerade - Justice.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.
|
||||
> Reduces shading away from the full-resolution area, 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 retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.
|
||||
|
||||
### <a id="device-headset_model"></a>Headset model
|
||||
|
||||
@@ -2531,7 +2588,7 @@ Lets typing reach this app from Steam's on-screen keyboard or your PC's keyboard
|
||||
- `device.text_input_window`
|
||||
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
|
||||
- **Suggested (on):** App with text fields: key presses only reach a shown Android window.
|
||||
- **Catalog games (114):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 106 more.
|
||||
- **Catalog games (120):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 112 more.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Lepton runs VR apps headless: their Android window is never shown, so it never gets keyboard focus and no key press (Steam's on-screen keyboard, a USB/Bluetooth keyboard, Type on Frame) reaches the app's text fields. With Lepton's lepton-show-flatscreen marker the window is shown (behind the VR view, which keeps working) and Steam's keyboard opens for a selected text field. Steam lists the shown window as "Gamescope" (the Frame's compositor). Pairs with "Make Unity text fields work without a system keyboard".
|
||||
|
||||
@@ -20,6 +20,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Conversion fails with `NoSuchElementException: Sequence is empty` in `PatcherContext.appName` (GitHub #269, Accounting+) | OVRPort assumes an application label exists; some APKs only label their activity | FramePort retries that exact failure once using a temporary APK whose application inherits the activity's label; existing labels and the original APK are kept |
|
||||
| Updating a flat Android app after saving its recipe fails with `no OVRPort patches selected` (GitHub #271, Plezy) | older FramePort loads the saved recipe without restoring its unchanged-install setting or applying the flat-app build rules, then tries an empty VR conversion | update FramePort: saved recipes keep `as_is`, and flat apps still skip OVRPort; existing catalog-derived recipes refresh at the next app update. Until then, enable "Install as is" for an app whose APK needs no fixes |
|
||||
| A launch test suggests a patch (for example triage `sdl-no-clipboard` → `frame.sdl_clipboard`) but the game page doesn't show it / lists it as not applicable; or a game added long ago lacks warnings newer ones get (Android version, web wrapper, missing OBB) (GitHub #104, Dramatic Shape) | the library entry was analyzed by an older FramePort: the analysis field the patch reads (`sdl_java`, `min_sdk`, `web_wrapper`, `expects_obb`, …) is missing, so `applies()` is false | entries whose `analysis.extra.analysis_version` is below `detect.ANALYSIS_VERSION` are analyzed again at the GUI's start (background) and before a build (`pipeline.refresh_analyses`); by hand: game menu → "Analyze again", then Update on Frame. An unreadable APK is marked `analysis_failed` (retried after the next version bump). Adding an analysis field: bump `ANALYSIS_VERSION` |
|
||||
| Foveation full-resolution area looks identical at 10% and 45% (GitHub #394), while render resolution changes it | `FOVE_INNER`/`FOVE_OUTER` are neither passed through by Lepton nor read by Valve's layer; shared injection uses rectangular density presets | Game settings → Picture now exports named `FOVE_LEVEL=lo/med/hi`; Default preserves the runtime choice. Medium/high shrink the center and reduce peripheral density further. Legacy radius preferences map to the nearest old nominal preset. Compare Fixed + Darken coarse fragments with adapter `snapshot=N` at a fixed render resolution; see FRAME_RUNTIME.md for addresses and profile data. Agent launcher allowlist needs `FOVE_LEVEL`. |
|
||||
| FDM layer loads but diagnostic logging reports `fdm_injection_CreateInstance: skipped` | Layer loading alone does not enable injection on the tested SteamOS 0.4.5 build; a skipped helper instance alone also does not establish game-wide inactivity | Opt-in `device.foveation` mode `tracked`: loads Valve's layer and sets `FDM_DEBUG=enable`. Batman probe reached foveated render passes with changing per-eye offsets. Validate graphics and matched gameplay GPU time; do not enable globally. Use `debug` only for brief diagnostics, preserving existing flags; never benchmark with debug spam. See FRAME_RUNTIME.md. |
|
||||
| One eye (usually the right) shimmers or jitters in the 3D view while menus and panels look steady; pacing is clean (GitHub #69, e.g. RTCWQuest, Moss) | Valve's eye-tracked foveation layer (`VALVE_fdm_injection`) moves its low-density regions with each eye's gaze, and the right eye's gaze filter restarts often (`CEyePoseUKF R: Large dt … Bootstrapped` in `~/.local/share/Steam/logs/eyetracking.txt`) | Game settings → Picture → "Foveated rendering (Valve)": **Fixed** (`FDM_DEBUG=disable_offsets`; verify injection remains active on the installed runtime) or **Off** (`VK_INSTANCE_LAYERS=""`); catalog field `foveation:` |
|
||||
| With `device.foveation` Eye-tracked/Fixed, the sharp area looks right but everything around it is black with scattered coloured blocks (menus on quads too); pacing fine (GitHub #303, Medieval Dynasty; Vampire Survivors reported the same) | Unity's Oculus XR plugin asks for SUBSAMPLED eye images (`XR_META_vulkan_swapchain_create_info`, `VK_IMAGE_CREATE_SUBSAMPLED_BIT_EXT`); under Valve's FDM their coarse areas stay in the compact subsampled layout, which the compositor reads as an ordinary image. `foveation_fix=1` only dropped the `XrSwapchainCreateInfoFoveationFB` nodes and passed the META node on. With `foveation_fix=0` + `foveation_storage_fix=1` the adapter logs `foveation_storage_fix: shared allocation SUBSAMPLED cleared` for the eye swapchains | `device.foveation` (tracked/fixed) now also sets `foveation_storage_fix=1`, which clears SUBSAMPLED whatever `foveation_fix` is (generic version of Batman's OVRPlugin allocation fix). Rebuild + reinstall (new adapter). Headless-checked only (72 fps, bit cleared); picture needs the headset |
|
||||
@@ -31,6 +32,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A Unity Quest game stays on a loading screen or doesn't go on; `An unknown error occurred. Request failed.` from `Oculus.Platform.Request`1:OnComplete` (GitHub #346 Eleven, #360 Spectre, #368 Thief Simulator; triage `meta-request-failed`) | A Meta platform call the game waits for got no usable answer: OVRPort's platform library answers some calls without Meta's services (request id 0) and Meta's Unity wrapper drops the callback | Find the call in the stack (the game's own coroutine after `OnComplete`); a per-game fix that skips it when it isn't needed to play (e.g. an analytics id request). Calls the game needs online stay unsupported |
|
||||
| Game aborts at start: `OVRPlatform-Loader: DisplayErrorAndExit: Failed to launch SystemActivities` after an `ActivityNotFoundException` stack (GitHub #357 Breachers; triage `meta-system-activities`) | The game ships its own renamed copy of Meta's platform loader (e.g. `libovr_custom.so`), which bypasses OVRPort's dispatcher and tries to open Meta's system activities (Horizon OS) | None in general: such games are online games built on Meta's services. Serving OVRPort's dispatcher under that name is possible but unlikely to make them playable |
|
||||
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
|
||||
| Quest saves need a cloud folder or backups after playing (discussion #352) | Steam does not sync Quest saves | Settings → Data: choose a writable local cloud-sync folder and optionally enable **Back up saves when a game closes**, then connect to apply it. The Frame keeps three changed snapshots per game; FramePort imports them on connection/poll. Manual **Back up saves** is next to Play; Restore stays in the menu. Old backup folders remain available and are never moved silently |
|
||||
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
|
||||
| Tales from the Galaxy's Edge stops during map loading (GitHub #61): game thread in `FlushRenderingCommands` / `UEngine::LoadMap`, render thread in `FSplash::RenderFrame_RenderThread` / `ovrp_WaitToBeginFrame` / SteamVR `WaitFrame` | the separate Oculus loading splash waits for an unbegun frame while map travel waits for rendering | `frame.unreal_splash_bypass`: returns from the splash renderer, with a checked symbol, size and prologue. The last image stays visible during loading; the normal renderer and menu layers continue. Headset test with the depth-size fix: gameplay, both eyes, controller input and sound work |
|
||||
| Upper part of the intro/environment is black or clipped, but a separate menu panel is intact (GitHub #61, Tales from the Galaxy's Edge) | `FSceneRenderTargets::AllocMobileMultiViewDepth` caches the initial Android window dimensions (1920x1016/1020), leaving the depth attachment shorter than the eye color texture (1728x1728); drawing is limited by the smaller attachment | `frame.unreal_multiview_depth`: refreshes both cached depth dimensions from the current scene `BufferSize` before allocation. Checks the exported symbol, function size and surrounding instructions, then replaces only the branch that skips refresh. Keeps multiview and multisampling. Headset test: full view and gameplay work |
|
||||
@@ -122,7 +124,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| PC VR game judders/stutters although the GPU keeps up; vrcompositor.txt "Timed out. N total" high or frames dropped | the game misses the headset's refresh (for example 96 Hz = 10.4 ms; Stormland's slow frames take 10.7 ms) | `pcvr.steamvr_tuning` (default on): next Play sets SteamVR per-app `preferredRefreshRate` + `motionSmoothingOverride` via `fp_vrsettings.exe` |
|
||||
| Quest app keeps recentering / snapping the view when you turn your head; log shows session state 5→4→3→4→5 within a second | the Frame briefly takes focus; the app recenters on focus changes | adapter `focus_hold` (on by default; an earlier global debounce made AC Nexus stay black and was removed) |
|
||||
| Video player / app finds no local videos; MediaProvider "Requested path /home/steamos/... doesn't appear under ..." | Lepton's /sdcard is a symlink to a host path, so Android's media index rejects every file | upload in the Files tab (Videos = /sdcard/Movies) and browse folders in the app |
|
||||
| Batman: Arkham Shadow closes during smoke-bomb effects; later input-tree corruption | Meta XR Audio Wwise deletes queued metadata still referenced by the current audio frame, then writes through the stale pointer | FrameBridge automatically retires metadata after current-frame references disappear, for the verified AArch64 SDK build only; see [AUDIO_METADATA.md](AUDIO_METADATA.md) |
|
||||
| Batman: Arkham Shadow closes during smoke-bomb effects; later input-tree corruption | Meta XR Audio Wwise deletes queued metadata still referenced by the current audio frame, then writes through the stale pointer | FrameBridge automatically retires metadata after current-frame references disappear, for the verified AArch64 SDK build only, using a single-word entry branch and nearby stub (hook skipped if no stub fits); see [AUDIO_METADATA.md](AUDIO_METADATA.md) |
|
||||
| Batman: Arkham Shadow aborts loading a save/scene, with a 26–147 GB allocation through `MetaXRAudioUnityMemoryAllocator.cpp` | The asynchronous audio geometry task retains a native view without its owning UnityWebRequest; source bytes can be reused before parsing | Update Batman on Frame: the adapter copies geometry before background dispatch and releases it after the original worker completes, only for the verified Batman IL2CPP/Meta SDK builds; see [AUDIO_GEOMETRY.md](AUDIO_GEOMETRY.md) |
|
||||
| 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 |
|
||||
@@ -195,7 +198,12 @@ 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 (Demeter) | no Meta scene API | adapter `scene_emul=1` (+ `frame.meta_permissions`) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -207,7 +215,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Unity game shows a picture but the player body is frozen: no head tracking, the controllers stay on the model, no buttons, although OpenXR input works (BONELAB 1.2068, Oculus XR Plugin) | OVRPlugin reports the headset as not worn (`ovrp_GetUserPresent2` → 0) a few seconds after start, and the game's rig only follows a present user (Marrow `XRHMD.IsUserPresent` = Unity's HMD `UserPresence`) | `frame.unity_user_presence` (the Oculus XR Plugin's lookup → `libfp_ovrp.so`, reports present; log `user presence: OVRPlugin 0 …`) |
|
||||
| Unreal 5 game crashes at its first render pass, backtrace `vulkan.freedreno.so` ← `libVkLayer_fossilize.so` ← `libfp_vk.so (create_render_pass2)` (Metro Awakening) | a subpass without a depth attachment names a depth/stencil resolve attachment; the Frame's Turnip reads the missing depth attachment (NULL) | the Vulkan shim drops that resolve (always, FramePort after 0.11.0; log `dropped the depth resolve of subpass N (no depth attachment)`); triage `unreal-vulkan-driver-crash` |
|
||||
| Unreal game crashes before its first frame: SIGSEGV `fault addr 0x10` on the main thread, backtrace all `libUnreal.so` above the linker's `soinfo::call_constructors` (inside `System.loadLibrary`), right after `Parsing commandline` (GitHub #349, Transformers Beyond Reality) | The game is built with Unreal's Binned3 allocator, which reserves its whole range while the library loads: one `mmap(NULL, 76 GiB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS)`. Quest grants it; the Frame's kernel (heuristic overcommit, mode 0) refuses a writable private mapping larger than RAM + swap, and Unreal's out-of-memory handler ("Ran out of memory allocating ...") dereferences its not-yet-created log | `frame.unreal_noreserve` (suggested from analysis `unreal_binned3`): every such anonymous read/write mmap in the engine library gets `MAP_NORESERVE` (one instruction each: `mov w3, #0x22` → `#0x4022`); triage `unreal-reserve-refused`. Found by disassembling the crash pc (the OOM handler; its UTF-16 format string) and the caller's `mov x0, #0x1300000000` |
|
||||
| Unreal Vulkan game fails on its first frames and OVRPlugin reports `foveationImage = 0x0, width = 0, height = 0` (for example Transformers Beyond Reality: Redux, #349) | Quest FB foveation remains advertised by OVRPort's dispatcher despite `foveation_fix=1`. This is a hypothesis for the failure, not a confirmed cause: Redux's alternate no-ForceQuit build dereferences a null graphics-pipeline-state object during Vulkan pipeline binding, not a density image | Diagnostic `adapter.hide_fb_foveation=1` (off by default, Vulkan): filters the dispatcher and runtime extension lists/instance requests, removes foveation create/image chains and refuses profile creation/updates. Rebuild with the adapter **and xrshim** artifacts, then compare first-frame survival and Unreal's original fatal log. Does not disable Valve FDM injection. Device verification pending |
|
||||
| Unreal Vulkan game ends on its first frames with "Shader compilation failures are Fatal." (tombstone abort message; `System.exit` / ForceQuit; the no-ForceQuit build dereferences a null pipeline instead), `vk_pipeline_log=1` shows `vkCreateGraphicsPipelines failed: -1` (for example Transformers Beyond Reality: Redux, #349) | With Lepton's Fossilize layer loaded **every** graphics pipeline failed with VK_ERROR_OUT_OF_HOST_MEMORY, even a constant-colour one; retries without the pipeline cache, without the app's CPU allocator, and without Valve's FDM/RPO layers all failed too. Without Fossilize a few multiview vertex shaders still fail in Turnip (a blank fragment shader alone doesn't help, blank vertex + fragment does) | `adapter.vk_no_fossilize=1` (Vulkan shim removes `VK_LAYER_fossilize` from GraphicsEnv's debug layer list when the engine loads it) + `adapter.vk_pipeline_fallback=1` (refused pipelines are made with blank shaders; logged "retried with a blank"); triage `vulkan-pipeline-refused`. Headless: runs at 72 fps; headset check pending. The `foveationImage = 0x0` warning (`hide_fb_foveation`) was ruled out |
|
||||
| Unreal 5 game crashes in `vulkan.freedreno.so` called straight from `libUnreal.so` right after its swapchains, or a few seconds into the first frames (Metro Awakening) | Unreal turns on fragment-density-map foveation because Turnip offers VK_EXT_fragment_density_map, then records barriers (to `FRAGMENT_DENSITY_MAP_OPTIMAL`) and image views (2D array R8G8) for a density map the Frame never provides (VK_NULL_HANDLE); Turnip dereferences it | adapter `vk_hide_fdm=1` (the game doesn't see the extension) + `vk_spec_fixes=1` (leaves out null-image barriers, refuses null-image views); triage `unreal-fdm-missing`. Found by disassembling the driver at the crash pc: the struct offsets (0x58 = VkSubpassDescription2, 0x48/0x60 = image barriers, viewType/format in registers) name the call |
|
||||
| Own-engine game crashes in `je_free` from its own library on the first frames (Roblox) | the game frees memory it doesn't own; not the Vulkan shim or the format fallback | unsupported |
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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]
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,338 @@
|
||||
# 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.
|
||||
@@ -87,6 +87,7 @@ shots:
|
||||
steps:
|
||||
- go: monitor
|
||||
- wait_for: "Processes"
|
||||
- hook: monitor_settled # the history replay finished (a slow runner shot it halfway: numbers differed)
|
||||
|
||||
# newer screens (docs/SHOWCASE.md's gallery; the README picks what it needs)
|
||||
- name: live-view # idle: a running stream shows its relay's random port and the data rate (no stable picture)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <errno.h>
|
||||
#ifndef MAP_FIXED_NOREPLACE
|
||||
#define MAP_FIXED_NOREPLACE 0x100000
|
||||
#endif
|
||||
#include <string.h>
|
||||
#include <sys/mman.h>
|
||||
|
||||
static size_t mx_audio_page;
|
||||
static int mx_audio_branch(uintptr_t from,uintptr_t to,uint32_t *word) {
|
||||
if((from|to)&3)return 0;
|
||||
if(to>=from) {if(to-from>=((uintptr_t)1<<27))return 0;}
|
||||
else if(from-to>((uintptr_t)1<<27))return 0;
|
||||
*word=0x14000000u|((uint32_t)((to-from)>>2)&0x03ffffffu);
|
||||
return 1;
|
||||
}
|
||||
static void mx_audio_jump(unsigned char *out,uintptr_t destination) {
|
||||
const uint32_t jump[2]={0x58000050u,0xd61f0200u};
|
||||
memcpy(out,jump,8);memcpy(out+8,&destination,8);
|
||||
}
|
||||
struct mx_audio_candidate { uintptr_t address,distance; };
|
||||
static int mx_audio_candidate_order(const void *a,const void *b) {
|
||||
const struct mx_audio_candidate *x=a,*y=b;
|
||||
if(x->distance!=y->distance)return x->distance<y->distance?-1:1;
|
||||
return x->address<y->address?-1:x->address>y->address;
|
||||
}
|
||||
// Maps are sorted by address. Clip every free gap to the intersection of both
|
||||
// branch ranges; enumerate pages, then sort outward (lower address breaks ties).
|
||||
static int mx_audio_candidates(FILE *maps,uintptr_t target,
|
||||
struct mx_audio_candidate **out,size_t *count,const char **failure) {
|
||||
uintptr_t radius=(uintptr_t)1<<27;
|
||||
uintptr_t low=target>radius?target-radius:0;
|
||||
uintptr_t high=target>UINTPTR_MAX-radius?UINTPTR_MAX:target+radius;
|
||||
uintptr_t cursor=low;
|
||||
size_t capacity=0,line_capacity=0;char *line=NULL;
|
||||
*out=NULL;*count=0;
|
||||
for(;;) {
|
||||
unsigned long long start,end;
|
||||
*failure="read /proc/self/maps";
|
||||
int eof=getline(&line,&line_capacity,maps)<0;
|
||||
if(eof && (ferror(maps) || !feof(maps)))goto fail;
|
||||
if(!eof && (sscanf(line,"%llx-%llx",&start,&end)!=2 || end<=start)) {*failure="parse /proc/self/maps";errno=EINVAL;goto fail;}
|
||||
uintptr_t stop=eof?high:(uintptr_t)start;
|
||||
if(stop>high)stop=high;
|
||||
uintptr_t page=cursor;
|
||||
if(page%mx_audio_page)page+=mx_audio_page-page%mx_audio_page;
|
||||
for(;page<stop && stop-page>=mx_audio_page;page+=mx_audio_page) {
|
||||
uint32_t word;
|
||||
if(!page || !mx_audio_branch(target,page,&word) ||
|
||||
!mx_audio_branch(page+20,target+4,&word))continue;
|
||||
if(*count==capacity) {
|
||||
size_t next=capacity?capacity*2:64;
|
||||
void *p=realloc(*out,next*sizeof(**out));
|
||||
if(!p){*failure="allocate stub candidates";goto fail;}
|
||||
*out=p;capacity=next;
|
||||
}
|
||||
(*out)[(*count)++]=(struct mx_audio_candidate){page,page>target?page-target:target-page};
|
||||
}
|
||||
if(eof || stop==high)break;
|
||||
if((uintptr_t)end>cursor)cursor=(uintptr_t)end;
|
||||
if(cursor>=high)break;
|
||||
}
|
||||
free(line);
|
||||
if(*count)qsort(*out,*count,sizeof(**out),mx_audio_candidate_order);
|
||||
return 1;
|
||||
fail:
|
||||
{int saved=errno;free(line);free(*out);*out=NULL;*count=0;errno=saved;return 0;}
|
||||
}
|
||||
// Callers serialize installation. Only the verified SUB SP / STP prologues
|
||||
// below may be replayed: no PC-relative instruction or branch relocation.
|
||||
static void *mx_audio_detour(unsigned char *address,void *replacement,const uint32_t expected[4],void *original_slot) {
|
||||
uint32_t branch;
|
||||
uintptr_t target=(uintptr_t)address;
|
||||
const char *failure=NULL;int failure_errno=0,rollback_errno=0;
|
||||
if(target&3)failure="unaligned entry";
|
||||
else if(memcmp(address,expected,16))failure="16-byte guard mismatch";
|
||||
else if(expected[0]!=0xd10743ffu && expected[0]!=0xa9bd7bfdu &&
|
||||
expected[0]!=0xd10243ffu && expected[0]!=0xd10183ffu)
|
||||
failure="unsupported replay instruction";
|
||||
if(failure) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: %s; entry unchanged",address,failure);
|
||||
return NULL;
|
||||
}
|
||||
if(!mx_audio_page || (mx_audio_page&(mx_audio_page-1))) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: invalid page size=%zu; entry unchanged",address,mx_audio_page);
|
||||
return NULL;
|
||||
}
|
||||
struct mx_audio_candidate *candidates=NULL;size_t count=0;
|
||||
FILE *maps=fopen("/proc/self/maps","r");
|
||||
if(!maps) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: open /proc/self/maps errno=%d; entry unchanged",address,errno);
|
||||
return NULL;
|
||||
}
|
||||
int parsed=mx_audio_candidates(maps,target,&candidates,&count,&failure);
|
||||
int parse_errno=errno;fclose(maps);
|
||||
if(!parsed) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: %s errno=%d; entry unchanged",address,failure,parse_errno);
|
||||
return NULL;
|
||||
}
|
||||
unsigned char *code=MAP_FAILED;void *result=MAP_FAILED,*attempt=NULL;
|
||||
void *closest=count?(void*)candidates[0].address:NULL;
|
||||
int map_errno=0;size_t attempts=0;
|
||||
for(size_t i=0;i<count;i++) {
|
||||
attempt=(void*)candidates[i].address;attempts++;
|
||||
result=mmap(attempt,mx_audio_page,PROT_READ|PROT_WRITE,
|
||||
MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED_NOREPLACE,-1,0);
|
||||
if(result==MAP_FAILED) {map_errno=errno;continue;}
|
||||
// Old kernels may ignore NOREPLACE. Never accept a relocated mapping.
|
||||
if(result!=attempt) {munmap(result,mx_audio_page);map_errno=0;continue;}
|
||||
code=result;break;
|
||||
}
|
||||
free(candidates);
|
||||
if(code==MAP_FAILED) {
|
||||
LOG("FrameBridge audio detour: target=%p failed: stub allocation closest=%p attempted=%p mmap=%p errno=%d attempts=%zu; no safe ELF slack fallback; entry unchanged",
|
||||
address,closest,attempt,result,map_errno,attempts);
|
||||
return NULL;
|
||||
}
|
||||
uint32_t back;
|
||||
if(!mx_audio_branch(target,(uintptr_t)code,&branch) ||
|
||||
!mx_audio_branch((uintptr_t)code+20,target+4,&back)) {
|
||||
failure="branch range validation";goto fail;
|
||||
}
|
||||
mx_audio_jump(code,(uintptr_t)replacement);
|
||||
memcpy(code+16,expected,4);memcpy(code+20,&back,4);
|
||||
__builtin___clear_cache((char*)code,(char*)code+24);
|
||||
if(mprotect(code,mx_audio_page,PROT_READ|PROT_EXEC)) {failure="mprotect stub RX";failure_errno=errno;goto fail;}
|
||||
unsigned char *page=(void*)(target&~(mx_audio_page-1));
|
||||
if(mprotect(page,mx_audio_page,PROT_READ|PROT_WRITE|PROT_EXEC)) {failure="mprotect entry RWX";failure_errno=errno;goto fail;}
|
||||
// Publish the callable original before any worker can enter the hook.
|
||||
if(original_slot)__atomic_store_n((void**)original_slot,code+16,__ATOMIC_RELEASE);
|
||||
__atomic_store_n((uint32_t*)address,branch,__ATOMIC_RELEASE);
|
||||
__builtin___clear_cache((char*)address,(char*)address+4);
|
||||
if(mprotect(page,mx_audio_page,PROT_READ|PROT_EXEC)) {
|
||||
failure_errno=errno;
|
||||
__atomic_store_n((uint32_t*)address,expected[0],__ATOMIC_RELEASE);
|
||||
__builtin___clear_cache((char*)address,(char*)address+4);
|
||||
if(mprotect(page,mx_audio_page,PROT_READ|PROT_EXEC))
|
||||
rollback_errno=errno; // Cannot force the kernel to restore RX.
|
||||
// A worker may already be in the stub/hook/trampoline. No grace period
|
||||
// exists here, so neither the mapping nor original_slot can be retired.
|
||||
LOG("FrameBridge audio detour: target=%p failed: mprotect entry RX errno=%d; rollback mprotect entry RX errno=%d; entry restored; published code retained",address,failure_errno,rollback_errno);return NULL;
|
||||
}
|
||||
return code+16;
|
||||
fail:
|
||||
munmap(code,mx_audio_page);
|
||||
LOG("FrameBridge audio detour: target=%p failed: %s errno=%d; entry unchanged",address,failure,failure_errno);return NULL;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#pragma once
|
||||
#include <pthread.h>
|
||||
#include <sched.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
// The source NativeArray belongs to a UnityWebRequest. Copy while its caller
|
||||
// still owns that request, before async-void queues the background parser.
|
||||
typedef struct MxGeometryOwned {
|
||||
struct MxGeometryOwned *next;
|
||||
size_t size;
|
||||
unsigned char bytes[];
|
||||
} MxGeometryOwned;
|
||||
static pthread_mutex_t mx_geometry_owned_mutex=PTHREAD_MUTEX_INITIALIZER;
|
||||
static MxGeometryOwned *mx_geometry_owned_head;
|
||||
static unsigned mx_geometry_queued,mx_geometry_finished;
|
||||
static void (*mx_geometry_queue_original)(void*,const void*,uint64_t,void*);
|
||||
static void (*mx_geometry_worker_original)(void*,void*);
|
||||
static void mx_geometry_queue(void *object,const void *data,uint64_t packed_length,void *method) {
|
||||
void (*original)(void*,const void*,uint64_t,void*);
|
||||
while(!(original=__atomic_load_n(&mx_geometry_queue_original,__ATOMIC_ACQUIRE)))sched_yield();
|
||||
int32_t length=(int32_t)(uint32_t)packed_length;
|
||||
size_t size=length>0?(size_t)length:0;
|
||||
if(!data || !size || size>SIZE_MAX-sizeof(MxGeometryOwned)){original(object,data,packed_length,method);return;}
|
||||
MxGeometryOwned *owned=malloc(sizeof(*owned)+size);
|
||||
if(!owned){original(object,data,packed_length,method);return;}
|
||||
owned->size=size;
|
||||
memcpy(owned->bytes,data,size);
|
||||
pthread_mutex_lock(&mx_geometry_owned_mutex);
|
||||
owned->next=mx_geometry_owned_head;mx_geometry_owned_head=owned;
|
||||
pthread_mutex_unlock(&mx_geometry_owned_mutex);
|
||||
unsigned queued=__atomic_fetch_add(&mx_geometry_queued,1,__ATOMIC_RELAXED);
|
||||
if(queued<4)LOG("FrameBridge audio geometry: owned %zu bytes before background task queued",size);
|
||||
original(object,owned->bytes,packed_length,method);
|
||||
}
|
||||
static void mx_geometry_worker(void *closure,void *method) {
|
||||
const void *data=NULL;
|
||||
if(closure)memcpy(&data,(const unsigned char*)closure+16,8);
|
||||
void (*original)(void*,void*);
|
||||
while(!(original=__atomic_load_n(&mx_geometry_worker_original,__ATOMIC_ACQUIRE)))sched_yield();
|
||||
original(closure,method);
|
||||
MxGeometryOwned *finished=NULL;
|
||||
pthread_mutex_lock(&mx_geometry_owned_mutex);
|
||||
for(MxGeometryOwned **p=&mx_geometry_owned_head;*p;p=&(*p)->next)if((*p)->bytes==data) {
|
||||
finished=*p;*p=finished->next;break;
|
||||
}
|
||||
pthread_mutex_unlock(&mx_geometry_owned_mutex);
|
||||
if(finished) {
|
||||
unsigned count=__atomic_fetch_add(&mx_geometry_finished,1,__ATOMIC_RELAXED);
|
||||
if(count<4)LOG("FrameBridge audio geometry: background parse finished; releasing %zu owned bytes",finished->size);
|
||||
free(finished);
|
||||
}
|
||||
}
|
||||
@@ -11,26 +11,7 @@
|
||||
#include <time.h>
|
||||
#include <link.h>
|
||||
#include <string.h>
|
||||
static size_t mx_audio_page;
|
||||
static void *mx_audio_detour(unsigned char *address,void *replacement,const uint32_t expected[4]) {
|
||||
if(memcmp(address,expected,16)) return NULL;
|
||||
unsigned char *trampoline=mmap(NULL,mx_audio_page,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);
|
||||
if(trampoline==MAP_FAILED) return NULL;
|
||||
uint32_t branch[2]={0x58000050u,0xd61f0200u}; // ldr x16, +8; br x16
|
||||
memcpy(trampoline,address,16);
|
||||
memcpy(trampoline+16,branch,8);
|
||||
uintptr_t continuation=(uintptr_t)address+16;
|
||||
memcpy(trampoline+24,&continuation,8);
|
||||
__builtin___clear_cache((char*)trampoline,(char*)trampoline+32);
|
||||
if(mprotect(trampoline,mx_audio_page,PROT_READ|PROT_EXEC)) return NULL;
|
||||
unsigned char *page=(unsigned char*)((uintptr_t)address&~(mx_audio_page-1));
|
||||
if(mprotect(page,mx_audio_page,PROT_READ|PROT_WRITE|PROT_EXEC)) return NULL;
|
||||
memcpy(address,branch,8);
|
||||
memcpy(address+8,&replacement,8);
|
||||
__builtin___clear_cache((char*)address,(char*)address+16);
|
||||
mprotect(page,mx_audio_page,PROT_READ|PROT_EXEC);
|
||||
return trampoline;
|
||||
}
|
||||
#include "audio_detour.h"
|
||||
#include "audio_metadata_queue.h"
|
||||
typedef struct {float previous,current;} MxAudioRamp;
|
||||
static uintptr_t mx_audio_base;
|
||||
@@ -49,8 +30,8 @@ static void mx_audio_kill_experimental(void) {mx_audio_kill(1);}
|
||||
static void mx_audio_kill_regular(void) {mx_audio_kill(0);}
|
||||
static uint64_t mx_audio_consume(void *sink,void *main_mix,void *passthrough,const void *objects,MxAudioRamp ramp) {
|
||||
const unsigned char *previous=mx_audio_objects;mx_audio_objects=objects;
|
||||
// An audio thread can enter just after its prologue was patched, before
|
||||
// the installing thread publishes the completed trampoline.
|
||||
// The installer publishes the completed trampoline before the entry branch.
|
||||
// Keep the acquire load paired with that publication.
|
||||
uint64_t (*original)(void*,void*,void*,const void*,MxAudioRamp);
|
||||
while(!(original=__atomic_load_n(&mx_audio_consume_original,__ATOMIC_ACQUIRE)))sched_yield();
|
||||
uint64_t result=original(sink,main_mix,passthrough,objects,ramp);
|
||||
@@ -79,15 +60,19 @@ static int mx_audio_find(struct dl_phdr_info *info,size_t size,void *unused) {
|
||||
const uint32_t kill[4]={0xa9bd7bfd,0xf9000bf5,0xa9024ff4,0x910003fd};
|
||||
unsigned char *base=(void*)info->dlpi_addr;
|
||||
if(memcmp(base+0x2c8fac,consume,16) || memcmp(base+0x2be2c0,kill,16) || memcmp(base+0x2be7b0,kill,16)) {
|
||||
const uintptr_t offsets[3]={0x2c8fac,0x2be2c0,0x2be7b0};
|
||||
for(unsigned i=0;i<3;i++)if(memcmp(base+offsets[i],i?kill:consume,16))
|
||||
LOG("FrameBridge audio detour: target=%p failed: 16-byte guard mismatch; entry unchanged",base+offsets[i]);
|
||||
__atomic_store_n(&mx_audio_checked,1,__ATOMIC_RELEASE);return 1;
|
||||
}
|
||||
// One installation attempt per verified library, including failure paths.
|
||||
__atomic_store_n(&mx_audio_checked,1,__ATOMIC_RELEASE);
|
||||
__atomic_store_n(&mx_audio_base,info->dlpi_addr,__ATOMIC_RELEASE);
|
||||
void *original=mx_audio_detour(base+0x2c8fac,(void*)mx_audio_consume,consume);
|
||||
__atomic_store_n(&mx_audio_consume_original,original,__ATOMIC_RELEASE);
|
||||
if(!__atomic_load_n(&mx_audio_consume_original,__ATOMIC_ACQUIRE)) {
|
||||
void *original=mx_audio_detour(base+0x2c8fac,(void*)mx_audio_consume,consume,&mx_audio_consume_original);
|
||||
if(!original) {
|
||||
__atomic_store_n(&mx_audio_base,0,__ATOMIC_RELEASE);return 1;
|
||||
}
|
||||
if(!mx_audio_detour(base+0x2be2c0,(void*)mx_audio_kill_experimental,kill) || !mx_audio_detour(base+0x2be7b0,(void*)mx_audio_kill_regular,kill)) {
|
||||
if(!mx_audio_detour(base+0x2be2c0,(void*)mx_audio_kill_experimental,kill,NULL) || !mx_audio_detour(base+0x2be7b0,(void*)mx_audio_kill_regular,kill,NULL)) {
|
||||
LOG("FrameBridge audio metadata: incomplete installation");return 1;
|
||||
}
|
||||
LOG("FrameBridge audio metadata: metadata reclamation follows current audio-frame references; Meta audio base=%p",base);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Batman 1.4.1: keep the web request's native audio data alive across Task.Run.
|
||||
#pragma once
|
||||
#if defined(__aarch64__)
|
||||
#include "audio_geometry_owned.h"
|
||||
#include "batman_audio_geometry_target.h"
|
||||
static int mx_geometry_checked;
|
||||
static int mx_geometry_find(struct dl_phdr_info *info,size_t ignored,void *unused) {
|
||||
(void)ignored;(void)unused;
|
||||
if(!strstr(info->dlpi_name,"/libil2cpp.so"))return 0;
|
||||
int match=0;
|
||||
for(unsigned i=0;i<info->dlpi_phnum;i++)if(info->dlpi_phdr[i].p_type==PT_NOTE) {
|
||||
const unsigned char *p=(void*)(info->dlpi_addr+info->dlpi_phdr[i].p_vaddr);
|
||||
const unsigned char *end=p+info->dlpi_phdr[i].p_memsz;
|
||||
while(p+sizeof(Elf64_Nhdr)<=end) {
|
||||
const Elf64_Nhdr *note=(const void*)p;
|
||||
const unsigned char *name=p+sizeof(*note),*desc=name+((note->n_namesz+3)&~3u);
|
||||
const unsigned char *next=desc+((note->n_descsz+3)&~3u);
|
||||
if(next>end || next<=p)break;
|
||||
if(note->n_type==NT_GNU_BUILD_ID && note->n_namesz==4 && !memcmp(name,"GNU",4) &&
|
||||
mx_geometry_matches_id(desc,note->n_descsz))match=1;
|
||||
p=next;
|
||||
}
|
||||
}
|
||||
__atomic_store_n(&mx_geometry_checked,1,__ATOMIC_RELEASE);
|
||||
if(!match)return 1;
|
||||
unsigned char *base=(void*)info->dlpi_addr;
|
||||
// Live VAs, not the file offsets printed by the managed-code dump.
|
||||
unsigned char *queue=base+0x2e61bb4,*worker=base+0x2e63a28;
|
||||
if(!mx_geometry_matches_entries(queue,worker)) {
|
||||
if(memcmp(queue,mx_geometry_queue_entry,16))
|
||||
LOG("FrameBridge audio detour: target=%p failed: 16-byte guard mismatch; entry unchanged",queue);
|
||||
if(memcmp(worker,mx_geometry_worker_entry,16))
|
||||
LOG("FrameBridge audio detour: target=%p failed: 16-byte guard mismatch; entry unchanged",worker);
|
||||
return 1;
|
||||
}
|
||||
// Hook the completion boundary first so every queued copy has a consumer.
|
||||
void *original=mx_audio_detour(worker,(void*)mx_geometry_worker,mx_geometry_worker_entry,&mx_geometry_worker_original);
|
||||
if(!original)return 1;
|
||||
original=mx_audio_detour(queue,(void*)mx_geometry_queue,mx_geometry_queue_entry,&mx_geometry_queue_original);
|
||||
LOG("FrameBridge audio geometry: Batman async input lifetime %s",original?"installed":"not installed");
|
||||
return 1;
|
||||
}
|
||||
static pthread_mutex_t mx_geometry_install_mutex=PTHREAD_MUTEX_INITIALIZER;
|
||||
static void mx_batman_geometry_initialize(void) {
|
||||
// The metadata initializer already verified the Meta SDK build/prologues.
|
||||
// Keep this independent of its early-out: IL2CPP may be loaded afterwards.
|
||||
if(__atomic_load_n(&mx_geometry_checked,__ATOMIC_ACQUIRE) ||
|
||||
!__atomic_load_n(&mx_audio_base,__ATOMIC_ACQUIRE))return;
|
||||
if(pthread_mutex_trylock(&mx_geometry_install_mutex))return;
|
||||
if(!mx_geometry_checked) {
|
||||
static time_t last_scan;
|
||||
struct timespec now;clock_gettime(CLOCK_MONOTONIC,&now);
|
||||
if(now.tv_sec!=last_scan) {
|
||||
last_scan=now.tv_sec;
|
||||
char package[64]={0};FILE *cmd=fopen("/proc/self/cmdline","rb");
|
||||
if(cmd){(void)fread(package,1,sizeof(package)-1,cmd);fclose(cmd);}
|
||||
if(strcmp(package,"com.camouflaj.manta"))__atomic_store_n(&mx_geometry_checked,1,__ATOMIC_RELEASE);
|
||||
else dl_iterate_phdr(mx_geometry_find,NULL);
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(&mx_geometry_install_mutex);
|
||||
}
|
||||
#else
|
||||
static void mx_batman_geometry_initialize(void) {}
|
||||
#endif
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
static const unsigned char mx_geometry_il2cpp_id[20]={
|
||||
0x1d,0x48,0xa4,0x1d,0x7e,0x90,0x11,0xbe,0xf5,0xfa,0xe0,0xc2,0x3c,0x40,0x4c,0x62,0xa4,0xe7,0xa7,0x3c
|
||||
};
|
||||
static const uint32_t mx_geometry_queue_entry[4]={0xd10243ff,0xa9065ffe,0xa90757f6,0xa9084ff4};
|
||||
static const uint32_t mx_geometry_worker_entry[4]={0xd10183ff,0xa90267fe,0xa9035ff8,0xa90457f6};
|
||||
static int mx_geometry_matches_id(const void *id,size_t size) {
|
||||
return size==sizeof(mx_geometry_il2cpp_id) && !memcmp(id,mx_geometry_il2cpp_id,size);
|
||||
}
|
||||
static int mx_geometry_matches_entries(const void *queue,const void *worker) {
|
||||
return !memcmp(queue,mx_geometry_queue_entry,sizeof(mx_geometry_queue_entry)) &&
|
||||
!memcmp(worker,mx_geometry_worker_entry,sizeof(mx_geometry_worker_entry));
|
||||
}
|
||||
@@ -77,6 +77,7 @@ static void fb_log(const char *fmt, ...) {
|
||||
}
|
||||
#define LOG(...) fb_log(__VA_ARGS__)
|
||||
#include "audio_metadata.h"
|
||||
#include "batman_audio_geometry.h"
|
||||
#include "surface_views.h"
|
||||
|
||||
static void *loader;
|
||||
@@ -145,6 +146,7 @@ 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 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
|
||||
@@ -219,6 +221,7 @@ 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;
|
||||
@@ -383,8 +386,9 @@ 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 the guardian bounds");
|
||||
if (!emulate_scene) LOG("emulating Meta scene/spatial-entity extensions from room file or 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");
|
||||
@@ -469,7 +473,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
}
|
||||
no_equirect = !has_equirect; no_equirect2 = !has_equirect2; no_cylinder = !has_cylinder; no_cube = !has_cube;
|
||||
free(available);
|
||||
mx_audio_initialize();
|
||||
mx_audio_initialize(); mx_batman_geometry_initialize();
|
||||
XrResult result = fn(&fixed, instance);
|
||||
free(names);
|
||||
LOG("xrCreateInstance result=%d", result);
|
||||
@@ -481,7 +485,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
|
||||
LOG("color_space: emulating XR_FB_color_space; REC709 SDR compatibility, no colour conversion");
|
||||
}
|
||||
}
|
||||
if (XR_SUCCEEDED(result)) { active_instance = *instance; mx_audio_initialize(); }
|
||||
if (XR_SUCCEEDED(result)) { active_instance = *instance; mx_audio_initialize(); mx_batman_geometry_initialize(); }
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -855,15 +859,44 @@ 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; (void)i; if (!out) return XR_ERROR_VALIDATION_FAILURE; *out = FAKE_HANDLE(XrPassthroughFB); return XR_SUCCESS;
|
||||
(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;
|
||||
}
|
||||
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; (void)i; if (!out) return XR_ERROR_VALIDATION_FAILURE; *out = FAKE_HANDLE(XrPassthroughLayerFB); return XR_SUCCESS;
|
||||
(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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -899,12 +932,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_handle},
|
||||
{"xrPassthroughPauseFB", (PFN_xrVoidFunction)emu_passthrough_handle},
|
||||
{"xrPassthroughStartFB", (PFN_xrVoidFunction)emu_passthrough_start},
|
||||
{"xrPassthroughPauseFB", (PFN_xrVoidFunction)emu_passthrough_pause},
|
||||
{"xrCreatePassthroughLayerFB", (PFN_xrVoidFunction)emu_create_passthrough_layer},
|
||||
{"xrDestroyPassthroughLayerFB", (PFN_xrVoidFunction)emu_passthrough_layer_handle},
|
||||
{"xrPassthroughLayerPauseFB", (PFN_xrVoidFunction)emu_passthrough_layer_handle},
|
||||
{"xrPassthroughLayerResumeFB", (PFN_xrVoidFunction)emu_passthrough_layer_handle},
|
||||
{"xrPassthroughLayerPauseFB", (PFN_xrVoidFunction)emu_passthrough_layer_pause},
|
||||
{"xrPassthroughLayerResumeFB", (PFN_xrVoidFunction)emu_passthrough_layer_resume},
|
||||
{"xrPassthroughLayerSetStyleFB", (PFN_xrVoidFunction)emu_passthrough_layer_style},
|
||||
{"xrCreateGeometryInstanceFB", (PFN_xrVoidFunction)emu_create_geometry},
|
||||
{"xrDestroyGeometryInstanceFB", (PFN_xrVoidFunction)emu_geometry_handle},
|
||||
@@ -942,6 +975,13 @@ 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)
|
||||
@@ -989,7 +1029,10 @@ 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;
|
||||
if (locate_fake(space, baseSpace, time, &pose, &flags)) {
|
||||
pthread_mutex_lock(&scene_lock);
|
||||
int fake = locate_fake(space, baseSpace, time, &pose, &flags);
|
||||
pthread_mutex_unlock(&scene_lock);
|
||||
if (fake) {
|
||||
location->pose = pose;
|
||||
location->locationFlags = flags;
|
||||
return XR_SUCCESS;
|
||||
@@ -1011,8 +1054,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 = 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;
|
||||
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]);
|
||||
}
|
||||
if (!any_fake) {
|
||||
PFN_xrLocateSpaces fn = (PFN_xrLocateSpaces)lookup(active_instance, name);
|
||||
@@ -1035,7 +1078,12 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_locate_spaces_khr(XrSession session, c
|
||||
}
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace space) {
|
||||
if (fake_space_index(space) >= 0) return XR_SUCCESS; // entity spaces live for the whole session
|
||||
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
|
||||
}
|
||||
PFN_xrDestroySpace fn = (PFN_xrDestroySpace)lookup(active_instance, "xrDestroySpace");
|
||||
return fn ? fn(space) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
@@ -1394,7 +1442,7 @@ static XrSession focused_session = XR_NULL_HANDLE;
|
||||
static int kicks_pending = -1; // -1: not yet armed
|
||||
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrPollEvent(XrInstance instance, XrEventDataBuffer *event) {
|
||||
mx_audio_initialize();
|
||||
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) {
|
||||
@@ -2230,6 +2278,19 @@ __attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_
|
||||
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;
|
||||
#define SCENE_PROC(fn) if (!strcmp(name, #fn)) return (PFN_xrVoidFunction)fn;
|
||||
SCENE_PROC(xrPollEvent)
|
||||
SCENE_PROC(xrGetSystemProperties)
|
||||
SCENE_PROC(xrLocateSpace)
|
||||
SCENE_PROC(xrLocateSpaces)
|
||||
SCENE_PROC(xrDestroySpace)
|
||||
#undef SCENE_PROC
|
||||
if (!strcmp(name, "xrLocateSpacesKHR")) return (PFN_xrVoidFunction)emu_locate_spaces_khr;
|
||||
}
|
||||
PFN_xrVoidFunction color = name ? color_space_emulation(name) : NULL;
|
||||
return color ? color : (name ? render_model_emulation(name) : NULL);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
// 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). 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.
|
||||
// lack them (Steam Frame). Room files use STAGE coordinates; absent/invalid files retain the guardian box.
|
||||
// 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]))
|
||||
|
||||
@@ -42,38 +45,58 @@ 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 64
|
||||
#define MAX_ENTITIES 2048
|
||||
#define MAX_OUTLINE 1024
|
||||
|
||||
typedef struct {
|
||||
XrSpace handle; // fake handle given to the app
|
||||
XrUuidEXT uuid;
|
||||
XrPosef pose_in_stage;
|
||||
const char *label; // semantic label, NULL if none
|
||||
char label[256]; // comma-separated Meta semantic labels
|
||||
XrRect2Df bounds; // 2D bounds in the entity's local XY plane
|
||||
int has_bounds, is_room, is_anchor;
|
||||
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;
|
||||
} scene_entity;
|
||||
|
||||
static pthread_mutex_t scene_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static scene_entity entities[MAX_ENTITIES];
|
||||
static int entity_count;
|
||||
static int entity_count, room_entity_count, wall_count, room_from_file;
|
||||
static char room_source[32];
|
||||
static uint64_t room_universe;
|
||||
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;
|
||||
|
||||
#define FAKE_SPACE_TAG 0x5ce0000000000000ull
|
||||
// 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; }
|
||||
static int fake_space_index(XrSpace space) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
static XrSpace fake_space(int index) { return (XrSpace)(uintptr_t)(FAKE_SPACE_TAG | (uint64_t)index); }
|
||||
static XrSpace fake_space(int index) { return (XrSpace)(FAKE_SPACE_TAG | ((uintptr_t)scene_generation << 12) | (uintptr_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); }
|
||||
@@ -86,7 +109,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;
|
||||
e->label = label;
|
||||
if (label) snprintf(e->label, sizeof(e->label), "%s", label);
|
||||
e->has_bounds = w > 0 && h > 0;
|
||||
e->bounds = (XrRect2Df){{-w / 2, -h / 2}, {w, h}};
|
||||
e->is_room = is_room;
|
||||
@@ -94,9 +117,23 @@ 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");
|
||||
@@ -117,10 +154,14 @@ 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
|
||||
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;
|
||||
}
|
||||
|
||||
// Supported components per entity.
|
||||
@@ -128,12 +169,17 @@ 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) {
|
||||
out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB;
|
||||
out[n++] = XR_SPACE_COMPONENT_TYPE_SEMANTIC_LABELS_FB;
|
||||
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;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -148,6 +194,7 @@ 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));
|
||||
@@ -171,6 +218,7 @@ 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}}};
|
||||
@@ -181,7 +229,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 (fake_space_index(space) < 0 && fake_space_index(base) < 0) return 0;
|
||||
if (!is_fake_space(space) && !is_fake_space(base)) return 0;
|
||||
XrPosef s, b;
|
||||
XrSpaceLocationFlags fs = 0, fb = 0;
|
||||
if (!stage_pose(space, time, &s, &fs) || !stage_pose(base, time, &b, &fb)) {
|
||||
@@ -195,99 +243,96 @@ static int locate_fake(XrSpace space, XrSpace base, XrTime time, XrPosef *pose,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- emulated entry points
|
||||
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();
|
||||
*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));
|
||||
pthread_mutex_unlock(&scene_lock);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
// Query filters are kept per request so retrieve can apply them.
|
||||
// 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;
|
||||
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];
|
||||
}
|
||||
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 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 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 filter_matches(const query_filter *f, const scene_entity *e) {
|
||||
static int fb_filter_matches(const XrSpaceFilterInfoBaseHeaderFB *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;
|
||||
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 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;
|
||||
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;
|
||||
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);
|
||||
pthread_mutex_unlock(&scene_lock);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
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);
|
||||
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;
|
||||
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));
|
||||
}
|
||||
++n;
|
||||
}
|
||||
results->resultCountOutput = n;
|
||||
pthread_mutex_unlock(&scene_lock);
|
||||
return (results->resultCapacityInput && results->resultCapacityInput < n) ? XR_ERROR_SIZE_INSUFFICIENT : XR_SUCCESS;
|
||||
return r;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_uuid(XrSpace space, XrUuidEXT *uuid) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL scene_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 emu_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count,
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL scene_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;
|
||||
@@ -296,16 +341,33 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_components(XrSpace space, ui
|
||||
*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) {
|
||||
int i = fake_space_index(space);
|
||||
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);
|
||||
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; }
|
||||
*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};
|
||||
@@ -314,44 +376,49 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_set_component_status(XrSpace space, co
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_component_status(XrSpace space, XrSpaceComponentTypeFB type,
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL scene_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)) return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
|
||||
if (!entity_has_component(&entities[i], type)) {
|
||||
log_refused_component("get", 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 emu_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL scene_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 = ENT_FIXED_COUNT - 1; // everything except the room itself
|
||||
uint32_t n = room_entity_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 emu_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL scene_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 = 4;
|
||||
layout->wallUuidCountOutput = (uint32_t)wall_count;
|
||||
if (!layout->wallUuidCapacityInput) return XR_SUCCESS;
|
||||
if (layout->wallUuidCapacityInput < 4) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||
for (int k = 0; k < 4; ++k) layout->wallUuids[k] = entities[ENT_WALL0 + k].uuid;
|
||||
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;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL scene_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;
|
||||
@@ -360,19 +427,29 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_2d(XrSession session,
|
||||
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_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_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL scene_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;
|
||||
boundary->vertexCountOutput = 4;
|
||||
uint32_t n = entities[i].boundary_count ? entities[i].boundary_count : 4;
|
||||
boundary->vertexCountOutput = n;
|
||||
if (!boundary->vertexCapacityInput) return XR_SUCCESS;
|
||||
if (boundary->vertexCapacityInput < 4) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||
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;
|
||||
}
|
||||
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};
|
||||
@@ -381,16 +458,76 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_get_boundary_2d(XrSession session, XrS
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
|
||||
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) {
|
||||
(void)session;
|
||||
int i = fake_space_index(space);
|
||||
if (i < 0 || !labels) return XR_ERROR_HANDLE_INVALID;
|
||||
if (!entities[i].label) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
|
||||
uint32_t n = (uint32_t)strlen(entities[i].label) + 1;
|
||||
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;
|
||||
labels->bufferCountOutput = n;
|
||||
if (!labels->bufferCapacityInput) return XR_SUCCESS;
|
||||
if (labels->bufferCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
|
||||
memcpy(labels->buffer, entities[i].label, n);
|
||||
if (!labels->buffer) return XR_ERROR_VALIDATION_FAILURE;
|
||||
memcpy(labels->buffer, text, n);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
@@ -401,11 +538,12 @@ 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;
|
||||
}
|
||||
|
||||
@@ -417,9 +555,16 @@ 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;
|
||||
stage_pose(info->space, info->time, &base, &flags);
|
||||
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; }
|
||||
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}}};
|
||||
@@ -431,31 +576,118 @@ 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;
|
||||
int i = fake_space_index(info->space);
|
||||
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);
|
||||
*request = next_request++;
|
||||
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};
|
||||
XrEventDataSpaceSaveCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB, NULL, *request, r, info->space,
|
||||
entities[i].uuid, 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;
|
||||
int i = fake_space_index(info->space);
|
||||
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);
|
||||
*request = next_request++;
|
||||
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};
|
||||
XrEventDataSpaceEraseCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB, NULL, *request, r, info->space,
|
||||
entities[i].uuid, 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},
|
||||
@@ -468,11 +700,17 @@ 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;
|
||||
@@ -485,6 +723,16 @@ 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);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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;
|
||||
}
|
||||
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);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -201,6 +201,18 @@ 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")
|
||||
@@ -440,7 +452,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,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,roomscan,a32")
|
||||
ap.add_argument("--ndk")
|
||||
args = ap.parse_args()
|
||||
parts = set(args.only.split(","))
|
||||
@@ -456,9 +468,10 @@ 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",
|
||||
"vroverlay_probe", "a32"):
|
||||
"roomscan", "vroverlay_probe", "a32"):
|
||||
if name in parts:
|
||||
log(f"build {name}")
|
||||
steps[name]()
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/* 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");
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/* 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";
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* 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
|
||||
@@ -0,0 +1,93 @@
|
||||
/* 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
|
||||
@@ -106,6 +106,8 @@ static int validation; // vk_validation: add Khronos' validation layer (bu
|
||||
static int hide_fdm; // vk_hide_fdm: the game doesn't see VK_EXT_fragment_density_map(2)
|
||||
static int want_dump; // vk_shader_dump: write the game's SPIR-V modules to files/fp_vk_shaders/
|
||||
static int want_pipeline_log; // vk_pipeline_log: log failing pipeline creations (pipeline_log.h)
|
||||
static int no_fossilize; // vk_no_fossilize: take Lepton's Fossilize layer out of GraphicsEnv's list
|
||||
static int pipeline_fallback; // vk_pipeline_fallback: retry a failed graphics pipeline with blank shaders
|
||||
static char app_files[400]; // /sdcard/Android/data/<package>/files
|
||||
|
||||
static void read_settings(const char *path) {
|
||||
@@ -119,6 +121,8 @@ static void read_settings(const char *path) {
|
||||
if (!strncmp(line, "vk_spec_fixes=", 14)) spec_fixes = atoi(line + 14) != 0;
|
||||
if (!strncmp(line, "vk_shader_dump=", 15)) want_dump = atoi(line + 15) != 0;
|
||||
if (!strncmp(line, "vk_pipeline_log=", 16)) want_pipeline_log = atoi(line + 16) != 0;
|
||||
if (!strncmp(line, "vk_no_fossilize=", 16)) no_fossilize = atoi(line + 16) != 0;
|
||||
if (!strncmp(line, "vk_pipeline_fallback=", 21)) pipeline_fallback = atoi(line + 21) != 0;
|
||||
if (!strncmp(line, "vk_query_slots=", 15)) {
|
||||
int v = atoi(line + 15);
|
||||
query_slots = v == 1 ? 2 : v >= 2 && v <= 4 ? v : 1; // 1 = on (the settings dialog's switch) = 2 slots
|
||||
@@ -183,6 +187,52 @@ static uint32_t *fixed_shader(const uint32_t *code, size_t size, size_t *out_siz
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Android's loader reads GraphicsEnv's debug layer list (Lepton's gpu_debug_layers) at each vkCreateInstance; this
|
||||
// takes one layer out of it (same libgraphicsenv calls as native/zinkfix, which adds its own layer)
|
||||
struct cxx_string { size_t cap; size_t size; char *data; };
|
||||
#define GE_INSTANCE "_ZN7android11GraphicsEnv11getInstanceEv"
|
||||
#define GE_GET_LAYERS "_ZN7android11GraphicsEnv14getDebugLayersEv"
|
||||
#define GE_SET_LAYERS "_ZN7android11GraphicsEnv14setDebugLayersENSt3__112basic_stringIcNS1_11char_traitsIcEENS1_9allocatorIcEEEE"
|
||||
|
||||
static const char *cxx_chars(const void *s, size_t *len) {
|
||||
const unsigned char *b = s;
|
||||
if (!b) { *len = 0; return NULL; }
|
||||
if (b[0] & 1) {
|
||||
const struct cxx_string *l = s;
|
||||
*len = l->size;
|
||||
return l->data;
|
||||
}
|
||||
*len = b[0] >> 1;
|
||||
return (const char *)b + 1;
|
||||
}
|
||||
|
||||
static void drop_debug_layer(void *vk, const char *name) {
|
||||
void *(*get_instance)(void) = (void *(*)(void))dlsym(vk, GE_INSTANCE);
|
||||
const void *(*get_layers)(void *) = (const void *(*)(void *))dlsym(vk, GE_GET_LAYERS);
|
||||
void (*set_layers)(void *, struct cxx_string *) = (void (*)(void *, struct cxx_string *))dlsym(vk, GE_SET_LAYERS);
|
||||
void *env = get_instance && get_layers && set_layers ? get_instance() : NULL;
|
||||
size_t len = 0;
|
||||
const char *cur = env ? cxx_chars(get_layers(env), &len) : NULL;
|
||||
if (!env || len > 4096 || (len && !cur)) {
|
||||
LOG("vk shim: can't change the debug layer list (GraphicsEnv not found)");
|
||||
return;
|
||||
}
|
||||
char old[4097], *out = malloc(len + 16);
|
||||
if (!out) return;
|
||||
memcpy(old, cur ? cur : "", len);
|
||||
old[len] = 0;
|
||||
size_t n = 0;
|
||||
for (char *save = NULL, *tok = strtok_r(old, ":", &save); tok; tok = strtok_r(NULL, ":", &save)) {
|
||||
if (!strcmp(tok, name)) continue;
|
||||
n += (size_t)sprintf(out + n, "%s%s", n ? ":" : "", tok);
|
||||
}
|
||||
struct cxx_string tmp = {((len + 16) & ~(size_t)15) | 1, n, out};
|
||||
set_layers(env, &tmp);
|
||||
if ((tmp.cap & 1) && tmp.data == out) free(out);
|
||||
const char *now = cxx_chars(get_layers(env), &len);
|
||||
LOG("vk shim: debug layers now \"%.*s\" (without %s)", (int)len, now ? now : "", name);
|
||||
}
|
||||
|
||||
static void init(void) {
|
||||
read_all_settings();
|
||||
if (nfixes) LOG("vk shim: %d shader fix(es) configured", nfixes);
|
||||
@@ -194,6 +244,7 @@ static void init(void) {
|
||||
pl_on = 1;
|
||||
LOG("vk shim: logging failed pipeline creations and sampler Y'CbCr conversions");
|
||||
}
|
||||
if (pipeline_fallback) LOG("vk shim: failed graphics pipelines get blank shaders");
|
||||
if (spec_fixes) LOG("vk shim: Vulkan spec fixes on (depth images can be cleared, no depth resolve in shader-resolve "
|
||||
"subpasses)");
|
||||
real_vk = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
|
||||
@@ -202,10 +253,18 @@ static void init(void) {
|
||||
real_gdpa = (PFN_vkGetDeviceProcAddr)dlsym(real_vk, "vkGetDeviceProcAddr");
|
||||
real_rp2_trampoline = (PFN_vkCreateRenderPass2)dlsym(real_vk, "vkCreateRenderPass2");
|
||||
real_csm_trampoline = (PFN_vkCreateShaderModule)dlsym(real_vk, "vkCreateShaderModule");
|
||||
if (no_fossilize) drop_debug_layer(real_vk, "VK_LAYER_fossilize");
|
||||
}
|
||||
LOG("vk shim: libvulkan.so %s", real_gipa && real_gdpa ? "OK" : "MISSING");
|
||||
}
|
||||
|
||||
// The engine's first vkCreateInstance can come from another thread before any wrapped call runs init(), so the layer
|
||||
// list is changed when the engine loads this library
|
||||
__attribute__((constructor)) static void early_init(void) {
|
||||
read_all_settings();
|
||||
if (no_fossilize) pthread_once(&once, init);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- pointer checks
|
||||
|
||||
static pthread_mutex_t probe_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
@@ -868,13 +927,94 @@ static VKAPI_ATTR void VKAPI_CALL cmd_pipeline_barrier2_khr(VkCommandBuffer cb,
|
||||
|
||||
// ---------------------------------------------------------------- pipeline log (vk_pipeline_log, diagnostics)
|
||||
|
||||
// GitHub #349: Turnip fails some of Transformers' pipelines (-1 = its "ir3 variant failed"), and Unreal treats a
|
||||
// failed pipeline as fatal. A failed pipeline is retried with a fragment shader that writes zero to location 0, then
|
||||
// with a vertex shader that only writes gl_Position as well: the draw then shows nothing instead of ending the game.
|
||||
static const uint32_t FALLBACK_FS[] = {
|
||||
0x07230203, 0x00010000, 0x00070000, 0x0000000b, 0x00000000, 0x00020011, 0x00000001, 0x0003000e,
|
||||
0x00000000, 0x00000001, 0x0006000f, 0x00000004, 0x00000001, 0x6e69616d, 0x00000000, 0x00000002,
|
||||
0x00030010, 0x00000001, 0x00000007, 0x00040047, 0x00000002, 0x0000001e, 0x00000000, 0x00030016,
|
||||
0x00000003, 0x00000020, 0x00040017, 0x00000004, 0x00000003, 0x00000004, 0x0004002b, 0x00000003,
|
||||
0x00000005, 0x00000000, 0x0007002c, 0x00000004, 0x00000006, 0x00000005, 0x00000005, 0x00000005,
|
||||
0x00000005, 0x00040020, 0x00000007, 0x00000003, 0x00000004, 0x00020013, 0x00000008, 0x00030021,
|
||||
0x00000009, 0x00000008, 0x0004003b, 0x00000007, 0x00000002, 0x00000003, 0x00050036, 0x00000008,
|
||||
0x00000001, 0x00000000, 0x00000009, 0x000200f8, 0x0000000a, 0x0003003e, 0x00000002, 0x00000006,
|
||||
0x000100fd, 0x00010038,
|
||||
};
|
||||
static const uint32_t FALLBACK_VS[] = {
|
||||
0x07230203, 0x00010000, 0x00070000, 0x0000000c, 0x00000000, 0x00020011, 0x00000001, 0x0003000e,
|
||||
0x00000000, 0x00000001, 0x0006000f, 0x00000000, 0x00000001, 0x6e69616d, 0x00000000, 0x00000002,
|
||||
0x00040047, 0x00000002, 0x0000000b, 0x00000000, 0x00030016, 0x00000003, 0x00000020, 0x00040017,
|
||||
0x00000004, 0x00000003, 0x00000004, 0x0004002b, 0x00000003, 0x00000005, 0x00000000, 0x0004002b,
|
||||
0x00000003, 0x00000006, 0x3f800000, 0x0007002c, 0x00000004, 0x00000007, 0x00000005, 0x00000005,
|
||||
0x00000005, 0x00000006, 0x00040020, 0x00000008, 0x00000003, 0x00000004, 0x00020013, 0x00000009,
|
||||
0x00030021, 0x0000000a, 0x00000009, 0x0004003b, 0x00000008, 0x00000002, 0x00000003, 0x00050036,
|
||||
0x00000009, 0x00000001, 0x00000000, 0x0000000a, 0x000200f8, 0x0000000b, 0x0003003e, 0x00000002,
|
||||
0x00000007, 0x000100fd, 0x00010038,
|
||||
};
|
||||
|
||||
static pthread_mutex_t fallback_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static VkDevice fallback_device;
|
||||
static VkShaderModule fallback_module[2]; // [0] fragment, [1] vertex; kept for the device's lifetime
|
||||
|
||||
static VkShaderModule fallback_shader(VkDevice device, int vertex) {
|
||||
pthread_mutex_lock(&fallback_lock);
|
||||
if (fallback_device != device) {
|
||||
fallback_device = device; // one device per game in practice; a new one gets new modules
|
||||
fallback_module[0] = fallback_module[1] = VK_NULL_HANDLE;
|
||||
}
|
||||
if (!fallback_module[vertex]) {
|
||||
PFN_vkCreateShaderModule csm = (PFN_vkCreateShaderModule)any_fn(device, FN_CSM);
|
||||
VkShaderModuleCreateInfo mi = {VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, NULL, 0,
|
||||
vertex ? sizeof FALLBACK_VS : sizeof FALLBACK_FS,
|
||||
vertex ? FALLBACK_VS : FALLBACK_FS};
|
||||
if (csm && csm(device, &mi, NULL, &fallback_module[vertex]) != VK_SUCCESS) fallback_module[vertex] = VK_NULL_HANDLE;
|
||||
}
|
||||
VkShaderModule m = fallback_module[vertex];
|
||||
pthread_mutex_unlock(&fallback_lock);
|
||||
return m;
|
||||
}
|
||||
|
||||
static VkResult fallback_pipeline(VkDevice device, VkPipelineCache cache, const VkGraphicsPipelineCreateInfo *ci,
|
||||
const VkAllocationCallbacks *alloc, VkPipeline *out,
|
||||
PFN_vkCreateGraphicsPipelines real, VkResult failed) {
|
||||
static int logged;
|
||||
VkPipelineShaderStageCreateInfo st[6];
|
||||
memcpy(st, ci->pStages, sizeof *st * ci->stageCount);
|
||||
VkGraphicsPipelineCreateInfo p = *ci;
|
||||
p.pStages = st;
|
||||
VkResult r = failed;
|
||||
for (int pass = 0; pass < 2 && r < 0; pass++) { // 0: fragment shader only, 1: vertex shader too
|
||||
int replaced = 0;
|
||||
for (uint32_t s = 0; s < p.stageCount; s++) {
|
||||
int vertex = st[s].stage == VK_SHADER_STAGE_VERTEX_BIT;
|
||||
if (st[s].stage != VK_SHADER_STAGE_FRAGMENT_BIT && !(pass && vertex)) continue;
|
||||
VkShaderModule m = fallback_shader(device, vertex);
|
||||
if (!m) continue;
|
||||
st[s].pNext = NULL;
|
||||
st[s].flags = 0;
|
||||
st[s].module = m;
|
||||
st[s].pName = "main";
|
||||
st[s].pSpecializationInfo = NULL;
|
||||
replaced++;
|
||||
}
|
||||
if (!replaced) continue;
|
||||
r = real(device, cache, 1, &p, alloc, out);
|
||||
if (logged++ < 20)
|
||||
LOG("vk shim: failed pipeline (%d) retried with a blank %s shader: %d", failed,
|
||||
pass ? "vertex + fragment" : "fragment", r);
|
||||
}
|
||||
return r < 0 ? failed : r;
|
||||
}
|
||||
|
||||
static VKAPI_ATTR VkResult VKAPI_CALL create_graphics_pipelines(VkDevice device, VkPipelineCache cache, uint32_t count,
|
||||
const VkGraphicsPipelineCreateInfo *ci,
|
||||
const VkAllocationCallbacks *alloc, VkPipeline *out) {
|
||||
PFN_vkCreateGraphicsPipelines real = (PFN_vkCreateGraphicsPipelines)any_fn(device, FN_CGP);
|
||||
if (!real) return VK_ERROR_INITIALIZATION_FAILED;
|
||||
VkResult r = real(device, cache, count, ci, alloc, out);
|
||||
pl_graphics(count, ci, out, r);
|
||||
if (pl_on) pl_graphics(count, ci, out, r);
|
||||
if (pipeline_fallback && r < 0 && count == 1 && ci && ci->pStages && ci->stageCount <= 6) r = fallback_pipeline(device, cache, ci, alloc, out, real, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -950,7 +1090,7 @@ static PFN_vkVoidFunction wrap(const char *name) {
|
||||
case FN_EDEP: return hide_fdm ? (PFN_vkVoidFunction)vkEnumerateDeviceExtensionProperties : NULL;
|
||||
case FN_CDEV: return hide_fdm || spec_fixes ? (PFN_vkVoidFunction)vkCreateDevice : NULL;
|
||||
case FN_CIV: return spec_fixes ? (PFN_vkVoidFunction)create_image_view : NULL;
|
||||
case FN_CGP: return pl_on ? (PFN_vkVoidFunction)create_graphics_pipelines : NULL;
|
||||
case FN_CGP: return pl_on || pipeline_fallback ? (PFN_vkVoidFunction)create_graphics_pipelines : NULL;
|
||||
case FN_CCP: return pl_on ? (PFN_vkVoidFunction)create_compute_pipelines : NULL;
|
||||
case FN_CSAMP: return pl_on ? (PFN_vkVoidFunction)create_sampler : NULL;
|
||||
case FN_CDSL: return pl_on ? (PFN_vkVoidFunction)create_set_layout : NULL;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
// 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
|
||||
|
||||
@@ -56,7 +56,7 @@ def from_data() -> dict:
|
||||
for t in (p.title, p.description, p.summary):
|
||||
if t:
|
||||
texts[t] = ""
|
||||
for attr in ("CHOICES", "RADIUS_CHOICES"):
|
||||
for attr in ("CHOICES", "LEVEL_CHOICES"):
|
||||
for _, label in getattr(p, attr, ()):
|
||||
texts[label] = ""
|
||||
for _, title in GROUPS: # the Game settings dialog
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/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()
|
||||
@@ -92,7 +92,7 @@ def _entry(spec: dict, tech: dict, now: float) -> dict:
|
||||
rdict = library.recipe_to_dict(recipe)
|
||||
fields = dict(title=title, name=title, data_bytes=tech.get("data_bytes") or 0, analysis=a.to_dict(),
|
||||
recipe=rdict, suggested=rdict, status=recipe.status, added=now - DAY * spec.get("added", 30),
|
||||
build={"sha256": fake_sha(pkg), "ok": True, "recipe_fp": recipe_fingerprint(rdict),
|
||||
build={"sha256": fake_sha(pkg), "ok": True, "recipe_fp": recipe_fingerprint(rdict, pkg),
|
||||
"applied": sorted(p for p in rdict.get("patches") or {} if "." in p)})
|
||||
if rift:
|
||||
folder = f"D:/Games/PC VR/{title}"
|
||||
|
||||
@@ -634,6 +634,16 @@ class FrozenMonitorSession(FakeMonitorSession):
|
||||
"""The fake stream for still pictures: the two minutes of history, then nothing new (every render of a page shows
|
||||
the same numbers, whenever the screenshot is taken)."""
|
||||
|
||||
# the newest session (the Monitor tab's faster interval starts a new one after the Frame card's) and whether it
|
||||
# has replayed its history; the monitor_settled hook waits for that, else a slow machine shot the page halfway
|
||||
# through the replay (set_modules("all") asks for the replay again)
|
||||
LATEST: FrozenMonitorSession | None = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.settled = threading.Event()
|
||||
FrozenMonitorSession.LATEST = self
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _run(self) -> None:
|
||||
self.t = 0
|
||||
while not self.closed:
|
||||
@@ -641,8 +651,14 @@ class FrozenMonitorSession(FakeMonitorSession):
|
||||
self.on_sample(self._sample())
|
||||
self.t += 1
|
||||
else:
|
||||
self.settled.set()
|
||||
time.sleep(0.2)
|
||||
|
||||
def set_modules(self, m):
|
||||
if m == "all":
|
||||
self.settled.clear()
|
||||
return super().set_modules(m)
|
||||
|
||||
|
||||
class LiveMonitorSession(FakeMonitorSession):
|
||||
"""The fake stream, livelier for videos: a sample every 0.6 s and a steady frame rate with a short, shallow dip
|
||||
|
||||
@@ -240,6 +240,20 @@ def _monitor_details(session) -> None:
|
||||
fakes.open_monitor_details(session.app)
|
||||
|
||||
|
||||
def _monitor_settled(session, timeout: float = 30) -> None:
|
||||
"""Still pictures of the Monitor: wait until the frozen stream has replayed its history (then one more second
|
||||
for the page to draw the last sample)."""
|
||||
end = time.monotonic() + timeout
|
||||
while time.monotonic() < end: # the newest session, settled, and still the newest a moment later
|
||||
session_ = fakes.FrozenMonitorSession.LATEST
|
||||
if session_ is None:
|
||||
time.sleep(0.2)
|
||||
elif session_.settled.wait(max(0.0, end - time.monotonic())):
|
||||
time.sleep(1)
|
||||
if fakes.FrozenMonitorSession.LATEST is session_ and session_.settled.is_set():
|
||||
return
|
||||
|
||||
|
||||
def _type_tab(session) -> None:
|
||||
fakes.open_type_tab(session.app)
|
||||
|
||||
@@ -399,4 +413,5 @@ HOOKS = {"fake_install": _fake_install, "wait_jobs": _wait_jobs, "held_install":
|
||||
"settings_section": _settings_section, "first_run": _first_run, "pairing_done": _pairing_done,
|
||||
"frame_asks": _frame_asks,
|
||||
"easter_eggs": _easter_eggs, "fps_dip": _fps_dip, "perfect_pacing": _perfect_pacing,
|
||||
"battery": _battery, "long_upload": _long_upload, "press_keys": _press_keys}
|
||||
"battery": _battery, "long_upload": _long_upload, "press_keys": _press_keys,
|
||||
"monitor_settled": _monitor_settled}
|
||||
@@ -142,7 +142,8 @@ def main() -> int:
|
||||
# the index on the left: "This PC" scrolls the page there
|
||||
("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-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"))]
|
||||
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)))
|
||||
@@ -544,6 +545,27 @@ 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
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
},
|
||||
"adapter.scene_emul": {
|
||||
"anchor": "adapter-scene_emul",
|
||||
"title": "Pretend room layout",
|
||||
"title": "Use room layout",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.scene_height": {
|
||||
@@ -269,6 +269,11 @@
|
||||
"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",
|
||||
@@ -324,6 +329,16 @@
|
||||
"title": "Hide Vulkan foveation",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_no_fossilize": {
|
||||
"anchor": "adapter-vk_no_fossilize",
|
||||
"title": "Without Steam's shader cache layer",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_pipeline_fallback": {
|
||||
"anchor": "adapter-vk_pipeline_fallback",
|
||||
"title": "Skip refused graphics",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.vk_pipeline_log": {
|
||||
"anchor": "adapter-vk_pipeline_log",
|
||||
"title": "Vulkan pipeline failures (log)",
|
||||
|
||||
@@ -46,7 +46,14 @@ 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)
|
||||
return (msg or (lines[-1] if lines else "no output"))[:300]
|
||||
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[])")
|
||||
|
||||
|
||||
def _plain_version(version: str) -> str:
|
||||
@@ -73,7 +80,9 @@ 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"): # Cpp2IL couldn't read this build: don't run it again per patch
|
||||
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 explicit or cached.get("nightly"):
|
||||
raise RuntimeError(cached["error"])
|
||||
nightly = True # only the release couldn't: the development build gets one try
|
||||
@@ -91,6 +100,8 @@ 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
|
||||
@@ -124,7 +135,10 @@ 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)
|
||||
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"})
|
||||
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:
|
||||
|
||||
@@ -811,6 +811,93 @@ 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)."""
|
||||
@@ -864,7 +951,8 @@ def saves_list(package: str = typer.Argument(..., help="the game (package or par
|
||||
found = saves.list_backups(pkg)
|
||||
if as_json:
|
||||
typer.echo(json.dumps([{"path": str(b.path), "created": b.created, "size": b.size, "files": b.files,
|
||||
"bytes": b.bytes, "version": b.version, "skipped": b.skipped} for b in found],
|
||||
"bytes": b.bytes, "version": b.version, "skipped": b.skipped,
|
||||
"automatic": b.automatic} for b in found],
|
||||
indent=1))
|
||||
return
|
||||
if not found:
|
||||
@@ -873,7 +961,8 @@ def saves_list(package: str = typer.Argument(..., help="the game (package or par
|
||||
for i, b in enumerate(found, 1):
|
||||
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(b.created))
|
||||
typer.echo(f"{i:3} {when} {b.files:5} file(s) {b.bytes / 2**20:8.1f} MiB"
|
||||
+ (f" v{b.version}" if b.version else "") + f" {b.path.name}")
|
||||
+ (f" v{b.version}" if b.version else "")
|
||||
+ (" automatic" if b.automatic else "") + f" {b.path.name}")
|
||||
|
||||
|
||||
@saves_app.command("restore")
|
||||
|
||||
@@ -317,6 +317,37 @@ 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():
|
||||
|
||||
@@ -20,9 +20,11 @@ def pc_usb_addresses(frame_ip: str) -> list[str]:
|
||||
"""This PC's IPv4 addresses in the same /24 as the Frame's USB address (Windows via ipconfig, else `ip`)."""
|
||||
prefix = frame_ip.rsplit(".", 1)[0] + "."
|
||||
texts = []
|
||||
# CREATE_NO_WINDOW: the windowed Windows app flashed a console for every ipconfig run (GitHub #398)
|
||||
hidden = {"creationflags": 0x08000000} if sys.platform == "win32" else {}
|
||||
for cmd in (["ip", "-4", "-o", "addr", "show"], ["ipconfig"], ["ifconfig"], ["ipconfig.exe"]):
|
||||
try:
|
||||
texts.append(subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout)
|
||||
texts.append(subprocess.run(cmd, capture_output=True, text=True, timeout=10, **hidden).stdout)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
text = "\n".join(texts)
|
||||
|
||||
@@ -497,25 +497,31 @@ def ensure_helper(frame) -> bool:
|
||||
if local is None:
|
||||
return False
|
||||
try:
|
||||
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())
|
||||
sync_helper(frame, local, HELPER)
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""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)
|
||||
@@ -11,9 +11,11 @@ game runs. No Flet here: the GUI and the CLI share it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -45,6 +47,7 @@ class Backup:
|
||||
bytes: int = 0 # the save files' size (uncompressed)
|
||||
version: str = ""
|
||||
title: str = ""
|
||||
automatic: bool = False
|
||||
skipped: int = 0 # large files left out
|
||||
|
||||
@property
|
||||
@@ -52,10 +55,41 @@ class Backup:
|
||||
return sidecar_of(self.path)
|
||||
|
||||
|
||||
def root_dir() -> Path:
|
||||
def default_root() -> Path:
|
||||
return user_data_dir() / "save-backups"
|
||||
|
||||
|
||||
def root_dir() -> Path:
|
||||
folder = library.setting("saves.folder", "")
|
||||
return Path(folder).expanduser() if folder else default_root()
|
||||
|
||||
|
||||
def writable_folder(folder: Path) -> Path:
|
||||
"""Probe actual creation, including on network/cloud mounts; don't change settings on failure."""
|
||||
folder = Path(folder).expanduser().absolute()
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryFile(dir=folder) as probe:
|
||||
probe.write(b"FramePort save backup folder\n")
|
||||
probe.flush()
|
||||
except OSError as exc:
|
||||
raise SavesError(f"Cannot write save backups to {folder}: {exc}") from exc
|
||||
return folder
|
||||
|
||||
|
||||
def set_folder(folder: str | None) -> Path:
|
||||
dest = writable_folder(Path(folder) if folder else default_root())
|
||||
roots = [str(p) for p in backup_roots()]
|
||||
library.set_setting("saves.previous_folders", roots)
|
||||
library.set_setting("saves.folder", str(dest) if folder else "")
|
||||
return dest
|
||||
|
||||
|
||||
def backup_roots() -> list[Path]:
|
||||
return list(dict.fromkeys([root_dir(), default_root(),
|
||||
*(Path(p) for p in library.setting("saves.previous_folders", []))]))
|
||||
|
||||
|
||||
def backups_dir(pkg: str) -> Path:
|
||||
if not _PKG.match(pkg):
|
||||
raise ValueError(f"bad package name {pkg!r}")
|
||||
@@ -96,7 +130,8 @@ def _sidecar_data(manifest: dict, archive: Path) -> dict:
|
||||
"version": manifest.get("version") or "", "title": manifest.get("title") or "",
|
||||
"files": len(files), "bytes": sum(int(f.get("size") or 0) for f in files),
|
||||
"skipped": [s for s in manifest.get("skipped") or [] if s.get("reason") in ("size", "unreadable")],
|
||||
"archive": archive.name, "agent": manifest.get("agent")}
|
||||
"archive": archive.name, "agent": manifest.get("agent"),
|
||||
"automatic": bool(manifest.get("automatic"))}
|
||||
|
||||
|
||||
def _from_sidecar(archive: Path, data: dict) -> Backup:
|
||||
@@ -104,14 +139,15 @@ def _from_sidecar(archive: Path, data: dict) -> Backup:
|
||||
created=float(data.get("created") or archive.stat().st_mtime), size=archive.stat().st_size,
|
||||
files=int(data.get("files") or 0), bytes=int(data.get("bytes") or 0),
|
||||
version=str(data.get("version") or ""), title=str(data.get("title") or ""),
|
||||
skipped=len(data.get("skipped") or []))
|
||||
skipped=len(data.get("skipped") or []), automatic=bool(data.get("automatic")))
|
||||
|
||||
|
||||
def list_backups(pkg: str) -> list[Backup]:
|
||||
"""This game's backups on this PC, newest first (a missing/broken sidecar is rebuilt from the archive)."""
|
||||
d = backups_dir(pkg)
|
||||
backups_dir(pkg) # validate before using the package as a path
|
||||
out = []
|
||||
for archive in sorted(d.glob("*.tar.gz")) if d.is_dir() else []:
|
||||
archives = [p for root in backup_roots() for p in (root / pkg).glob("*.tar.gz")]
|
||||
for archive in sorted(archives):
|
||||
if not _NAME.match(archive.name):
|
||||
continue
|
||||
try:
|
||||
@@ -119,9 +155,12 @@ def list_backups(pkg: str) -> list[Backup]:
|
||||
except (OSError, ValueError):
|
||||
try:
|
||||
data = _sidecar_data(read_manifest(archive), archive)
|
||||
sidecar_of(archive).write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
try:
|
||||
sidecar_of(archive).write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
except OSError:
|
||||
pass # backups in an old read-only folder remain usable
|
||||
try:
|
||||
out.append(_from_sidecar(archive, data))
|
||||
except OSError:
|
||||
@@ -131,17 +170,17 @@ def list_backups(pkg: str) -> list[Backup]:
|
||||
|
||||
def has_backups(pkg: str) -> bool:
|
||||
try:
|
||||
d = backups_dir(pkg)
|
||||
backups_dir(pkg)
|
||||
except ValueError:
|
||||
return False
|
||||
return d.is_dir() and any(_NAME.match(p.name) for p in d.glob("*.tar.gz"))
|
||||
return any(_NAME.match(p.name) for root in backup_roots() for p in (root / pkg).glob("*.tar.gz"))
|
||||
|
||||
|
||||
def prune(pkg: str, keep: int | None = None, protect: Path | None = None) -> list[Path]:
|
||||
"""Delete all but the newest `keep` backups of a game (never `protect`). Returns the deleted archives."""
|
||||
keep = keep_count() if keep is None else max(1, keep)
|
||||
gone = []
|
||||
for b in list_backups(pkg)[keep:]:
|
||||
for b in [b for b in list_backups(pkg) if b.path.parent == backups_dir(pkg)][keep:]:
|
||||
if protect is not None and b.path == protect:
|
||||
continue
|
||||
for p in (b.path, b.sidecar):
|
||||
@@ -190,21 +229,31 @@ def _new_name(d: Path) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None = None) -> Backup:
|
||||
def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None = None,
|
||||
snapshot: str | None = None) -> Backup:
|
||||
"""Back up the game's saves from the Frame into a new archive on this PC, then keep the newest `keep`."""
|
||||
rep = reporter or Reporter()
|
||||
rep.stage("saves", "Backing up saves")
|
||||
d = writable_folder(backups_dir(pkg))
|
||||
try:
|
||||
expected = int(info(frame, pkg).get("bytes") or 0)
|
||||
expected = 0 if snapshot else int(info(frame, pkg).get("bytes") or 0)
|
||||
except Exception: # noqa: BLE001 - only for the progress bar
|
||||
expected = 0
|
||||
d = backups_dir(pkg)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
dest = _new_name(d)
|
||||
part = dest.with_name(dest.name + ".part")
|
||||
stdin, stdout, stderr = _exec(frame, "_backup_saves", {"package": pkg})
|
||||
# Automatic imports can overlap a manual job; reserve the name before opening SSH.
|
||||
while True:
|
||||
dest = _new_name(d)
|
||||
part = dest.with_name(dest.name + ".part")
|
||||
try:
|
||||
part.touch(exist_ok=False)
|
||||
break
|
||||
except FileExistsError:
|
||||
continue
|
||||
stdout = None
|
||||
received = 0
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
stdin, stdout, stderr = _exec(frame, "_fetch_save_snapshot" if snapshot else "_backup_saves",
|
||||
{"package": pkg, "snapshot": snapshot})
|
||||
stdin.channel.shutdown_write()
|
||||
with open(part, "wb") as f:
|
||||
while True:
|
||||
@@ -213,6 +262,7 @@ def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None =
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
digest.update(chunk)
|
||||
received += len(chunk)
|
||||
if expected: # compressed: an estimate, never "done" before the end
|
||||
rep.progress(min(0.95, received / expected), f"{received / 2**20:.1f} MiB")
|
||||
@@ -220,21 +270,26 @@ def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | None =
|
||||
reply = _last_json(stderr.read().decode("utf-8", "replace")) or {}
|
||||
if code or not reply.get("ok"):
|
||||
raise SavesError(reply.get("error") or f"the Frame couldn't back up the saves (exit {code})")
|
||||
if snapshot and digest.hexdigest() != snapshot.split("-", 1)[1][:-7]:
|
||||
raise SavesError("the Frame sent a damaged save snapshot (hash mismatch)")
|
||||
manifest = read_manifest(part)
|
||||
if manifest.get("package") != pkg:
|
||||
raise SavesError(f"the Frame sent saves of {manifest.get('package')}, not {pkg}")
|
||||
part.replace(dest)
|
||||
except BaseException:
|
||||
try:
|
||||
stdout.channel.close()
|
||||
if stdout is not None:
|
||||
stdout.channel.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
part.unlink(missing_ok=True)
|
||||
raise
|
||||
data = _sidecar_data(manifest, dest)
|
||||
if snapshot:
|
||||
data.update(automatic=True, snapshot=snapshot)
|
||||
sidecar_of(dest).write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||
result = _from_sidecar(dest, data)
|
||||
prune(pkg, keep, protect=dest)
|
||||
prune(pkg, keep, protect=None if snapshot else dest)
|
||||
rep.progress(1.0, "")
|
||||
rep.log(f"saved {result.files} file(s), {result.bytes / 2**20:.1f} MiB of saves, to {dest}"
|
||||
+ (f"; {result.skipped} large file(s) left out" if result.skipped else ""))
|
||||
@@ -299,3 +354,28 @@ def undo_restore(frame, pkg: str) -> dict:
|
||||
raise SavesError(reply.get("error") or "the Frame couldn't put the saves back")
|
||||
return reply["result"]
|
||||
|
||||
|
||||
|
||||
def sync_snapshots(frame, reporter: Reporter | None = None) -> int:
|
||||
"""Import each remote snapshot once, including after local retention has pruned its archive."""
|
||||
frame.agent("save_snapshot_settings", enabled=bool(library.setting("saves.auto", False)))
|
||||
state_path = user_data_dir() / "save-snapshots-synced.json"
|
||||
try:
|
||||
seen = set(json.loads(state_path.read_text(encoding="utf-8")))
|
||||
except (OSError, ValueError):
|
||||
seen = set()
|
||||
count = 0
|
||||
for item in frame.agent("save_snapshots").get("snapshots", []):
|
||||
pkg, name = item["package"], item["snapshot"]
|
||||
# Content digest identity is stable across reconnects and different Frames.
|
||||
identity = pkg + ":" + item["sha256"]
|
||||
if identity in seen:
|
||||
continue
|
||||
backup(frame, pkg, reporter, snapshot=name)
|
||||
seen.add(identity)
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = state_path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(sorted(seen)), encoding="utf-8")
|
||||
tmp.replace(state_path)
|
||||
count += 1
|
||||
return count
|
||||
@@ -32,6 +32,7 @@
|
||||
"(top folder)": "",
|
||||
"+ {base_count} standard patches": "",
|
||||
", and your signing keys were saved to {backup}.": "",
|
||||
", ceiling seen": "",
|
||||
", {n} already there": [
|
||||
"",
|
||||
""
|
||||
@@ -39,17 +40,11 @@
|
||||
", {n} screenshots": "",
|
||||
"0 = the size of your play area.": "",
|
||||
"10 games on the Frame. It's getting cozy in there.": "",
|
||||
"10% radius": "",
|
||||
"100 games on the Frame. The Frame thanks you.": "",
|
||||
"108 Hz": "",
|
||||
"120 Hz": "",
|
||||
"144 Hz": "",
|
||||
"15% radius": "",
|
||||
"20% radius": "",
|
||||
"25% radius": "",
|
||||
"30% radius": "",
|
||||
"32-bit ARM bridge (Gear VR / Go games)": "",
|
||||
"35% radius": "",
|
||||
"360p (lightest)": "",
|
||||
"360° (equirect) layers, for example a video player's virtual theater or 360° videos (for example 4XVR), which the Frame's runtime lacks, are drawn by a background thread into a layer behind the game's own picture: the 360° image is converted only when it changes (a theater once, a 360° video once per video frame) and the view is drawn for each frame's head pose. GLES games only; without it these layers are missing (black).": "",
|
||||
"360° 3D": "",
|
||||
@@ -58,8 +53,6 @@
|
||||
"360° pictures and video": "",
|
||||
"360° sharpness": "",
|
||||
"360° video frame rate limit": "",
|
||||
"40% radius": "",
|
||||
"45% radius": "",
|
||||
"72 Hz": "",
|
||||
"720p (recommended)": "",
|
||||
"80 Hz": "",
|
||||
@@ -202,12 +195,14 @@
|
||||
"Asset-file service bypass": "",
|
||||
"At most this many redraws per second of a 360° layer (0 = no limit). Video players need at least the video's frame rate.": "",
|
||||
"Automatic": "",
|
||||
"automatic": "",
|
||||
"Automatic ({auto:.0%})": "",
|
||||
"Automatic SteamVR performance settings (this PC)": "",
|
||||
"Back to library": "",
|
||||
"Back up all saves": "",
|
||||
"Back up its saves to this PC before deleting them": "",
|
||||
"Back up saves": "",
|
||||
"Back up saves when a game closes": "",
|
||||
"Back up saves: {title}": "",
|
||||
"Back up the signing keys ({len}) to {shown_dir} first": "",
|
||||
"Backed up the saves of {n} game": [
|
||||
@@ -228,6 +223,7 @@
|
||||
"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.": "",
|
||||
@@ -274,8 +270,10 @@
|
||||
"Choose a file": "",
|
||||
"Choose a folder": "",
|
||||
"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 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.": "",
|
||||
@@ -351,6 +349,7 @@
|
||||
"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}": "",
|
||||
@@ -370,6 +369,7 @@
|
||||
"Couldn't read: {names}": "",
|
||||
"Couldn't remove the theme: {error}": "",
|
||||
"Couldn't start the live view: {exc}": "",
|
||||
"Couldn't sync save backups: {error}": "",
|
||||
"Couldn't take a screenshot: {error}": "",
|
||||
"Couldn't use {choice}: {exc}": "",
|
||||
"Counts the headset as worn, so games that only move the player while it's worn get head tracking and controls (for example BONELAB).": "",
|
||||
@@ -392,8 +392,10 @@
|
||||
"Declares Meta's permissions some mixed-reality games ask for, so they don't stop.": "",
|
||||
"Decodes the game's videos on the Frame's hardware video decoder instead of Android's software decoders, which stutter or fall behind with 4K/8K video (for example video players, Batman: Arkham Shadow's cutscenes). Adds FramePort's codec plugin (OMX.frameport.avc/hevc/vp9.decoder) to this game's container only; the APK isn't changed. When the hardware is busy (for example Steam's own hardware video decoding is on) the video is decoded in software as before.": "",
|
||||
"Default": "",
|
||||
"Default (30% radius)": "",
|
||||
"Default (large)": "",
|
||||
"Delete": "",
|
||||
"Delete room": "",
|
||||
"Delete room?": "",
|
||||
"Delete {names}?": "",
|
||||
"Delete {n} items…": "",
|
||||
"Delete {n} screenshot?": [
|
||||
@@ -506,6 +508,7 @@
|
||||
"Every {n}": "",
|
||||
"everything in ~ (not seen by games)": "",
|
||||
"Everything is up to date": "",
|
||||
"Existing backups stay in their original folder and remain available.": "",
|
||||
"Experimental": "",
|
||||
"experimental": "",
|
||||
"Experimental bypass for a DLC manager blocked on Meta asset-file service errors at startup (e.g. PixelRipped 1995: Asset status error (code 100)). Returns an empty successful asset list and reports requested asset IDs as installed, with an empty file path. Does not supply DLC or change download requests. Enable only to test this startup failure; disable with FRAMEPORT_ASSET_FILES_STUB=0. Shares the language-pack library and needs no language packs.": "",
|
||||
@@ -522,7 +525,6 @@
|
||||
"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": "",
|
||||
@@ -565,6 +567,7 @@
|
||||
"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).": "",
|
||||
@@ -689,6 +692,7 @@
|
||||
"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)": "",
|
||||
@@ -740,6 +744,7 @@
|
||||
"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.": "",
|
||||
@@ -809,6 +814,7 @@
|
||||
"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": "",
|
||||
@@ -857,6 +863,7 @@
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keeps the hand and brush visible over Vermillion's painting canvas (the canvas is drawn in the scene instead of as a layer in front).": "",
|
||||
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a render resolution change).": "",
|
||||
"Keeps three snapshots per game on the Frame and copies new ones here when connected.": "",
|
||||
"Keeps your progress in games that file their saves under your Meta user.": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
@@ -864,6 +871,7 @@
|
||||
"kshoji's USB MIDI driver (Unity MIDI plugins, for example PianoVision) polls Android's USB service for MIDI devices; the Frame's Android has no USB service, so the app crashes a few seconds after start (NullPointerException on MidiDeviceConnectionWatchThread). The poll returns at once instead, so no USB MIDI device is ever found (an in-place edit of classes.dex).": "",
|
||||
"Language": "",
|
||||
"Language packs from the game's files": "",
|
||||
"Large": "",
|
||||
"Large files upload together using OpenSSH when available. Default: 3. Lower this on a busy network.": "",
|
||||
"Larger areas retain more detail but save less GPU work.": "",
|
||||
"Last build": "",
|
||||
@@ -925,6 +933,7 @@
|
||||
"Letters, Enter, Backspace, arrows, Esc and shortcuts reach the headset.": "",
|
||||
"Library": "",
|
||||
"Library folders": "",
|
||||
"Lines up with the current play area": "",
|
||||
"Link": "",
|
||||
"Linux": "",
|
||||
"Linux app": "",
|
||||
@@ -977,6 +986,7 @@
|
||||
"Marks the game as a VR app so headsets show it as one.": "",
|
||||
"Maximum": "",
|
||||
"Maximum size in pixels of each face of the cube the 360° image is converted to (256–2730). Higher is sharper but uses more GPU memory.": "",
|
||||
"Medium (saves more)": "",
|
||||
"Memory": "",
|
||||
"Memory used by everything on the Frame. \"Waiting\" is how often programs waited for memory in the last 10 s; when it climbs, the Frame may freeze or close a game.": "",
|
||||
"Menus and screens": "",
|
||||
@@ -990,7 +1000,7 @@
|
||||
"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.": "",
|
||||
"Mirrored": "",
|
||||
"Mixed reality": "",
|
||||
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
|
||||
"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.": "",
|
||||
"Modem": "",
|
||||
"Monitor": "",
|
||||
"Monitor is using {pct}% of the Frame's CPU": "",
|
||||
@@ -1050,6 +1060,7 @@
|
||||
"No process matches.": "",
|
||||
"no reason given": "",
|
||||
"No release notes.": "",
|
||||
"No room saved yet": "",
|
||||
"No screenshots yet": "",
|
||||
"No Unreal crash reporter": "",
|
||||
"No updates": "",
|
||||
@@ -1239,7 +1250,6 @@
|
||||
"Power": "",
|
||||
"Power ICs": "",
|
||||
"Prepare {title}": "",
|
||||
"Pretend room layout": "",
|
||||
"Pretty smooth": "",
|
||||
"Prevents a start-up crash in some Unreal games.": "",
|
||||
"Previous": "",
|
||||
@@ -1257,6 +1267,7 @@
|
||||
"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.": "",
|
||||
@@ -1301,10 +1312,11 @@
|
||||
"Reconnect": "",
|
||||
"Recreate spaces after the first frames (diagnostic).": "",
|
||||
"Reduce motion": "",
|
||||
"Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.": "",
|
||||
"Reduces shading away from the full-resolution area, 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 retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments. Runtime choices are in Game settings. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.": "",
|
||||
"Reduces shading away from the full-resolution area. Changes apply at the next game start.": "",
|
||||
"Refresh": "",
|
||||
"Refresh rate": "",
|
||||
"Refresh room": "",
|
||||
"Refresh store details": "",
|
||||
"Reinstall anyway…": "",
|
||||
"Reinstall on Frame": "",
|
||||
@@ -1406,8 +1418,14 @@
|
||||
"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.": "",
|
||||
@@ -1425,6 +1443,7 @@
|
||||
"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": "",
|
||||
@@ -1444,8 +1463,13 @@
|
||||
"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": "",
|
||||
@@ -1465,7 +1489,10 @@
|
||||
"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": "",
|
||||
@@ -1516,18 +1543,21 @@
|
||||
"Size of text and layout. Under WSL, Automatic follows half of Windows' display scaling (150% → 125%).": "",
|
||||
"Skip for now": "",
|
||||
"Skip Meta avatars": "",
|
||||
"Skip refused graphics": "",
|
||||
"Skip this version": "",
|
||||
"Skips a Quest-only reporting step in Meta's audio library that crashes some games.": "",
|
||||
"Skips a separate data-file check when an Unreal game still hangs at startup with the content check off. Experimental; the game still needs its data.": "",
|
||||
"Skips Meta XR Audio's telemetry under x86_64 ARM translation (emulators). Not needed on the Frame, which runs games natively.": "",
|
||||
"Skips picture layers the Frame would refuse. Leave it on.": "",
|
||||
"Sleep": "",
|
||||
"Small (saves most)": "",
|
||||
"Smooth as butter": "",
|
||||
"software encoder": "",
|
||||
"Some apps open a flat Android launcher that then starts a separate VR activity (for example WiiCompiled). Shown with Lepton's flat window, that window stays in view after the VR part starts. This makes the VR activity the one Lepton starts (its LAUNCHER category), so the app opens in VR with no flat window. Everything only the launcher does (choosing or importing content) is skipped: turn it off for a run when you need the launcher, and turn off \"Show the app's Android window\".": "",
|
||||
"Some engines contain assembly (for example OpenSSL's) that checks a return-address signature it never made. The Quest's CPU ignores those checks, the Steam Frame's enforces them: the game crashes with SIGILL as soon as it uses that code, for example on its first network connection (Star Wars Pinball VR). In a library with unpaired checks, the signing and checking instructions become no-ops.": "",
|
||||
"Some engines leave the pNext of unused Vulkan attachment references uninitialized. The Frame's driver never reads it, but Lepton always loads Steam's Fossilize shader-cache layer, which follows it and crashes on the first frame (SIGSEGV in libVkLayer_fossilize.so from FVulkanRenderPass, for example Deadpool VR). Loads Vulkan through a small shim that keeps valid pointers and drops only unreadable ones or ones pointing at the wrong structure type. It also drops a depth resolve named in a subpass without a depth attachment, which crashes the Frame's driver at the first render pass (for example Metro Awakening).": "",
|
||||
"Some game folders are already set up for SteamVR: a Revive copy (LibRevive64.dll) next to the game, started by a small loader DLL (for example xinput1_3.dll) that Windows loads from the game's folder. Then FramePort starts the game program directly — like double-clicking it — instead of through its own Revive (two Revives conflict; for example the Oculus Platform check then fails). On the Frame, Wine is told to use those loader DLLs from the game folder instead of its own.": "",
|
||||
"Some games can't create any graphics with it (for example Transformers Beyond Reality).": "",
|
||||
"Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.": "",
|
||||
"Some games save their render resolution in a settings file and load it after creating the headset's eye buffers; after a resolution scale change the saved size no longer matches and the picture is displaced or stretched, with black borders (for example BlazeRush's user_config.xml). FramePort writes the values its recipe names into the game's file while the game isn't running (after an install and after a Game settings change), from the Frame's own eye-buffer size times the resolution scale: w and h = the eye buffer's width and height, for example h*16/9. Only existing entries change; the previous file is kept as <file>.frameport-backup. The size is learned from the game's last start, so a file the game wrote before is corrected from the next install or settings change on.": "",
|
||||
"Some OpenGL ES engines compile every shader for multiview (both eyes in one draw, `num_views=2`) and use the same shaders to draw into ordinary single-layer framebuffers, for example a HUD or PDA screen rendered to a texture. Quest's driver draws them anyway; the Frame's driver (Mesa) follows the OVR_multiview rule and silently drops every such draw, so those panels stay black (for example Doom3Quest's HUD and PDA). Loads OpenGL ES through a small interposer (libfpglmv.so) that draws those cases with a single-view copy of the shader program (left-eye view; uniforms copied before each such draw). Eye-buffer draws are unchanged. Logs under the tag GLMV; the setting gl_mv_debug adds error checks and counters.": "",
|
||||
@@ -1715,6 +1745,7 @@
|
||||
"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": "",
|
||||
@@ -1899,6 +1930,9 @@
|
||||
"Use controllers": "",
|
||||
"Use FramePort": "",
|
||||
"Use FramePort for these links": "",
|
||||
"Use my play area": "",
|
||||
"Use room layout": "",
|
||||
"Use the default": "",
|
||||
"Use the new recipe": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use the setup command": "",
|
||||
@@ -1906,10 +1940,12 @@
|
||||
"Use this PC's keyboard on the Frame.": "",
|
||||
"Use this program": "",
|
||||
"Use your own artwork…": "",
|
||||
"Use your SteamVR play area as walls, without furniture.": "",
|
||||
"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.": "",
|
||||
@@ -1940,10 +1976,12 @@
|
||||
"Vulkan pipeline failures (log)": "",
|
||||
"Vulkan shader dump (diagnostics)": "",
|
||||
"Vulkan shader fixes": "",
|
||||
"Vulkan shim (frame.vk_sanitize): a graphics pipeline the Frame's driver refuses is created again with a fragment shader that writes zero, then with a vertex shader that only sets the position as well, so the draw shows nothing instead of Unreal ending with \"Shader compilation failures are Fatal.\" (for example a few multiview vertex shaders in Transformers Beyond Reality, GitHub #349). Logged as \"retried with a blank\".": "",
|
||||
"Vulkan shim (frame.vk_sanitize): logs every failing graphics/compute pipeline creation with its shader stages (named like the vk_shader_dump files), structure types and any sampler Y'CbCr conversion involved, to find the pipeline behind Unreal's \"Shader compilation failures are Fatal.\" No effect on the game.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): makes two Unreal habits valid Vulkan - depth images get VK_IMAGE_USAGE_TRANSFER_DST_BIT (they are cleared with vkCmdClearDepthStencilImage) and Qualcomm shader-resolve subpasses lose an invalid depth resolve. Found with the validation layer in Into The Radius 2 (flickering models, windows behind models). Also leaves out image barriers without an image and refuses image views without one, which crash the Frame's driver (for example Metro Awakening).": "",
|
||||
"Vulkan shim (frame.vk_sanitize): on gives every occlusion query room for both eyes. In multiview passes the Frame's driver writes a zero result for the second eye into the next query, so engines that count on one slot cull visible objects (models popping in and out, for example Into The Radius 2). 0 = off, 1 = on (2 slots).": "",
|
||||
"Vulkan shim (frame.vk_sanitize): SPIR-V modules matching size + SHA-256 get words inserted at a byte offset, for example stores that initialize locals a shader reads before writing (an undefined loop counter hung the GPU in VR4's campaign). Format: <size>:<sha256>:<byte offset>:<word>,<word>,...; several separated by ';'. Comes from a game's recipe.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): takes Lepton's Fossilize shader-cache layer out of the game's Vulkan layers (Android's debug layer list) when the engine loads its Vulkan library. With Fossilize loaded every graphics pipeline of Transformers Beyond Reality failed (VK_ERROR_OUT_OF_HOST_MEMORY), which Unreal treats as fatal (GitHub #349). Lepton has no switch for it; instances created before the engine loads the shim keep it.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): the game doesn't see VK_EXT_fragment_density_map(2). Unreal Engine 5 turns on fragment-density-map foveation when the driver offers it and expects the density map from the headset, which the Frame doesn't provide: image views and barriers for a missing image, then a crash in the Frame's Vulkan driver (for example Metro Awakening). Valve's own foveation layer isn't affected.": "",
|
||||
"Vulkan shim (frame.vk_sanitize): writes every distinct SPIR-V module the game creates to Android/data/<package>/files/fp_vk_shaders/<size>_<sha256>.spv, with index.txt listing every creation in order with its time, to find the shader behind a GPU hang and capture it for a new vk_shader_fix. Diagnostics include the newest modules. No effect on the game.": "",
|
||||
"Vulkan: drop invalid render-pass pointers": "",
|
||||
@@ -1969,6 +2007,7 @@
|
||||
"What your Frame is ready for and what's installed": "",
|
||||
"What's new in FramePort {version}": "",
|
||||
"What's new…": "",
|
||||
"When the Frame's driver refuses one of the game's shaders, that object is left out instead of the game ending.": "",
|
||||
"Where it's installed": "",
|
||||
"Where new games go on the Frame, such as a microSD card. Installed games stay put; right-click → Move to… moves one. Cards formatted as FAT, exFAT or NTFS can't hold games.": "",
|
||||
"Where the power goes": "",
|
||||
@@ -1988,7 +2027,9 @@
|
||||
"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": "",
|
||||
"Working…": "",
|
||||
"Works": "",
|
||||
@@ -2190,6 +2231,7 @@
|
||||
"{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": "",
|
||||
"Русский": "",
|
||||
"Українська": "",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Portable room files shared by the CLI and GUI."""
|
||||
@@ -0,0 +1,308 @@
|
||||
"""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>"])
|
||||
@@ -34,6 +34,7 @@ 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)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ def settings_text(recipe_patches: dict) -> bytes:
|
||||
|
||||
class FrameBridgeAdapter(Patch):
|
||||
id = "frame.adapter"
|
||||
package_revisions = {"com.camouflaj.manta": 3} # above revision 2: Batman builds before #389 are outdated
|
||||
a32 = True # also for 32-bit games on the bridge
|
||||
title = "FrameBridge OpenXR adapter"
|
||||
description = (
|
||||
|
||||
@@ -91,7 +91,14 @@ class HeadsetModel(Patch):
|
||||
if not ws.has(ws.lib(loader)):
|
||||
ctx.checks.append((self.id, None, "No OVRPort dispatcher; only FrameBridge's systemName override applies."))
|
||||
return False
|
||||
fixed = patch_dispatcher(ws.read_lib(loader), key)
|
||||
try:
|
||||
fixed = patch_dispatcher(ws.read_lib(loader), key)
|
||||
except ValueError:
|
||||
# Quest 3 is everyone's default: an APK converted with another OVRPort runtime (e.g. an already converted
|
||||
# copy) keeps OVRPort's Quest 2 answers instead of failing its build
|
||||
ctx.checks.append((self.id, None, "This OVRPort dispatcher isn't a known build; the game keeps seeing a "
|
||||
"Quest 2 (only FrameBridge's systemName override applies)."))
|
||||
return False
|
||||
ws.put(ws.lib(loader), fixed)
|
||||
ctx.notes.append(f"{loader}: systemName={SYSTEM_NAMES[key].decode()}, HeadsetId={UUIDS[key].hex()}")
|
||||
return True
|
||||
|
||||
@@ -75,11 +75,14 @@ class MetaPermissions(Patch):
|
||||
order = 22
|
||||
|
||||
def detect(self, a):
|
||||
from ..applicability import needs_scene
|
||||
from ..applicability import needs_scene, uses_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])
|
||||
|
||||
@@ -152,8 +152,9 @@ class UnrealNoReserve(Patch):
|
||||
if not lib or ws.abi != "arm64-v8a":
|
||||
return []
|
||||
todo, done = mmap_sites(ws.read_lib(lib))
|
||||
return [("Unreal memory reservations use MAP_NORESERVE", bool(done) and not todo,
|
||||
f"{len(done)} rewritten, {len(todo)} left")]
|
||||
# nothing to rewrite (an engine build without these reservations, e.g. Lies Beneath, GitHub #393) passes
|
||||
return [("Unreal memory reservations use MAP_NORESERVE", not todo,
|
||||
f"{len(done)} rewritten, {len(todo)} left" if todo or done else "none in this build")]
|
||||
|
||||
|
||||
register(UnrealNoReserve)
|
||||
@@ -150,13 +150,17 @@ 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)",
|
||||
"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)."),
|
||||
"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)."),
|
||||
("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)."),
|
||||
@@ -240,6 +244,16 @@ SETTINGS = [
|
||||
"Vulkan shim (frame.vk_sanitize): logs every failing graphics/compute pipeline creation with its shader stages "
|
||||
"(named like the vk_shader_dump files), structure types and any sampler Y'CbCr conversion involved, to find the "
|
||||
"pipeline behind Unreal's \"Shader compilation failures are Fatal.\" No effect on the game."),
|
||||
("vk_no_fossilize", "int", 0, "Vulkan shim: without Lepton's Fossilize layer",
|
||||
"Vulkan shim (frame.vk_sanitize): takes Lepton's Fossilize shader-cache layer out of the game's Vulkan layers "
|
||||
"(Android's debug layer list) when the engine loads its Vulkan library. With Fossilize loaded every graphics "
|
||||
"pipeline of Transformers Beyond Reality failed (VK_ERROR_OUT_OF_HOST_MEMORY), which Unreal treats as fatal "
|
||||
"(GitHub #349). Lepton has no switch for it; instances created before the engine loads the shim keep it."),
|
||||
("vk_pipeline_fallback", "int", 0, "Vulkan shim: blank shaders for refused pipelines",
|
||||
"Vulkan shim (frame.vk_sanitize): a graphics pipeline the Frame's driver refuses is created again with a fragment "
|
||||
"shader that writes zero, then with a vertex shader that only sets the position as well, so the draw shows "
|
||||
"nothing instead of Unreal ending with \"Shader compilation failures are Fatal.\" (for example a few multiview "
|
||||
"vertex shaders in Transformers Beyond Reality, GitHub #349). Logged as \"retried with a blank\"."),
|
||||
("vk_validation", "int", 0, "Vulkan shim: validation layer",
|
||||
"Diagnostics: the Vulkan shim (frame.vk_sanitize) adds Khronos' validation layer to the game's instance; its "
|
||||
"findings go to launch.log. The layer library (libVkLayer_khronos_validation.so) must be in the APK."),
|
||||
@@ -300,7 +314,7 @@ REFRESH_CHOICES = [(0.0, "Game's choice"), *((float(hz), f"{hz} Hz") for hz in (
|
||||
FOVEATION_UI = {
|
||||
"mode": dict(group="picture", level="common", label="Foveated rendering (Valve)",
|
||||
help="Reduces shading away from the full-resolution area. Changes apply at the next game start."),
|
||||
"radius": dict(group="picture", level="common", label="Full-resolution area",
|
||||
"level": dict(group="picture", level="common", label="Full-resolution area",
|
||||
help="Larger areas retain more detail but save less GPU work."),
|
||||
"test": dict(group="picture", level="advanced", label="Darken coarse fragments (test)",
|
||||
help="Diagnostic only; do not use for benchmarking."),
|
||||
@@ -387,9 +401,11 @@ 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="Pretend room layout",
|
||||
help="Mixed-reality games that build their level from your room get a simple room with walls.",
|
||||
"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.",
|
||||
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",
|
||||
@@ -441,6 +457,12 @@ UI: dict[str, dict] = {
|
||||
help="Shows each eye's picture to the other eye. For testing.", control=("switch",)),
|
||||
"vk_pipeline_log": dict(group="troubleshooting", level="advanced", label="Vulkan pipeline failures (log)",
|
||||
help="Logs graphics pipelines the Frame's driver refuses. No effect on the game.", control=("switch",)),
|
||||
"vk_no_fossilize": dict(group="troubleshooting", level="advanced", label="Without Steam's shader cache layer",
|
||||
help="Some games can't create any graphics with it (for example Transformers Beyond Reality).",
|
||||
control=("switch",)),
|
||||
"vk_pipeline_fallback": dict(group="troubleshooting", level="advanced", label="Skip refused graphics",
|
||||
help="When the Frame's driver refuses one of the game's shaders, that object is left out instead of "
|
||||
"the game ending.", control=("switch",)),
|
||||
"vk_validation": dict(group="troubleshooting", level="advanced", label="Vulkan error checks (log)",
|
||||
help="Logs Vulkan mistakes the game makes. Needs a special test build.", control=("switch",)),
|
||||
"fb_color_space": dict(group="troubleshooting", level="advanced", label="Colour space support",
|
||||
@@ -506,8 +528,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
|
||||
XRSHIM_SETTINGS = ("hide_fb_foveation", "controller_models", "haptic_fix", "surface_native")
|
||||
# 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")
|
||||
|
||||
|
||||
class AdapterSetting(Patch):
|
||||
@@ -530,7 +554,7 @@ class AdapterSetting(Patch):
|
||||
return UI.get(self.key, {}).get("help") or super().summary
|
||||
|
||||
def detect(self, a):
|
||||
from .applicability import needs_scene, uses_equirect_layers, uses_render_models, uses_room_model
|
||||
from .applicability import uses_equirect_layers, uses_render_models, uses_scene
|
||||
|
||||
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 "
|
||||
@@ -566,15 +590,9 @@ 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 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})
|
||||
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})
|
||||
return None
|
||||
|
||||
def applies(self, a):
|
||||
@@ -585,6 +603,7 @@ 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")
|
||||
@@ -609,6 +628,8 @@ class AdapterSetting(Patch):
|
||||
"vk_validation": lambda a: a.engine == "Unreal",
|
||||
"vk_pipeline_log": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
"fb_color_space": lambda a: a.xr == "OpenXR",
|
||||
"vk_no_fossilize": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
"vk_pipeline_fallback": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
# wherever the Vulkan shim can be (vk_sanitize); Unreal's Vulkan builds are rarely detected as such
|
||||
"vk_shader_dump": lambda a: a.engine in ("Unreal", "Other") and "arm64-v8a" in a.abis,
|
||||
"zink_shader_fix": ap.is_gles,
|
||||
@@ -809,23 +830,33 @@ def foveation_options(params):
|
||||
mode = params.get("mode") or ""
|
||||
if mode not in ("", "tracked", "fixed", "off"):
|
||||
raise ValueError("Unknown foveation mode")
|
||||
radius = float(params.get("radius") or 0)
|
||||
if not math.isfinite(radius) or (radius != 0 and not 0.05 <= radius <= 0.45):
|
||||
raise ValueError("Full-resolution radius must be Default (0), or 0.05–0.45")
|
||||
level = params.get("level", "")
|
||||
if "level" not in params and params.get("radius"):
|
||||
# Old radius exports never reached the layer. Preserve intent with a supported
|
||||
# preset; unknown legacy values restore Default. No numeric FOVE_LEVEL values.
|
||||
try:
|
||||
radius = float(params["radius"])
|
||||
except (ValueError, TypeError):
|
||||
radius = 0
|
||||
if math.isfinite(radius) and 0.05 <= radius <= 0.45:
|
||||
level = min(((.30, "lo"), (.25, "med"), (.20, "hi")),
|
||||
key=lambda pair: abs(pair[0] - radius))[1]
|
||||
if level not in ("", "lo", "med", "hi"):
|
||||
raise ValueError("Unknown foveation profile")
|
||||
test = params.get("test", False)
|
||||
if not isinstance(test, bool):
|
||||
raise ValueError("Foveation test mode must be a boolean")
|
||||
if (test or radius) and mode not in ("tracked", "fixed"):
|
||||
if (test or level) and mode not in ("tracked", "fixed"):
|
||||
raise ValueError("Choose Eye-tracked or Fixed before configuring foveation size or test mode")
|
||||
return mode, radius, test
|
||||
return mode, level, test
|
||||
|
||||
|
||||
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 area size keeps the runtime's current profile unchanged. Custom radius is a fraction "
|
||||
"of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas "
|
||||
"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. "
|
||||
@@ -840,13 +871,13 @@ class Foveation(Patch):
|
||||
experimental = True
|
||||
params = [Param("support", "bool", False, "Internal APK compatibility support"),
|
||||
Param("mode", "str", "", "Catalog default: tracked, fixed or off (empty = Valve's default)"),
|
||||
Param("radius", "float", 0.0, "Full-resolution radius; 0 keeps the current runtime default", 0, 0.45),
|
||||
Param("level", "str", "", "Runtime profile: lo, med, hi (empty = Default)"),
|
||||
Param("test", "bool", False, "Darken lower-resolution regions (diagnostic)")]
|
||||
CHOICES = (("", "Default"), ("tracked", "Eye-tracked"),
|
||||
("fixed", "Fixed (doesn't follow the eyes)"), ("off", "Off"))
|
||||
RADIUS_CHOICES = (("0", "Default (30% radius)"), ("0.1", "10% radius"), ("0.15", "15% radius"),
|
||||
("0.2", "20% radius"), ("0.25", "25% radius"), ("0.3", "30% radius"),
|
||||
("0.35", "35% radius"), ("0.4", "40% radius"), ("0.45", "45% radius"))
|
||||
# Valve's layer starts at lo when FOVE_LEVEL is unset, so Default is the large area
|
||||
LEVEL_CHOICES = (("", "Default (large)"), ("lo", "Large"), ("med", "Medium (saves more)"),
|
||||
("hi", "Small (saves most)"))
|
||||
|
||||
def apply(self, ctx):
|
||||
from .frame.foveation import apply
|
||||
@@ -858,11 +889,13 @@ class Foveation(Patch):
|
||||
|
||||
def install(self, ctx: InstallContext) -> None:
|
||||
# runs after device.lepton_env (install_context), so this choice wins over a recipe's VK_INSTANCE_LAYERS
|
||||
mode, radius, test = foveation_options(ctx.params)
|
||||
mode, level, test = foveation_options(ctx.params)
|
||||
ctx.env.pop("FOVE_INNER", None)
|
||||
ctx.env.pop("FOVE_OUTER", None)
|
||||
if not mode:
|
||||
return
|
||||
for key in ("FDM_DEBUG", "ENABLE_VULKAN_FDM_INJECTION_LAYER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "FOVE_INNER", "FOVE_OUTER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "FOVE_INNER", "FOVE_OUTER", "FOVE_LEVEL",
|
||||
"LEPTON_ENV_FRAMEPORT_FOVEATION_TEST", "LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS",
|
||||
"LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO",
|
||||
"LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE"):
|
||||
@@ -881,8 +914,8 @@ class Foveation(Patch):
|
||||
ctx.env.update(LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS="1",
|
||||
LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO="1",
|
||||
LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE="1")
|
||||
if radius:
|
||||
ctx.env.update(FOVE_INNER=f"{radius:g}", FOVE_OUTER=f"{2 * radius:g}")
|
||||
if level:
|
||||
ctx.env["FOVE_LEVEL"] = level
|
||||
if test:
|
||||
ctx.env["LEPTON_ENV_FRAMEPORT_FOVEATION_TEST"] = "1"
|
||||
|
||||
@@ -944,16 +977,20 @@ register(Foveation)
|
||||
|
||||
|
||||
FOVEATION_ENV_KEYS = ("FDM_DEBUG", "ENABLE_VULKAN_FDM_INJECTION_LAYER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER",
|
||||
"LEPTON_ENV_DISABLE_VULKAN_FDM_INJECTION_LAYER", "VK_INSTANCE_LAYERS", "FOVE_INNER", "FOVE_OUTER", "FOVE_LEVEL",
|
||||
"LEPTON_ENV_FRAMEPORT_FOVEATION_TEST", "LEPTON_ENV_FRAMEPORT_CLIMB_STEREO_OFFSETS",
|
||||
"LEPTON_ENV_FRAMEPORT_VRAPI_FDM_ISOLATE_MONO", "LEPTON_ENV_FRAMEPORT_VRAPI_KEEP_FOCUSED_IMAGE")
|
||||
|
||||
|
||||
def foveation_preference(entry):
|
||||
if "foveation" in entry:
|
||||
return dict(entry["foveation"])
|
||||
params = ((entry.get("recipe") or {}).get("patches") or {}).get("device.foveation") or {}
|
||||
return {k: v for k, v in params.items() if k in ("mode", "radius", "test")}
|
||||
params = entry.get("foveation") if "foveation" in entry else (
|
||||
((entry.get("recipe") or {}).get("patches") or {}).get("device.foveation") or {})
|
||||
pref = {k: v for k, v in params.items() if k in ("mode", "level", "radius", "test")}
|
||||
if "radius" in pref:
|
||||
_, level, _ = foveation_options(pref)
|
||||
pref.pop("radius")
|
||||
pref["level"] = level
|
||||
return pref
|
||||
|
||||
|
||||
def foveation_env(package, params, settings=None, baseline=None):
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
|
||||
@@ -61,6 +62,11 @@ 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
|
||||
|
||||
@@ -2600,6 +2600,28 @@ class FramePortApp:
|
||||
"it.").format(title=game.get("title") or pkg), error=True,
|
||||
action=tr("Report a problem…"), on_action=lambda e: self.report_problem_dialog(pkg))
|
||||
|
||||
def _sync_save_snapshots(self) -> None:
|
||||
"""Connection/poll hook: disk and SSH work stay off rendering paths."""
|
||||
if getattr(self, "_save_snapshots_busy", False) or self.target is None:
|
||||
return
|
||||
target = self.target
|
||||
self._save_snapshots_busy = True
|
||||
|
||||
def work():
|
||||
from ..install import saves
|
||||
|
||||
try:
|
||||
saves.sync_snapshots(target.frame)
|
||||
self._save_snapshots_error = None
|
||||
except Exception as exc: # noqa: BLE001 - retry on the next connection poll
|
||||
applog.log.exception("syncing automatic save backups failed")
|
||||
if isinstance(exc, saves.SavesError) and str(exc) != getattr(self, "_save_snapshots_error", None):
|
||||
self._save_snapshots_error = str(exc)
|
||||
self.toast(tr("Couldn't sync save backups: {error}").format(error=str(exc)), error=True)
|
||||
finally:
|
||||
self._save_snapshots_busy = False
|
||||
self.run_bg(work)
|
||||
|
||||
def _check_sessions(self, info: dict | None) -> None:
|
||||
"""A FramePort game's play session ended since FramePort last looked (agent v70 `last_play`): fetch and triage
|
||||
its log on a background thread (pipeline.triage_session); the game page shows the result ("Last session")."""
|
||||
@@ -2753,6 +2775,7 @@ class FramePortApp:
|
||||
return False
|
||||
self.frame_info, self.frame_state = info, "connected"
|
||||
self._check_frame_restart(info)
|
||||
self._sync_save_snapshots()
|
||||
self._awake_at = 0.0 # the lock went away with the old connection's Frame session: take it again
|
||||
self.jobs.resume()
|
||||
self.toast(tr("Your Frame is back — continuing"))
|
||||
@@ -2828,6 +2851,7 @@ class FramePortApp:
|
||||
t.label = target.label
|
||||
save_target(target)
|
||||
old, self.target, self.frame_info, self.frame_state = self.target, t, info, "connected"
|
||||
self._sync_save_snapshots()
|
||||
self._check_frame_restart(info)
|
||||
self._remember_drives(t)
|
||||
if old is not None and old is not t and not self.jobs.current():
|
||||
@@ -2893,6 +2917,7 @@ class FramePortApp:
|
||||
changed = info.get("installed") != (self.frame_info or {}).get("installed") or \
|
||||
self.frame_state != "connected"
|
||||
self.frame_info, self.frame_state = info, "connected"
|
||||
self._sync_save_snapshots()
|
||||
self._check_frame_restart(info)
|
||||
self._check_sessions(info)
|
||||
if with_drives:
|
||||
|
||||
@@ -19,6 +19,12 @@ 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
|
||||
|
||||