Compare commits

..
5 Commits
Author SHA1 Message Date
spoopyghosty0 3434ce6c0e Catalog: Puzzling Places (issues), I Expect You To Die 2 (works)
Closes #359
Closes #358
2026-10-16 14:23:43 -04:00
spoopyghosty0 eedf6e4c06 Test: glslang checks the Doom3Quest shaders from #version on
Doom3Quest's shader sources start with a newline before #version (raw string literals). GL drivers accept that, but
glslangValidator's ES profile rejects it, so the optional validator step failed wherever glslang is installed.
The copy it checks now starts at #version; the rewriter output itself is unchanged.
2026-10-16 14:15:24 -04:00
spoopyghosty0 3890676dcb Save backups: agent v95 (rift-on-frame already uses 94); game version from the deployment's source_version 2026-10-16 11:47:09 -04:00
spoopyghosty0 d0dbbe32e5 Save backups for Quest games (discussion #352)
Agent v94: _backup_saves / _restore_saves stream a tar.gz of a game's internal
and external Android storage over the SSH exec channel (caches, IL2CPP and
shader caches, FrameBridge files, videos/OBBs and files over 64 MiB left out;
manifest with sha256 last). File work runs in podman unshare, holds the
launch lock and is refused while the game runs. Restore treats the archive as
untrusted (confined paths, no links/devices, size limits, hashes checked,
staged first), keeps the replaced saves as saves-before-restore.tar.gz (undo)
and puts them back if swapping fails.

PC: install/saves.py (save-backups/<pkg>/, keep 10 by default), game menu
section Saves (back up, restore..., open backups folder), Frame page
"Back up all saves", back up before deleting saves on uninstall, Settings ->
Data keep count, CLI frameport saves backup|list|restore|undo.
2026-10-16 11:47:07 -04:00
spoopyghosty0 6101314ce1 Library folders list + scans relink moved games (discussion #351)
Settings -> Library folders lists the folders Rescan scans (scan.roots: status,
games from each, remove, Add folder...), CLI frameport folders / rescan.
A scan that finds a library game elsewhere whose own files are gone and that
is the same build points the entry at the new place (title, tags, art, user
recipe, builds and installs stay); another version is taken only by Scan a
folder... (as Update with a newer APK/version), Rescan reports it; a second
copy leaves the existing one. A full scan no longer overwrites a user recipe.
2026-10-16 10:37:11 -04:00
96 changed files with 3030 additions and 7249 deletions

No files matched your search

+9 -18
View File
@@ -138,6 +138,7 @@ Read `docs/PLAYBOOK.md` (symptom → fix) before debugging a game, and `docs/FRA
uploads are interruptible (Cancel checked per MiB) and resumable (big files via SFTP `.part` append, small files
streamed in tar batches; the agent counts files already in `incoming/`). Multi-select in the Library queues
installs after asking every needed question up front. Failures end in one pop-up (Resume / Uninstall / log).
- Library folders (GitHub discussion #351): `core/folders.py` (no Flet) owns setting `scan.roots` (remember/add/remove/status/rescan_roots; the origin-based fallback only while the setting was never set, so removing every folder doesn't guess them back), Settings → Library folders (`settings.library_folders`, status read via run_bg), CLI `frameport folders [--add/--remove/--json]` + `frameport rescan`. Scans relink moved games (`pipeline.ScanReport`: a library game found elsewhere whose own files are gone and the copy is the same build (Quest: source_sha256, else versionName; Rift: same program path + size from the fingerprint, matched by game id or, renamed, by program) → only paths change (`relink_quest`/`_relink_rift`), user fields/builds/installs stay; another version → Scan a folder… takes it via `replace_apk(found=)`/`replace_rift_game`, Rescan only reports it; the old copy still there → kept). `add_game` no longer overwrites a user recipe (refreshes `suggested` only). Linux apps aren't scanned. PC installs of a moved Rift game (Steam shortcut path) untested.
- Other drives (GitHub #90, agent v63): anchors stay in ~/Applications/quest-frame, a game's files may live in
`<mount>/FramePort/<pkg>` (`deployment.json` base). Agent `drives` (/proc/mounts, /run/media + Steam library
drives; vfat/exfat/ntfs/read-only refused), prepare*/finalize_linux `dest` (unmounted = error, never a fallback;
@@ -241,6 +242,14 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
flask icon (SCIENCE_OUTLINED) so they aren't confused with Play.
- `uninstall.py` + `frameport uninstall-app` + Settings: removes the data dir (after an optional key backup zip),
PC Steam shortcuts, the WSL Revive copy, and via agent `purge` FramePort's games/files on the Frame.
- Save backups (discussion #352, agent v95; docs/FRAME_RUNTIME.md "Save backups"): agent `_backup_saves`/
`_restore_saves '<json>'` stream a tar.gz of `lepton-data/internal/<pkg>` + `external/Android/data/<pkg>` (caches,
IL2CPP/shader caches, FrameBridge files, videos/OBBs, files >64 MiB left out; manifest `frameport-saves.json` last)
over the exec channel, file work in `podman unshare` (`_saves_ns`), launch lock held, refused while running;
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`.
- `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(...)`.
@@ -820,24 +829,6 @@ shows what the game draws. Vader: Lucasfilm logo (an OBB mp4: video works), then
bar) that never advances; the async loader thread sleeps and OBB reads stop (~168 MB of a 2.7 GB pak).
**SteamVR overlay apps (agent v75, 2026-10-10):** host OpenVR overlays (VRApplication_Overlay) are composited over Lepton (Quest) games on the Frame (probe checker seen over 4XVR in the headset view, `native/vroverlay_probe`); Windows ones register through Proton's vrclient too (Temporal Reality's Windows build created its overlays; fpsVR never reaches OpenVR under Proton). Detection `analysis/vroverlay.py` (bundled `.vrmanifest` with `is_dashboard_overlay`, else OpenVR + IVROverlay + self-registration markers, engine Other; Rift fingerprint `RIFT_ANALYSIS`), patch `pcvr.vr_overlay` (param autostart), Linux entry fields `vr_overlay`/`vr_overlay_autostart` (game page switch); agent `register_vr_overlay`/`unregister_vr_overlay`/`launch_vr_overlay` (IVRApplications via ctypes, live; manifest `<anchor>/frameport-overlay.vrmanifest`, the app's own key, binary = launch.sh). **SteamVR on the Frame reads only `binary_path_linux_arm`** (binary_path_linux alone is skipped). Overlay apps: no launch test, no Linux Steam-parent watchdog (`FRAMEPORT_OVERLAY`), not a "running game". A Steam overlay-app shortcut and a game shortcut run together. SteamVR's SetApplicationAutoLaunch didn't persist on the dev Frame (the watch never started by itself) → **agent v76: FramePort's own autostart**: the deployment's `overlay.autostart` is the source of truth; `ensure_host_fixes` (`ensure_overlay_service`) keeps the user service `frameport-vr-overlays.service` (`_vr_overlay_watch`, 5 s polls of the known vrserver's /proc stat) only while an overlay app has autostart on (register/unregister, uninstall, purge and the kill switch `~/.local/share/frameport/vr-overlays.disabled` / `FRAMEPORT_NO_OVERLAY_AUTOSTART` update it); a new vrserver (pid + start time, kept in `~/.cache/frameport-vr-overlays.json` across agent updates) → child `_vr_overlay_round`: wait for IVRApplications, 15 s grace, `launch_vr_overlay` for apps not running (folder process or GetApplicationProcessId); log `~/.local/share/frameport/vr-overlays.log`. Dev Frame: adopted the running vrserver, skipped the already running watch; a start after a real SteamVR start/boot is not yet seen. Details: docs/FRAME_RUNTIME.md "Overlay apps".
**Vader's leak solved / AC Nexus flips (2026-10-14):** Vader Immortal leaves GL errors pending on its context; SteamVR's GL interop (`openxr_graphics_gl_openvr.cpp`, `xrclient_<pkg>.txt` in Steam's logs: `SXR_GL_CHECK: error:0x502 ... glGenTextures`) takes the stale error as its own failure once, and from then on `CVRCompositorSharedTextures::BeginCommandBuffer` allocates a command buffer every frame (perf page-fault stacks; ~430 renderD128 mappings + 22 MB/s). Adapter `gl_error_clear` (GLES, catalog on for Vader ep1-3) clears them before xrCreateSwapchain/xrBeginFrame/xrEndFrame: flat memory, no SXR_GL_CHECK (headless). The per-game `xrclient_<pkg>.txt` logs are worth reading for runtime errors. The Frame's runtime caps swapchains per session (XR_ERROR_LIMIT_REACHED from ~15; AC Nexus asks for more): flip emulation now turns quads upright in place (scratch VkImage, once per acquire; transfer usage added to single-layer colour swapchains) instead of a shadow swapchain per quad, which ran into the cap (#138). Headless sessions don't draw AC Nexus's panels (adapter `flip_dump` saves before/after images of flipped quads, for headset runs).
**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".
**Unreal's self-restart (agent v90, 2026-10-13):** UE 4.21+ restarts the app once after its first-run shader
precompile ("optimizing" screen; `r.ProgramBinaryCache.RestartAndroidAfterPrecompile`): it starts its own LAUNCHER
activity and calls System.exit(0); Lepton ends the container with the first process (the restarted one with it).
+592 -885
View File
File diff suppressed because it is too large. Load diff
+2 -21
View File
@@ -13,11 +13,11 @@ e8c0965c188665a4e565e4a3af983ef1866d1cf9225665cc90d8df286bc96ef3 ./arm64-v8a/li
5432674a59d02f411cd853a5dc57e3fa547d1ac518f25bf433fc54c43677177a ./arm64-v8a/libfpglmv.so
8a8f6b1da8952cb3933a5b53558424a5fc56043b2527a16bb6dd62a00d2de01a ./arm64-v8a/libframe_xrshim.so
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
c9e286ff25984992954c1d80eb1fc3530643421d859d20c8c1fd07ce1d584372 ./arm64-v8a/libopenxr_loader_generic.so
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
ba2dc70263bc3cca82a313805c5b4890452b1f740570bc7b65320a825a4012d2 ./armeabi-v7a/libopenxr_loader_generic.so
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
@@ -27,25 +27,6 @@ b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
63af0050054ad3705b17a0b00ce0c076ba1af0b73abd673ba6c588f64b4591cd ./linux-arm64-bin/fp_roomscan
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
00c2dead0fd7497929833467f0e55699d57d26be1b85dce35402993852f7b111 ./roomscan/ceiling.png
c1962de31ed2810d31c760cf9ee63316ac2b8c9528ccbcd9299ebf5ba39b2995 ./roomscan/done.png
0dfaea4c68979b0a45a9fef06fdf1a92468ffc8f46f80a3a014fce1d805972d5 ./roomscan/failed.png
1147391822be3f1aa5351374b8fd9ec85f8b5ce40e3cb9f3146d778e6fbc652a ./roomscan/floor.png
eb3c8b55a9dbafa030797ca9f06a22c3ee3bbef96cb7adea26d433ebad43d43f ./roomscan/look_00.png
6ba965ff6d22a469c558f2a68229bd2ff48fc28badb95889ca245310d1dd3e96 ./roomscan/look_01.png
0a11cec25ebc76fe11d505d1f2ec162c9b4a6754393065800e73b1f81e2d94c8 ./roomscan/look_02.png
fa5a4c94d20af56797b9fcc111148767847a6633ade9217cab373de232efad90 ./roomscan/look_03.png
e80fa493e380fba39084be2ee62859adcff4e39aa66566abee10a4d6339c52e9 ./roomscan/look_04.png
133617f0d8d1e69c37408aac80033f71856b592b62e3b780cc12b446bd974680 ./roomscan/look_05.png
d5dd296ff23641c6973c57ac139147e99c7b783316ab60537adb2155dd196880 ./roomscan/look_06.png
c08570aa78f966ae937b2e21d0bad887c5352fbca56aa579a2a7ef8541fb96ad ./roomscan/look_07.png
3767e7db5fb6babc872fa62c42604f749c65936717d332101745b201cd450cd5 ./roomscan/look_08.png
2924dc65e5c497545340209edc16060e12e1834d11af6871d1c1baa50b7b2562 ./roomscan/look_09.png
cd0150dfc10b2aea39403c74eff72fd05fb0111178ef39b4c98657a6d16d105e ./roomscan/look_10.png
011355e412f73ade3896e9037490e607c4365d0cf8ceb807d236bf68b414d3ed ./roomscan/look_11.png
1b8b47ffb47568d540921f065b5958eeb44a6489498d58e0f33f5d1f535154e2 ./roomscan/look_12.png
926c7bc6a103adee5b9bf938f5d4e39095ab77fd6270b5301be3b0428edd9ae6 ./roomscan/start.png
c2c70f4af1a5c3e6089dff1130161dd417f8044f99ec639eeecf1083930aab16 ./win-x64/fp_oculushmd.exe
60ebc5fea05b8945082e2ca8b9f2eb74616012a26eb50133af273dff0fcddc42 ./win-x64/fp_vrsettings.exe
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,25 @@
package: com.RealitiesIO.puzzlingPlaces
title: Puzzling Places
status: issues
notes: Works fine, but pointers dont show well on main menu, easy to work around.
tested_version: '2.14'
engine: Unreal
xr: OpenXR
overport_extra:
- patch_disable_space_warp
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.unreal_skip_obb_check
- frame.unreal_ovrp_entrypoints
- frame.hw_video_decode
adapter:
equirect_emul: 1
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 8d9bb64b0af4c34269fffb64c5ef7515d6ba4715f5234f95517975b8e2b56c00
app: 1.2.0
overport_cli: 1.2.5
issue: 359
source_hint: Puzzling Places
+2 -4
View File
@@ -5,10 +5,8 @@ source_hint: NOPE CHALLENGE
engine: Unreal
xr: OpenXR
status: works
notes: "Meta XR Audio telemetry patch + nodebug. MR mode: needs a scanned room."
details: The MR 'Reality Breach' mode needs passthrough and a room; standard VR mode was verified.
adapter:
scene_emul: 1
notes: Meta XR Audio telemetry patch + nodebug. MR "Reality Breach" mode unavailable.
details: The MR 'Reality Breach' mode needs passthrough; standard VR mode used.
alt_overport:
- patch_remove_unreal_force_quit
frame:
+27
View File
@@ -0,0 +1,27 @@
package: com.schellgames.ieytd2
title: I Expect You To Die 2
status: works
notes: loaded up and played 5 minutes looks fine.
tested_version: 1.0.1.171177 (ReleaseCandidate_1.0.1_QuestStore)
engine: Unity
xr: VrApi
frame:
- frame.unity_no_msaa
- frame.unity_oculus_check
- frame.unity_text_input
- frame.unity_runtime_msaa_off
- frame.unity_gl_shim
device:
- device.text_input_window
adapter:
haptic_fix: 1
scale: 2.0
verified:
date: '2026-10-16'
known_good_sha256: fc626ec62e49e19210a188d90f94c370e956d0de729cbd04cbe8866430d77730
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 358
source_hint: Expect
+1 -2
View File
@@ -1,14 +1,13 @@
package: com.zenstudios.PFXVRQuest
title: Pinball FX VR
status: issues
notes: "Plays; space warp off (fewer stutters). MR mode: needs a scanned room."
notes: Plays; mixed reality mode not working yet. Space warp off (fewer stutters).
tested_version: '1.9'
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
adapter:
scene_emul: 1
equirect_emul: 1
hide_space_warp: 1
verified:
-10
View File
@@ -6,16 +6,6 @@
# with diagnostics. Launch tests and real play sessions (agent session_log, `frameport session`) are both matched.
# Sources: every failure we hit porting 34 games to the Steam Frame (see docs/PLAYBOOK.md).
signatures:
- id: mr-no-room
pattern: 'FrameBridge[^\r\n]*scene: (?:capture requested -> (?:guardian box|using guardian room)|room from guardian|room [^\r\n]*(?:from guardian|configured/default size|box))'
severity: info
diagnosis: 'The game is using a simple fallback room. Scan your room for mixed reality, or use Frame > Room > Use my play area for walls without furniture. A recorded scan can be imported until headset capture is available.'
suggest: []
- id: mr-discovery-unsupported
pattern: '(?:Unknown proc addr:\s*xrDiscoverSpacesMETA|(?:MRUK[^\r\n]*)?NoRoomsFound)'
severity: warning
diagnosis: 'The game cannot discover a Meta room. Enable room layout (adapter.scene_emul), rebuild and reinstall for the updated extension shim, then save a room under Frame > Room.'
suggest: [adapter.scene_emul]
- id: meta-asset-status-error
pattern: 'Asset status error\s+\(code 100\)'
severity: error
+32
View File
@@ -26,6 +26,25 @@ Games/ ← scan this folder (Add games → Scan
Not sure of the package name? It's in the OBB file names (`main.<version>.<package name>.obb`), and the game's page
shows it under **Details**.
## I moved my games to another folder or drive. How do I update the library?
Scan the new folder (**Add games → Scan a folder…**, or **Settings → Library folders → Add folder…**). A game whose
files are gone from their old place and that FramePort finds again in the new folder keeps its library entry: only
where its files are changes. Its name, tags, artwork, your own recipe and what's installed on the Frame stay; you don't
have to remove it from the library first. **Rescan folders** in the Library does the same for every library folder.
- **Removing a folder:** **Settings → Library folders** lists every folder FramePort scans, with how many library games
come from it and whether it's there now (a folder on an unplugged drive shows as not found). The bin icon stops
scanning a folder; its games stay in the library. `frameport folders [--add DIR] [--remove DIR]` does the same on the
command line, `frameport rescan` rescans.
- **Another version in the new folder** (a different APK or game build): **Scan a folder…** on that folder takes it, as
**Update with a newer APK…** would (name, tags, art and your own recipe stay). **Rescan folders** leaves such games
alone and says so; use the game's **Update with a newer APK…** / **…version…** to switch.
- **Two copies** (the old folder is still there): the library keeps the copy it has. To use the other one, delete or
rename the old copy and rescan.
- PC VR games are recognised by their folder name, or a renamed folder by its program file. Linux apps aren't part of
folder scans: add the moved app again with **Add a Linux app…** (it keeps its name, art and switches).
## Can I scan SideQuest backups or AXRB downloads directly?
Yes. Scan the folder as it is; FramePort finds the APK and its OBB files in both layouts:
@@ -178,6 +197,19 @@ draw more than the battery delivers, especially at lower charge, and the Frame t
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
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).
**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>`.
## A game with mods crashes
How to tell: play once without mods and custom content. If it then works, a mod is the cause; the diagnostics zip
+25 -23
View File
@@ -60,29 +60,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.
## 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`. Headset capture (`room_capture`) deliberately
returns "not implemented yet" pending the RoomView device spike. The scanner consumes recorded RoomView meshes
from `/xrservice/roomview3d/meshData` + `/meshLayout` (`IVRBlockQueue_005`); it does not capture them itself.
## Save backups (Quest games, agent v95, 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
dev Frame (2026-10-16) every file in them belongs to `steamos` (Lepton's `keep-id:uid=0`: the container's root and
app = steamos), mostly mode 600/660 in 700/771/775/2771 folders; Lepton's links (`Android/obb/<pkg>`, `Movies`,
`Download`, `Documents`) sit outside these two folders.
- Agent `_backup_saves '<json>'` streams a tar.gz of both (members `internal/…`, `external/…`, PAX, numeric owners and
modes; last member `frameport-saves.json` = manifest with size + sha256 per file and what was left out) on the SSH
exec channel's stdout, the result JSON on stderr. Left out: folders named cache/code_cache/no_backup/il2cpp/
UnityShaderCache/(Vulkan)ProgramBinaryCache/GPUCache/… at any depth, Unreal `Saved/Logs|Crashes`, FrameBridge's
`fb_snap_*`/`framebridge*` files, `.obb/.apk/.foz/.mp4/.mkv/.webm/.mov`, symlinks and special files, and files over
64 MiB (big downloaded content; e.g. BattleTalent's `files/Video`). More than 4 GiB of saves is refused.
- `_restore_saves '<json>'` reads the archive from stdin and treats it as untrusted: only regular files and folders
under `internal/`/`external/` (no `..`, absolute paths, links, devices, duplicates), ≤1 GiB per file / 8 GiB in all,
each file's sha256 must match the manifest, the manifest's package must be the game. Everything is staged in
`<base>/.saves-restore-*` first, a path that would pass through a link on the Frame is refused, then the current
saves are written to `<base>/saves-before-restore.tar.gz` (one copy; `rollback: true` restores it and keeps the
replaced saves as the new copy) and each file is renamed into place. Existing files keep their owner and mode, new
ones get their folder's owner and the backup's mode (at least u+rw); save files the backup doesn't have are removed
(left-out files stay). A failure while swapping puts the rollback copy back.
- Both run their file work in `podman unshare` (the agent re-runs itself as `_saves_ns <op>` with stdio inherited) so
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.
## 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),
+2
View File
@@ -65,6 +65,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| H.U.N.T | Quest | ✅ Works | |
| I Am Cat | Quest | ✅ Works | |
| I Am Monkey | Quest | ✅ Works | |
| I Expect You To Die 2 | Quest | ✅ Works | |
| IEYTD3 | Quest | ✅ Works | |
| In Death: Unchained | Quest | ✅ Works | |
| Into The Radius 2 | Quest | ✅ Works | |
@@ -176,6 +177,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
| Puzzling Places | Quest | ⚠️ Works with issues | Works fine, but pointers dont show well on main menu, easy to work around. |
| Quantum Void | Quest | ⚠️ Works with issues | no hands |
| Ragnarock | Quest | ⚠️ Works with issues | |
| Real VR Fishing | Quest | ⚠️ Works with issues | Seems to be almost 100% functional with meta permissions enabled. |
+11
View File
@@ -167,6 +167,7 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
**Update on Frame**.
- **Moved your games?** Scan the new folder (or **Rescan folders**): games found there whose old files are gone keep their library entry, name, artwork, tags and settings, and only their location changes. **Settings → Library folders** lists the folders FramePort scans; remove the old one there (the games stay). See the [FAQ](FAQ.md#i-moved-my-games-to-another-folder-or-drive-how-do-i-update-the-library).
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
@@ -220,6 +221,14 @@ The **Files** tab copies files between your PC and the Frame: videos, documents,
`/sdcard/Download`, `/sdcard/Documents`).
- **Game storage** holds each game's own files. A game's menu → **Add videos and files…** opens it.
## Backing up saves
Steam doesn't sync Quest games' saves. A game's menu (**…** on its page) → **Saves** → **Back up saves** copies them
to your PC (close the game first); **Back up all saves** on the Steam Frame page does every installed Quest game.
**Restore saves…** puts a backup back on the Frame and keeps a copy of the replaced saves there (**Undo last
restore**). Backups live in FramePort's data folder under `save-backups/`; the 10 newest per game are kept (Settings →
Data). Caches and large downloaded content stay out. More in the [FAQ](FAQ.md#can-i-back-up-a-games-saves).
## Share a recipe or report a problem
- **Share working recipe…** (in the game's menu) opens a GitHub issue with the game's recipe filled in. Accepted
@@ -258,6 +267,7 @@ option. The main ones:
| Command | What it does |
|---|---|
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
| `folders [--add <folder>] [--remove <folder>]` / `rescan` | list the library folders, add or remove one (its games stay); scan them again for new and moved games |
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
@@ -265,6 +275,7 @@ option. The main ones:
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
| `frame send <files> --dest videos` / `frame storage` / `frame cleanup` | copy files to the Frame, list where they can go, free space |
| `frame drives` / `frame move <game> --to <drive>` / `install --dest <drive>` | list the Frame's drives, move a game, install to a drive |
| `saves backup <game>\|--all` / `saves list <game>` / `saves restore <game> [n]` / `saves undo <game>` | back up Quest games' saves to this PC, restore one, undo the last restore |
| `tools status` / `tools install` | the tools FramePort downloads |
| `open-link "<link>"` | install from an install button's address or a download link (`--yes`, `--no-install`) |
| `diag report <game>` / `share-recipe <game>` | report a problem / share a working recipe |
+36 -36
View File
@@ -27,7 +27,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Copy OVRPlugin if VrApi is present](#patch_copy_ovrplugin_vrapi) | `patch_copy_ovrplugin_vrapi` | Patches from OVRPort | default |
| [Disable controller tracking offset](#patch_disable_controller_offset) | `patch_disable_controller_offset` | Patches from OVRPort | |
| [Disable Meta XR Audio telemetry](#patch_disable_meta_xr_audio_telemetry) | `patch_disable_meta_xr_audio_telemetry` | Patches from OVRPort | |
| [Disable application space warp if used](#patch_disable_space_warp) | `patch_disable_space_warp` | Patches from OVRPort | 3 |
| [Disable application space warp if used](#patch_disable_space_warp) | `patch_disable_space_warp` | Patches from OVRPort | 4 |
| [Fix minimal Android SDK](#patch_fix_min_android_sdk) | `patch_fix_min_android_sdk` | Patches from OVRPort | default |
| [Fix UE4 crash with Unity stub](#patch_fix_unreal_crash) | `patch_fix_unreal_crash` | Patches from OVRPort | default |
| [Force enable passthrough](#patch_force_passthrough) | `patch_force_passthrough` | Patches from OVRPort | 3 |
@@ -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 | 41 |
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 42 |
| [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 |
@@ -56,7 +56,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [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 |
| [Godot apps: start without a clipboard service](#frame-godot_clipboard) | `frame.godot_clipboard` | Steam Frame patches | |
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 23 |
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 24 |
| [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 |
@@ -76,21 +76,21 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [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 |
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 23 |
| [GL shim for Unity (no multisampled render-to-texture)](#frame-unity_gl_shim) | `frame.unity_gl_shim` | Steam Frame patches | 24 |
| [Unity: render each eye separately (no multiview)](#frame-unity_multipass) | `frame.unity_multipass` | Steam Frame patches | 1 |
| [Unity: disable MSAA](#frame-unity_no_msaa) | `frame.unity_no_msaa` | Steam Frame patches | 9 |
| [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 | 12 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 28 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 107 |
| [Unity: start VR without Meta's system apps](#frame-unity_oculus_check) | `frame.unity_oculus_check` | Steam Frame patches | 13 |
| [Unity: keep MSAA off at runtime (OVRManager)](#frame-unity_runtime_msaa_off) | `frame.unity_runtime_msaa_off` | Steam Frame patches | 29 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 108 |
| [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 |
| [Unreal: match multiview depth to the eye size](#frame-unreal_multiview_depth) | `frame.unreal_multiview_depth` | Steam Frame patches | 1 |
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 2 |
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 3 |
| [Unreal: Quest controller bindings (RPOC key map)](#frame-unreal_quest_keymap) | `frame.unreal_quest_keymap` | Steam Frame patches | 3 |
| [Unreal: don't wait for the Quest shader precompile](#frame-unreal_quest_precompile) | `frame.unreal_quest_precompile` | Steam Frame patches | 3 |
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 6 |
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 7 |
| [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 |
@@ -107,7 +107,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 6 |
| [Stand in for cube layers](#adapter-cube_standin) | `adapter.cube_standin` | Game settings | |
| [Show curved menus and screens](#adapter-cylinder_strips) | `adapter.cylinder_strips` | Game settings | |
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 5 |
| [Show 360° backgrounds and videos](#adapter-equirect_emul) | `adapter.equirect_emul` | Game settings | 6 |
| [360° detail](#adapter-equirect_face) | `adapter.equirect_face` | Game settings | |
| [360° picture orientation](#adapter-equirect_flip) | `adapter.equirect_flip` | Game settings | |
| [360° video frame rate limit](#adapter-equirect_fps) | `adapter.equirect_fps` | Game settings | |
@@ -127,7 +127,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 | 88 |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 90 |
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 5 |
| [Controller input diagnostics (log)](#adapter-input_diag) | `adapter.input_diag` | Game settings | |
@@ -146,9 +146,9 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Refresh rate](#adapter-refresh_rate) | `adapter.refresh_rate` | Game settings | 3 |
| [Wait for the game's GPU](#adapter-release_wait) | `adapter.release_wait` | Game settings | |
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 5 |
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
| [Use room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 13 |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 11 |
| [Room height](#adapter-scene_height) | `adapter.scene_height` | Game settings | |
| [Room width](#adapter-scene_width) | `adapter.scene_width` | Game settings | |
| [Eye-image snapshots (diagnostics)](#adapter-snapshot) | `adapter.snapshot` | Game settings | |
@@ -175,7 +175,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Hide Android's navigation bar](#device-hide_navbar) | `device.hide_navbar` | 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 | 110 |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 111 |
| [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) | |
@@ -284,7 +284,7 @@ Turns off Meta's frame-rate trick, which causes glitches or hangs outside Quest
- **Changes:** Changes the APK (during the OVRPort conversion).
- **Suggested (on):** Very heavy game (… GiB) using OVRPlugin: application space warp causes artifacts/hangs off-Quest.
- **Offered (off):** Enable if the game shows warping artifacts or hangs (uses OVRPlugin space warp).
- **Catalog games (3):** Asgard's Wrath 2, Batman: Arkham Shadow, Marvel's Deadpool VR.
- **Catalog games (4):** Asgard's Wrath 2, Batman: Arkham Shadow, Marvel's Deadpool VR, Puzzling Places.
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
> For heavy games that use application space warp: it causes artifacts or hangs on non-Quest runtimes (for example Asgard's Wrath 2, Batman: Arkham Shadow).
@@ -420,7 +420,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 (41):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 33 more.
- **Catalog games (42):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Breathedge VR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2 and 34 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)
@@ -645,7 +645,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 (23):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 15 more.
- **Catalog games (24):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Breathedge VR, Dance Central, Exploding Kittens VR, Eye of the Temple and 16 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.
@@ -904,7 +904,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
- `frame.unity_gl_shim`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 15 more.
- **Catalog games (24):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 16 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)
@@ -931,7 +931,7 @@ Turns off a smoothing setting that crashes some older Unity games on the Frame.
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Legacy VrApi Unity game on GLES with MSAA (… quality levels): multisampled render-to-texture hangs the Frame's GL driver (for example Sniper Elite VR).
- **Offered (off):** GLES Unity game with MSAA on (… quality levels); enable if it hangs the GPU ('zink: DEVICE LOST').
- **Catalog games (9):** Audica, BattleSisters, Eye of the Temple, IEYTD3, Jurassic World Aftermath Collection, Mini Motor Racing X, Sniper Elite VR, SUPERHOT VR and 1 more.
- **Catalog games (10):** Audica, BattleSisters, Eye of the Temple, I Expect You To Die 2, IEYTD3, Jurassic World Aftermath Collection, Mini Motor Racing X, Sniper Elite VR and 2 more.
- **Suggested by launch-test signatures:**
- `zink-device-lost` (fatal): The GPU hung, so the game froze or closed.
- **Source:** [patches/frame/unity_no_msaa.py](../src/frameport/patches/frame/unity_no_msaa.py)
@@ -959,7 +959,7 @@ Lets older Unity games start VR without Meta's system apps (they'd stay on a 2D
- `frame.unity_oculus_check` · revision 5
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unity with built-in Oculus support: it checks for Meta's system apps before starting VR, else it runs as a 2D app (for example Accounting+, BattleSisters).
- **Catalog games (12):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Eye of the Temple, Jurassic World Aftermath Collection, Mini Motor Racing X and 4 more.
- **Catalog games (13):** Accounting+, Audica, BattleSisters, Cosmodread, End Space, Eye of the Temple, I Expect You To Die 2, Jurassic World Aftermath Collection and 5 more.
- **Suggested by launch-test signatures:**
- `unity-vr-device-none` (fatal): Unity didn't start VR, so the game runs as a hidden 2D app.
- **Source:** [patches/frame/unity_oculus_check.py](../src/frameport/patches/frame/unity_oculus_check.py)
@@ -973,7 +973,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
- `frame.unity_runtime_msaa_off` · revision 2
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 20 more.
- **Catalog games (29):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 21 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.
@@ -989,7 +989,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 (107):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 99 more.
- **Catalog games (108):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 100 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 -&gt; false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -&gt; 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.
@@ -1056,7 +1056,7 @@ Lets older Unreal games start VR on the Frame (for example Star Wars Pinball VR)
- `frame.unreal_ovrp_entrypoints`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unreal looks up OVRPlugin function(s) OVRPort's OVRPlugin lacks: ……. Without them Unreal starts no VR.
- **Catalog games (2):** Ghostbusters: Rise of the Ghost Lord, Star Wars Pinball VR.
- **Catalog games (3):** Ghostbusters: Rise of the Ghost Lord, Puzzling Places, Star Wars Pinball VR.
- **Source:** [patches/frame/unreal_ovrp.py](../src/frameport/patches/frame/unreal_ovrp.py)
> Unreal's Oculus module looks up every OVRPlugin function it was built against and starts no VR when one is missing; OVRPort's OpenXR OVRPlugin lacks some old ones (for example ovrp_GetPTWNear, which Unreal 4.25 builds like Star Wars Pinball VR look up). The game then never starts an OpenXR session and crashes a few seconds in. Adds a generated library to libOVRPlugin.so with a stand-in for each missing function that reports failure (ovrpFailure), so Unreal's Oculus support starts. The functions OVRPort has are untouched.
@@ -1092,7 +1092,7 @@ Starts Unreal games without re-checking their data file (they'd seem to hang on
- `frame.unreal_skip_obb_check` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Unreal checks the OBB's contents before starting (bVerifyOBBOnStartUp); on the Frame a failed or slow check can't be seen and the game seems to hang.
- **Catalog games (6):** Dance Central, Kill It With Fire VR, Lost Recipes, MacacoVR, Maestro, Ragnarock.
- **Catalog games (7):** Dance Central, Kill It With Fire VR, Lost Recipes, MacacoVR, Maestro, Puzzling Places, Ragnarock.
- **Suggested by launch-test signatures:**
- `unreal-obb-check-stuck` (fatal): The game stays on Unreal's OBB check screen (DownloaderActivity), which the Frame doesn't show, and never starts VR (for example Contractors, GitHub #159).
- **Source:** [patches/frame/unreal_obb_check.py](../src/frameport/patches/frame/unreal_obb_check.py)
@@ -1340,7 +1340,7 @@ Video players' virtual theaters and 360° videos would otherwise stay black.
- **Options:**
- Value: int, default `0`.
- Game settings → 360° pictures and video → "Show 360° backgrounds and videos".
- **Catalog games (5):** 4XVR Video Player, Into The Radius 2, Pinball FX VR, Riven, Tea For God.
- **Catalog games (6):** 4XVR Video Player, Into The Radius 2, Pinball FX VR, Puzzling Places, Riven, Tea For God.
- **Suggested by launch-test signatures:**
- `equirect-layers-dropped` (warning): The game's 360° pictures (for example a video player's theater or 360° videos) are missing because the Frame's runtime can't show equirect layers.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -1633,7 +1633,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 (88):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber and 80 more.
- **Catalog games (90):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber and 82 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.
@@ -1911,7 +1911,7 @@ Higher looks sharper but needs more power; lower runs smoother. 1.0× is the gam
- **Options:**
- Value: float, default `1.0` (always written).
- Game settings → Picture → "Sharpness": slider 0.5–2 in steps of 0.1.
- **Catalog games (4):** 4XVR Video Player, BlazeRush, cubism, Walkabout Mini Golf.
- **Catalog games (5):** 4XVR Video Player, BlazeRush, cubism, I Expect You To Die 2, Walkabout Mini Golf.
- **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.
@@ -1930,23 +1930,23 @@ Higher looks sharper but needs more power; lower runs smoother. 1.0× is the gam
> Override the guardian depth (0 = use guardian, min 1.5 m).
### <a id="adapter-scene_emul"></a>Use room layout
### <a id="adapter-scene_emul"></a>Pretend room layout
Scene games get your saved room. Without one, they get a simple room from the play area.
Mixed-reality games that build their level from your room get a simple room with walls.
- `adapter.scene_emul`
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
- **Suggested (on), value `1`:** The game uses Meta's scene or spatial anchor APIs: serve the saved room on the Frame, or a simple play-area room until one is scanned.
- **Suggested (on), value `1`:** Mixed-reality game that builds its level from the room model: emulate a guardian-sized room (for example Demeter).
- **Suggested (on), value `1`:** The game asks Meta's system for your room layout (scene), which the Frame doesn't have: emulate a room from the play area (for example VR HOT's room setup).
- **Options:**
- Value: int, default `0`.
- Game settings → Mixed reality → "Use room layout".
- **Catalog games (13):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect, NOPE CHALLENGE and 5 more.
- Game settings → Mixed reality → "Pretend room layout".
- **Catalog games (11):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Neko Atsume Purrfect, Ocean Rift and 3 more.
- **Suggested by launch-test signatures:**
- `mr-discovery-unsupported` (warning): The game cannot discover a Meta room.
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved room, use a simple play-area box (for example Demeter).
> Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (for example Demeter).
### <a id="adapter-scene_height"></a>Room height
@@ -2335,7 +2335,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 (110):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 102 more.
- **Catalog games (111):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 103 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".
+1 -4
View File
@@ -176,10 +176,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Passthrough black (BAM) | XR_FB_passthrough missing | adapter `passthrough_emul=1` (default) + `patch_force_passthrough` for MR-only games |
| Quest Touch controllers (or none) shown in a game that uses Meta's runtime controller models | XR_FB_render_model missing on the Frame | adapter `controller_models=1`: the agent converts the Frame's SteamVR controller models to glb at install, the adapter serves them. Games with their own controller meshes aren't affected (none of the 34 catalog games use runtime models) |
| `overportOXR: Unknown proc addr: xr…` for a function the adapter emulates | overport's dispatcher only forwards functions in its own table | route the plugin's lookups through `native/xrshim` (done for XR_FB_render_model when `controller_models=1`) |
| MR game stuck waiting for room data (e.g. Demeter) | No Meta scene API | `adapter.scene_emul=1` (+ `frame.meta_permissions`); Frame → Room → Use my play area, or import a scanned room |
| Unity MR game rejects every room / its "use guardian" fallback does nothing; `ovrpBoundary_Outer is not supported`, `IndexOutOfRangeException` in a guardian check (e.g. Demeter's `RemoveObjectNotInGuardian`) | OVRPlugin calls the Frame's 1x1 m STAGE rect a configured boundary but can't give its outline | `adapter.scene_emul=1`: FrameBridge then answers the app's STAGE `xrGetReferenceSpaceBoundsRect` with `XR_SPACE_BOUNDS_UNAVAILABLE` (log `scene: STAGE bounds hidden`). Demeter also needs a floor of at least 2.5 x 2.5 m, as on a Quest |
| MR uses an empty/simple room; FrameBridge `scene: capture requested -> guardian box` / `using guardian room`, `room from guardian`, or fallback box dimensions (`mr-no-room`) | No saved room, so FrameBridge uses its guardian box | Save a room in Frame → Room. A real SteamVR boundary supplies walls; Valve's default circle is refused. Import a scanner-built room for furniture/mesh; headset capture is not implemented yet |
| `Unknown proc addr: xrDiscoverSpacesMETA` or MRUK `NoRoomsFound` (`mr-discovery-unsupported`) | Scene discovery is unavailable or no room was discovered | Enable `adapter.scene_emul`, rebuild/reinstall to get the updated extension shim, then save a room. All games declaring USE_SCENE or USE_ANCHOR_API now get this suggestion |
| MR game stuck waiting for room data (Demeter) | no Meta scene API | adapter `scene_emul=1` (+ `frame.meta_permissions`) |
| Hand-tracking game janky (Silhouette) | Frame synthesizes hands from controllers | `controller_fix=0` passes hands through; not really fixable |
| Eye distortion while moving (Arcsmith, Time Stall) | unknown (not eye swap, tracking, Valve layers, depth or pacing) | unresolved |
| The Climb 666.0: hand outlines ripple in both eyes while menus and gameplay work (GitHub #238) | Unknown. Sessions paced around 72 fps, controller tracking reported valid, and SteamVR motion smoothing was already off; these checks do not establish the cause | Unresolved after wearer comparisons on 2026-10-12: disabling Valve's Vulkan layers, setting `r_AntialiasingMode = 1`, and setting `r_AntialiasingMode = 0` with `r_MotionBlur = 0` each gave the same or worse result. These were config comparisons; runtime cvar values were not verified. Restored the playable configuration (`r_vr_foveated = 0` with the default Vulkan layers), retaining the startup/rendering crash fixes above. Do not keep the unsuccessful experimental settings as a fix |
-49
View File
@@ -1,49 +0,0 @@
# Phase 0: reading Valve's RoomView 3D mesh (dev Frame, SteamOS 0.4.5 build 20261007, 2026-10-16)
Result: **a third-party OpenVR client can read the passthrough depth mesh, and it carries enough of the room for
a scan** (floor, table-height surfaces, orthogonal walls; the ceiling only weakly).
## Access
- Background OpenVR app (`VR_InitInternal2(..., VRApplication_Background)`, never starts SteamVR) through
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`.
- `FnTable:IVRBlockQueue_005`: `Connect("/xrservice/roomview3d/meshData")` and `.../meshLayout` work (created by
Valve's cv driver; no exclusivity seen next to the compositor's own reader). Read with
`WaitAndAcquireReadOnlyBlock(queue, &block, &buffer, EBlockQueueReadType_New, timeout_ms)` + `ReleaseReadOnlyBlock`.
- Metadata through the C++ interface `IVRPaths_002` (vtable slot 0 `ReadPathBatch(root, PathRead_t*, n)`, slot 2
`StringToHandle(&handle, path)`; `FnTable:IVRPaths_001` doesn't exist), root = the **block** handle, except
`/meshData/meshIsUpToDate` which is a property of the **queue** handle:
| path | tag | meaning |
|---|---|---|
| `/meshLayout/layoutId` | 3 (u64) | id of the index layout (static while the mesh resolution stays) |
| `/meshLayout/vertexCount` | 2 (i32) | 16896 = 132 x 128 |
| `/meshLayout/indexCount` | 2 | 99822 (u32 triangle indices into the grid, row width 132) |
| `/meshData/meshId` | 3 | frame counter (~30 per second while passthrough runs) |
| `/meshData/layoutId` | 3 | matches the layout block |
| `/meshData/elementCount` | 2 | floats in the data block (vertexCount x 3) |
| `/meshData/rawFromMeshPose` | 20 (HmdMatrix34) | mesh -> SteamVR raw tracking space |
| `/meshData/posePredictionBaseTime` | 7 (f64) | seconds (system uptime clock) |
| `/meshData/meshIsUpToDate` (queue) | 4 (bool) | false in standby / headset off |
- STAGE (= standing universe) = `IVRSystem_023` vtable slot 13 `GetRawZeroPoseToStandingAbsoluteTrackingPose()` x
rawFromMeshPose. Check: the mesh origin is 6.7 cm from `GetDeviceToAbsoluteTrackingPose(Standing)`'s head
position in every frame (camera vs head centre). **The raw->standing transform changes during a session** (its
height went 1.60 -> 1.07 m while recording: Valve's floor estimate) -> capture raw poses and convert all frames
with the transform at the end of the capture.
## The mesh
- A fixed fan of view directions, +Z forward, rows top->bottom (row 0 = up in the image, -Y), ~126 deg across the
corners (corner directions ~(+-0.67, +-0.66, 0.34)); one depth per grid point. Not head-locked in world terms:
its pose is the passthrough camera pose at capture.
- Range: reliable ~0.3-4 m; far values are inpainted (up to ~10 m) -> drop > 4 m. Strongly smoothed (soft edges,
small objects lost); locally planar: plane-fit residual of 12x12 patches median 5-17 mm.
- First frames after tracking starts can have NaN poses -> skip non-finite frames.
- Fused normals (60 s of looking around, ~3 frames/s used): floor clearly at y ~= 0, a table-height surface at
0.7 m, a lower one at 0.5 m; vertical surfaces in two orientation clusters ~85 deg apart (orthogonal walls), wall
offsets readable; ceiling: few down-facing points (people rarely look straight up; prompt for it).
- Data rate: 16896 x 12 B vertices + 99822 x 4 B indices per frame (the dump repeats the indices per frame).
## Consequences for the design
- Capture = stdlib Python on the Frame (ctypes), as above; the C scanner works offline on the dump.
- Scanner input quality: smooth, noisy at depth discontinuities; walls/floor via normals + RANSAC are robust;
furniture only as coarse boxes; ceiling needs a guided "look up" step, else the default height.
-82
View File
@@ -1,82 +0,0 @@
# FramePort room files (mixed reality)
Quest MR games read a room model (floor, ceiling, walls, furniture, a global mesh) through Meta's scene APIs. The
Steam Frame has no such data for apps (see docs/FRAME_RUNTIME.md "Room data"), so FramePort scans the room itself
(`fp_roomscan`) and FrameBridge (`native/adapter/scene_emu.c`) serves it to games. This file is the contract between
the scanner, the Frame agent, the PC app and FrameBridge.
## Coordinates
- Everything is in **STAGE space** = SteamVR's standing universe = OpenXR `XR_REFERENCE_SPACE_TYPE_STAGE`:
metres, right-handed, +Y up, floor at y = 0, -Z = the universe's forward.
- A room belongs to one chaperone universe (`universe` line). The Frame sometimes starts a new universe without
relocalizing; then the stored room may be misaligned (the agent reports `aligned: false`, nothing breaks).
## room.txt (UTF-8 text, one record per line, `#` starts a comment, fields separated by spaces)
```
frameport-room 1 # magic + format version (required, first line)
universe <decimal u64> # chaperone universe id the room was scanned in (0 = unknown)
source scan|boundary|box|manual # how it was made
time <unix seconds>
room <uuid> # the room anchor
floor <uuid> <y> # floor height (normally 0)
ceiling <uuid> <y> # ceiling height
outline <n> x0 z0 x1 z1 ... x(n-1) z(n-1)
# floor polygon, n >= 3, not closed, POSITIVE signed area:
# sum(x[i]*z[i+1] - x[i+1]*z[i]) > 0 (turning from +X towards +Z; seen from
# above with -Z forward that is clockwise). Readers reverse a negative outline.
wall <uuid> x0 z0 x1 z1 <y0> <y1> [LABEL]
# one wall face from (x0,z0) to (x1,z1), bottom y0, top y1.
# Its normal points INTO the room: walls follow the outline's order, and the
# inward normal is (-dz, dx)/|d| for d = (x1-x0, z1-z0).
# LABEL (optional, default WALL_FACE): INVISIBLE_WALL_FACE for an edge the
# scanner closed without seeing a wall (open side of a room, doorway gap).
volume <uuid> <LABEL> cx cz top_y yaw_deg w d h
# furniture box: top-face centre (cx, top_y, cz), yaw about +Y in degrees
# (0 = local X along world X), width w along local X, depth d along the
# horizontal local axis perpendicular to it, height h downwards from the top
plane <uuid> <LABEL> px py pz qx qy qz qw w h
# extra 2D plane (DOOR_FRAME, WINDOW_FRAME, WALL_ART, ...): pose of its centre,
# Meta convention (bounds in local XY, local +Z = the plane's normal)
mesh <uuid> <file name> <vertex count> <index count>
# global mesh sidecar in the same folder (GLOBAL_MESH entity)
```
- `<uuid>` = 32 lowercase hex digits (no dashes). New ones per scan; FrameBridge uses them as the XrUuidEXT bytes.
- Labels are Meta's semantic label strings: FLOOR, CEILING, WALL_FACE, INVISIBLE_WALL_FACE, TABLE, COUCH, BED,
STORAGE, SCREEN, LAMP, PLANT, DOOR_FRAME, WINDOW_FRAME, WALL_ART, OTHER, GLOBAL_MESH.
- Readers ignore unknown record types (forward compatibility) and reject a file whose first line isn't
`frameport-room <version>` with a version they know. Missing ceiling -> 2.5 m above the floor. Missing walls ->
walls from the outline edges. Missing outline -> the bounding rectangle of the walls.
### How FrameBridge maps records to Meta entities (scene_emu.c)
- ROOM: ROOM_LAYOUT (floor, ceiling, walls in file order) + SPACE_CONTAINER (every other entity) + LOCATABLE/STORABLE.
- floor/ceiling: pose at the outline centroid at height y; local +Z = +Y (floor) / -Y (ceiling);
BOUNDED_2D = the outline's bounding rect in local XY; BOUNDARY_2D = the outline polygon in local XY.
- wall: pose at the segment midpoint, height (y0+y1)/2; local +X along the wall (x0,z0)->(x1,z1), local +Y up,
local +Z = inward normal; BOUNDED_2D = length x (y1-y0) centred.
- volume: pose at the top-face centre, rotation = yaw about +Y then local +Z pointing up (+Y); BOUNDED_3D offset
(-w/2, -d/2, -h), extent (w, d, h) (Meta: volumes hang below their anchor); BOUNDED_2D = the top face (w x d).
- plane: pose as given; BOUNDED_2D = w x h centred.
- mesh: entity at the identity pose labelled GLOBAL_MESH with TRIANGLE_MESH (XR_META_spatial_entity_mesh).
## room-mesh.bin (little-endian)
```
char[4] "FPRM", u32 version = 1, u32 vertex_count, u32 index_count,
float32 xyz[vertex_count], u32 indices[index_count] (triangles; counter-clockwise seen from the room's inside)
```
Coordinates in STAGE space. Kept below 200k triangles (decimated).
## Where the files live
- Frame: `~/.local/share/frameport/rooms/<universe>/room.txt` + `room-mesh.bin` (+ `scan.log`).
`~/.local/share/frameport/rooms/current` = symlink to the newest room's folder.
- Per game: launch.sh copies the current universe's room (else `current`) to `<app_dir>/room.txt` (+ mesh) before
Lepton starts, next to `settings.conf`; FrameBridge reads `dirname($FRAMEBRIDGE_CONFIG)/room.txt`. No room ->
the old guardian box.
- A game's own saved anchors: `/sdcard/Android/data/<pkg>/files/framebridge-anchors.txt`
(`anchor <uuid> px py pz qx qy qz qw` per line, STAGE space), written by FrameBridge on save/erase.
## Scan dumps (*.fprs) — recorded RoomView 3D meshes, input of `fp_roomscan --offline`
```
char[4] "FPRS", u32 version = 1, then frames until EOF:
f64 time_s, float32 pose[7] (px py pz qx qy qz qw: mesh -> STAGE), u32 vertex_count, u32 index_count,
float32 xyz[vertex_count] (mesh space), u32 indices[index_count]
```
-216
View File
@@ -1,216 +0,0 @@
# Scene emulation stream
Implemented against `ROOM_FORMAT.md` without changing the contract, patch revisions, agent version or analysis version.
No Frame access or external downloads were used. Headset behavior still needs maintainer verification.
## Implementation
- `scene_emu.c` owns entities, component APIs, pose transforms, bounded query snapshots and the event queue.
`scene_room.c` parses room files and meshes; `scene_meta.c` implements META discovery and batch persistence;
`scene_anchors.c` implements per-game saved anchors. All are included by the adapter, with no new dependencies.
- At session creation, read `dirname(FRAMEBRIDGE_CONFIG)/room.txt`. Invalid magic/version, unusable geometry or
absent files retain the original guardian box, settings and seven deterministic UUIDs. Unknown records are
ignored; malformed records are logged and skipped. Missing ceiling, outline and walls follow the contract.
- Floor/ceiling use the polygon area centroid and real local-XY polygons. Walls retain file order, with local +X
along their segment, +Y up and +Z inward. Furniture poses use yaw then +Z up; volume bounds extend downwards.
The room container includes every other room-file entity, excluding independently created application anchors.
- Geometry exposes BOUNDED_2D, BOUNDED_3D, semantic labels and TRIANGLE_MESH as appropriate. OpenXR has no separate
`BOUNDARY_2D` component enum: `xrGetSpaceBoundary2DFB` belongs to BOUNDED_2D. Labels preserve comma-separated strings.
All variable-sized outputs support count-only and insufficient-capacity calls, including independent mesh arrays.
- Limits: 2,048 entities including application anchors, 1,024 outline points, 255 label bytes, 64 KiB record lines,
one global mesh, 600,000 vertices/indices (200,000 triangles) and 16 MiB mesh payload. Sidecar filenames cannot
escape the config directory. Magic, version, counts, exact byte size, finite coordinates and indices are checked.
- FB queries and META discovery snapshot results under the mutation lock, before queuing available/complete events.
META component/UUID filters intersect; unrecognized filters impose no restriction. FB max-result limits are honored.
There are 64 retained query snapshots and 63 queued events; calls that cannot fit their events return
`XR_ERROR_LIMIT_REACHED`. Geometry readers share the mutation lock with persistence.
- Application anchors use kernel randomness (`getrandom`, then `/dev/urandom`), never index-derived UUIDs.
Saved poses are in STAGE space. FB/META saves write a private temporary file, flush/fsync, then rename to
`/sdcard/Android/data/<package>/files/framebridge-anchors.txt`. A process suffix such as `:game` is stripped.
Erase removes application anchors from disk and the discoverable in-memory set. Failed writes report failure
through completion events and restore memory. Room-file entities remain owned by the scan: save/erase are no-ops
for those entities. Storage is local; there is no cloud synchronization.
- A new session reloads the room and saved anchors. Fake handles fit both adapter ABIs and include a session
generation; erased/stale fake spaces are never passed to SteamVR. Session generations wrap after 256 sessions.
- Capture completes immediately and logs exactly `scene: capture requested -> scanned room` for a usable room file,
or `scene: capture requested -> guardian box` for the fallback. The former covers all file sources, including manual
and boundary files. The load summary preserves the actual `source` and `universe`.
The cached `openxr-f2448a8797` headers already contain discovery, persistence and mesh declarations and enum values.
No local structure/enum substitutes were necessary. Both META extensions are added to the existing conditional
extension advertisement/instance filtering. Their system capability structures report support when emulation is active.
Passthrough and unrelated adapter settings are unchanged.
## Routing and integration
All of the following use `scene_emulation` through **both** the adapter's normal `xrGetInstanceProcAddr` and
`framebridge_extension_proc`, reached through xrshim's existing name-agnostic lookup:
```
xrQuerySpacesFB xrRetrieveSpaceQueryResultsFB
xrGetSpaceUuidFB xrEnumerateSpaceSupportedComponentsFB
xrSetSpaceComponentStatusFB xrGetSpaceComponentStatusFB
xrGetSpaceContainerFB xrGetSpaceRoomLayoutFB
xrGetSpaceBoundingBox2DFB xrGetSpaceBoundingBox3DFB
xrGetSpaceBoundary2DFB xrGetSpaceSemanticLabelsFB
xrRequestSceneCaptureFB xrCreateSpatialAnchorFB
xrSaveSpaceFB xrEraseSpaceFB
xrGetSpaceTriangleMeshMETA xrDiscoverSpacesMETA
xrRetrieveSpaceDiscoveryResultsMETA xrSaveSpacesMETA
xrEraseSpacesMETA
```
The shim additionally routes `xrPollEvent`, `xrGetSystemProperties`, `xrLocateSpace`, `xrLocateSpaces`,
`xrLocateSpacesKHR` and `xrDestroySpace` to the adapter while scene emulation is active. Other shim names continue to
OVRPort, preserving existing haptics/render-model handling. The real shim is host-tested against a dispatcher that
refuses every scene function; every emulated scene lookup succeeds and an actual discovery/retrieve round trip runs.
**Plumb-stream requirement:** ensure `scene_emul=1` also installs xrshim and rewrites OVRPlugin's loader string to it.
The pre-existing adapter patch only did this for other shim settings. These files are outside this stream's ownership.
Delivery of room files before Lepton starts and detection of universe misalignment also belong to plumb. FrameBridge
serves the supplied coordinates; it does not silently transform a room belonging to another universe.
No contract changes are needed. Whether each MRUK version accepts a room without further permissions, how it chooses
volume versus mesh geometry, and whether its visible placement matches the scan need headset tests. The existing
single-active-session architecture is retained; simultaneous independent sessions are not implemented.
## Offline verification
- 25 host tests passed, compiling the actual emulation and shim. Coverage includes malformed records, unknown
records, mesh corruption/path/cap checks, derived geometry, 300 walls, poses/normals, all buffer sizing idioms,
discovery event order/filters/backpressure, UUID query/discovery of saved anchors, new sessions/processes,
FB/META save/erase and atomic-write failure rollback.
- ASan/UBSan: scanned room, generated walls and box cases passed. LeakSanitizer was disabled because this sandbox
prohibits ptrace; its default exit failed with that environmental limitation.
- `python native/build.py --only adapter,xrshim` succeeded from cached tools. Two final builds were byte-identical,
including `SHA256SUMS`. The shim was rebuilt and is byte-identical to its previous artifact (the routing lives in
the adapter). Artifact content changes are the two adapter binaries and two checksum lines.
- Full pytest was attempted: **1,771 passed, 40 skipped, 11 failed, 16 errors**. All 27 failing/erroring tests create
local HTTP sockets, which the sandbox prohibits (`PermissionError: [Errno 1] Operation not permitted`). They are
in `test_deeplink`, `test_devkit`, `test_install_links_extra`, `test_livestream`, `test_setup_url` and `test_ui_state`.
With exactly those 27 tests deselected, and the additional real-shim test included: **1,772 passed, 40 skipped,
27 deselected**. An unrestricted maintainer run of the whole suite is still required.
- The worktree had no usable venv. Verification reused the existing local dependencies through an ignored venv
`.pth`, with `PYTHONPATH="$PWD/src"`, `UV_CACHE_DIR=/tmp/frameport-uv-cache`, `UV_OFFLINE=true` and
`uv run --no-sync pytest`. No dependencies were downloaded. The build cache was made a local directory with
symlinks to cached tools/headers so generated shader files stayed inside this writable worktree.
Adapter SHA-256:
```
arm64-v8a: a90c8d58904857bc198a48fd7a411b7807f68f14fa58fd268c57e2c2ca87e389
armeabi-v7a: 92daecaf5b86e1857ee8a61b76598e5645f8b427e5f373067c1f87bf8ff99d07
```
## Maintainer device tests
These commands are a handoff, not commands run by this agent. Use an isolated populated `FRAMEPORT_HOME`, cached
build tools, a paired Frame and legally obtained game sources. Stop games before changing their files. Back up the
existing recipe, APK, room files and saved anchors first. Integrate plumb before testing normal room delivery.
On the PC, from the integrated checkout (replace `frame.local` if necessary):
```sh
export FRAMEPORT_HOME="$PWD/.home"
export FRAMEPORT_NO_UPDATE_CHECK=1 FRAMEPORT_NO_CATALOG_UPDATE=1
FRAME=steamos@frame.local
PKG=com.Novelab.Demeter
uv run frameport recipe "$PKG" --set scene_emul=1
uv run frameport install "$PKG" --frame "$FRAME" --force
uv run frameport test "$PKG" --frame "$FRAME" --seconds 45
ssh "$FRAME"
```
In that Frame shell, find the deployment's actual base (also supports a game moved to another drive):
```sh
PKG=com.Novelab.Demeter
BASE=$(python3 -c 'import json,os,sys; p=os.path.expanduser("~/Applications/quest-frame/"+sys.argv[1]+"/deployment.json"); print(json.load(open(p)).get("base",os.path.dirname(p)))' "$PKG")
grep -E 'scene:|extension shim:.*(Spaces|TriangleMesh|BoundingBox3D|PollEvent)' "$BASE/launch.log"
```
With a current scan delivered beside `settings.conf`, expect:
```
scene: room from scan file (N walls, M volumes, P planes, K mesh triangles, universe U)
scene: capture requested -> scanned room
```
The capture line appears only if the game requests capture. A source made from a boundary/manual file names that
source in the summary. The shim logs each requested function, for example
`extension shim: xrDiscoverSpacesMETA -> FrameBridge`. There must be no `Unknown proc addr` for a scene function
that the game obtains through the shim. Repeat the rebuild/install/headset check for `com.gritGames.Nope` and
`com.zenstudios.PFXVRQuest` with `scene_emul=1`; these exercise newer MRUK discovery/persistence paths.
Wear the headset and launch from Steam. Enter each game's MR mode and request room setup if offered. Verify the
floor polygon, every wall, ceiling and furniture match the scan; tables sit below their top-face anchors; doors and
windows have the correct orientation. Headless startup alone cannot prove tracking or visual placement.
Fallback/undo test, while stopped: preserve `$BASE/room.txt` and `$BASE/room-mesh.bin` under a separate backup
folder, then disable plumb's room delivery for this run or move the source room out of `rooms/current` as well.
Without that precaution launch.sh will restore the file. Launch again; expect the original box summary and
`scene: capture requested -> guardian box`. Test `scene_width=3 scene_depth=4 scene_height=2.7` with the existing
`frameport settings` command and expect `scene: room 3.00 x 4.00 x 2.70 m`. Restore the saved files, recipe/settings
and normal room delivery afterward. Do not delete a real game's saved anchors to test the fallback.
### Android persistence probe (independent of a game's UI)
This probe uses the **real scene code with the fake runtime from the host harness**. It checks Android storage,
process-name handling and both persistence APIs; the headset tests above check the real SteamVR integration.
The following cross-compilation command was verified offline:
```sh
native/.cache/ndk-27.2.12479018/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android29-clang \
-std=gnu11 -O2 -Wall -Wextra -Werror -Wno-unused-function \
-I native/.cache/openxr-f2448a8797 -I native/adapter tests/native/scene_emu_test.c \
-lm -o /tmp/frameport-scene-test-android
scp /tmp/frameport-scene-test-android "$FRAME":/tmp/
```
Keep a freshly installed game running so its Lepton container exists, then run in the Frame shell:
```sh
APPID=$(python3 -c 'import json,os,sys; print(json.load(open(os.path.expanduser("~/Applications/quest-frame/"+sys.argv[1]+"/deployment.json")))["appid"])' "$PKG")
CONTAINER=lepton-steamlaunch-$APPID
podman cp /tmp/frameport-scene-test-android "$CONTAINER":/data/local/tmp/frameport-scene-test
podman exec "$CONTAINER" test ! -e /sdcard/Android/data/org.frameport.scenetest
# Continue only if that test succeeds: use a fresh test-only package directory.
podman exec "$CONTAINER" mkdir -p /sdcard/Android/data/org.frameport.scenetest/files /data/local/tmp/frameport-scene-probe
for mode in save reload erase-failure reload erase empty; do
podman exec "$CONTAINER" sh -c "FRAMEBRIDGE_CONFIG=/data/local/tmp/frameport-scene-probe/settings.conf exec -a org.frameport.scenetest /data/local/tmp/frameport-scene-test $mode" || break
done
podman exec "$CONTAINER" wc -l /sdcard/Android/data/org.frameport.scenetest/files/framebridge-anchors.txt
```
Every invocation must exit 0 without `FAIL`; logs show `anchors saved`, `loaded 2 saved anchors`, a deliberate
`anchor write failed` during `erase-failure` (then the following reload still finds two), and `anchors erased`.
The final file has zero lines. `save` also recreates a session internally, while separate invocations validate
new-process reload. Undo, before stopping this container:
```sh
podman exec "$CONTAINER" rm -rf /sdcard/Android/data/org.frameport.scenetest /data/local/tmp/frameport-scene-probe
podman exec "$CONTAINER" rm -f /data/local/tmp/frameport-scene-test
rm -f /tmp/frameport-scene-test-android
```
Remove the PC's temporary probe binary too. Restore backed-up game files/settings and install the previous APK if
rolling the feature back. No probe command changes real application anchors.
## Commit handoff under the sandbox
This worktree's real Git metadata is on a read-only mount. Commits therefore live on `mr/emul` in
`/tmp/frameport-mr-emul.git`, with a portable bundle at `/tmp/frameport-mr-emul.bundle`. The actual worktree contains
all final source/artifact edits but its original branch metadata cannot be advanced here. No local instruction,
app-data or credential files were staged. The base is `4bf48d3`.
After reviewing the worktree and verifying it contains exactly these changes, the maintainer can import the commits
and align the existing worktree index without discarding its files:
```sh
git bundle verify /tmp/frameport-mr-emul.bundle
git fetch /tmp/frameport-mr-emul.bundle mr/emul
git log --oneline 4bf48d3..FETCH_HEAD
git reset --mixed FETCH_HEAD
```
Run the unrestricted whole suite before merging. The bundle preserves the ordered implementation/test/build commits.
-171
View File
@@ -1,171 +0,0 @@
# Room plumbing stream
Implemented on `mr/plumb`; no Frame access or device verification. The shared `ROOM_FORMAT.md`, agent version,
analysis version and patch revisions were left unchanged. No native binaries belong to this stream.
## What changed
- `frameport.mr.room`: room/entity dataclasses, v1 text and binary-mesh validation, canonical serialization,
missing ceiling/wall/outline defaults, clockwise outline/wall repair, summaries and top-down SVG previews.
Mesh counts, indices, finite coordinates, UUIDs and sidecar basenames are checked before import.
- Standalone stdlib agent room commands live in one marked block. Rooms are staged under
`~/.local/share/frameport/rooms/<universe>/`; a `current` symlink selects the latest saved room. Uploads validate
before replacing a room. Deleting the current room selects the newest remaining room.
- `room_status` returns `rooms`, current `universe`, selected `room`, `room_text`, `aligned`, `default_circle`
(null when no usable boundary exists) and `scanner_installed`. Summaries include wall/plane counts, furniture
counts by label, floor area and ceiling height. Status text lets the UI preview a room without another transfer.
- Universe detection takes the last matching vrserver log line, then the newest chaperone record if no log match
exists. Dummy universe 424242 means unknown. Boundary conversion chains collision quads' bottom edges and refuses
the default 20-point, roughly 1 m circle. Boundary ceilings default to 2.5 m; SteamVR's 2.43 m wall extrusion
does not measure the real ceiling.
- Finalize records `scene_room` from adapter settings. A marked `_room_sync` launcher line follows the existing
config-sync pattern, before Lepton starts. Launcher upgrades also handle old recipes listing
`adapter.scene_emul`. Matching universe wins over `current`; absent rooms remove stale sidecars. Live setting
changes update the launcher and clear copies when disabled. Non-scene launchers keep their existing layout.
- PC room helpers implement status/import/export/delete/boundary/build/show/capture. Scanner upload reuses the
SHA-256 helper extracted from live view; `fp_venc` retains its soft-failure behavior. Import uploads canonical
text. Export checks mesh counts; build uploads FPRS dumps, runs the scanner and cleans its staging directory.
- CLI: `frameport frame room status|from-boundary|import|export|delete|build|show`, with `--frame` on each command.
`show --output <path.svg>` writes a preview (default below `FRAMEPORT_HOME/rooms`). Build accepts `--universe`.
- Frame page Room card: checklist, asset-URL preview, boundary/delete/capture actions, refresh and recoverable errors.
SSH and preview writes run in a background worker; state is cached per connection. Scene-game pages link to Room
setup when the connected Frame has no room. `ui_smoke.py --fake-frame` supplies local room-command answers.
- Every USE_SCENE/USE_ANCHOR_API game gets the scene-emulation suggestion. Migration `scene_emul_all` enables it once
in automatic recipes, preserving user recipes. NOPE and Pinball FX keep their other recipe fields and now request
scene emulation. Triage explains guardian fallback and missing discovery; patch docs and translation template
were regenerated.
## Integration questions and limits
1. Headset capture is intentionally unfinished: `room_capture` returns `{"error":"not implemented yet"}`. The
`capture_roomview(seconds, out_path)` hook documents the pending RoomView queue work. The GUI explains this and
offers the boundary path. Do not present the capture button as a completed scan workflow yet.
2. This branch has no `fp_roomscan` artifact; merge the scan stream before using `frame room build`. The exact scanner
invocation is `fp_roomscan --offline <dump> --out <temporary directory> --universe U --json`. Stdout must contain
one JSON value, stderr is saved as `scan.log`, and output must include a valid `room.txt` with the requested U.
3. Merge the emulation/shim stream before expecting games to use saved rooms, meshes or new Meta entry points.
Runtime log spelling is owned there; triage accepts both existing `using guardian room`/dimension logs and the
brief's `guardian box`/`room from guardian` messages. Reconcile new success logs at integration.
4. Confirm the chaperone JSON path, exact universe-switch logs and whether collision quads are already in STAGE on
the device. This implementation follows the brief's path and coordinate contract; it does not apply a second
standing-origin transform. A universe match alone cannot detect Valve changing an origin without changing its ID.
5. Agent and desktop perform independent validation because the deployed agent cannot import the desktop package.
The desktop canonicalizes outlines and fills defaults; the agent preserves scanner output and validates it.
The format contract needed no changes.
6. Rooms are shared across games; per-game saved anchors are not deleted or rewritten by these commands. Replacing
a room creates new entity UUIDs, so a game may need its own room-setup reset. No automatic game reset was added.
## Offline validation
All commands used `FRAMEPORT_HOME` in this worktree (tests use temporary isolated homes), an existing dependency
venv and offline/no-sync uv settings. No dependencies or native toolchains were downloaded.
- Room, PC/CLI, UI, heuristics, migration, MR triage and translation checks passed: 63 tests. The related
agent/launcher/screenshots/codec/gamepad regression group passed 170 tests. The final deployment/configuration
regression check passed 48 tests, including all four recipe/settings precedence cases.
- Ruff over changed Python files and `scripts/patch_docs.py --check` passed. The UI detector reported no findings.
`scripts/i18n_extract.py` regenerated `src/frameport/locales/template.json`.
- `pytest -m games`: 3 skipped (no game dumps). Dev/holdout evaluation used only the checked-in demo analysis
snapshot, 21 catalog matches: dev dated 9/17 exact, F1 81.1%; holdout dated 1/4 exact, F1 73.3%. These are limited
fixture results, not a new score for the owner's complete collection; the evaluator exits 1 for recipe differences.
- Final full suite: **1790 passed, 40 skipped, 11 failed, 16 errors** in 87.35 s. All 27 failures/errors are
`PermissionError: [Errno 1] Operation not permitted` when existing tests create sockets. The whole-suite green
requirement cannot be met in this sandbox; rerun `uv run pytest -q` on a host permitting loopback sockets.
Browser UI smoke also requires a loopback server, so it
was not run. New UI tests construct the real Flet controls and exercise deferred preview/error/cache states.
## Exact device checks (maintainer only)
Run these after integrating emul + scan. Substitute the paired Frame address below; keep the current worktree's
`FRAMEPORT_HOME=.home` and use explicit `--frame` so the test does not open another app data directory. The source
connection syncs the agent by digest even without a version bump, unless the Frame has a newer agent version.
The maintainer must perform integration version bumps before testing against a newer installed agent.
```sh
export FRAME='steamos@<frame>'
export PKG='com.gritGames.Nope' # repeat with com.zenstudios.PFXVRQuest and a known scene game
uv run frameport frame info --frame "$FRAME"
uv run frameport frame room status --frame "$FRAME" > .home/mr-status.json
# Back up ALL rooms on the Frame before destructive checks; use an unused backup name.
ssh "$FRAME" 'test ! -e ~/.local/share/frameport/rooms.before-mr-test && { test ! -d ~/.local/share/frameport/rooms || cp -a ~/.local/share/frameport/rooms ~/.local/share/frameport/rooms.before-mr-test; }'
```
1. Inspect status: no room gives `room: null`, `aligned: false`. With Valve's default circle, `default_circle: true`;
`uv run frameport frame room from-boundary --frame "$FRAME"` must fail with the default-circle explanation.
Set a real play area in SteamVR, repeat `from-boundary`: expect source `boundary`, one wall per boundary edge,
correct floor area and `aligned: true`. Open Frame → Room: visible preview, warning for the unscanned room,
aligned row ready; the capture button should show the friendly unavailable message without crashing.
2. Exercise import/export and preview with a manual fixture in the current universe:
```sh
uv run frameport frame room status --frame "$FRAME" > .home/mr-status.json
uv run python - <<'PY'
import json
from pathlib import Path
from frameport.mr.room import Mesh, Volume, box_room, dumps, write_mesh
p = Path('.home/mr-check'); p.mkdir(parents=True, exist_ok=True)
r = box_room(4, 3, 2.5)
r.source = 'manual'
r.universe = json.loads(Path('.home/mr-status.json').read_text())['universe']
r.volumes.append(Volume('a'*32, 'TABLE', .5, 0, .8, 20, 1.2, .7, .8))
r.mesh = Mesh(vertices=[(-2, 0, -1.5), (2, 0, -1.5), (2, 0, 1.5)], indices=[0, 1, 2])
(p/'room.txt').write_text(dumps(r))
(p/'room-mesh.bin').write_bytes(write_mesh(r.mesh))
PY
uv run frameport frame room import .home/mr-check/room.txt --frame "$FRAME"
uv run frameport frame room export .home/mr-export --frame "$FRAME"
uv run frameport frame room show --output .home/mr-check/preview.svg --frame "$FRAME"
cmp .home/mr-check/room-mesh.bin .home/mr-export/room-mesh.bin
```
Expect four walls, one TABLE, area 12 m² and height 2.5 m. The preview must label TABLE and the game must show it
hanging below its top-face anchor. Check the corresponding universe folder and `rooms/current` on the Frame.
3. Use a recorded dump from the same standing universe (do not invent one). Copy it to `.home/mr-check/scan.fprs`,
then run the command below. Expect the scanner JSON under `report`, source `scan`, aligned room data, `scan.log`
on the Frame and an installed executable. Run twice: an unchanged scanner must not upload again.
```sh
uv run frameport frame room build .home/mr-check/scan.fprs --frame "$FRAME"
uv run frameport frame room status --frame "$FRAME"
```
If the recording is from another universe, pass its original `--universe <id>`; do not relabel its coordinates.
4. Prepare the game's source and its existing backed-up signing key in the isolated test library, then force a
rebuild/reinstall after integration: `uv run frameport install "$PKG" --frame "$FRAME" --force --apk-only`.
Repeat for both catalog games. In each deployment, expect `scene_room: true`. Run `frame info` twice to upgrade
old launchers and check one marker only:
```sh
uv run frameport frame info --frame "$FRAME"
uv run frameport frame info --frame "$FRAME"
ssh "$FRAME" "grep -c '# frameport-room-sync' ~/Applications/quest-frame/$PKG/launch.sh"
uv run frameport settings "$PKG" scene_emul=1 --frame "$FRAME"
```
Expected marker count: 1. Start the game with Steam's Play button in the headset, enter its MR mode, then inspect
`<deployment.base>/room-sync.log`: JSON has the selected universe and alignment. The two room files must be
beside `<deployment.base>/settings.conf`. FrameBridge must load that room; `xrDiscoverSpacesMETA` must resolve
through the extension shim, with no `Unknown proc addr`/MRUK `NoRoomsFound`. Exact new native success log strings
should be taken from the emul stream. Verify walls/table placement physically; never infer alignment from FPS.
5. Exit the game. Switch `scene_emul=0` using Game settings (or the settings command above with 0): marker and game
sidecars disappear. Switch it back to 1: one marker returns. Delete the room and launch again: stale copies are
removed and existing FrameBridge guardian fallback logs trigger informational `mr-no-room` triage.
6. To check a universe mismatch, back up/export the room, change its `universe` record to another valid ID and import
it, then delete the original universe's room with `frame room delete --universe <original-id>`. Status must select
`current` but report `aligned: false`; the Room card warns. Restore the original file before playing physically.
The dummy universe 424242 should report current universe 0 rather than claim alignment.
## Undo device checks
Close all games. Restore the backed-up store (or remove only the fixture universes if there was no previous store):
```sh
ssh "$FRAME" 'if test -d ~/.local/share/frameport/rooms.before-mr-test; then rm -rf ~/.local/share/frameport/rooms; mv ~/.local/share/frameport/rooms.before-mr-test ~/.local/share/frameport/rooms; fi'
```
Set each tested game's `scene_emul` back to its recorded original value. To remove a test room without a backup:
`uv run frameport frame room delete --universe <fixture-universe> --frame "$FRAME"`. Start a scene game once to
refresh/remove its copies. The scanner can be removed with
`ssh "$FRAME" 'rm -f ~/.local/share/frameport/bin/fp_roomscan'`; the next build reinstalls it. Game saves and
`framebridge-anchors.txt` are untouched. Restore the previous app/agent/native artifacts through the maintainer's
normal integration rollback if testing the combined native implementation fails.
-338
View File
@@ -1,338 +0,0 @@
# Offline room scanner (scan stream)
`fp_roomscan` converts recorded FPRS v1 meshes to the [room contract](ROOM_FORMAT.md), including the optional
wall label adopted from upstream commit `8cb2783`:
`room.txt` and `room-mesh.bin`. It does not capture RoomView queues, contact a headset, install rooms, or modify
the current-room symlink. Scanner work changes `native/roomscan/`, its build target and artifact/checksum,
`scripts/mr/synth_scan.py`, `tests/test_roomscan.py`, and this note. The upstream contract commit also brings its
existing prompt assets and renderer unchanged. No runtime, agent, patch, or analysis versions were bumped.
## Build and run
Use the cached NDK; no downloads are needed for this target:
```sh
python native/build.py --only roomscan
```
The result is `artifacts/linux-arm64-bin/fp_roomscan`, a static aarch64 Linux ELF with its own `_start`, raw Linux
syscalls, and no interpreter, dynamic section, libc, libm, UAPI headers, generated tables, or new dependencies.
`platform.h` is the reconstruction code's entire I/O/allocation boundary. The same five C sources compile on the
host:
```sh
cc -std=c11 -O2 -Wall -Wextra -Werror -ffp-contract=off -fno-math-errno -DFP_HOSTED native/roomscan/*.c -o /tmp/fp_roomscan
python scripts/mr/synth_scan.py /tmp/room.fprs
/tmp/fp_roomscan --offline /tmp/room.fprs --out /tmp/scanned-room --universe 0 --json
```
The output directory's parent must exist; the scanner creates the final directory if needed. It always prints
exactly one JSON summary, whether `--json` is supplied or omitted. Success exits 0, errors exit 2 with
`{"ok":false,"error":"..."}`. `--help` prints usage and exits 0. Successful summaries contain:
```json
{"ok":true,"frames":30,"skipped_frames":0,"far_vertices":0,"discontinuity_triangles":0,
"walls":4,"detected_walls":4,"volumes":2,"floor_y":-0.0023,
"ceiling_y":2.5992,"coverage_percent":100.0,"hint_used":false,"triangles":70997,
"accepted_triangles":118804,"rejected_triangles":704,"warnings":[],
"timings_ms":{"read":0,"fusion":0,"model":0,"mesh":0,"total":0}}
```
The example above illustrates the schema; actual counts and timings depend on the input. Heights are absolute
STAGE coordinates. `frames` includes skipped frames; `skipped_frames`, `far_vertices`, and
`discontinuity_triangles` count input exclusions once, not once per pass. `walls` counts output polygon
edges including synthesized edges; `detected_walls` counts fitted physical planes. Coverage means the percentage
of the output perimeter supported by observed wall planes, **not camera coverage of all room surfaces**. Timings
include parsing, fusion, structural/furniture modelling, mesh generation, and total time including output.
All frame headers, timestamps/vertices/poses, approximately unit quaternions, counts, indices and EOF boundaries
are checked before publication. A non-finite timestamp/pose/vertex or invalid tracking quaternion skips the entire
frame after consuming and structurally validating its record, with a count and warning. A bad frame never erases
usable frames elsewhere in the dump. Vertices outside the camera-relative range (default 4 m) and every triangle
touching them are discarded. Triangles with a camera-depth jump over `--depth-jump + --depth-ratio * nearest_depth`
are rejected before fusion; this complements long-edge and grazing rejection. Frame counts are capped at
1 million vertices / 3 million indices;
oversized records fail rather than allocating from untrusted counts. A header-only dump or one without usable
triangles fails. Empty frames may accompany useful frames. Structural corruption (truncation, unsupported header,
bad counts or indices) remains fatal, preserves any existing output byte for byte and creates no output directory.
UUIDs come from `getrandom`, fresh per scan; mesh geometry is repeatable.
Both complete files are written to exclusive, randomly named temporary files, then individually renamed, with
the mesh first and `room.txt` last. A write failure removes the temporary files. These are atomic **file**
replacements, not a two-file filesystem transaction: the installer should scan into a fresh staging directory
and publish that directory/symlink after success. Do not run concurrent writers into the same output directory.
There is no fsync-based power-loss durability promise. Neither scan nor build writes application data outside
the worktree's `FRAMEPORT_HOME`; fixtures and test output use temporary directories.
## Reconstruction
1. The first streaming pass validates every frame, rotates its mesh by the supplied quaternion, and translates
it into STAGE. It rejects degenerate triangles, excessively long edges, out-of-range points, and grazing
surfaces. Winding is corrected toward the observing camera. A deterministic bounded reservoir of 32,768
accepted triangle centroids determines median position and padded bounds. No frames are retained across reads.
2. The second pass rasterizes accepted triangles at 0.75 voxel spacing. Per-cell surfels average positions and
inward normals, weighted by area and squared incidence. A capped accumulated weight lets later views correct
earlier ones. The grid is sized from the sample bounds and capped at 8 x 8 x 4 metres about the data median.
Unobserved cells have zero weight. This implementation uses a bounded dense backing array for the sparse
observations, rather than a hash table; the 4.2-million-cell hard cap bounds all later work. Complete regular
fans are recognized from their index topology, without hardcoding a camera resolution. Their normals come from
12 x 12 neighbourhood covariance plane fits, rejecting patches above `--patch-residual` RMS; irregular meshes
retain triangle normals. Each cell counts contributing frames once and needs `--min-views` evidence, capped at
ten percent of usable frames (rounded down, at least one) for brief captures. This suppresses transient stereo
geometry without requiring a short scan to see each surface five times.
3. Horizontal normal/height histograms select the dominant floor within the STAGE floor band and a ceiling above
the minimum ceiling height. Missing floor uses STAGE y=0 with a warning; missing ceiling uses floor plus
`--ceiling-default`, warning exactly `ceiling not seen`. A ceiling must also have at least 15% as many supported
cells as all upward-facing horizontal surfaces, so a few isolated down-facing patches do not invent a ceiling.
4. RANSAC fits near-vertical planes using distance and normal agreement, then refines them by position covariance.
Minimum height, span, and area reject furniture faces. Wall directions within the snap tolerance of the
dominant wall's orthogonal directions are snapped, with offsets refitted. Directed wall segments are connected
by nearest endpoints and adjacent line intersections. This preserves the L-shaped fixture's re-entrant corner.
Parallel same-facing planes within 25 cm / approximately 14° merge, suppressing duplicate softened faces.
Planes below 20% of the strongest original plane's area are discarded. When at least 65% of candidate wall area
supports an orthogonal family, directions outside 12° of that family are discarded (`--snap-degrees 0` disables
this orthogonal consensus filter). Large gaps close along observed endpoints. Ambiguous endpoints with three
or four planes can use inward half-plane intersections; concave rooms retain segment ordering. Otherwise a
warned observed-bounds rectangle is used. Polygon checks reject crossing edges. The output follows the contract's positive
shoelace ordering in (x,z), with left-facing inward normals.
5. Furniture tops are clustered separately from vertical faces, within supported horizontal height bands. A
softened edge connecting a tabletop to a wall can no longer absorb the whole room into one box. Top boxes use
a minimum-area horizontal orientation search and the median surface height; vertical screen candidates use
face-direction harmonics / PCA. Bounds trim the outer 2% to suppress stereo halos. Top boxes extend down to the
floor (the footprint and lower extent remain geometric estimates). Weak/sparse top patches are discarded.
Labels follow height, area, long-side (with two voxels of measurement tolerance),
thinness and near-wall rules, in order BED, COUCH, TABLE, SCREEN, STORAGE, OTHER. Only volumes are emitted;
optional table plane records are not required. Floor/ceiling/wall surfaces are excluded before clustering.
6. Surfel splats form an incidence/area-weighted signed distance field within three voxels of each surface. The
sign is positive in observed free room space. Marching cubes traces zero crossings on cube faces, resolving
ambiguous faces from their centre sign. It fans closed contours and orients triangles toward the positive
gradient. Unknown corners suppress extraction rather than bridging unobserved space. If necessary, extraction
is repeated on a coarser lattice (strides 2–4) until at most 200,000 triangles remain, with a decimation warning;
excess beyond that fails. Vertices are unshared triangle vertices, so at most 600,000 vertices. Structural
planes are semantic anchors, not substituted for the observed global mesh.
Peak allocation stays below 260 MiB, including maximum frame buffers, the grid, model worklists and mesh buffers
(the latter phases do not retain frame buffers). Default synthetic scenes use a much smaller grid. Thresholds
are CLI options below or named constants next to their algorithms. No room-specific coordinates or dimensions are
used; on-device quality and speed still need validation.
## Tuning
Distances are metres, areas m², volumes m³. `--universe` accepts the full unsigned 64-bit range; 0 means unknown.
| Option | Default | Accepted range / meaning |
| --- | ---: | --- |
| `--voxel` | 0.05 | 0.04–0.20, spatial resolution; lower values cost more memory/work |
| `--ceiling-default` | 2.5 | 1.8–3.8, height above floor when ceiling is unseen |
| `--truncation` | 3 | 1–5, TSDF band in voxel units |
| `--max-edge` | 0.6 | 0.05–1.5, reject longer triangle edges |
| `--grazing` | 0.12 | 0.01–0.9, minimum absolute camera/normal cosine |
| `--range` | 4 | 1–10, maximum camera-relative vertex distance; >4 m opts back into inpainted depth |
| `--depth-jump` | 0.25 | 0.02–1, absolute part of a triangle's maximum depth discontinuity |
| `--depth-ratio` | 0.12 | 0–0.5, additional fraction of nearest vertex depth |
| `--min-views` | 5 | 1–16, temporal support target; brief scans cap it as described above |
| `--patch-residual` | 0.03 | 0.005–0.1, maximum RMS residual of a regular-fan normal patch |
| `--plane-distance` | 0.045 | 0.01–0.15, point-to-plane inlier tolerance |
| `--min-plane-area` | 0.45 | 0.1–4, minimum occupied plane area estimate |
| `--wall-height` | 1.5 | 0.5–3, minimum observed vertical wall span |
| `--snap-degrees` | 5 | 0–15, orthogonal snap tolerance |
| `--floor-band` | 0.35 | 0.05–1, floor search distance from STAGE y=0 |
| `--ceiling-min` | 1.8 | 1.5–3, minimum absolute ceiling y |
| `--min-volume` | 0.05 | 0.005–1, reject smaller furniture boxes |
| `--cluster-gap` | 0.10 | 0.04–0.20, voxel neighbourhood gap (rounded up to cells) |
| `--gap` | 0.65 | 0.1–1.5, maximum adjacent-wall endpoint extrapolation |
| `--horizontal` | 0.85 | 0.6–0.99, minimum floor/ceiling/top normal cosine |
Additional constants in `model.c` include RANSAC trials, normal agreement, wall span, furniture margins,
label height/area thresholds, box trimming and circle recognition. Fusion sampling/weight limits are in
`fusion.c`; mesh splat radii/decimation limits in `mesh.c`; hard resource limits in `scan.h`.
`--hint outline.txt` accepts one `x z` point per line, decimal/scientific notation, optional `#` comments, either
winding, at most 96 points / 8 KiB. At least eight points with radial coefficient of variation below 5% count as
the default circle and are ignored. A non-circular hint is used only when walls are incomplete **and every
observed wall has a compatible hint edge** within `--gap`. Complete scans are never constrained to chaperone.
Incompatible inset play boundaries are ignored with a warning. Hint-completed edges retain synthesized status.
## Limits for integration
- Synthesized wall records now append `INVISIBLE_WALL_FACE` as their optional eighth field. Observed walls omit
the label and default to `WALL_FACE`; comments are no longer used to carry semantics. This matches upstream's
authorized contract change exactly; adapters need that same contract revision.
- A private RoomView recording was used for offline robustness work. It, its derived geometry, and its results
stay outside committed files. Synthetic fixtures independently exercise the input characteristics. They are
regressions, not proof
of headset accuracy. Furniture labels are geometric heuristics; adjacent/touching objects can merge, occluded
objects may split, top-only clusters have inferred depth, and thin fragments can be screen candidates. Nearby
parallel walls and small wall jogs can merge under coarse depth uncertainty. Large disconnected coplanar wall segments are
currently fitted together; complicated rooms can fall back to a warned rectangle.
- Cropped or sparse captures can produce an incomplete mesh and synthesized outline. Surfaces are not filled
through unseen regions. Furniture is not an accessibility/safety boundary. Rooms beyond the grid cap are
cropped with a warning. Default-circle recognition may also ignore an intentionally circular boundary.
- Output paths are fixed by the contract. The plumbing stream should stage a new directory and switch `current`
after scanner success, preserving the previous room for rollback. Assign the recording's actual universe id;
this standalone scanner cannot query SteamVR or validate universe alignment.
## Offline validation
`tests/test_roomscan.py` builds with the host compiler (skips only if none exists). Its 41 tests cover the noisy
4 x 3 x 2.6 m room with table/couch, six-wall L, moving and rotated/translated mesh poses, ceiling fallback, a single
frame, missing walls, circular/matching hints, correct UUIDs/wire layout, interior mesh winding, triangle budget,
repeatable mesh bytes with fresh UUIDs, truncated/corrupt dumps, invalid counts/indices/poses and invalid CLI values.
The test module takes about fourteen seconds including synthetic rendering and compilation (under the 60 s budget).
New cases cover non-finite frames before/after usable frames, per-vertex far rejection, depth jumps below the edge
limit, weak ceiling evidence, wall labels and a complete camera-fan scan. The deterministic stdlib renderer now
defaults to a 132 x 128 full index fan (99,822 indices), +Z forward / -Y image up, with poses mapping to STAGE.
It adds separable depth smoothing, spatially correlated noise, far inpainting, two initial NaN poses, 3 Hz timestamps
and head-height drift from 0.9 to 1.7 m. `--profile legacy` retains the smaller 110° x 76° test cone. New CLI controls
are `--profile`, `--invalid-start`, `--smoothing` and optional resolution overrides. Use `--invalid-start 0` for a
usable one-frame fixture; default RoomView warm-up frames otherwise have no valid tracking.
```sh
UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run pytest -q tests/test_roomscan.py
UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run ruff check scripts/mr/synth_scan.py tests/test_roomscan.py
```
The synthetic camera fan passes AddressSanitizer + UndefinedBehaviorSanitizer. Two consecutive final native builds
produce identical SHA-256 `0d88e5adfa1a21da7e0cfec2698a835d7767961e15c0b41c84235fa24ae1e751` (54,248 bytes),
matching `artifacts/SHA256SUMS`. ELF inspection confirms aarch64, little-endian, no PT_INTERP / PT_DYNAMIC, and no
embedded local build paths.
The required full `uv run pytest -q` run completed with **1,788 passed, 40 skipped, 11 failed, 16 errors** in 79 s.
All 27 failures/errors are the sandbox's `PermissionError: [Errno 1] Operation not permitted` on socket creation
in existing HTTP/pairing/live-stream tests (`test_deeplink`, `test_devkit`, `test_install_links_extra`,
`test_livestream`, `test_setup_url`, `test_ui_state`). This environment forbids sockets, including loopback.
The unchanged baseline had the identical 27 failures/errors before the scanner tests were added (1,747 passed).
Consequently the whole-suite-pass requirement cannot be satisfied here; rerun it outside that restriction at
integration. No tests were changed/skipped to hide the restriction and no network/device access was attempted.
No patch/settings/catalog/triage files were touched, so the conditional patch documentation regeneration is
unnecessary.
## Ten-minute capture budget (synthetic benchmark)
The scanner is streaming and single-threaded. On an Intel Core i7-14700K host, a **synthetic-only** 1,800-frame
132 x 128 dump (1,083,751,208 bytes, equivalent to ten minutes at 3 Hz) took **7.41 s elapsed / 7.08 s CPU**, with
**27,208 KiB maximum RSS** at the default voxel size. The file repeats a 30-view synthetic sweep, including its
warm-up frames; it tests parsing/fusion throughput rather than 1,800 independent viewpoints. Geometry-dependent
model/mesh work and storage caches affect timing. No private recording or derived room data is in that benchmark.
Reproduce with bounded copies rather than loading a gigabyte into Python:
```sh
python scripts/mr/synth_scan.py /tmp/roomview.fprs
python - <<'PY'
import shutil
with open('/tmp/roomview.fprs', 'rb') as source, open('/tmp/roomscan-1800.fprs', 'wb') as out:
out.write(source.read(8))
for repeat in range(60):
source.seek(8)
shutil.copyfileobj(source, out, 1024 * 1024)
PY
/usr/bin/time -v /tmp/fp_roomscan --offline /tmp/roomscan-1800.fprs --out /tmp/roomscan-benchmark --json
```
For the Frame's Cortex-A720/X4-class cores, budgeting **3–5 times the host CPU time plus storage** gives roughly
**23–38 s** for this workload, leaving headroom under the requested ~60 s. This is an estimate, not an ARM
measurement or an 8-core speedup claim. Worst-case allocations are bounded below 260 MiB independently of frame
count (48-byte cells, 4.2-million-cell cap, bounded frame buffers/worklists), below both the original 300 MB and
follow-up 600 MB limits. A maximum-sized complex room can add model/mesh work beyond this small synthetic sweep.
The maintainer must time a ten-minute real capture on the Frame and check CPU time, wall time and RSS before
claiming the target is achieved on-device. Hardware square root instructions replace iterative division; neither
build links libm. The build and host command both require `-fno-math-errno` for that intrinsic.
## Exact device checks (maintainer only; none executed by this stream)
1. On the development PC, build, then render the canonical fixtures:
```sh
python native/build.py --only roomscan
python scripts/mr/synth_scan.py /tmp/box.fprs
python scripts/mr/synth_scan.py /tmp/l.fprs --shape l
python scripts/mr/synth_scan.py /tmp/down.fprs --no-ceiling
python scripts/mr/synth_scan.py /tmp/one.fprs --frames 1 --invalid-start 0
scp artifacts/linux-arm64-bin/fp_roomscan /tmp/box.fprs /tmp/l.fprs /tmp/down.fprs /tmp/one.fprs steamos@<FRAME>:~/Downloads/
```
2. In a terminal **on the Frame**, use an isolated directory; no game or current room is changed:
```sh
mkdir -p "$HOME/.local/share/frameport/roomscan-test"
cd "$HOME/.local/share/frameport/roomscan-test"
cp "$HOME/Downloads/fp_roomscan" ./fp_roomscan
chmod 700 ./fp_roomscan
./fp_roomscan --help
./fp_roomscan --offline "$HOME/Downloads/box.fprs" --out box --universe 0 --json > box.json
cat box.json
./fp_roomscan --offline "$HOME/Downloads/l.fprs" --out l --json > l.json
./fp_roomscan --offline "$HOME/Downloads/down.fprs" --out down --ceiling-default 2.7 --json > down.json
./fp_roomscan --offline "$HOME/Downloads/one.fprs" --out one --json > one.json
```
Expected JSON: box has `ok:true`, `walls:4`, `detected_walls:4`, `volumes:2`, floor within 5 cm of 0,
ceiling within 5 cm of 2.6, coverage 100; the only expected box warnings concern skipped warm-up frames and
out-of-range vertices. `box/room.txt` includes TABLE and COUCH volume records.
L has six walls and the concave corner near (0,0), rather than a rectangle. Down includes exactly the warning
string `ceiling not seen` (other warnings are expected), with ceiling minus floor 2.7. One includes
`few frames; scan more viewpoints`. Every mesh should have at most 200,000 triangles. JSON is the scanner log;
there are no expected stderr status lines.
3. Verify sidecars, wall labels and the corrupt-input exit status on the Frame:
```sh
python3 - <<'PY'
import json, pathlib, struct
for name in ('box', 'l', 'down', 'one'):
report = json.loads(pathlib.Path(name + '.json').read_text())
data = pathlib.Path(name, 'room-mesh.bin').read_bytes()
magic, version, nv, ni = struct.unpack_from('<4sIII', data)
assert report['ok'] and magic == b'FPRM' and version == 1
assert ni % 3 == 0 and 0 < ni // 3 <= 200000
assert len(data) == 16 + nv * 12 + ni * 4
for line in pathlib.Path(name, 'room.txt').read_text().splitlines():
if line.startswith('wall '):
fields = line.split()
assert len(fields) in (8, 9)
if len(fields) == 9:
assert fields[-1] == 'INVISIBLE_WALL_FACE'
print(name, nv, ni // 3, report['warnings'])
pathlib.Path('broken.fprs').write_bytes(b'FPRS\x01\0\0\0\0')
PY
./fp_roomscan --offline broken.fprs --out broken --json
echo "$?"
test ! -e broken/room.txt
test ! -e broken/room-mesh.bin
```
Expected: `{"ok":false,"error":"truncated frame header"}`, exit status 2, no `broken` outputs.
4. Record actual RoomView dumps with the maintainer's recorder (FPRS v1, mesh-to-STAGE poses, stable universe).
Copy the recording to `~/Downloads/capture.fprs`. Look around slowly for about a minute, including floor,
ceiling, every wall, and furniture from several directions. With the **recorded universe id** assigned below:
```sh
scan_universe=0 # replace 0 with the recorder's actual decimal universe id
/usr/bin/time -v ./fp_roomscan --offline "$HOME/Downloads/capture.fprs" --out real --universe "$scan_universe" --json > real.json 2> real-timing.log
cat real.json real-timing.log
cat real/room.txt
```
Measure wall offsets/yaws, ceiling and table top physically and compare to the records (target 5 cm / 2°).
Check resident memory below 300 MB, sensible coverage/warnings, tabletop labels and no false wall/furniture
blobs from smeared edges. Repeat with `--max-edge 0.35 --grazing 0.2` into `real-stricter` if edges smear;
try `--voxel 0.08 --plane-distance 0.07` into `real-coarser` if depth is noisy. Keep both JSON logs and raw
dumps **outside git** for threshold calibration. These are proposed tuning probes, not established device fixes.
5. Repeat the synthetic 1,800-frame benchmark from the preceding section on the Frame, using the same command
and `/usr/bin/time -v`. Then test a ten-minute independent real capture. Target: about 60 s elapsed or less,
less than 600 MB RSS, successful JSON and bounded mesh size. Record CPU time as well as wall time so storage
overhead is visible. With difficult stereo edges compare `--min-views 8 --patch-residual 0.02` in a fresh output
directory; short captures cap view support automatically. Include the JSON's exclusion counters in triage;
skipped frames may occur anywhere in the capture. Keep all personal results outside git.
6. After merging plumbing/emulation, publish this staged room through that stream's room-install path and try
Demeter, then an MRUK discovery/mesh game. Check room loading, furniture alignment, and warning/fallback
behaviour in-headset; the scanner alone cannot verify the Meta API path. Do not replace `rooms/current` by hand
during these standalone checks.
Undo standalone checks: remove only the isolated `~/.local/share/frameport/roomscan-test` directory and the five
test files copied to `~/Downloads` (plus `capture.fprs` if no longer needed). No Steam settings, games, agent,
room universe folder, or current-room symlink were changed by these steps.
+5 -32
View File
@@ -348,9 +348,8 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
}
if (supported) names[kept++] = ext;
else if (scene_emul && is_scene_extension(ext)) {
if (!emulate_scene) LOG("emulating Meta scene/spatial-entity extensions from room file or guardian bounds");
if (!emulate_scene) LOG("emulating Meta scene/spatial-entity extensions from the guardian bounds");
emulate_scene = 1;
if (!strcmp(ext, "XR_META_spatial_entity_mesh")) scene_app_mesh = 1;
} else if (controller_models && render_models_available && !strcmp(ext, XR_FB_RENDER_MODEL_EXTENSION_NAME)) {
emulate_render_model = 1;
LOG("emulating XR_FB_render_model with Steam Frame controller models");
@@ -876,11 +875,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetSystemProperties(XrInstance instance, XrSyst
((XrSystemPassthroughPropertiesFB *)p)->supportsPassthrough = XR_TRUE;
if (p->type == XR_TYPE_SYSTEM_SPATIAL_ENTITY_PROPERTIES_FB && emulate_scene)
((XrSystemSpatialEntityPropertiesFB *)p)->supportsSpatialEntity = XR_TRUE;
// META scene capability chains used by current MRUK.
if (p->type == XR_TYPE_SYSTEM_SPACE_DISCOVERY_PROPERTIES_META && emulate_scene)
((XrSystemSpaceDiscoveryPropertiesMETA *)p)->supportsSpaceDiscovery = XR_TRUE;
if (p->type == XR_TYPE_SYSTEM_SPACE_PERSISTENCE_PROPERTIES_META && emulate_scene)
((XrSystemSpacePersistencePropertiesMETA *)p)->supportsSpacePersistence = XR_TRUE;
if (p->type == XR_TYPE_SYSTEM_PASSTHROUGH_PROPERTIES2_FB)
((XrSystemPassthroughProperties2FB *)p)->capabilities = XR_PASSTHROUGH_CAPABILITY_BIT_FB;
if (p->type == XR_TYPE_SYSTEM_RENDER_MODEL_PROPERTIES_FB && emulate_render_model)
@@ -927,10 +921,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpace(XrSpace space, XrSpace baseSpace, X
pose_time_note("xrLocateSpace", (uint64_t)(uintptr_t)space, (uint64_t)(uintptr_t)baseSpace, time);
if (emulate_scene && location) {
XrPosef pose; XrSpaceLocationFlags flags;
pthread_mutex_lock(&scene_lock);
int fake = locate_fake(space, baseSpace, time, &pose, &flags);
pthread_mutex_unlock(&scene_lock);
if (fake) {
if (locate_fake(space, baseSpace, time, &pose, &flags)) {
location->pose = pose;
location->locationFlags = flags;
return XR_SUCCESS;
@@ -952,8 +943,8 @@ static XrResult locate_spaces_common(const char *name, XrSession session, const
if (info) pose_time_note(name, info->spaceCount ? (uint64_t)(uintptr_t)info->spaces[0] : 0,
(uint64_t)(uintptr_t)info->baseSpace, info->time);
if (emulate_scene && info && locations) {
any_fake = is_fake_space(info->baseSpace);
for (uint32_t i = 0; i < info->spaceCount && !any_fake; ++i) any_fake = is_fake_space(info->spaces[i]);
any_fake = fake_space_index(info->baseSpace) >= 0;
for (uint32_t i = 0; i < info->spaceCount && !any_fake; ++i) any_fake = fake_space_index(info->spaces[i]) >= 0;
}
if (!any_fake) {
PFN_xrLocateSpaces fn = (PFN_xrLocateSpaces)lookup(active_instance, name);
@@ -976,12 +967,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_locate_spaces_khr(XrSession session, c
}
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace space) {
if (is_fake_space(space)) {
pthread_mutex_lock(&scene_lock);
int valid = fake_space_index(space) >= 0;
pthread_mutex_unlock(&scene_lock);
return valid ? XR_SUCCESS : XR_ERROR_HANDLE_INVALID; // entity spaces live for the whole session
}
if (fake_space_index(space) >= 0) return XR_SUCCESS; // entity spaces live for the whole session
PFN_xrDestroySpace fn = (PFN_xrDestroySpace)lookup(active_instance, "xrDestroySpace");
return fn ? fn(space) : XR_ERROR_FUNCTION_UNSUPPORTED;
}
@@ -2123,19 +2109,6 @@ __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;
}
return name ? render_model_emulation(name) : NULL;
}
-115
View File
@@ -1,115 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by scene_emu.c. Persist only application anchors; scanned entities remain owned by the room file.
#include <sys/random.h>
#include <sys/stat.h>
#include <unistd.h>
#ifndef SCENE_EXTERNAL_ROOT
#define SCENE_EXTERNAL_ROOT "/sdcard/Android/data"
#endif
static int anchor_path(char *path, size_t size) {
char process[256] = {0};
FILE *f = fopen("/proc/self/cmdline", "r");
if (!f) return 0;
size_t n = fread(process, 1, sizeof(process)-1, f);
fclose(f);
if (!n) return 0;
char *colon = strchr(process, ':');
if (colon) *colon = 0;
// Only package-like process names: never let a command line become a path traversal.
if (!strchr(process, '.') || !strcmp(process, ".") || !strcmp(process, "..")) return 0;
for (const char *p = process; *p; ++p)
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') || *p == '.' || *p == '_')) return 0;
int len = snprintf(path, size, "%s/%s/files/framebridge-anchors.txt", SCENE_EXTERNAL_ROOT, process);
return len > 0 && (size_t)len < size;
}
static int anchor_uuid(XrUuidEXT *u) {
size_t have = 0;
while (have < sizeof(u->data)) {
ssize_t n = getrandom(u->data + have, sizeof(u->data) - have, 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
have += (size_t)n;
}
if (have < sizeof(u->data)) {
FILE *f = fopen("/dev/urandom", "rb");
if (!f) return 0;
have = fread(u->data, 1, sizeof(u->data), f);
fclose(f);
if (have != sizeof(u->data)) return 0;
}
u->data[6] = (uint8_t)((u->data[6] & 15) | 0x40);
u->data[8] = (uint8_t)((u->data[8] & 63) | 0x80);
for (int i = 0; i < entity_count; ++i) if (uuid_equal(u, &entities[i].uuid)) return 0;
return 1;
}
static int write_anchors(void) {
char path[4096], tmp[4128];
if (!anchor_path(path, sizeof(path))) return 0;
snprintf(tmp, sizeof(tmp), "%s.tmp-XXXXXX", path);
int fd = mkstemp(tmp);
if (fd < 0) return 0;
FILE *f = fdopen(fd, "w");
if (!f) { close(fd); unlink(tmp); return 0; }
int ok = 1;
for (int i = 0; i < entity_count; ++i) {
const scene_entity *e = &entities[i];
if (!e->is_anchor || !e->saved || e->erased) continue;
char uuid[33];
for (int k = 0; k < 16; ++k) snprintf(uuid + k*2, 3, "%02x", e->uuid.data[k]);
const XrPosef p = e->pose_in_stage;
if (fprintf(f, "anchor %s %.9g %.9g %.9g %.9g %.9g %.9g %.9g\n", uuid,
p.position.x, p.position.y, p.position.z, p.orientation.x, p.orientation.y, p.orientation.z, p.orientation.w) < 0) ok = 0;
}
if (fflush(f) || fsync(fd)) ok = 0;
if (fclose(f)) ok = 0;
if (ok && rename(tmp, path)) ok = 0;
if (!ok) { LOG("scene: anchor write failed (%d)", errno); unlink(tmp); }
return ok;
}
static void load_anchors(void) {
char path[4096];
if (!anchor_path(path, sizeof(path))) return;
FILE *f = fopen(path, "r");
if (!f) return;
char line[1024], *t[10];
unsigned loaded = 0;
while (fgets(line, sizeof(line), f)) {
if (!strchr(line, '\n') && !feof(f)) { int c; while ((c=fgetc(f)) != EOF && c != '\n') {} continue; }
XrUuidEXT uuid; float v[7];
int n = room_tokens(line, t, 10);
if (n != 9 || strcmp(t[0], "anchor") || !room_uuid(t[1], &uuid) || !room_values(t, 2, 7, v)) continue;
float norm = sqrtf(v[3]*v[3]+v[4]*v[4]+v[5]*v[5]+v[6]*v[6]);
if (norm < 0.0001f) continue;
int duplicate = 0;
for (int i = 0; i < entity_count; ++i) duplicate |= uuid_equal(&uuid, &entities[i].uuid);
if (duplicate) continue;
int i = add_entity((XrPosef){{v[3]/norm,v[4]/norm,v[5]/norm,v[6]/norm},{v[0],v[1],v[2]}}, NULL, 0, 0, 0, 1);
if (i < 0) break;
entities[i].uuid = uuid; entities[i].saved = 1; ++loaded;
}
fclose(f);
LOG("scene: loaded %u saved anchors", loaded);
}
// Batch writes are atomic both on disk and in memory. Failure never silently destroys the old anchor set.
static XrResult persist_spaces(uint32_t count, const XrSpace *spaces, uint32_t uuid_count, const XrUuidEXT *uuids, int erase) {
unsigned char selected[MAX_ENTITIES] = {0}, old_saved[MAX_ENTITIES], old_erased[MAX_ENTITIES];
for (uint32_t k = 0; k < count; ++k) {
int i = fake_space_index(spaces[k]);
if (i < 0) return XR_ERROR_HANDLE_INVALID;
if (entities[i].is_anchor) selected[i] = 1;
}
if (erase) for (int i = 0; i < entity_count; ++i)
if (entities[i].is_anchor && !entities[i].erased && uuid_matches(uuid_count, uuids, &entities[i])) selected[i] = 1;
int changed = 0;
for (int i = 0; i < entity_count; ++i) {
old_saved[i] = (unsigned char)entities[i].saved; old_erased[i] = (unsigned char)entities[i].erased;
if (selected[i]) { entities[i].saved = !erase; entities[i].erased = erase; changed = 1; }
}
if (changed && !write_anchors()) {
for (int i = 0; i < entity_count; ++i) { entities[i].saved = old_saved[i]; entities[i].erased = old_erased[i]; }
return XR_ERROR_RUNTIME_FAILURE;
}
if (changed) LOG("scene: anchors %s", erase ? "erased" : "saved");
return XR_SUCCESS;
}
+117 -362
View File
@@ -1,18 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by frame_adapter.c. Emulates Meta's scene / spatial-entity extensions (XR_FB_spatial_entity,
// _query, _storage, _container, XR_FB_scene, XR_FB_scene_capture) for mixed-reality apps on runtimes that
// lack them (Steam Frame). Room files use STAGE coordinates; absent/invalid files retain the guardian box.
// lack them (Steam Frame). The "room" is a box built from the runtime's STAGE bounds (the guardian /
// chaperone rectangle): floor, ceiling and four walls, reported as one room anchor with a layout.
// Entity spaces are fake handles; every function that consumes a space is intercepted for them.
static float scene_min_size = 1.5f; // smallest room edge if the guardian is tiny or unavailable
static int emulate_scene; // app requested the scene extensions and the runtime lacks them
static int scene_app_mesh; // app enabled XR_META_spatial_entity_mesh: only then is the global mesh an entity
// (an older SDK's scene loader fails on an anchor it can't place)
static const char *const scene_extensions[] = {
"XR_FB_spatial_entity", "XR_FB_spatial_entity_query", "XR_FB_spatial_entity_storage",
"XR_FB_spatial_entity_container", "XR_FB_scene", "XR_FB_scene_capture",
"XR_META_spatial_entity_mesh", "XR_META_spatial_entity_discovery", "XR_META_spatial_entity_persistence",
};
#define SCENE_EXTENSION_COUNT (sizeof(scene_extensions) / sizeof(scene_extensions[0]))
@@ -44,58 +42,38 @@ static XrPosef pose_inv(XrPosef a) {
// ---------------------------------------------------------------- entities
enum { ENT_ROOM, ENT_FLOOR, ENT_CEILING, ENT_WALL0, ENT_WALL1, ENT_WALL2, ENT_WALL3, ENT_FIXED_COUNT };
#define MAX_ENTITIES 2048
#define MAX_OUTLINE 1024
#define MAX_ENTITIES 64
typedef struct {
XrSpace handle; // fake handle given to the app
XrUuidEXT uuid;
XrPosef pose_in_stage;
char label[256]; // comma-separated Meta semantic labels
const char *label; // semantic label, NULL if none
XrRect2Df bounds; // 2D bounds in the entity's local XY plane
XrRect3DfFB bounds3;
XrVector2f *boundary;
uint32_t boundary_count;
XrVector3f *vertices;
uint32_t *indices, vertex_count, index_count;
int has_bounds, has_bounds3, is_room, is_anchor, is_wall, erased, saved;
int has_bounds, is_room, is_anchor;
} scene_entity;
static pthread_mutex_t scene_lock = PTHREAD_MUTEX_INITIALIZER;
static scene_entity entities[MAX_ENTITIES];
static int entity_count, room_entity_count, wall_count, room_from_file;
static char room_source[32];
static uint64_t room_universe;
static int entity_count;
static XrSpace scene_stage = XR_NULL_HANDLE; // real STAGE space used to anchor everything
static XrSession scene_session = XR_NULL_HANDLE;
static uint64_t next_request = 0x5ce0000001ull;
// Fit the tag in uintptr_t as the adapter is also built for AArch32. Session generations prevent stale
// handles from aliasing new room entities when an engine recreates its session in the same process.
#if UINTPTR_MAX > 0xffffffffu
#define FAKE_SPACE_TAG ((uintptr_t)0x5ce0000000000000ull)
#define FAKE_SPACE_MASK ((uintptr_t)0xfff0000000000000ull)
#else
#define FAKE_SPACE_TAG ((uintptr_t)0x5ce00000u)
#define FAKE_SPACE_MASK ((uintptr_t)0xfff00000u)
#endif
static unsigned scene_generation;
static int is_fake_space(XrSpace space) { return ((uintptr_t)space & FAKE_SPACE_MASK) == FAKE_SPACE_TAG; }
#define FAKE_SPACE_TAG 0x5ce0000000000000ull
static int fake_space_index(XrSpace space) {
uintptr_t v = (uintptr_t)space;
if ((v & FAKE_SPACE_MASK) != FAKE_SPACE_TAG || ((v >> 12) & 255) != scene_generation) return -1;
int i = (int)(v & 4095);
return i < entity_count && !entities[i].erased ? i : -1;
uint64_t v = (uint64_t)(uintptr_t)space;
if ((v & 0xfff0000000000000ull) != FAKE_SPACE_TAG) return -1;
int i = (int)(v & 0xffff);
return i < entity_count ? i : -1;
}
static XrSpace fake_space(int index) { return (XrSpace)(FAKE_SPACE_TAG | ((uintptr_t)scene_generation << 12) | (uintptr_t)index); }
static XrSpace fake_space(int index) { return (XrSpace)(uintptr_t)(FAKE_SPACE_TAG | (uint64_t)index); }
static XrUuidEXT make_uuid(int index) {
XrUuidEXT u;
for (int i = 0; i < XR_UUID_SIZE_EXT; ++i) u.data[i] = (uint8_t)(0x5c ^ (i * 37) ^ (index * 101));
u.data[6] = (uint8_t)((u.data[6] & 0x0f) | 0x40); // version 4 style
u.data[15] = (uint8_t)index;
u.data[14] ^= (uint8_t)((unsigned)index >> 8);
return u;
}
static int uuid_equal(const XrUuidEXT *a, const XrUuidEXT *b) { return !memcmp(a->data, b->data, XR_UUID_SIZE_EXT); }
@@ -108,7 +86,7 @@ static int add_entity(XrPosef pose, const char *label, float w, float h, int is_
e->handle = fake_space(i);
e->uuid = make_uuid(i);
e->pose_in_stage = pose;
if (label) snprintf(e->label, sizeof(e->label), "%s", label);
e->label = label;
e->has_bounds = w > 0 && h > 0;
e->bounds = (XrRect2Df){{-w / 2, -h / 2}, {w, h}};
e->is_room = is_room;
@@ -116,23 +94,9 @@ static int add_entity(XrPosef pose, const char *label, float w, float h, int is_
return i;
}
static void reset_room(void) {
for (int i = 0; i < entity_count; ++i) {
free(entities[i].boundary);
free(entities[i].vertices);
free(entities[i].indices);
}
memset(entities, 0, sizeof(entities));
entity_count = room_entity_count = wall_count = room_from_file = 0;
room_universe = 0;
}
#include "scene_room.c"
// Build the room once the session exists: guardian rectangle -> floor/ceiling/walls.
static void build_room(void) {
if (entity_count) return;
if (load_room_file()) return;
float width = 0, depth = 0;
PFN_xrGetReferenceSpaceBoundsRect bounds_fn =
(PFN_xrGetReferenceSpaceBoundsRect)lookup(active_instance, "xrGetReferenceSpaceBoundsRect");
@@ -153,14 +117,10 @@ static void build_room(void) {
add_entity((XrPosef){{0, 0, 0, 1}, {0, 0, 0}}, NULL, 0, 0, 1, 0); // ENT_ROOM
add_entity((XrPosef){{-s, 0, 0, s}, {0, 0, 0}}, "FLOOR", width, depth, 0, 0); // +Z (normal) -> +Y
add_entity((XrPosef){{s, 0, 0, s}, {0, h, 0}}, "CEILING", width, depth, 0, 0); // +Z -> -Y
// Walls in loop order around the floor, as Meta's ROOM_LAYOUT lists them (Demeter walks them as an outline).
add_entity((XrPosef){{0, 0, 0, 1}, {0, h / 2, -depth / 2}}, "WALL_FACE", width, h, 0, 0); // faces +Z
add_entity((XrPosef){{0, -s, 0, s}, {width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces -X
add_entity((XrPosef){{0, 1, 0, 0}, {0, h / 2, depth / 2}}, "WALL_FACE", width, h, 0, 0); // faces -Z
add_entity((XrPosef){{0, s, 0, s}, {-width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces +X
wall_count = 4;
room_entity_count = entity_count;
for (int i = ENT_WALL0; i < entity_count; ++i) entities[i].is_wall = 1;
add_entity((XrPosef){{0, -s, 0, s}, {width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces -X
}
// Supported components per entity.
@@ -168,17 +128,12 @@ static int entity_components(const scene_entity *e, XrSpaceComponentTypeFB *out)
int n = 0;
out[n++] = XR_SPACE_COMPONENT_TYPE_LOCATABLE_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_STORABLE_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SHARABLE_FB; // Meta's runtime lists it; nothing is shared from here
if (e->is_room) {
out[n++] = XR_SPACE_COMPONENT_TYPE_ROOM_LAYOUT_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SPACE_CONTAINER_FB;
} else if (!e->is_anchor) {
if (e->has_bounds) {
out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB;
}
if (e->has_bounds3) out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_3D_FB;
if (e->vertex_count) out[n++] = XR_SPACE_COMPONENT_TYPE_TRIANGLE_MESH_META;
if (e->label[0]) out[n++] = XR_SPACE_COMPONENT_TYPE_SEMANTIC_LABELS_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SEMANTIC_LABELS_FB;
}
return n;
}
@@ -193,7 +148,6 @@ static int entity_has_component(const scene_entity *e, XrSpaceComponentTypeFB ty
#define MAX_EVENTS 64
static XrEventDataBuffer scene_events[MAX_EVENTS];
static int event_head, event_tail;
static int event_free(void) { return (event_head - event_tail - 1 + MAX_EVENTS) % MAX_EVENTS; }
static void push_event(const void *event, size_t size) {
if ((event_tail + 1) % MAX_EVENTS == event_head) return;
memset(&scene_events[event_tail], 0, sizeof(XrEventDataBuffer));
@@ -217,7 +171,6 @@ static int stage_pose(XrSpace space, XrTime time, XrPosef *pose, XrSpaceLocation
XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT | XR_SPACE_LOCATION_POSITION_TRACKED_BIT;
return 1;
}
if (is_fake_space(space)) return 0; // erased or from a prior session: never pass a fake handle to SteamVR
PFN_xrLocateSpace locate = (PFN_xrLocateSpace)lookup(active_instance, "xrLocateSpace");
if (!locate || !scene_stage) return 0;
XrSpaceLocation loc = {XR_TYPE_SPACE_LOCATION, NULL, 0, {{0, 0, 0, 1}, {0, 0, 0}}};
@@ -228,7 +181,7 @@ static int stage_pose(XrSpace space, XrTime time, XrPosef *pose, XrSpaceLocation
}
// Locate `space` in `base` when either is fake. Returns 0 if neither is fake (caller forwards).
static int locate_fake(XrSpace space, XrSpace base, XrTime time, XrPosef *pose, XrSpaceLocationFlags *flags) {
if (!is_fake_space(space) && !is_fake_space(base)) return 0;
if (fake_space_index(space) < 0 && fake_space_index(base) < 0) return 0;
XrPosef s, b;
XrSpaceLocationFlags fs = 0, fb = 0;
if (!stage_pose(space, time, &s, &fs) || !stage_pose(base, time, &b, &fb)) {
@@ -242,96 +195,99 @@ static int locate_fake(XrSpace space, XrSpace base, XrTime time, XrPosef *pose,
}
// ---------------------------------------------------------------- emulated entry points
// Results are snapshotted before their available event is queued, so callers on another thread cannot
// observe a half-stored filter. A bounded request history supports the count/data calls independently.
#define MAX_QUERIES 64
typedef struct {
XrAsyncRequestIdFB id;
uint32_t count;
XrSpaceQueryResultFB *results;
} scene_query;
static scene_query queries[MAX_QUERIES];
static unsigned query_next;
static scene_query *new_query(void) {
scene_query *q = &queries[query_next % MAX_QUERIES];
XrSpaceQueryResultFB *results = malloc((size_t)entity_count * sizeof(*results));
if (!results) return NULL;
++query_next;
free(q->results);
*q = (scene_query){next_request++, 0, results};
return q;
}
static scene_query *find_query(XrAsyncRequestIdFB id) {
for (int i = 0; i < MAX_QUERIES; ++i) if (queries[i].id == id) return &queries[i];
return NULL;
}
static int uuid_matches(uint32_t n, const XrUuidEXT *uuids, const scene_entity *e) {
for (uint32_t i = 0; i < n; ++i) if (uuid_equal(&uuids[i], &e->uuid)) return 1;
return 0;
}
#include "scene_anchors.c"
static int fb_filter_matches(const XrSpaceFilterInfoBaseHeaderFB *f, const scene_entity *e) {
if (!f) return 1;
if (f->type == XR_TYPE_SPACE_COMPONENT_FILTER_INFO_FB)
return entity_has_component(e, ((const XrSpaceComponentFilterInfoFB *)f)->componentType);
if (f->type == XR_TYPE_SPACE_UUID_FILTER_INFO_FB) {
const XrSpaceUuidFilterInfoFB *u = (const XrSpaceUuidFilterInfoFB *)f;
return u->uuids && uuid_matches(u->uuidCount, u->uuids, e);
}
return 1;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces_filtered(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
if (!info || !request || info->type != XR_TYPE_SPACE_QUERY_INFO_FB) return XR_ERROR_VALIDATION_FAILURE;
const XrSpaceQueryInfoFB *info_fb = (const XrSpaceQueryInfoFB *)info;
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
scene_session = session;
build_room();
if (event_free() < 2) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
scene_query *q = new_query();
if (!q) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_OUT_OF_MEMORY; }
for (int i = 0; i < entity_count && q->count < info_fb->maxResultCount; ++i) {
scene_entity *e = &entities[i];
if (!e->erased && fb_filter_matches(info_fb->filter, e))
q->results[q->count++] = (XrSpaceQueryResultFB){e->handle, e->uuid};
}
*request = q->id;
*request = next_request++;
XrEventDataSpaceQueryResultsAvailableFB avail = {XR_TYPE_EVENT_DATA_SPACE_QUERY_RESULTS_AVAILABLE_FB, NULL, *request};
XrEventDataSpaceQueryCompleteFB done = {XR_TYPE_EVENT_DATA_SPACE_QUERY_COMPLETE_FB, NULL, *request, XR_SUCCESS};
push_event(&avail, sizeof(avail)); push_event(&done, sizeof(done));
LOG("scene: query request=%llu results=%u", (unsigned long long)*request, q->count);
push_event(&avail, sizeof(avail));
push_event(&done, sizeof(done));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
// Query filters are kept per request so retrieve can apply them.
typedef struct {
XrAsyncRequestIdFB id;
int has_component;
XrSpaceComponentTypeFB component;
uint32_t uuid_count;
XrUuidEXT uuids[64];
} query_filter;
static query_filter filters[16];
static int filter_next;
static void store_filter(XrAsyncRequestIdFB id, const XrSpaceQueryInfoFB *q) {
query_filter *f = &filters[filter_next++ % 16];
memset(f, 0, sizeof(*f));
f->id = id;
const XrSpaceFilterInfoBaseHeaderFB *flt = q->filter;
if (flt && flt->type == XR_TYPE_SPACE_COMPONENT_FILTER_INFO_FB) {
f->has_component = 1;
f->component = ((const XrSpaceComponentFilterInfoFB *)flt)->componentType;
} else if (flt && flt->type == XR_TYPE_SPACE_UUID_FILTER_INFO_FB) {
const XrSpaceUuidFilterInfoFB *u = (const XrSpaceUuidFilterInfoFB *)flt;
f->uuid_count = u->uuidCount < 64 ? u->uuidCount : 64;
for (uint32_t i = 0; i < f->uuid_count; ++i) f->uuids[i] = u->uuids[i];
}
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces_filtered(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
XrResult r = emu_query_spaces(session, info, request);
if (XR_SUCCEEDED(r) && info->type == XR_TYPE_SPACE_QUERY_INFO_FB) {
pthread_mutex_lock(&scene_lock);
store_filter(*request, (const XrSpaceQueryInfoFB *)info);
const XrSpaceQueryInfoFB *q = (const XrSpaceQueryInfoFB *)info;
LOG("scene: query request=%llu filter=%d", (unsigned long long)*request, q->filter ? q->filter->type : 0);
pthread_mutex_unlock(&scene_lock);
}
return r;
}
static int filter_matches(const query_filter *f, const scene_entity *e) {
if (!f) return 1;
if (f->has_component) return entity_has_component(e, f->component);
if (f->uuid_count) {
for (uint32_t i = 0; i < f->uuid_count; ++i) if (uuid_equal(&f->uuids[i], &e->uuid)) return 1;
return 0;
}
return 1;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_retrieve_results(XrSession session, XrAsyncRequestIdFB request,
XrSpaceQueryResultsFB *results) {
(void)session;
if (!results) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
scene_query *q = find_query(request);
XrResult r = XR_SUCCESS;
if (!q) r = XR_ERROR_VALIDATION_FAILURE;
else {
results->resultCountOutput = q->count;
if (results->resultCapacityInput && results->resultCapacityInput < q->count) r = XR_ERROR_SIZE_INSUFFICIENT;
else if (results->resultCapacityInput) {
if (!results->results) r = XR_ERROR_VALIDATION_FAILURE;
else memcpy(results->results, q->results, q->count * sizeof(*q->results));
const query_filter *f = NULL;
for (int i = 0; i < 16; ++i) if (filters[i].id == request) f = &filters[i];
uint32_t n = 0;
for (int i = 0; i < entity_count; ++i) {
if (!filter_matches(f, &entities[i])) continue;
if (results->resultCapacityInput && n < results->resultCapacityInput && results->results) {
results->results[n].space = entities[i].handle;
results->results[n].uuid = entities[i].uuid;
}
++n;
}
results->resultCountOutput = n;
pthread_mutex_unlock(&scene_lock);
return r;
return (results->resultCapacityInput && results->resultCapacityInput < n) ? XR_ERROR_SIZE_INSUFFICIENT : XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_uuid(XrSpace space, XrUuidEXT *uuid) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_uuid(XrSpace space, XrUuidEXT *uuid) {
int i = fake_space_index(space);
if (i < 0 || !uuid) return XR_ERROR_HANDLE_INVALID;
*uuid = entities[i].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count,
static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count,
XrSpaceComponentTypeFB *types) {
int i = fake_space_index(space);
if (i < 0 || !count) return XR_ERROR_HANDLE_INVALID;
@@ -340,33 +296,16 @@ static XRAPI_ATTR XrResult XRAPI_CALL scene_enumerate_components(XrSpace space,
*count = (uint32_t)n;
if (!capacity) return XR_SUCCESS;
if (capacity < (uint32_t)n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!types) return XR_ERROR_VALIDATION_FAILURE;
for (int k = 0; k < n; ++k) types[k] = list[k];
return XR_SUCCESS;
}
// Which entity refused which component (the first ones only): an app's scene loader gives up on the whole room over
// one of these, and OVRPlugin's own error line doesn't name the component (Demeter, 2026-10-16).
static void log_refused_component(const char *op, int i, XrSpaceComponentTypeFB type) {
static int logged;
if (logged >= 20) return;
++logged;
const scene_entity *e = &entities[i];
LOG("scene: %s component %d refused on %s (entity %d)", op, (int)type,
e->is_room ? "room" : e->is_anchor ? "app anchor" : e->label[0] ? e->label : "unlabelled", i);
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_set_component_status(XrSpace space, const XrSpaceComponentStatusSetInfoFB *info,
XrAsyncRequestIdFB *request) {
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(space);
if (i < 0 || !info || !request) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!entity_has_component(&entities[i], info->componentType)) {
log_refused_component("set", i, info->componentType);
pthread_mutex_unlock(&scene_lock);
return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
}
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
if (i < 0 || !info || !request) return XR_ERROR_HANDLE_INVALID;
if (!entity_has_component(&entities[i], info->componentType)) return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
pthread_mutex_lock(&scene_lock);
*request = next_request++;
XrEventDataSpaceSetStatusCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SET_STATUS_COMPLETE_FB, NULL, *request, XR_SUCCESS,
space, entities[i].uuid, info->componentType, info->enabled};
@@ -375,49 +314,44 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_set_component_status(XrSpace space, co
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_component_status(XrSpace space, XrSpaceComponentTypeFB type,
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_component_status(XrSpace space, XrSpaceComponentTypeFB type,
XrSpaceComponentStatusFB *status) {
int i = fake_space_index(space);
if (i < 0 || !status) return XR_ERROR_HANDLE_INVALID;
if (!entity_has_component(&entities[i], type)) {
log_refused_component("get", i, type);
return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
}
if (!entity_has_component(&entities[i], type)) return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
status->enabled = XR_TRUE;
status->changePending = XR_FALSE;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !container) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].is_room) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
uint32_t n = room_entity_count - 1; // everything except the room itself
uint32_t n = ENT_FIXED_COUNT - 1; // everything except the room itself
container->uuidCountOutput = n;
if (!container->uuidCapacityInput) return XR_SUCCESS;
if (container->uuidCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!container->uuids) return XR_ERROR_VALIDATION_FAILURE;
for (uint32_t k = 0; k < n; ++k) container->uuids[k] = entities[ENT_FLOOR + k].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !layout) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].is_room) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
layout->floorUuid = entities[ENT_FLOOR].uuid;
layout->ceilingUuid = entities[ENT_CEILING].uuid;
layout->wallUuidCountOutput = (uint32_t)wall_count;
layout->wallUuidCountOutput = 4;
if (!layout->wallUuidCapacityInput) return XR_SUCCESS;
if (layout->wallUuidCapacityInput < (uint32_t)wall_count) return XR_ERROR_SIZE_INSUFFICIENT;
if (!layout->wallUuids) return XR_ERROR_VALIDATION_FAILURE;
for (int k = 0; k < wall_count; ++k) layout->wallUuids[k] = entities[ENT_WALL0 + k].uuid;
if (layout->wallUuidCapacityInput < 4) return XR_ERROR_SIZE_INSUFFICIENT;
for (int k = 0; k < 4; ++k) layout->wallUuids[k] = entities[ENT_WALL0 + k].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !rect) return XR_ERROR_HANDLE_INVALID;
@@ -426,29 +360,19 @@ static XRAPI_ATTR XrResult XRAPI_CALL scene_get_bounding_box_2d(XrSession sessio
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_bounding_box_3d(XrSession session, XrSpace space, XrRect3DfFB *box) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !box) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].has_bounds3) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
*box = entities[i].bounds3;
return XR_SUCCESS;
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_3d(XrSession session, XrSpace space, XrRect3DfFB *box) {
(void)session; (void)space; (void)box;
return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !boundary) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].has_bounds) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
uint32_t n = entities[i].boundary_count ? entities[i].boundary_count : 4;
boundary->vertexCountOutput = n;
boundary->vertexCountOutput = 4;
if (!boundary->vertexCapacityInput) return XR_SUCCESS;
if (boundary->vertexCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!boundary->vertices) return XR_ERROR_VALIDATION_FAILURE;
if (entities[i].boundary_count) {
memcpy(boundary->vertices, entities[i].boundary, n * sizeof(*boundary->vertices));
return XR_SUCCESS;
}
if (boundary->vertexCapacityInput < 4) return XR_ERROR_SIZE_INSUFFICIENT;
const XrRect2Df b = entities[i].bounds;
boundary->vertices[0] = (XrVector2f){b.offset.x, b.offset.y};
boundary->vertices[1] = (XrVector2f){b.offset.x + b.extent.width, b.offset.y};
@@ -457,76 +381,16 @@ static XRAPI_ATTR XrResult XRAPI_CALL scene_get_boundary_2d(XrSession session, X
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_triangle_mesh(XrSpace space, const XrSpaceTriangleMeshGetInfoMETA *info,
XrSpaceTriangleMeshMETA *mesh) {
(void)info;
int i = fake_space_index(space);
if (i < 0 || !mesh) return XR_ERROR_HANDLE_INVALID;
const scene_entity *e = &entities[i];
if (!e->vertex_count) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
mesh->vertexCountOutput = e->vertex_count;
mesh->indexCountOutput = e->index_count;
if ((mesh->vertexCapacityInput && mesh->vertexCapacityInput < e->vertex_count) ||
(mesh->indexCapacityInput && mesh->indexCapacityInput < e->index_count)) return XR_ERROR_SIZE_INSUFFICIENT;
if ((mesh->vertexCapacityInput && !mesh->vertices) || (mesh->indexCapacityInput && !mesh->indices))
return XR_ERROR_VALIDATION_FAILURE;
if (mesh->vertexCapacityInput) memcpy(mesh->vertices, e->vertices, e->vertex_count * sizeof(*e->vertices));
if (mesh->indexCapacityInput) memcpy(mesh->indices, e->indices, e->index_count * sizeof(*e->indices));
return XR_SUCCESS;
}
// Labels as Meta's runtime hands them to an app (XR_FB_scene): an app that sends no XrSemanticLabelsSupportInfoFB
// knows only the first label set; one that does gets "OTHER" for labels missing from its recognizedLabels, WALL_FACE
// for invisible walls unless it accepts them, and DESK for TABLE unless it accepts that migration. Older Meta SDKs
// (Demeter, 2026-10-16) rejected a whole scanned room over one INVISIBLE_WALL_FACE and kept asking for Space Setup.
static const char *const scene_legacy_labels = "DESK,COUCH,FLOOR,CEILING,WALL_FACE,DOOR_FRAME,WINDOW_FRAME,OTHER";
static int label_listed(const char *list, const char *label, size_t len) {
for (const char *p = list; p && *p;) {
const char *end = strchr(p, ',');
size_t n = end ? (size_t)(end - p) : strlen(p);
if (n == len && !strncmp(p, label, len)) return 1;
p = end ? end + 1 : NULL;
}
return 0;
}
static void scene_app_labels(const char *label, const XrSemanticLabelsFB *labels, char *out, size_t size) {
const XrSemanticLabelsSupportInfoFB *support = NULL;
for (const XrBaseInStructure *n = labels->next; n; n = n->next)
if (n->type == XR_TYPE_SEMANTIC_LABELS_SUPPORT_INFO_FB) support = (const XrSemanticLabelsSupportInfoFB *)n;
const char *known = support ? support->recognizedLabels : scene_legacy_labels;
XrSemanticLabelsSupportFlagsFB flags = support ? support->flags : 0;
out[0] = 0;
for (const char *p = label; *p;) {
const char *end = strchr(p, ',');
size_t len = end ? (size_t)(end - p) : strlen(p);
char one[64];
snprintf(one, sizeof(one), "%.*s", (int)len, p);
if (!strcmp(one, "INVISIBLE_WALL_FACE") && !(flags & XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_INVISIBLE_WALL_FACE_BIT_FB))
snprintf(one, sizeof(one), "WALL_FACE");
if (!strcmp(one, "TABLE") && !(flags & XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_DESK_TO_TABLE_MIGRATION_BIT_FB))
snprintf(one, sizeof(one), "DESK");
if (known && *known && !label_listed(known, one, strlen(one))) snprintf(one, sizeof(one), "OTHER");
if (!label_listed(out, one, strlen(one)))
snprintf(out + strlen(out), size - strlen(out), "%s%s", out[0] ? "," : "", one);
p = end ? end + 1 : p + len;
}
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !labels) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].label[0]) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
char text[sizeof(entities[0].label)];
scene_app_labels(entities[i].label, labels, text, sizeof(text));
uint32_t n = (uint32_t)strlen(text) + 1;
if (!entities[i].label) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
uint32_t n = (uint32_t)strlen(entities[i].label) + 1;
labels->bufferCountOutput = n;
if (!labels->bufferCapacityInput) return XR_SUCCESS;
if (labels->bufferCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!labels->buffer) return XR_ERROR_VALIDATION_FAILURE;
memcpy(labels->buffer, text, n);
memcpy(labels->buffer, entities[i].label, n);
return XR_SUCCESS;
}
@@ -537,12 +401,11 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_request_scene_capture(XrSession sessio
pthread_mutex_lock(&scene_lock);
scene_session = session;
build_room();
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
*request = next_request++;
XrEventDataSceneCaptureCompleteFB e = {XR_TYPE_EVENT_DATA_SCENE_CAPTURE_COMPLETE_FB, NULL, *request, XR_SUCCESS};
push_event(&e, sizeof(e));
LOG("scene: capture requested -> %s", room_from_file ? "scanned room" : "guardian box");
pthread_mutex_unlock(&scene_lock);
LOG("scene: capture requested -> using guardian room");
return XR_SUCCESS;
}
@@ -554,16 +417,9 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_create_anchor(XrSession session, const
build_room();
XrPosef base = {{0, 0, 0, 1}, {0, 0, 0}};
XrSpaceLocationFlags flags = 0;
if (event_free() < 1) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
if (!stage_pose(info->space, info->time, &base, &flags) ||
!(flags & XR_SPACE_LOCATION_POSITION_VALID_BIT) || !(flags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT)) {
pthread_mutex_unlock(&scene_lock); return XR_ERROR_POSE_INVALID;
}
XrUuidEXT uuid;
if (!anchor_uuid(&uuid)) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_RUNTIME_FAILURE; }
stage_pose(info->space, info->time, &base, &flags);
int i = add_entity(pose_mul(base, info->poseInSpace), NULL, 0, 0, 0, 1);
*request = next_request++;
if (i >= 0) entities[i].uuid = uuid;
XrEventDataSpatialAnchorCreateCompleteFB e = {XR_TYPE_EVENT_DATA_SPATIAL_ANCHOR_CREATE_COMPLETE_FB, NULL, *request,
i >= 0 ? XR_SUCCESS : XR_ERROR_LIMIT_REACHED, i >= 0 ? entities[i].handle : XR_NULL_HANDLE,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}};
@@ -575,114 +431,29 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_create_anchor(XrSession session, const
static XRAPI_ATTR XrResult XRAPI_CALL emu_save_space(XrSession session, const XrSpaceSaveInfoFB *info, XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(info->space);
if (i < 0) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(1, &info->space, 0, NULL, 0);
pthread_mutex_lock(&scene_lock);
*request = next_request++;
XrEventDataSpaceSaveCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB, NULL, *request, r, info->space,
entities[i].uuid, info->location};
XrEventDataSpaceSaveCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB, NULL, *request, XR_SUCCESS, info->space,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}, info->location};
push_event(&e, sizeof(e));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_erase_space(XrSession session, const XrSpaceEraseInfoFB *info, XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(info->space);
if (i < 0) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(1, &info->space, 0, NULL, 1);
pthread_mutex_lock(&scene_lock);
*request = next_request++;
XrEventDataSpaceEraseCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB, NULL, *request, r, info->space,
entities[i].uuid, info->location};
XrEventDataSpaceEraseCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB, NULL, *request, XR_SUCCESS, info->space,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}, info->location};
push_event(&e, sizeof(e));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
// Reads share the mutation lock: engine worker threads may retrieve geometry while anchors are saved.
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_uuid(XrSpace space, XrUuidEXT *uuid) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_uuid(space, uuid);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count, XrSpaceComponentTypeFB *types) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_enumerate_components(space, capacity, count, types);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_component_status(XrSpace space, XrSpaceComponentTypeFB type, XrSpaceComponentStatusFB *status) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_component_status(space, type, status);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_container(session, space, container);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_room_layout(session, space, layout);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_bounding_box_2d(session, space, rect);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_3d(XrSession session, XrSpace space, XrRect3DfFB *box) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_bounding_box_3d(session, space, box);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_boundary_2d(session, space, boundary);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_triangle_mesh(XrSpace space, const XrSpaceTriangleMeshGetInfoMETA *info, XrSpaceTriangleMeshMETA *mesh) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_triangle_mesh(space, info, mesh);
pthread_mutex_unlock(&scene_lock);
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
pthread_mutex_lock(&scene_lock);
XrResult r = scene_get_semantic_labels(session, space, labels);
pthread_mutex_unlock(&scene_lock);
return r;
}
// The app's STAGE bounds: the Frame reports a 1x1 m rect but OVRPlugin can't give its outline ("ovrpBoundary_Outer
// is not supported"), so Unity games that trust a configured boundary index an empty array (Demeter's
// RemoveObjectNotInGuardian). With an emulated room there is no play area for the app; build_room asks the runtime.
static XRAPI_ATTR XrResult XRAPI_CALL emu_stage_bounds(XrSession session, XrReferenceSpaceType type, XrExtent2Df *bounds) {
if (type == XR_REFERENCE_SPACE_TYPE_STAGE) {
if (!bounds) return XR_ERROR_VALIDATION_FAILURE;
static int logged;
if (!logged++) LOG("scene: STAGE bounds hidden from the app (no play-area outline on this runtime)");
*bounds = (XrExtent2Df){0, 0};
return XR_SPACE_BOUNDS_UNAVAILABLE;
}
PFN_xrGetReferenceSpaceBoundsRect fn =
(PFN_xrGetReferenceSpaceBoundsRect)lookup(active_instance, "xrGetReferenceSpaceBoundsRect");
return fn ? fn(session, type, bounds) : XR_ERROR_FUNCTION_UNSUPPORTED;
}
#include "scene_meta.c"
static PFN_xrVoidFunction scene_emulation(const char *name) {
if (!emulate_scene) return NULL;
static const struct { const char *name; PFN_xrVoidFunction fn; } table[] = {
@@ -697,17 +468,11 @@ static PFN_xrVoidFunction scene_emulation(const char *name) {
{"xrGetSpaceBoundingBox2DFB", (PFN_xrVoidFunction)emu_get_bounding_box_2d},
{"xrGetSpaceBoundingBox3DFB", (PFN_xrVoidFunction)emu_get_bounding_box_3d},
{"xrGetSpaceBoundary2DFB", (PFN_xrVoidFunction)emu_get_boundary_2d},
{"xrGetSpaceTriangleMeshMETA", (PFN_xrVoidFunction)emu_get_triangle_mesh},
{"xrGetSpaceSemanticLabelsFB", (PFN_xrVoidFunction)emu_get_semantic_labels},
{"xrRequestSceneCaptureFB", (PFN_xrVoidFunction)emu_request_scene_capture},
{"xrCreateSpatialAnchorFB", (PFN_xrVoidFunction)emu_create_anchor},
{"xrSaveSpaceFB", (PFN_xrVoidFunction)emu_save_space},
{"xrEraseSpaceFB", (PFN_xrVoidFunction)emu_erase_space},
{"xrDiscoverSpacesMETA", (PFN_xrVoidFunction)emu_discover_spaces},
{"xrRetrieveSpaceDiscoveryResultsMETA", (PFN_xrVoidFunction)emu_retrieve_discovery},
{"xrSaveSpacesMETA", (PFN_xrVoidFunction)emu_save_spaces},
{"xrEraseSpacesMETA", (PFN_xrVoidFunction)emu_erase_spaces},
{"xrGetReferenceSpaceBoundsRect", (PFN_xrVoidFunction)emu_stage_bounds},
};
for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); ++i)
if (!strcmp(name, table[i].name)) return table[i].fn;
@@ -720,16 +485,6 @@ static void scene_on_create_session(XrSession session) {
PFN_xrCreateReferenceSpace create = (PFN_xrCreateReferenceSpace)lookup(active_instance, "xrCreateReferenceSpace");
XrReferenceSpaceCreateInfo ci = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO, NULL, XR_REFERENCE_SPACE_TYPE_STAGE,
{{0, 0, 0, 1}, {0, 0, 0}}};
pthread_mutex_lock(&scene_lock);
reset_room();
scene_generation = (scene_generation + 1) & 255;
event_head = event_tail = 0;
for (int i = 0; i < MAX_QUERIES; ++i) { free(queries[i].results); memset(&queries[i], 0, sizeof(queries[i])); }
query_next = 0;
scene_session = session;
scene_stage = XR_NULL_HANDLE;
if (create && XR_SUCCEEDED(create(session, &ci, &scene_stage))) LOG("scene: emulation active (anchored to STAGE)");
build_room();
load_anchors();
pthread_mutex_unlock(&scene_lock);
}
-84
View File
@@ -1,84 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by scene_emu.c, using the cached OpenXR registry declarations (no private ABI copies).
static int meta_filter_matches(const XrSpaceDiscoveryInfoMETA *info, const scene_entity *e) {
for (uint32_t k = 0; k < info->filterCount; ++k) {
const XrSpaceFilterBaseHeaderMETA *f = info->filters[k];
if (!f) continue;
if (f->type == XR_TYPE_SPACE_FILTER_COMPONENT_META &&
!entity_has_component(e, ((const XrSpaceFilterComponentMETA *)f)->componentType)) return 0;
if (f->type == XR_TYPE_SPACE_FILTER_UUID_META) {
const XrSpaceFilterUuidMETA *u = (const XrSpaceFilterUuidMETA *)f;
if (!u->uuids || !uuid_matches(u->uuidCount, u->uuids, e)) return 0;
}
}
return 1;
}
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;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_erase_spaces(XrSession session, const XrSpacesEraseInfoMETA *info,
XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request || (info->spaceCount && !info->spaces) || (info->uuidCount && !info->uuids))
return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(info->spaceCount, info->spaces, info->uuidCount, info->uuids, 1);
*request = next_request++;
XrEventDataSpacesEraseResultMETA done = {XR_TYPE_EVENT_DATA_SPACES_ERASE_RESULT_META, NULL, *request, r};
push_event(&done, sizeof(done));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
-268
View File
@@ -1,268 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by scene_emu.c. Bounded parsing keeps corrupt scan files from exhausting an Android game.
#include <errno.h>
#include <stdint.h>
static int room_uuid(const char *s, XrUuidEXT *u) {
if (strlen(s) != 32) return 0;
for (int i = 0; i < 16; ++i) {
unsigned n = 0;
for (int k = 0; k < 2; ++k) {
char c = s[i * 2 + k];
if (c >= '0' && c <= '9') n = n * 16 + (unsigned)(c - '0');
else if (c >= 'a' && c <= 'f') n = n * 16 + (unsigned)(c - 'a' + 10);
else return 0;
}
u->data[i] = (uint8_t)n;
}
return 1;
}
static int room_float(const char *s, float *v) {
char *end;
errno = 0;
*v = strtof(s, &end);
return *s && !*end && !errno && isfinite(*v) && fabsf(*v) <= 100000;
}
static int room_uint(const char *s, uint64_t *v) {
char *end;
if (*s < '0' || *s > '9') return 0;
errno = 0;
*v = strtoull(s, &end, 10);
return !*end && !errno;
}
static int room_tokens(char *s, char **t, int cap) {
char *comment = strchr(s, '#');
if (comment) *comment = 0;
int n = 0;
char *save;
for (char *p = strtok_r(s, " \t\r\n", &save); p; p = strtok_r(NULL, " \t\r\n", &save)) {
if (n == cap) return -1;
t[n++] = p;
}
return n;
}
static int room_values(char **t, int start, int count, float *v) {
for (int i = 0; i < count; ++i) if (!room_float(t[start + i], &v[i])) return 0;
return 1;
}
static int room_path(char *out, size_t size, const char *name) {
const char *config = getenv("FRAMEBRIDGE_CONFIG");
if (!config || !*config || strchr(name, '/') || strchr(name, '\\') || !strcmp(name, "..")) return 0;
const char *slash = strrchr(config, '/');
int n = slash ? snprintf(out, size, "%.*s/%s", (int)(slash - config), config, name)
: snprintf(out, size, "%s", name);
return n >= 0 && (size_t)n < size;
}
static uint32_t room_u32(const unsigned char *p) {
return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}
static int room_mesh(scene_entity *e, const char *name, uint64_t nv, uint64_t ni) {
// At most 200k triangles and 16 MiB of payload; validate every index before publishing the mesh.
if (!nv || nv > 600000 || !ni || ni > 600000 || ni % 3 || nv * 12 + ni * 4 > 16 * 1024 * 1024) return 0;
char path[4096];
if (!room_path(path, sizeof(path), name)) return 0;
FILE *f = fopen(path, "rb");
if (!f) return 0;
unsigned char header[16], word[4];
int ok = fread(header, 1, 16, f) == 16 && !memcmp(header, "FPRM", 4) && room_u32(header + 4) == 1 &&
room_u32(header + 8) == nv && room_u32(header + 12) == ni;
XrVector3f *verts = ok ? malloc((size_t)nv * sizeof(*verts)) : NULL;
uint32_t *indices = ok ? malloc((size_t)ni * sizeof(*indices)) : NULL;
ok = ok && verts && indices;
for (uint64_t i = 0; ok && i < nv; ++i) {
float xyz[3];
for (int k = 0; ok && k < 3; ++k) {
ok = fread(word, 1, 4, f) == 4;
uint32_t bits = ok ? room_u32(word) : 0;
memcpy(&xyz[k], &bits, 4);
ok = ok && isfinite(xyz[k]) && fabsf(xyz[k]) <= 100000;
}
if (ok) verts[i] = (XrVector3f){xyz[0], xyz[1], xyz[2]};
}
for (uint64_t i = 0; ok && i < ni; ++i) {
ok = fread(word, 1, 4, f) == 4;
indices[i] = ok ? room_u32(word) : 0;
ok = ok && indices[i] < nv;
}
ok = ok && fgetc(f) == EOF && !ferror(f);
fclose(f);
if (!ok) { free(verts); free(indices); return 0; }
e->vertices = verts; e->indices = indices;
e->vertex_count = (uint32_t)nv; e->index_count = (uint32_t)ni;
return 1;
}
static int room_set_uuid(int i, XrUuidEXT uuid) {
for (int k = 0; k < entity_count; ++k)
if (k != i && uuid_equal(&entities[k].uuid, &uuid)) return 0;
entities[i].uuid = uuid;
return 1;
}
static int room_wall(XrUuidEXT uuid, const float *v, const char *label) {
float dx = v[2] - v[0], dz = v[3] - v[1], length = hypotf(dx, dz);
if (length < 0.001f || v[5] <= v[4]) return 0;
float angle = atan2f(-dz, dx) / 2;
int i = add_entity((XrPosef){{0, sinf(angle), 0, cosf(angle)},
{(v[0] + v[2]) / 2, (v[4] + v[5]) / 2, (v[1] + v[3]) / 2}},
label, length, v[5] - v[4], 0, 0);
if (i < 0) return 0;
if (!room_set_uuid(i, uuid)) { --entity_count; return 0; }
entities[i].is_wall = 1;
return 1;
}
static int room_outline(scene_entity *e, const XrVector2f *points, int n, float cx, float cz, int ceiling) {
e->boundary = malloc((size_t)n * sizeof(*e->boundary));
if (!e->boundary) return 0;
e->boundary_count = (uint32_t)n;
float xmin = INFINITY, ymin = INFINITY, xmax = -INFINITY, ymax = -INFINITY;
for (int i = 0; i < n; ++i) {
XrVector2f p = {points[i].x - cx, (points[i].y - cz) * (ceiling ? 1 : -1)};
e->boundary[i] = p;
xmin = fminf(xmin, p.x); xmax = fmaxf(xmax, p.x);
ymin = fminf(ymin, p.y); ymax = fmaxf(ymax, p.y);
}
e->has_bounds = 1;
e->bounds = (XrRect2Df){{xmin, ymin}, {xmax - xmin, ymax - ymin}};
return 1;
}
static int load_room_file(void) {
char path[4096];
if (!room_path(path, sizeof(path), "room.txt")) return 0;
FILE *f = fopen(path, "r");
if (!f) return 0;
// Long enough for a 1024-point outline. Reject whole overlong lines, never interpret their tails.
char *line = malloc(65536);
char **t = malloc((2 * MAX_OUTLINE + 4) * sizeof(*t));
if (!line || !t) { free(line); free(t); fclose(f); return 0; }
int ok = fgets(line, 65536, f) != NULL;
int n = ok ? room_tokens(line, t, 2 * MAX_OUTLINE + 4) : 0;
if (n != 2 || strcmp(t[0], "frameport-room") || strcmp(t[1], "1")) {
LOG("scene: bad room magic/version -> guardian box");
free(line); free(t); fclose(f); return 0;
}
const float s = 0.70710678f;
add_entity((XrPosef){{0, 0, 0, 1}, {0, 0, 0}}, NULL, 0, 0, 1, 0);
add_entity((XrPosef){{-s, 0, 0, s}, {0, 0, 0}}, "FLOOR", 0, 0, 0, 0);
add_entity((XrPosef){{s, 0, 0, s}, {0, 2.5f, 0}}, "CEILING", 0, 0, 0, 0);
XrVector2f outline[MAX_OUTLINE];
int outline_n = 0, ceiling_set = 0, volumes = 0, planes = 0, triangles = 0;
unsigned lineno = 1;
snprintf(room_source, sizeof(room_source), "manual");
while (fgets(line, 65536, f)) {
++lineno;
if (!strchr(line, '\n') && !feof(f)) {
int c; while ((c = fgetc(f)) != EOF && c != '\n') {}
LOG("scene: bad room line %u (too long)", lineno); continue;
}
n = room_tokens(line, t, 2 * MAX_OUTLINE + 4);
if (!n) continue;
ok = n > 0;
float v[10]; XrUuidEXT uuid; uint64_t a, b;
if (n < 0) ok = 0;
else if (!strcmp(t[0], "source")) {
ok = n == 2 && (!strcmp(t[1], "scan") || !strcmp(t[1], "boundary") || !strcmp(t[1], "box") || !strcmp(t[1], "manual"));
if (ok) snprintf(room_source, sizeof(room_source), "%s", t[1]);
} else if (!strcmp(t[0], "universe")) {
ok = n == 2 && room_uint(t[1], &a);
if (ok) room_universe = a;
} else if (!strcmp(t[0], "room") || !strcmp(t[0], "floor") || !strcmp(t[0], "ceiling")) {
int i = !strcmp(t[0], "room") ? ENT_ROOM : !strcmp(t[0], "floor") ? ENT_FLOOR : ENT_CEILING;
ok = n == (i == ENT_ROOM ? 2 : 3) && room_uuid(t[1], &uuid) &&
(i == ENT_ROOM || room_float(t[2], v)) && room_set_uuid(i, uuid);
if (ok && i != ENT_ROOM) entities[i].pose_in_stage.position.y = v[0];
if (ok && i == ENT_CEILING) ceiling_set = 1;
} else if (!strcmp(t[0], "outline")) {
ok = n >= 2 && room_uint(t[1], &a) && a >= 3 && a <= MAX_OUTLINE && n == 2 + 2 * (int)a;
XrVector2f parsed[MAX_OUTLINE];
for (uint64_t k = 0; ok && k < a; ++k) ok = room_float(t[2 + k * 2], &parsed[k].x) && room_float(t[3 + k * 2], &parsed[k].y);
if (ok) { memcpy(outline, parsed, (size_t)a * sizeof(*outline)); outline_n = (int)a; }
} else if (!strcmp(t[0], "wall")) {
// optional 8th field: WALL_FACE (default) or INVISIBLE_WALL_FACE for an edge the scanner closed itself
const char *label = n == 9 ? t[8] : "WALL_FACE";
ok = (n == 8 || (n == 9 && (!strcmp(label, "WALL_FACE") || !strcmp(label, "INVISIBLE_WALL_FACE")))) &&
room_uuid(t[1], &uuid) && room_values(t, 2, 6, v) && room_wall(uuid, v, label);
} else if (!strcmp(t[0], "volume") || !strcmp(t[0], "plane")) {
int volume = !strcmp(t[0], "volume");
ok = n == (volume ? 10 : 12) && room_uuid(t[1], &uuid) && strlen(t[2]) < sizeof(entities[0].label) && room_values(t, 3, volume ? 7 : 9, v);
XrPosef pose = {{0, 0, 0, 1}, {0, 0, 0}};
float w = 0, h = 0;
if (ok && volume) {
float yaw = v[3] * 0.00872664626f;
pose = (XrPosef){quat_mul((XrQuaternionf){0, sinf(yaw), 0, cosf(yaw)}, (XrQuaternionf){-s, 0, 0, s}), {v[0], v[2], v[1]}};
w = v[4]; h = v[5]; ok = v[6] > 0;
} else if (ok) {
float norm = sqrtf(v[3]*v[3]+v[4]*v[4]+v[5]*v[5]+v[6]*v[6]);
ok = norm > 0.0001f;
if (ok) pose = (XrPosef){{v[3]/norm,v[4]/norm,v[5]/norm,v[6]/norm}, {v[0],v[1],v[2]}};
w = v[7]; h = v[8];
}
int i = ok && w > 0 && h > 0 ? add_entity(pose, t[2], w, h, 0, 0) : -1;
ok = i >= 0;
if (ok && !room_set_uuid(i, uuid)) { --entity_count; ok = 0; }
if (ok && volume) {
entities[i].has_bounds3 = 1;
entities[i].bounds3 = (XrRect3DfFB){{-w/2, -h/2, -v[6]}, {w, h, v[6]}};
++volumes;
} else if (ok) ++planes;
} else if (!strcmp(t[0], "mesh")) {
if (!scene_app_mesh) continue; // the app didn't enable XR_META_spatial_entity_mesh
ok = n == 5 && room_uuid(t[1], &uuid) && room_uint(t[3], &a) && room_uint(t[4], &b) && !triangles;
int i = ok ? add_entity((XrPosef){{0,0,0,1},{0,0,0}}, "GLOBAL_MESH", 0, 0, 0, 0) : -1;
ok = i >= 0 && room_set_uuid(i, uuid) && room_mesh(&entities[i], t[2], a, b);
if (ok) triangles = (int)b / 3;
else if (i >= 0) --entity_count;
}
if (!ok) LOG("scene: bad room line %u (skipped)", lineno);
}
ok = !ferror(f);
free(line); free(t); fclose(f);
// Layout walls must be contiguous and in file order, regardless of where other records occurred.
for (int i = ENT_WALL0; i < entity_count; ++i) if (entities[i].is_wall) {
scene_entity wall = entities[i];
memmove(&entities[ENT_WALL0 + wall_count + 1], &entities[ENT_WALL0 + wall_count],
(size_t)(i - ENT_WALL0 - wall_count) * sizeof(wall));
entities[ENT_WALL0 + wall_count++] = wall;
}
if (!outline_n && wall_count) {
float xmin = INFINITY, zmin = INFINITY, xmax = -INFINITY, zmax = -INFINITY;
for (int i = ENT_WALL0; i < ENT_WALL0 + wall_count; ++i) {
scene_entity *e = &entities[i];
XrVector3f d = quat_rotate(e->pose_in_stage.orientation, (XrVector3f){e->bounds.extent.width/2,0,0});
xmin = fminf(xmin, e->pose_in_stage.position.x - fabsf(d.x)); xmax = fmaxf(xmax, e->pose_in_stage.position.x + fabsf(d.x));
zmin = fminf(zmin, e->pose_in_stage.position.z - fabsf(d.z)); zmax = fmaxf(zmax, e->pose_in_stage.position.z + fabsf(d.z));
}
outline[0]=(XrVector2f){xmin,zmin}; outline[1]=(XrVector2f){xmax,zmin};
outline[2]=(XrVector2f){xmax,zmax}; outline[3]=(XrVector2f){xmin,zmax}; outline_n=4;
}
double area = 0, cx = 0, cz = 0;
for (int i = 0; i < outline_n; ++i) {
XrVector2f a = outline[i], b = outline[(i+1)%outline_n];
double cross = (double)a.x*b.y-(double)b.x*a.y;
area += cross; cx += (a.x+b.x)*cross; cz += (a.y+b.y)*cross;
}
if (!ok || outline_n < 3 || fabs(area) < 0.00001) { reset_room(); return 0; }
cx /= 3*area; cz /= 3*area;
if (!ceiling_set) entities[ENT_CEILING].pose_in_stage.position.y = entities[ENT_FLOOR].pose_in_stage.position.y + 2.5f;
for (int i = ENT_FLOOR; i <= ENT_CEILING; ++i) {
entities[i].pose_in_stage.position.x = (float)cx; entities[i].pose_in_stage.position.z = (float)cz;
if (!room_outline(&entities[i], outline, outline_n, (float)cx, (float)cz, i == ENT_CEILING)) { reset_room(); return 0; }
}
if (!wall_count) for (int i = 0; i < outline_n; ++i) {
XrVector2f a = outline[i], b = outline[(i+1)%outline_n];
float v[] = {a.x,a.y,b.x,b.y,entities[ENT_FLOOR].pose_in_stage.position.y,entities[ENT_CEILING].pose_in_stage.position.y};
if (!room_wall(make_uuid(entity_count), v, "WALL_FACE")) { reset_room(); return 0; }
++wall_count;
}
// Move generated walls ahead of furniture as well.
if (wall_count && !entities[ENT_WALL0].is_wall) {
scene_entity *walls = malloc((size_t)wall_count * sizeof(*walls));
if (!walls) { reset_room(); return 0; }
memcpy(walls, &entities[entity_count-wall_count], (size_t)wall_count*sizeof(*walls));
memmove(&entities[ENT_WALL0+wall_count], &entities[ENT_WALL0], (size_t)(entity_count-wall_count-ENT_WALL0)*sizeof(*walls));
memcpy(&entities[ENT_WALL0], walls, (size_t)wall_count*sizeof(*walls)); free(walls);
}
for (int i = 0; i < entity_count; ++i) entities[i].handle = fake_space(i);
room_entity_count = entity_count; room_from_file = 1;
LOG("scene: room from %s file (%d walls, %d volumes, %d planes, %d mesh triangles, universe %llu)",
room_source, wall_count, volumes, planes, triangles, (unsigned long long)room_universe);
return 1;
}
+2 -15
View File
@@ -199,18 +199,6 @@ def build_venc(tc: Path):
f"-ffile-prefix-map={HERE}=native", "fp_venc.c", "convert.c", "sys.c", "-o", out / "fp_venc"], cwd=src)
def build_roomscan(tc: Path):
"""Offline room reconstruction (Linux aarch64). No libc, headers, generated tables or fetched dependencies."""
src = HERE / "roomscan"
out = ART / "linux-arm64-bin"
out.mkdir(parents=True, exist_ok=True)
run([tc / "bin/clang", "--target=aarch64-linux-gnu", "-ffreestanding", "-nostdlib", "-nostdlibinc", "-static",
"-fno-stack-protector", "-fno-builtin", "-ffp-contract=off", "-fno-math-errno", "-O2", "-Wall", "-Wextra", "-Werror",
"-fuse-ld=lld", "-Wl,--build-id=none", "-Wl,-z,max-page-size=65536", "-e", "_start",
f"-ffile-prefix-map={HERE}=native", "main.c", "fusion.c", "model.c", "mesh.c", "platform.c",
"-o", out / "fp_roomscan"], cwd=src)
OCULUSHMD_IMPORTS = ("CreateEventW", "CreateJobObjectW", "AssignProcessToJobObject", "QueryInformationJobObject",
"CreateProcessW", "ResumeThread", "WaitForSingleObject", "GetExitCodeProcess", "CloseHandle",
"Sleep", "GetCommandLineW", "GetLastError", "GetStdHandle", "WriteFile", "ExitProcess")
@@ -450,7 +438,7 @@ def write_sums():
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,roomscan,a32")
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,a32")
ap.add_argument("--ndk")
args = ap.parse_args()
parts = set(args.only.split(","))
@@ -466,10 +454,9 @@ def main():
"ovrpshim": lambda: build_ovrpshim(tc), "tlskeys": lambda: build_tlskeys(tc),
"luajit": lambda: build_luajit(tc),
"vrsettings": lambda: build_vrsettings(tc), "venc": lambda: build_venc(tc),
"roomscan": lambda: build_roomscan(tc),
"vroverlay_probe": lambda: build_vroverlay_probe(tc), "a32": lambda: build_a32(tc)}
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "luajit", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
"roomscan", "vroverlay_probe", "a32"):
"vroverlay_probe", "a32"):
if name in parts:
log(f"build {name}")
steps[name]()
-325
View File
@@ -1,325 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define MIN_TRIANGLE_TWICE_AREA 1e-8f
#define MIN_RANGE .1f
#define MAX_COORDINATE 10000.f
#define QUATERNION_NORM_TOLERANCE .1f
#define SAMPLE_SPACING_VOXELS .75f
#define MAX_SURFEL_WEIGHT 10.f
#define FEW_FRAMES 8
#define MAX_GRID_XZ 8.f
#define MAX_GRID_Y 4.f
#define GRID_PADDING_VOXELS 5.f
#define NORMAL_PATCH_SIZE 12
#define MIN_PATCH_POINTS 24
float root(float x)
{
if (x <= 0) return 0;
/* -fno-math-errno makes this one hardware sqrt, with no libc/libm symbol on either build. */
return __builtin_sqrtf(x);
}
Vec unit(Vec p) { float n = root(dot(p,p)); return n > 1e-12f ? mul(p,1/n) : vec(0,0,0); }
int flo(float x) { int i = (int)x; return x < (float)i ? i-1 : i; }
float sine(float x)
{
while (x > PI) x -= 2*PI;
while (x < -PI) x += 2*PI;
if (x > PI/2) x = PI-x;
if (x < -PI/2) x = -PI-x;
float q = x*x;
return x*(1-q/6+q*q/120-q*q*q/5040+q*q*q*q/362880);
}
float cosine(float x) { return sine(x+PI/2); }
float angle(float y, float x)
{
/* atan approximation, maximum error < .012 degrees. */
float a = mn(ab(x),ab(y)) / mx(mx(ab(x),ab(y)),1e-20f), q = a*a;
float r = a*(.999866f+q*(-.3302995f+q*(.180141f+q*(-.085133f+q*.0208351f))));
if (ab(y) > ab(x)) r = PI/2-r;
if (x < 0) r = PI-r;
return y < 0 ? -r : r;
}
int same(const char *a, const char *b)
{
while (*a && *a == *b) a++,b++;
return *a == *b;
}
u32 random_next(Scan *s) { s->rng ^= s->rng<<13; s->rng ^= s->rng>>17; s->rng ^= s->rng<<5; return s->rng; }
void warn(Scan *s, const char *m)
{
for (int i = 0; i < s->nwarnings; i++) if (same(s->warnings[i],m)) return;
if (s->nwarnings < 24) s->warnings[s->nwarnings++] = m;
}
static u32 u32le(const u8 *p) { return (u32)p[0]|(u32)p[1]<<8|(u32)p[2]<<16|(u32)p[3]<<24; }
static float f32le(const u8 *p) { union { u32 u; float f; } v = {u32le(p)}; return v.f; }
/* 1 = complete, 0 = clean EOF, -1 = I/O error or partial record. */
static int exact(int fd, void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = io_read(fd,(u8 *)p+done,n-done);
if (r < 0 || (!r && done)) return -1;
if (!r) return 0;
done += (usize)r;
}
return 1;
}
Vec grid_point(Scan *s, int x, int y, int z)
{
return add(s->origin,vec(x*s->opt.voxel,y*s->opt.voxel,z*s->opt.voxel));
}
int cell_index(Scan *s, Vec p)
{
Vec q = mul(sub(p,s->origin),1/s->opt.voxel);
int x = flo(q.x+.5f), y = flo(q.y+.5f), z = flo(q.z+.5f);
if (x < 0 || y < 0 || z < 0 || x >= s->nx || y >= s->ny || z >= s->nz) return -1;
return (y*s->nz+z)*s->nx+x;
}
static void sample(Scan *s, Vec p, Vec n, float w)
{
int i = cell_index(s,p);
if (i < 0) { s->clipped++; return; }
Cell *c = &s->cells[i];
/* Cap repeated views' influence without throwing away later measurements. */
float scale = c->weight > MAX_SURFEL_WEIGHT ? MAX_SURFEL_WEIGHT/c->weight : 1;
c->p = add(mul(c->p,scale),mul(p,w));
c->n = add(mul(c->n,scale),mul(n,w));
c->weight = c->weight*scale+w;
if(c->last_frame!=s->fuse_frame) { c->last_frame=s->fuse_frame;c->views++; }
}
static void triangle(Scan *s, Vec a, Vec b, Vec c, Vec eye, Vec patch_normal, int fuse)
{
Vec abv = sub(b,a), ac = sub(c,a), bc = sub(c,b);
float longest = root(mx(dot(abv,abv),mx(dot(ac,ac),dot(bc,bc))));
Vec p = mul(add(a,add(b,c)),1.f/3), n = cross(abv,ac);
float twice_area = root(dot(n,n));
Vec view = sub(eye,p);
float distance = root(dot(view,view));
if (twice_area < MIN_TRIANGLE_TWICE_AREA || longest > s->opt.max_edge || distance > s->opt.range || distance < MIN_RANGE) {
if (!fuse) s->rejected++;
return;
}
n = mul(n,1/twice_area);
if(dot(patch_normal,patch_normal)>.5f) n=patch_normal;
float facing = dot(n,view)/distance;
if (ab(facing) < s->opt.grazing) { if (!fuse) s->rejected++; return; }
if (facing < 0) n = mul(n,-1);
if (!fuse) {
s->accepted++;
s->seen++;
int slot = s->seen <= MAX_SAMPLES ? (int)s->seen-1 : (int)(random_next(s)%s->seen);
if (slot < MAX_SAMPLES) s->samples[slot] = p;
s->nsamples = (int)mn((float)s->seen,MAX_SAMPLES);
} else {
int steps = (int)(longest/(s->opt.voxel*SAMPLE_SPACING_VOXELS))+1;
float weight = twice_area*facing*facing / ((steps+1)*(steps+2)*.5f);
for (int j = 0; j <= steps; j++) for (int k = 0; k <= steps-j; k++) {
Vec q = add(a,add(mul(abv,(float)j/steps),mul(ac,(float)k/steps)));
sample(s,q,n,weight);
}
}
}
static Vec patch_normal(Vec *points, float *depths, int width, int x0, int y0, int x1, int y1,
float tolerance)
{
Vec mean=vec(0,0,0);int count=0;
/* Shift before accumulating the covariance: room positions can be large relative to patch residuals. */
Vec origin=points[y0*width+x0];
for(int y=y0;y<y1;y++) for(int x=x0;x<x1;x++) if(depths[y*width+x]>0) {
mean=add(mean,sub(points[y*width+x],origin));count++;
}
if(count<MIN_PATCH_POINTS) return vec(0,0,0);
mean=mul(mean,1.f/count);
float cov[3][3]={{0}},basis[3][3]={{1,0,0},{0,1,0},{0,0,1}};
for(int y=y0;y<y1;y++) for(int x=x0;x<x1;x++) if(depths[y*width+x]>0) {
Vec p=sub(sub(points[y*width+x],origin),mean);float a[3]={p.x,p.y,p.z};
for(int i=0;i<3;i++) for(int j=0;j<3;j++) cov[i][j]+=a[i]*a[j];
}
for(int sweep=0;sweep<8;sweep++) {
int p=0,q=1;
if(ab(cov[0][2])>ab(cov[p][q])) { p=0;q=2; }
if(ab(cov[1][2])>ab(cov[p][q])) { p=1;q=2; }
if(ab(cov[p][q])<1e-10f) break;
float phi=.5f*angle(2*cov[p][q],cov[q][q]-cov[p][p]),co=cosine(phi),si=sine(phi);
float aa=cov[p][p],bb=cov[q][q],abv=cov[p][q];
for(int k=0;k<3;k++) if(k!=p && k!=q) {
float a=cov[k][p],b=cov[k][q];
cov[k][p]=cov[p][k]=co*a-si*b;cov[k][q]=cov[q][k]=si*a+co*b;
}
cov[p][p]=co*co*aa-2*si*co*abv+si*si*bb;
cov[q][q]=si*si*aa+2*si*co*abv+co*co*bb;cov[p][q]=cov[q][p]=0;
for(int k=0;k<3;k++) {
float a=basis[k][p],b=basis[k][q];basis[k][p]=co*a-si*b;basis[k][q]=si*a+co*b;
}
}
int least=0;
for(int i=1;i<3;i++) if(cov[i][i]<cov[least][least]) least=i;
if(cov[least][least]/count>tolerance*tolerance) return vec(0,0,0);
return unit(vec(basis[0][least],basis[1][least],basis[2][least]));
}
static int grid_normals(Scan *s, Vec *points, float *depths, Vec *normals, u32 nv, u32 ni, const u8 *indices)
{
if(ni<6 || u32le(indices)!=0 || u32le(indices+8)!=1) return 0;
u32 width=u32le(indices+4);
if(width<3 || width>nv || nv%width || ni!=6*(width-1)*(nv/width-1)) return 0;
/* Only a complete regular fan uses patch normals. Other meshes retain ordinary triangle normals. */
u32 at=0;
for(u32 row=0;row+1<nv/width;row++) for(u32 col=0;col+1<width;col++) {
u32 a=row*width+col,expected[6]={a,a+width,a+1,a+1,a+width,a+width+1};
for(int k=0;k<6;k++) if(u32le(indices+4*at++)!=expected[k]) return 0;
}
int height=(int)(nv/width);
for(int y=0;y<height;y+=NORMAL_PATCH_SIZE) for(int x=0;x<(int)width;x+=NORMAL_PATCH_SIZE) {
int x1=(int)mn(x+NORMAL_PATCH_SIZE,(float)width),y1=(int)mn(y+NORMAL_PATCH_SIZE,height);
Vec n=patch_normal(points,depths,(int)width,x,y,x1,y1,s->opt.patch_residual);
for(int yy=y;yy<y1;yy++) for(int xx=x;xx<x1;xx++) normals[yy*width+xx]=n;
}
return 1;
}
const char *read_dump(Scan *s, int fuse)
{
int fd = io_open(s->opt.input,0);
if (fd < 0) return "cannot open scan dump";
u8 header[44];
const char *error = 0;
Vec *vertices = 0,*normals=0;
float *depths = 0;
u8 *indices = 0;
usize vs = 0, is = 0, ds = 0;
if (exact(fd,header,8) != 1 || header[0]!='F' || header[1]!='P' || header[2]!='R' || header[3]!='S' ||
u32le(header+4) != 1) { error = "invalid FPRS header"; goto done; }
unsigned frames = 0;
for (;;) {
int r = exact(fd,header,44);
if (r == 0) break;
if (r < 0) { error = "truncated frame header"; break; }
/* Tracking can start before poses are available. Consume the entire bounded record before skipping,
* so one NaN cannot hide a truncated payload or desynchronize the next frame. */
int invalid=(u32le(header+4)&0x7ff00000u) == 0x7ff00000u;
float pose[7];
for (int i = 0; i < 7; i++) {
pose[i] = f32le(header+8+i*4);
if (!finite(pose[i]) || ab(pose[i]) > MAX_COORDINATE) invalid=1;
}
float norm = 0;
for (int i = 3; i < 7; i++) norm += pose[i]*pose[i];
if (!finite(norm) || ab(norm-1) > QUATERNION_NORM_TOLERANCE) invalid=1;
if(!invalid) for (int i = 3; i < 7; i++) pose[i] /= root(norm);
u32 nv = u32le(header+36), ni = u32le(header+40);
if (nv > MAX_FRAME_VERTICES || ni > MAX_FRAME_INDICES || ni%3 || (!nv && ni)) {
error = "invalid frame counts"; break;
}
usize needv = (usize)nv*12, needi = (usize)ni*4;
if (needv > vs) {
io_free(vertices,vs);io_free(normals,vs);vs=needv;vertices=io_alloc(vs);normals=io_alloc(vs);
}
if (needi > is) { io_free(indices,is); is=needi; indices=io_alloc(is); }
if((usize)nv*4>ds) { io_free(depths,ds);ds=(usize)nv*4;depths=io_alloc(ds); }
if ((nv && (!vertices || !depths || !normals)) || (ni && !indices)) { error = "frame allocation failed"; break; }
if ((needv && exact(fd,vertices,needv)!=1) || (needi && exact(fd,indices,needi)!=1)) {
error = "truncated frame data"; break;
}
Vec eye = vec(pose[0],pose[1],pose[2]), q = vec(pose[3],pose[4],pose[5]);
u64 far=0;
for (u32 i = 0; i < nv; i++) {
u8 *p = (u8 *)&vertices[i];
Vec v = vec(f32le(p),f32le(p+4),f32le(p+8));
if (!finite(v.x) || !finite(v.y) || !finite(v.z) ||
ab(v.x)>MAX_COORDINATE || ab(v.y)>MAX_COORDINATE || ab(v.z)>MAX_COORDINATE) {
invalid=1;break;
}
depths[i]=root(dot(v,v));
if(depths[i]>s->opt.range || depths[i]<MIN_RANGE) { depths[i]=-1;far++;continue; }
Vec t = mul(cross(q,v),2);
vertices[i] = add(eye,add(v,add(mul(t,pose[6]),cross(q,t))));
}
int regular=!invalid && grid_normals(s,vertices,depths,normals,nv,ni,indices);
/* Structural errors are fatal even in a frame whose tracking/depth is unusable. */
s->fuse_frame=frames+1;
for (u32 i = 0; i < ni; i+=3) {
u32 a=u32le(indices+4*i), b=u32le(indices+4*(i+1)), c=u32le(indices+4*(i+2));
if (a>=nv || b>=nv || c>=nv) { error = "index outside vertex array"; break; }
if(invalid) continue;
if(depths[a]<0 || depths[b]<0 || depths[c]<0) { if(!fuse) s->rejected++;continue; }
float near=mn(depths[a],mn(depths[b],depths[c])),far_depth=mx(depths[a],mx(depths[b],depths[c]));
if(far_depth-near>s->opt.depth_jump+s->opt.depth_ratio*near) {
if(!fuse) { s->rejected++;s->discontinuities++; }continue;
}
Vec normal=regular ? normals[a] : vec(0,0,0);
if(regular && dot(normal,normal)<.5f) { if(!fuse) s->rejected++;continue; }
triangle(s,vertices[a],vertices[b],vertices[c],eye,normal,fuse);
}
if (error) break;
if(!fuse) { if(invalid) s->skipped_frames++;else s->far_vertices+=far; }
frames++;
}
if (!error && !frames) error = "scan contains no frames";
if (!error && !fuse) s->frames = frames;
done:
io_free(vertices,vs);
io_free(normals,vs);
io_free(indices,is);
io_free(depths,ds);
io_close(fd);
return error;
}
static float median(Scan *s, int axis)
{
/* In-place quickselect on the bounded reservoir; no dump-sized allocations. */
int lo=0, hi=s->nsamples-1, mid=s->nsamples/2;
while (lo < hi) {
float pivot = ((float *)&s->samples[(lo+hi)/2])[axis];
int i=lo,j=hi;
while (i<=j) {
while (((float *)&s->samples[i])[axis] < pivot) i++;
while (((float *)&s->samples[j])[axis] > pivot) j--;
if (i<=j) { Vec t=s->samples[i]; s->samples[i++]=s->samples[j]; s->samples[j--]=t; }
}
if (mid<=j) hi=j;
else if (mid>=i) lo=i;
else break;
}
return ((float *)&s->samples[mid])[axis];
}
const char *init_grid(Scan *s)
{
if (!s->nsamples) return "scan contains no usable triangles";
float x=median(s,0), y=median(s,1), z=median(s,2), v=s->opt.voxel;
Vec low=s->samples[0],high=low;
for(int i=1;i<s->nsamples;i++) {
Vec p=s->samples[i];
low=vec(mn(low.x,p.x),mn(low.y,p.y),mn(low.z,p.z));
high=vec(mx(high.x,p.x),mx(high.y,p.y),mx(high.z,p.z));
}
float pad=v*mx(GRID_PADDING_VOXELS,s->opt.truncation+2);
low=sub(low,vec(pad,pad,pad));high=add(high,vec(pad,pad,pad));
s->origin=vec(mx(flo(low.x/v)*v,flo((x-MAX_GRID_XZ/2)/v)*v),
mx(flo(low.y/v)*v,flo((y-MAX_GRID_Y/2)/v)*v),
mx(flo(low.z/v)*v,flo((z-MAX_GRID_XZ/2)/v)*v));
s->nx=(int)mn(-flo(-(high.x-s->origin.x)/v)+1,(int)(MAX_GRID_XZ/v)+1);
s->ny=(int)mn(-flo(-(high.y-s->origin.y)/v)+1,(int)(MAX_GRID_Y/v)+1);
s->nz=(int)mn(-flo(-(high.z-s->origin.z)/v)+1,(int)(MAX_GRID_XZ/v)+1);
usize count = (usize)s->nx*s->ny*s->nz;
if (count > MAX_CELLS) return "voxel grid exceeds memory budget; increase --voxel";
s->count = (int)count;
s->cells = io_alloc(count*sizeof(Cell));
return s->cells ? 0 : "grid allocation failed";
}
void finish_fusion(Scan *s)
{
/* A short look-around cannot expose each surface in many frames. Cap the target at ten percent of usable
* views (at least one); long recordings still require repeated evidence rather than transient depth. */
unsigned required=(unsigned)mn(s->opt.min_views,mx(1,(s->frames-s->skipped_frames)/10.f));
for (int i=0; i<s->count; i++) {
Cell *c=&s->cells[i];
if(c->views<required) { c->weight=0;continue; }
if (c->weight) { c->p=mul(c->p,1/c->weight); c->n=unit(c->n); }
}
if (s->clipped) warn(s,"scan clipped to 8 x 8 x 4 metre grid");
if (s->frames < FEW_FRAMES) warn(s,"few frames; scan more viewpoints");
if(s->skipped_frames) warn(s,"non-finite or invalid frames skipped");
if(s->far_vertices) warn(s,"out-of-range depth vertices skipped");
if (s->accepted < s->rejected) warn(s,"many triangles rejected; check depth quality");
}
-227
View File
@@ -1,227 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
typedef struct { char data[65536]; usize n; int ok; } Text;
static void text(Text *t, const char *p)
{
while(*p) {
if(t->n>=sizeof(t->data)) { t->ok=0;return; }
t->data[t->n++]=*p++;
}
}
static void integer(Text *t, u64 value)
{
char rev[24];int n=0;
do { rev[n++]=(char)('0'+value%10);value/=10; } while(value);
while(n) { char a[2]={rev[--n],0};text(t,a); }
}
static void decimal(Text *t, float value)
{
if(value<0) { text(t,"-");value=-value; }
u64 scaled=(u64)(value*10000.f+.5f);
integer(t,scaled/10000);text(t,".");
u64 fraction=scaled%10000;
if(fraction<1000) text(t,"0");
if(fraction<100) text(t,"0");
if(fraction<10) text(t,"0");
integer(t,fraction);
}
static void space_number(Text *t, float n) { text(t," ");decimal(t,n); }
static int uuid(Text *t)
{
u8 bytes[16];if(!io_random(bytes,sizeof(bytes))) return 0;
bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;
const char *hex="0123456789abcdef";
for(int i=0;i<16;i++) { char pair[3]={hex[bytes[i]>>4],hex[bytes[i]&15],0};text(t,pair); }
return 1;
}
static int record(Text *t, const char *name)
{
text(t,name);text(t," ");return uuid(t);
}
static int path(char *out, const char *dir, const char *name)
{
usize n=0;
while(*dir) { if(n>=3900) return 0;out[n++]=*dir++; }
if(n && out[n-1]!='/') out[n++]='/';
while(*name) { if(n>=4095) return 0;out[n++]=*name++; }
out[n]=0;return 1;
}
static void le32(u8 *out,u32 v) { for(int i=0;i<4;i++) out[i]=(u8)(v>>(8*i)); }
static const char *write_files(Scan *s)
{
Text t={.ok=1};text(&t,"frameport-room 1\nuniverse ");integer(&t,s->opt.universe);
text(&t,"\nsource scan\ntime ");integer(&t,(u64)(io_time(0)/1000000000));text(&t,"\n");
if(!record(&t,"room")) return "getrandom failed";
text(&t,"\n");if(!record(&t,"floor")) return "getrandom failed";
space_number(&t,s->floor);text(&t,"\n");if(!record(&t,"ceiling")) return "getrandom failed";
space_number(&t,s->ceiling);text(&t,"\noutline ");integer(&t,(u64)s->noutline);
for(int i=0;i<s->noutline;i++) { space_number(&t,s->outline[i].x);space_number(&t,s->outline[i].z); }
text(&t,"\n");
for(int i=0;i<s->noutline;i++) {
Pt a=s->outline[i],b=s->outline[(i+1)%s->noutline];
if(!record(&t,"wall")) return "getrandom failed";
space_number(&t,a.x);space_number(&t,a.z);space_number(&t,b.x);space_number(&t,b.z);
space_number(&t,s->floor);space_number(&t,s->ceiling);
if(s->invisible[i]) text(&t," INVISIBLE_WALL_FACE");
text(&t,"\n");
}
for(int i=0;i<s->nvolumes;i++) {
Volume *v=&s->volumes[i];
if(!record(&t,"volume")) return "getrandom failed";
text(&t," ");text(&t,v->label);space_number(&t,v->p.x);space_number(&t,v->p.z);
space_number(&t,v->p.y);space_number(&t,v->yaw);space_number(&t,v->w);space_number(&t,v->d);space_number(&t,v->h);
text(&t,"\n");
}
if(!record(&t,"mesh")) return "getrandom failed";
text(&t," room-mesh.bin ");integer(&t,(u64)s->nv);text(&t," ");integer(&t,(u64)s->ni);text(&t,"\n");
if(!t.ok) return "room text exceeds output budget";
Text rname={.ok=1},mname={.ok=1};
text(&rname,"room.txt.");text(&mname,"room-mesh.bin.");
if(!uuid(&rname) || !uuid(&mname)) return "getrandom failed";
text(&rname,".tmp");text(&mname,".tmp");rname.data[rname.n]=0;mname.data[mname.n]=0;
char room[4096],mesh[4096],rtmp[4096],mtmp[4096];
if(!path(room,s->opt.out,"room.txt") || !path(mesh,s->opt.out,"room-mesh.bin") ||
!path(rtmp,s->opt.out,rname.data) || !path(mtmp,s->opt.out,mname.data)) return "output path too long";
if(!io_mkdir(s->opt.out)) return "cannot create output directory";
int fd=io_open(rtmp,1),ok;
if(fd<0) return "cannot open room temporary file";
ok=io_write(fd,t.data,t.n);if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);return "cannot write room temporary file"; }
fd=io_open(mtmp,1);
if(fd<0) { io_remove(rtmp);return "cannot open mesh temporary file"; }
u8 header[16]={'F','P','R','M'};le32(header+4,1);le32(header+8,(u32)s->nv);le32(header+12,(u32)s->ni);
ok=io_write(fd,header,16);
/* Both supported targets are little-endian IEEE float32; parser always decodes explicitly. */
ok=ok && io_write(fd,s->vertices,(usize)s->nv*sizeof(Vec));
ok=ok && io_write(fd,s->indices,(usize)s->ni*sizeof(u32));
if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);io_remove(mtmp);return "cannot write mesh temporary file"; }
if(io_rename(mtmp,mesh)) { io_remove(rtmp);io_remove(mtmp);return "cannot publish mesh"; }
if(io_rename(rtmp,room)) { io_remove(rtmp);return "cannot publish room"; }
return 0;
}
static int error_json(const char *error)
{
Text t={.ok=1};text(&t,"{\"ok\":false,\"error\":\"");text(&t,error);text(&t,"\"}\n");
io_write(1,t.data,t.n);return 2;
}
static int parse_float(const char *p,float *out)
{
int digits=0;float n=0,scale=1;
while(*p>='0' && *p<='9') { n=n*10+*p++-'0';digits++;if(n>10000) return 0; }
if(*p=='.') { p++;while(*p>='0' && *p<='9') { scale*=.1f;n+=(*p++-'0')*scale;digits++; } }
if(!digits || *p || !finite(n)) return 0;
*out=n;return 1;
}
static int parse_universe(const char *p,u64 *out)
{
u64 n=0;if(!*p) return 0;
for(;*p;p++) {
if(*p<'0' || *p>'9') return 0;
unsigned digit=(unsigned)(*p-'0');
if(n>(~(u64)0-digit)/10) return 0;
n=n*10+digit;
}
*out=n;return 1;
}
static const char *options(Options *o,int argc,char **argv)
{
*o=(Options){.voxel=.05f,.ceiling_default=2.5f,.truncation=3,.max_edge=.6f,.grazing=.12f,.range=4.f,
.depth_jump=.25f,.depth_ratio=.12f,.min_views=5,.patch_residual=.03f,
.plane_distance=.045f,.min_plane_area=.45f,.wall_height=1.5f,.snap_degrees=5,.floor_band=.35f,
.ceiling_min=1.8f,.min_volume=.05f,.cluster_gap=.10f,.gap=.65f,.horizontal=.85f};
for(int i=1;i<argc;i++) {
const char *arg=argv[i];
if(same(arg,"--json")) continue;
if(i+1==argc) return "option requires a value";
const char *value=argv[++i];
if(same(arg,"--offline")) o->input=value;
else if(same(arg,"--out")) o->out=value;
else if(same(arg,"--hint")) o->hint=value;
else if(same(arg,"--universe")) { if(!parse_universe(value,&o->universe)) return "invalid universe"; }
else {
float *dest=0,low=0,high=100;
if(same(arg,"--voxel")) { dest=&o->voxel;low=.04f;high=.2f; }
else if(same(arg,"--ceiling-default")) { dest=&o->ceiling_default;low=1.8f;high=3.8f; }
else if(same(arg,"--truncation")) { dest=&o->truncation;low=1;high=5; }
else if(same(arg,"--max-edge")) { dest=&o->max_edge;low=.05f;high=1.5f; }
else if(same(arg,"--grazing")) { dest=&o->grazing;low=.01f;high=.9f; }
else if(same(arg,"--range")) { dest=&o->range;low=1;high=10; }
else if(same(arg,"--depth-jump")) { dest=&o->depth_jump;low=.02f;high=1; }
else if(same(arg,"--depth-ratio")) { dest=&o->depth_ratio;high=.5f; }
else if(same(arg,"--min-views")) { dest=&o->min_views;low=1;high=16; }
else if(same(arg,"--patch-residual")) { dest=&o->patch_residual;low=.005f;high=.1f; }
else if(same(arg,"--plane-distance")) { dest=&o->plane_distance;low=.01f;high=.15f; }
else if(same(arg,"--min-plane-area")) { dest=&o->min_plane_area;low=.1f;high=4; }
else if(same(arg,"--wall-height")) { dest=&o->wall_height;low=.5f;high=3; }
else if(same(arg,"--snap-degrees")) { dest=&o->snap_degrees;high=15; }
else if(same(arg,"--floor-band")) { dest=&o->floor_band;low=.05f;high=1; }
else if(same(arg,"--ceiling-min")) { dest=&o->ceiling_min;low=1.5f;high=3; }
else if(same(arg,"--min-volume")) { dest=&o->min_volume;low=.005f;high=1; }
else if(same(arg,"--cluster-gap")) { dest=&o->cluster_gap;low=.04f;high=.2f; }
else if(same(arg,"--gap")) { dest=&o->gap;low=.1f;high=1.5f; }
else if(same(arg,"--horizontal")) { dest=&o->horizontal;low=.6f;high=.99f; }
else return "unknown option";
if(!parse_float(value,dest) || *dest<low || *dest>high) return "option outside supported range";
}
}
return o->input && o->out ? 0 : "required: --offline dump.fprs --out directory";
}
static void duration(Text *t,i64 a,i64 b) { decimal(t,(float)(b-a)/1000000); }
static void summary(Scan *s)
{
Text t={.ok=1};text(&t,"{\"ok\":true,\"frames\":");integer(&t,s->frames);
text(&t,",\"skipped_frames\":");integer(&t,s->skipped_frames);
text(&t,",\"far_vertices\":");integer(&t,s->far_vertices);
text(&t,",\"discontinuity_triangles\":");integer(&t,s->discontinuities);
text(&t,",\"walls\":");integer(&t,(u64)s->noutline);
text(&t,",\"detected_walls\":");integer(&t,(u64)s->nplanes);
text(&t,",\"volumes\":");integer(&t,(u64)s->nvolumes);
text(&t,",\"floor_y\":");decimal(&t,s->floor);text(&t,",\"ceiling_y\":");decimal(&t,s->ceiling);
text(&t,",\"coverage_percent\":");decimal(&t,s->coverage);
text(&t,",\"hint_used\":");text(&t,s->hint_used ? "true" : "false");
text(&t,",\"triangles\":");integer(&t,(u64)s->ni/3);
text(&t,",\"accepted_triangles\":");integer(&t,s->accepted);
text(&t,",\"rejected_triangles\":");integer(&t,s->rejected);
text(&t,",\"warnings\":[");
for(int i=0;i<s->nwarnings;i++) { if(i) text(&t,",");text(&t,"\"");text(&t,s->warnings[i]);text(&t,"\""); }
text(&t,"],\"timings_ms\":{\"read\":");duration(&t,s->t0,s->tread);
text(&t,",\"fusion\":");duration(&t,s->tread,s->tfuse);
text(&t,",\"model\":");duration(&t,s->tfuse,s->tmodel);
text(&t,",\"mesh\":");duration(&t,s->tmodel,s->tmesh);
text(&t,",\"total\":");duration(&t,s->t0,io_time(1));text(&t,"}}\n");io_write(1,t.data,t.n);
}
int main(int argc,char **argv)
{
if(argc==2 && same(argv[1],"--help")) {
const char usage[]="fp_roomscan --offline dump.fprs --out directory [--universe N] [--voxel 0.05]\n"
" [--ceiling-default 2.5] [--hint outline.txt] [--json]\n"
"Tuning: --truncation --max-edge --grazing --range --depth-jump --depth-ratio --plane-distance --min-plane-area\n"
" --min-views --patch-residual --wall-height --snap-degrees --floor-band --ceiling-min\n"
" --min-volume --cluster-gap --gap --horizontal\n"
"Always prints one JSON summary; errors exit 2. See docs/mr/scan.md for units and ranges.\n";
io_write(1,usage,sizeof(usage)-1);return 0;
}
Scan *s=io_alloc(sizeof(Scan));
if(!s) return error_json("scan allocation failed");
s->rng=0x714ce3u;s->t0=io_time(1);
const char *error=options(&s->opt,argc,argv);
if(!error) error=read_dump(s,0);
s->tread=io_time(1);
if(!error) error=init_grid(s);
if(!error) error=read_dump(s,1);
if(!error) finish_fusion(s);
s->tfuse=io_time(1);
if(!error) error=make_model(s);
s->tmodel=io_time(1);
if(!error) error=make_mesh(s);
s->tmesh=io_time(1);
if(!error) error=write_files(s);
int result=0;
if(error) result=error_json(error);else summary(s);
io_free(s->cells,(usize)s->count*sizeof(Cell));
io_free(s->vertices,(usize)MAX_MESH_VERTICES*sizeof(Vec));
io_free(s->indices,(usize)MAX_TRIANGLES*3*sizeof(u32));
io_free(s,sizeof(Scan));return result;
}
-112
View File
@@ -1,112 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define TANGENT_RADIUS_SQUARED 2.25f
#define TANGENT_WEIGHT_RADIUS_SQUARED 2.5f
#define MAX_EXTRACTION_STRIDE 4
/* Marching cubes without a 256-case table: trace the zero contour on the six faces, then fan each closed loop.
* Ambiguous faces choose their diagonal from the face centre. Adjacent cubes therefore make the same choice. */
static const int corners[8][3]={{0,0,0},{1,0,0},{1,0,1},{0,0,1},{0,1,0},{1,1,0},{1,1,1},{0,1,1}};
static const int edges[12][2]={{0,1},{1,2},{2,3},{3,0},{4,5},{5,6},{6,7},{7,4},{0,4},{1,5},{2,6},{3,7}};
static const int faces[6][4]={{0,1,2,3},{4,5,6,7},{0,9,4,8},{1,10,5,9},{2,11,6,10},{3,8,7,11}};
static void tsdf(Scan *s)
{
float v=s->opt.voxel,trunc=v*s->opt.truncation;
int radius=(int)s->opt.truncation+2;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if (!c->weight) continue;
int x=i%s->nx,z=(i/s->nx)%s->nz,y=i/(s->nx*s->nz);
for (int dy=-radius;dy<=radius;dy++) for (int dz=-radius;dz<=radius;dz++) for (int dx=-radius;dx<=radius;dx++) {
int xx=x+dx,yy=y+dy,zz=z+dz;
if (xx<0 || yy<0 || zz<0 || xx>=s->nx || yy>=s->ny || zz>=s->nz) continue;
Vec delta=sub(grid_point(s,xx,yy,zz),c->p);
float d=dot(delta,c->n),tangent=dot(delta,delta)-d*d;
if (ab(d)>trunc || tangent>v*v*TANGENT_RADIUS_SQUARED) continue;
Cell *dest=&s->cells[(yy*s->nz+zz)*s->nx+xx];
float w=(1-tangent/(v*v*TANGENT_WEIGHT_RADIUS_SQUARED))*mn(c->weight/(v*v),1.f);
dest->sdf+=d*w;dest->sw+=w;
}
}
for (int i=0;i<s->count;i++) if(s->cells[i].sw) s->cells[i].sdf/=s->cells[i].sw;
}
static void connect(int links[12][2], int a, int b)
{
if(links[a][0]<0) links[a][0]=b;else links[a][1]=b;
if(links[b][0]<0) links[b][0]=a;else links[b][1]=a;
}
static int cube(Scan *s, int x, int y, int z, int stride)
{
float f[8];Vec p[8],normal=vec(0,0,0);
for(int k=0;k<8;k++) {
int xx=x+corners[k][0]*stride,yy=y+corners[k][1]*stride,zz=z+corners[k][2]*stride;
Cell *c=&s->cells[(yy*s->nz+zz)*s->nx+xx];
if (c->sw<=0) return 1; /* Unknown space never creates an invented surface. */
f[k]=c->sdf;p[k]=grid_point(s,xx,yy,zz);
}
Vec cuts[12];int crossing[12],links[12][2],visited[12]={0};
for(int e=0;e<12;e++) {
int a=edges[e][0],b=edges[e][1];
crossing[e]=(f[a]<0)!=(f[b]<0);links[e][0]=links[e][1]=-1;
if(crossing[e]) cuts[e]=add(p[a],mul(sub(p[b],p[a]),f[a]/(f[a]-f[b])));
}
/* Gradient points to positive TSDF: toward the observed free room, including furniture's exterior. */
normal.x=f[1]+f[2]+f[5]+f[6]-f[0]-f[3]-f[4]-f[7];
normal.y=f[4]+f[5]+f[6]+f[7]-f[0]-f[1]-f[2]-f[3];
normal.z=f[2]+f[3]+f[6]+f[7]-f[0]-f[1]-f[4]-f[5];
for(int face=0;face<6;face++) {
int hit[4],n=0;float centre=0;
for(int j=0;j<4;j++) {
int e=faces[face][j];
if(crossing[e]) hit[n++]=e;
centre+=f[edges[e][0]]+f[edges[e][1]];
}
if(n==2) connect(links,hit[0],hit[1]);
else if(n==4) {
/* The shared first edge's first corner fixes the phase on this face. */
int first=edges[hit[0]][0],second=edges[hit[0]][1];
int common=(edges[hit[1]][0]==second || edges[hit[1]][1]==second) ? second : first;
if((centre<0)==(f[common]<0)) {
connect(links,hit[0],hit[3]);connect(links,hit[1],hit[2]);
} else { connect(links,hit[0],hit[1]);connect(links,hit[2],hit[3]); }
}
}
for(int e=0;e<12;e++) if(crossing[e] && !visited[e]) {
int loop[12],n=0,prev=-1,cur=e;
while(cur>=0 && !visited[cur] && n<12) {
visited[cur]=1;loop[n++]=cur;
int next=links[cur][0]==prev ? links[cur][1] : links[cur][0];prev=cur;cur=next;
}
if(cur!=e) continue;
for(int k=1;k+1<n;k++) {
Vec a=cuts[loop[0]],b=cuts[loop[k]],c=cuts[loop[k+1]];
Vec norm=cross(sub(b,a),sub(c,a));
if(dot(norm,norm)<1e-14f) continue;
if(s->ni/3>=MAX_TRIANGLES) return 0;
if(dot(norm,normal)<0) { Vec t=b;b=c;c=t; }
s->vertices[s->nv]=a;s->vertices[s->nv+1]=b;s->vertices[s->nv+2]=c;
for(int j=0;j<3;j++) s->indices[s->ni++]=(u32)s->nv++;
}
}
return 1;
}
const char *make_mesh(Scan *s)
{
s->vertices=io_alloc((usize)MAX_MESH_VERTICES*sizeof(Vec));
s->indices=io_alloc((usize)MAX_TRIANGLES*3*sizeof(u32));
if(!s->vertices || !s->indices) return "mesh allocation failed";
tsdf(s);
for(int stride=1;stride<=MAX_EXTRACTION_STRIDE;stride++) {
s->nv=s->ni=0;int ok=1;
for(int y=0;y+stride<s->ny && ok;y+=stride)
for(int z=0;z+stride<s->nz && ok;z+=stride)
for(int x=0;x+stride<s->nx && ok;x+=stride) ok=cube(s,x,y,z,stride);
if(ok) {
if(stride>1) warn(s,"mesh decimated by coarsening extraction grid");
if(!s->ni) warn(s,"no mesh surface; scan denser views");
return 0;
}
}
return "mesh exceeds triangle budget";
}
-672
View File
@@ -1,672 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define RANSAC_TRIALS 96
#define NORMAL_AGREEMENT .90f
#define MIN_WALL_SPAN .60f
#define FURNITURE_MARGIN .12f
#define NEAR_WALL .35f
#define MIN_TOP_AREA .25f
#define BOX_TRIM .02f
#define VERTICAL_NORMAL_Y .35f
#define BOX_SIDE_NORMAL_Y .30f
#define BOX_EDGE_STRENGTH .40f
#define MIN_CLUSTER_SURFELS 12
#define MIN_BOX_EDGE_VOTES 12
#define MIN_OUTLINE_EXTENT 1.f
#define MIN_OUTLINE_TWICE_AREA .5f
#define FLOOR_INFER_DISTANCE .3f
#define TABLE_MIN_HEIGHT .55f
#define TABLE_MAX_HEIGHT 1.1f
#define COUCH_MIN_HEIGHT .35f
#define COUCH_MAX_HEIGHT .9f
#define COUCH_MIN_LENGTH 1.4f
#define BED_MIN_HEIGHT .3f
#define BED_MAX_HEIGHT .8f
#define BED_MIN_AREA 2.f
#define STORAGE_MIN_HEIGHT 1.1f
#define SCREEN_MAX_DEPTH .22f
#define SCREEN_MIN_WIDTH .4f
#define SCREEN_MAX_WIDTH 2.f
#define DEFAULT_CIRCLE_MIN_POINTS 8
#define DEFAULT_CIRCLE_RADIAL_CV .05f
#define LOW_COVERAGE_PERCENT 80.f
#define WALL_MERGE_DISTANCE .25f
#define WALL_MERGE_COSINE .97f
#define RELATIVE_WALL_AREA .20f
#define DOMINANT_WALL_TOLERANCE 12.f
#define ORTHOGONAL_SUPPORT_FRACTION .65f
#define CEILING_MIN_AREA .08f /* m^2 of fused down-facing cells at one height (fused surfaces are sparse: the
* owner's floor was 0.7 m^2 of cells, the ceiling 0.11 m^2 after a 2 s look up) */
#define CEILING_MIN_HEIGHT 2.0f /* m above the floor: lower ones are shelves, door frames, table undersides */
#define CEILING_MAX_HEIGHT 4.0f
#define BOX_AXIS_COS .96f /* walls within ~16 deg of the two room axes (smeared depth tilted a real
* wall 12 deg in the owner's scan) */
#define BOX_SNAP .5f /* m: a rectangle side snaps to a detected wall this close */
#define BOX_LOW_SHARE .02f /* surfaces beyond the 2nd/98th percentile along an axis are outliers */
#define WALL_BAND_TOP 2.4f /* m above the floor: walls are fitted below this (and below the ceiling) */
#define MIN_FURNITURE_TOP .25f
#define WALL_FURNITURE_CLEARANCE .15f
#define MIN_TOP_DENSITY .30f
static void consolidate_walls(Scan *s)
{
if(!s->nplanes) return;
Plane result[MAX_PLANES]={0};int count=0;
float largest=0;
for(int i=0;i<s->nplanes;i++) {
Plane p=s->planes[i];int merge=-1;
largest=mx(largest,p.area);
for(int j=0;j<count;j++) if(dot(p.n,result[j].n)>WALL_MERGE_COSINE &&
ab(p.d-result[j].d)<WALL_MERGE_DISTANCE) { merge=j;break; }
if(merge<0) result[count++]=p;
else {
Plane *r=&result[merge];
r->area+=p.area;r->bottom=mn(r->bottom,p.bottom);r->top=mx(r->top,p.top);
r->lo=mn(r->lo,p.lo);r->hi=mx(r->hi,p.hi);
}
}
float base=angle(result[0].n.z,result[0].n.x),aligned=0,total=0;
for(int i=0;i<count;i++) {
Plane *p=&result[i];float a=angle(p->n.z,p->n.x)-base;
float delta=ab(a-flo(a/(PI/2)+.5f)*(PI/2));
total+=p->area;
if(delta<DOMINANT_WALL_TOLERANCE*PI/180) aligned+=p->area;
}
int orthogonal=aligned>total*ORTHOGONAL_SUPPORT_FRACTION && s->opt.snap_degrees>0;
s->nplanes=0;
for(int i=0;i<count;i++) {
Plane p=result[i];float a=angle(p.n.z,p.n.x)-base;
float delta=ab(a-flo(a/(PI/2)+.5f)*(PI/2));
if(p.area<largest*RELATIVE_WALL_AREA) continue;
if(orthogonal && delta>DOMINANT_WALL_TOLERANCE*PI/180) continue;
s->planes[s->nplanes++]=p;
}
}
static float plane_height(Scan *s, int ceiling, int *found)
{
float hist[512]={0}, step=s->opt.voxel*.5f;
int bins=(int)(4/step)+2;
if (bins>512) bins=512;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if (!c->weight || (ceiling ? c->n.y > -s->opt.horizontal : c->n.y < s->opt.horizontal)) continue;
if (ceiling ? c->p.y<s->opt.ceiling_min : ab(c->p.y)>s->opt.floor_band) continue;
int b=flo((c->p.y-s->origin.y)/step);
if (b>=0 && b<bins) hist[b]+=1;
}
int best=0;
float score=0;
for (int b=1;b<bins-1;b++) {
float h=hist[b-1]+hist[b]+hist[b+1];
if (h>score) { score=h; best=b; }
}
float y=s->origin.y+(best+.5f)*step, total=0, sum=0,upward=0;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if(c->weight && c->n.y>s->opt.horizontal) upward++;
if (!c->weight || ab(c->p.y-y)>s->opt.plane_distance) continue;
if (ceiling ? c->n.y > -s->opt.horizontal : c->n.y < s->opt.horizontal) continue;
total++; sum+=c->p.y;
}
*found=(ceiling || score* s->opt.voxel*s->opt.voxel >= s->opt.min_plane_area) && total>0;
/* A ceiling is judged on its own area and height, not against the floor: players glance up for a second and look
* at the floor all the time, so a share-of-upward-surfaces rule rejected every real ceiling (owner's scans,
* 2026-10-16: down-facing surfaces at ~2.9 m in both recordings, "ceiling not seen"). */
(void)upward;
if(ceiling && (total*s->opt.voxel*s->opt.voxel<CEILING_MIN_AREA || y-s->floor<CEILING_MIN_HEIGHT ||
y-s->floor>CEILING_MAX_HEIGHT)) *found=0;
return *found ? sum/total : (ceiling ? s->floor+s->opt.ceiling_default : 0);
}
static int on_plane(Scan *s, Cell *c, Plane *p)
{
return ab(dot(c->p,p->n)-p->d)<s->opt.plane_distance && dot(c->n,p->n)>NORMAL_AGREEMENT;
}
static void refine(Scan *s, int *ids, int n, Plane *p)
{
Vec avg=vec(0,0,0), normal=vec(0,0,0);
float count=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) { avg=add(avg,c->p); normal=add(normal,c->n); count++; }
}
if (!count) return;
avg=mul(avg,1/count);
/* Fit positions, rather than averaging noisy per-triangle normals. */
float xx=0,zz=0,xz=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) {
float x=c->p.x-avg.x,z=c->p.z-avg.z;
xx+=x*x; zz+=z*z; xz+=x*z;
}
}
float yaw=.5f*angle(2*xz,xx-zz);
Vec nn=vec(-sine(yaw),0,cosine(yaw));
if (dot(nn,normal)<0) nn=mul(nn,-1);
p->n=nn; p->d=dot(nn,avg);
p->lo=1e10f; p->hi=-1e10f; p->bottom=1e10f; p->top=-1e10f;
count=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) {
float t=c->p.x*p->n.z-c->p.z*p->n.x;
p->lo=mn(p->lo,t); p->hi=mx(p->hi,t);
p->bottom=mn(p->bottom,c->p.y); p->top=mx(p->top,c->p.y); count++;
}
}
p->area=count*s->opt.voxel*s->opt.voxel;
}
static void walls(Scan *s, int *ids, int n)
{
/* Walls are fitted in a fixed band above the floor whatever the ceiling: Valve's smoothed depth bends where wall
* meets ceiling, and with a detected 2.9 m ceiling that band confused the fit (owner's scan, 2026-10-16). */
float top=mn(s->ceiling,s->floor+WALL_BAND_TOP)-FURNITURE_MARGIN;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
c->mark=ab(c->n.y)>VERTICAL_NORMAL_Y || c->p.y<s->floor+FURNITURE_MARGIN || c->p.y>top;
}
for (int attempt=0;attempt<MAX_PLANES*2 && s->nplanes<MAX_PLANES;attempt++) {
Plane best={0}; int best_count=0;
for (int trial=0;trial<RANSAC_TRIALS;trial++) {
Cell *seed=&s->cells[ids[random_next(s)%(unsigned)n]];
if (seed->mark) continue;
Plane p={0}; p.n=unit(vec(seed->n.x,0,seed->n.z)); p.d=dot(seed->p,p.n);
int count=0;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (!c->mark && on_plane(s,c,&p)) count++;
}
if (count>best_count) { best_count=count; best=p; }
}
if (best_count*s->opt.voxel*s->opt.voxel<s->opt.min_plane_area) break;
refine(s,ids,n,&best);
/* Height separates large furniture faces from room walls. */
int wall=best.area>=s->opt.min_plane_area && best.top-best.bottom>=s->opt.wall_height &&
best.hi-best.lo>=MIN_WALL_SPAN;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (!c->mark && on_plane(s,c,&best)) c->mark=wall ? 2 : 3;
}
if (wall) s->planes[s->nplanes++]=best;
/* Rejected candidates are consumed too, ensuring bounded progress. */
}
if (s->nplanes==MAX_PLANES) warn(s,"wall limit reached");
if (s->nplanes) {
float base=angle(s->planes[0].n.z,s->planes[0].n.x);
for (int k=0;k<s->nplanes;k++) {
Plane *p=&s->planes[k];
float a=angle(p->n.z,p->n.x), diff=a-base;
int quarter=flo(diff/(PI/2)+.5f);
float target=base+quarter*(PI/2);
if (ab(a-target)<s->opt.snap_degrees*PI/180) {
Vec nn=vec(cosine(target),0,sine(target));
float sum=0,count=0,lo=1e10f,hi=-1e10f;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (on_plane(s,c,p)) {
sum+=dot(c->p,nn); count++;
float t=c->p.x*nn.z-c->p.z*nn.x;
lo=mn(lo,t); hi=mx(hi,t);
}
}
if (count) { p->n=nn; p->d=sum/count; p->lo=lo; p->hi=hi; }
}
}
}
consolidate_walls(s);
}
static Pt endpoint(Plane *p, int end)
{
float t=end ? p->hi : p->lo;
return (Pt){p->n.x*p->d+p->n.z*t,p->n.z*p->d-p->n.x*t};
}
static float distance2(Pt a, Pt b) { float x=a.x-b.x,z=a.z-b.z; return x*x+z*z; }
static int intersect(Plane *a, Plane *b, Pt *p)
{
float det=a->n.x*b->n.z-a->n.z*b->n.x;
if (ab(det)<.1f) return 0;
p->x=(a->d*b->n.z-a->n.z*b->d)/det;
p->z=(a->n.x*b->d-a->d*b->n.x)/det;
return 1;
}
static void push_outline(Scan *s, Pt p, int invisible)
{
if (s->noutline && distance2(s->outline[s->noutline-1],p)<.0001f) return;
if (s->noutline<MAX_OUTLINE) {
s->outline[s->noutline]=p;
s->invisible[s->noutline++]=invisible;
}
}
static void rectangle(Scan *s, int *ids, int n)
{
float x0=1e10f,x1=-1e10f,z0=1e10f,z1=-1e10f;
for (int i=0;i<n;i++) {
Vec p=s->cells[ids[i]].p;
x0=mn(x0,p.x);x1=mx(x1,p.x);z0=mn(z0,p.z);z1=mx(z1,p.z);
}
if (x1-x0<MIN_OUTLINE_EXTENT) { float m=(x0+x1)*.5f; x0=m-MIN_OUTLINE_EXTENT/2; x1=m+MIN_OUTLINE_EXTENT/2; }
if (z1-z0<MIN_OUTLINE_EXTENT) { float m=(z0+z1)*.5f; z0=m-MIN_OUTLINE_EXTENT/2; z1=m+MIN_OUTLINE_EXTENT/2; }
push_outline(s,(Pt){x0,z0},1);push_outline(s,(Pt){x1,z0},1);
push_outline(s,(Pt){x1,z1},1);push_outline(s,(Pt){x0,z1},1);
warn(s,"incomplete walls; synthesized outline");
}
static int segment_cross(Pt a, Pt b, Pt c, Pt d)
{
float p=(b.x-a.x)*(c.z-a.z)-(b.z-a.z)*(c.x-a.x);
float q=(b.x-a.x)*(d.z-a.z)-(b.z-a.z)*(d.x-a.x);
float r=(d.x-c.x)*(a.z-c.z)-(d.z-c.z)*(a.x-c.x);
float t=(d.x-c.x)*(b.z-c.z)-(d.z-c.z)*(b.x-c.x);
return p*q<-.000001f && r*t<-.000001f;
}
static int valid_polygon(Scan *s)
{
float area=0;
for (int i=0;i<s->noutline;i++) {
Pt a=s->outline[i], b=s->outline[(i+1)%s->noutline];
area+=a.x*b.z-b.x*a.z;
for (int j=i+2;j<s->noutline;j++) {
if ((j+1)%s->noutline==i) continue;
if (segment_cross(a,b,s->outline[j],s->outline[(j+1)%s->noutline])) return 0;
}
}
return s->noutline>=3 && area>MIN_OUTLINE_TWICE_AREA;
}
static int convex_fallback(Scan *s)
{
if(s->nplanes<3 || s->nplanes>4) return 0;
/* A concave multi-wall room must use directed segments. A small set of enclosure planes can instead be
* clipped as inward half-planes when smeared endpoints prevent reliable corner ordering. */
Pt a[MAX_OUTLINE],b[MAX_OUTLINE];float x0=1e10f,x1=-1e10f,z0=1e10f,z1=-1e10f;
for(int i=0;i<s->nplanes;i++) for(int end=0;end<2;end++) {
Pt p=endpoint(&s->planes[i],end);x0=mn(x0,p.x);x1=mx(x1,p.x);z0=mn(z0,p.z);z1=mx(z1,p.z);
}
int n=4;a[0]=(Pt){x0,z0};a[1]=(Pt){x1,z0};a[2]=(Pt){x1,z1};a[3]=(Pt){x0,z1};
for(int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];int count=0;
for(int j=0;j<n;j++) {
Pt u=a[j],v=a[(j+1)%n];float du=p->n.x*u.x+p->n.z*u.z-p->d,dv=p->n.x*v.x+p->n.z*v.z-p->d;
if(du>=0) b[count++]=u;
if((du>=0)!=(dv>=0)) {
float t=du/(du-dv);b[count++]=(Pt){u.x+t*(v.x-u.x),u.z+t*(v.z-u.z)};
}
}
if(count<3 || count>=MAX_OUTLINE) return 0;
n=count;memcpy(a,b,(usize)n*sizeof(Pt));
}
s->noutline=n;
for(int j=0;j<n;j++) {
s->outline[j]=a[j];s->invisible[j]=1;Pt v=a[(j+1)%n];
for(int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];
if(ab(p->n.x*a[j].x+p->n.z*a[j].z-p->d)<s->opt.plane_distance &&
ab(p->n.x*v.x+p->n.z*v.z-p->d)<s->opt.plane_distance) s->invisible[j]=0;
}
}
if(!valid_polygon(s)) { s->noutline=0;return 0; }
warn(s,"wall endpoints uncertain; using plane intersections");return 1;
}
/* Value below which `share` of the projections fall (2 cm bins over +-8 m around the data). */
static float projection_percentile(Scan *s, int *ids, int n, Pt axis, float share)
{
static u32 hist[800];
memset(hist,0,sizeof(hist));
for (int i=0;i<n;i++) {
Vec p=s->cells[ids[i]].p;
int b=(int)((p.x*axis.x+p.z*axis.z+8)/.02f);
if (b>=0 && b<800) hist[b]++;
}
u64 total=0,run=0;
for (int b=0;b<800;b++) total+=hist[b];
for (int b=0;b<800;b++) { run+=hist[b]; if (run>=share*total) return b*.02f-8+.01f; }
return 8;
}
/* Most rooms are rectangles. When every detected wall runs along one of two perpendicular axes (2-4 walls), the
* outline is that rectangle: its sides at the 2nd/98th percentile of the surfaces along each axis, each snapped to a
* detected wall when one lies within BOX_SNAP. Directed wall chaining made a skewed quadrilateral of the owner's
* room from three smeared walls (2026-10-16). L-shaped rooms have more walls and keep the chaining below. */
static int orthogonal_box(Scan *s, int *ids, int n)
{
if (s->nplanes<2 || s->nplanes>4) return 0;
Vec u=s->planes[0].n; float len=root(u.x*u.x+u.z*u.z);
if (len<.5f) return 0;
Pt axis[2]={{u.x/len,u.z/len},{-u.z/len,u.x/len}};
float side[2][2]; int wall[2][2]={{0,0},{0,0}}, snapped=0;
for (int k=0;k<2;k++) {
side[k][0]=projection_percentile(s,ids,n,axis[k],BOX_LOW_SHARE);
side[k][1]=projection_percentile(s,ids,n,axis[k],1-BOX_LOW_SHARE);
}
for (int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];
float c0=p->n.x*axis[0].x+p->n.z*axis[0].z, c1=p->n.x*axis[1].x+p->n.z*axis[1].z;
int k=ab(c0)>=BOX_AXIS_COS ? 0 : ab(c1)>=BOX_AXIS_COS ? 1 : -1;
if (k<0) return 0; // a wall off both axes: not a rectangle
float offset=p->d*(k ? c1 : c0)/ab(k ? c1 : c0);
int end=ab(offset-side[k][0])<ab(offset-side[k][1]) ? 0 : 1;
if (ab(offset-side[k][end])<BOX_SNAP) { side[k][end]=offset; snapped+=!wall[k][end]; wall[k][end]=1; }
}
if (snapped<2 || side[0][1]-side[0][0]<MIN_OUTLINE_EXTENT || side[1][1]-side[1][0]<MIN_OUTLINE_EXTENT) return 0;
/* corners (s,t) -> world x = a0.x s + a1.x t, z = a0.z s + a1.z t; edge flags = whether its side is a wall */
int order[4][2]={{0,0},{1,0},{1,1},{0,1}}, edge[4][2]={{1,0},{0,1},{1,1},{0,0}}; // edge k lies on side[a][e]
float twice=0;
Pt c[4];
for (int j=0;j<4;j++) {
float a=side[0][order[j][0]], b=side[1][order[j][1]];
c[j]=(Pt){axis[0].x*a+axis[1].x*b, axis[0].z*a+axis[1].z*b};
}
for (int j=0;j<4;j++) twice+=c[j].x*c[(j+1)%4].z-c[(j+1)%4].x*c[j].z;
s->noutline=0;
if (twice>0) for (int j=0;j<4;j++) push_outline(s,c[j],!wall[edge[j][0]][edge[j][1]]);
else for (int j=0;j<4;j++) { // reversed: corner 0,3,2,1; edge from corner k to k-1 is the old edge k-1
int k=(4-j)%4, e=(k+3)%4;
push_outline(s,c[k],!wall[edge[e][0]][edge[e][1]]);
}
if (!valid_polygon(s)) { s->noutline=0; return 0; }
if (snapped<4) warn(s,"gaps closed with invisible walls");
return 1;
}
static void outline(Scan *s, int *ids, int n)
{
if (orthogonal_box(s,ids,n)) return;
if (s->nplanes<3) { rectangle(s,ids,n); return; }
int order[MAX_PLANES]={0};
s->planes[0].used=1;
for (int k=1;k<s->nplanes;k++) {
Pt end=endpoint(&s->planes[order[k-1]],1);
float best=1e10f; int which=-1;
for (int j=0;j<s->nplanes;j++) if (!s->planes[j].used) {
float d=distance2(end,endpoint(&s->planes[j],0));
if (d<best) { best=d;which=j; }
}
order[k]=which;s->planes[which].used=1;
}
Pt starts[MAX_PLANES], ends[MAX_PLANES];
for (int k=0;k<s->nplanes;k++) {
Plane *p=&s->planes[order[k]], *next=&s->planes[order[(k+1)%s->nplanes]];
ends[k]=endpoint(p,1); starts[(k+1)%s->nplanes]=endpoint(next,0);
Pt corner;
if (intersect(p,next,&corner) && distance2(corner,ends[k])<s->opt.gap*s->opt.gap &&
distance2(corner,starts[(k+1)%s->nplanes])<s->opt.gap*s->opt.gap)
ends[k]=starts[(k+1)%s->nplanes]=corner;
}
for (int k=0;k<s->nplanes;k++) {
push_outline(s,starts[k],0);
if (distance2(ends[k],starts[(k+1)%s->nplanes])>.0001f) {
push_outline(s,ends[k],1);warn(s,"gaps closed with invisible walls");
}
}
if (!valid_polygon(s)) {
s->noutline=0;
if(!convex_fallback(s)) { rectangle(s,ids,n);warn(s,"wall ordering ambiguous"); }
}
}
static float wall_distance(Scan *s, Vec p)
{
float best=1e10f;
for (int i=0;i<s->nplanes;i++) best=mn(best,ab(dot(s->planes[i].n,p)-s->planes[i].d));
return best;
}
static void trimmed_bounds(Scan *s, int *queue, int n, Vec axis, float *lo, float *hi)
{
int hist[512]={0};float start=*lo,range=*hi-start;
if(range<.001f) return;
for(int i=0;i<n;i++) {
float value=dot(s->cells[queue[i]].p,axis);
int bin=(int)clamp((value-start)/range*511,0,511);hist[bin]++;
}
int count=0,target=(int)(n*BOX_TRIM);
for(int b=0;b<512;b++) { count+=hist[b];if(count>target) { *lo=start+range*b/511;break; } }
count=0;
for(int b=511;b>=0;b--) { count+=hist[b];if(count>target) { *hi=start+range*(b+1)/511;break; } }
}
static float top_height(Scan *s, int *queue, int n, float low, float high)
{
int histogram[512]={0},bins=(int)mn((high-low)/(s->opt.voxel*.25f)+2,512);
float step=s->opt.voxel*.25f;
for(int i=0;i<n;i++) {
int bin=flo((s->cells[queue[i]].p.y-low)/step);
if(bin>=0 && bin<bins) histogram[bin]++;
}
int count=0;
for(int bin=0;bin<bins;bin++) {
count+=histogram[bin];
/* The median resists a softened lip without selecting one of several noisy sub-peaks as a new top. */
if(count>=n/2) return low+(bin+.5f)*step;
}
return (low+high)*.5f;
}
static void furniture(Scan *s, int *ids, int n, int *queue)
{
/* Tops remain separable when a softened sofa/table edge blends into its neighbouring wall. Vertical-only
* components are considered separately for screens; they cannot turn a whole wall into an OTHER volume. */
for(int mode=0;mode<2;mode++) {
float histogram[256]={0},levels[32];int nlevels=0;
if(!mode) {
for(int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if(c->n.y>s->opt.horizontal && c->p.y>s->floor+MIN_FURNITURE_TOP &&
c->p.y<s->ceiling-FURNITURE_MARGIN && wall_distance(s,c->p)>WALL_FURNITURE_CLEARANCE) {
int bin=flo((c->p.y-s->floor)/s->opt.voxel);
if(bin>=0 && bin<256) histogram[bin]++;
}
}
for(int k=0;k<32;k++) {
int best=0;float score=0;
for(int bin=1;bin<255;bin++) {
float count=histogram[bin-1]+histogram[bin]+histogram[bin+1];
if(count>score) { score=count;best=bin; }
}
if(score*s->opt.voxel*s->opt.voxel<MIN_TOP_AREA) break;
levels[nlevels++]=s->floor+(best+.5f)*s->opt.voxel;
int radius=(int)(s->opt.plane_distance*2/s->opt.voxel)+1;
for(int bin=best-radius;bin<=best+radius;bin++) if(bin>=0 && bin<256) histogram[bin]=0;
}
}
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
int normal=mode ? ab(c->n.y)<BOX_SIDE_NORMAL_Y : c->n.y>s->opt.horizontal;
c->mark=(normal && c->p.y>s->floor+MIN_FURNITURE_TOP && c->p.y<s->ceiling-FURNITURE_MARGIN &&
wall_distance(s,c->p)>WALL_FURNITURE_CLEARANCE) ? 1 : 0;
if(c->mark && !mode) {
c->mark=0;float best=s->opt.plane_distance*2;
for(int k=0;k<nlevels;k++) if(ab(c->p.y-levels[k])<best) {
best=ab(c->p.y-levels[k]);c->mark=k+1;
}
}
}
int radius=(int)(s->opt.cluster_gap/s->opt.voxel)+1;
for (int j=0;j<n;j++) {
int seed=ids[j];
if (s->cells[seed].mark<=0) continue;
int group=s->cells[seed].mark;
int head=0,tail=1;queue[0]=seed;s->cells[seed].mark=-1;
while (head<tail) {
int id=queue[head++],x=id%s->nx,z=(id/s->nx)%s->nz,y=id/(s->nx*s->nz);
for (int dy=-radius;dy<=radius;dy++) for (int dz=-radius;dz<=radius;dz++) for (int dx=-radius;dx<=radius;dx++) {
if (dx*dx+dy*dy+dz*dz>radius*radius) continue;
int xx=x+dx,yy=y+dy,zz=z+dz;
if (xx<0 || yy<0 || zz<0 || xx>=s->nx || yy>=s->ny || zz>=s->nz) continue;
int next=(yy*s->nz+zz)*s->nx+xx;
Cell *c=&s->cells[next];
if (c->mark==group) { c->mark=-1;queue[tail++]=next; }
}
}
if (tail<MIN_CLUSTER_SURFELS) continue;
Vec avg=vec(0,0,0);float top=-1e10f,bottom=1e10f;
for (int k=0;k<tail;k++) {
Vec p=s->cells[queue[k]].p;avg=add(avg,p);top=mx(top,p.y);bottom=mn(bottom,p.y);
}
avg=mul(avg,1.f/tail);
float xx=0,zz=0,xz=0,edge_x=0,edge_y=0,edge_count=0;
for (int k=0;k<tail;k++) {
Cell *c=&s->cells[queue[k]];
Vec p=sub(c->p,avg);xx+=p.x*p.x;zz+=p.z*p.z;xz+=p.x*p.z;
if(ab(c->n.y)<BOX_SIDE_NORMAL_Y) {
Vec norm=unit(vec(c->n.x,0,c->n.z));
float co2=norm.x*norm.x-norm.z*norm.z,si2=2*norm.x*norm.z;
edge_x+=co2*co2-si2*si2;edge_y+=2*co2*si2;edge_count++;
}
}
float yaw=.5f*angle(2*xz,xx-zz);
/* Opposing faces and perpendicular edges vote for the same fourth harmonic. Unlike PCA, this is not
* biased by one side being more visible. Non-box objects fall back to position covariance. */
if(edge_count>MIN_BOX_EDGE_VOTES && root(edge_x*edge_x+edge_y*edge_y)>edge_count*BOX_EDGE_STRENGTH)
yaw=.25f*angle(edge_y,edge_x);
if(!mode) {
float smallest=1e10f;
for(int degree=0;degree<90;degree++) {
float a=degree*PI/180,co=cosine(a),si=sine(a),u0=1e10f,u1=-1e10f,v0=1e10f,v1=-1e10f;
for(int k=0;k<tail;k++) {
Vec p=s->cells[queue[k]].p;float u=p.x*co+p.z*si,v=-p.x*si+p.z*co;
u0=mn(u0,u);u1=mx(u1,u);v0=mn(v0,v);v1=mx(v1,v);
}
float area=(u1-u0)*(v1-v0);
if(area<smallest) { smallest=area;yaw=a; }
}
}
float co=cosine(yaw),si=sine(yaw);
float u0=1e10f,u1=-1e10f,v0=1e10f,v1=-1e10f,top_count=0;
for (int k=0;k<tail;k++) {
Cell *c=&s->cells[queue[k]];Vec p=sub(c->p,avg);
float u=p.x*co+p.z*si,v=-p.x*si+p.z*co;
u0=mn(u0,u);u1=mx(u1,u);v0=mn(v0,v);v1=mx(v1,v);
if (c->n.y>s->opt.horizontal && c->p.y>top-s->opt.plane_distance*2) top_count++;
}
/* Stereo discontinuities leave a low-density halo even after long-edge rejection. Trim the outer
* two percent before measuring boxes; otherwise a table can become wide enough to look like a couch. */
float up=dot(avg,vec(co,0,si)),vp=dot(avg,vec(-si,0,co));
u0+=up;u1+=up;v0+=vp;v1+=vp;
trimmed_bounds(s,queue,tail,vec(co,0,si),&u0,&u1);
trimmed_bounds(s,queue,tail,vec(-si,0,co),&v0,&v1);
u0-=up;u1-=up;v0-=vp;v1-=vp;
trimmed_bounds(s,queue,tail,vec(0,1,0),&bottom,&top);
if(!mode) top=top_height(s,queue,tail,bottom,top);
float w=u1-u0,d=v1-v0,h=top-(bottom<s->floor+FLOOR_INFER_DISTANCE ? s->floor : mn(bottom,s->floor+FURNITURE_MARGIN));
if(d>w) { float t=w;w=d;d=t;yaw+=PI/2; }
while(yaw>PI/2) yaw-=PI;
if(!mode) h=top-s->floor;
if (w*d*h<s->opt.min_volume) continue;
float height=top-s->floor,area=w*d,top_area=(mode ? top_count : tail)*s->opt.voxel*s->opt.voxel;
if(!mode && (top_area<MIN_TOP_AREA || top_area<area*MIN_TOP_DENSITY)) continue;
const char *label="OTHER";
if (height>=BED_MIN_HEIGHT && height<=BED_MAX_HEIGHT && area>=BED_MIN_AREA) label="BED";
else if (height>=COUCH_MIN_HEIGHT && height<=COUCH_MAX_HEIGHT && mx(w,d)+2*s->opt.voxel>=COUCH_MIN_LENGTH) label="COUCH";
else if (height>=TABLE_MIN_HEIGHT && height<=TABLE_MAX_HEIGHT && top_area>=MIN_TOP_AREA) label="TABLE";
else if (mn(w,d)<SCREEN_MAX_DEPTH && mx(w,d)>=SCREEN_MIN_WIDTH && mx(w,d)<=SCREEN_MAX_WIDTH &&
wall_distance(s,avg)<NEAR_WALL) label="SCREEN";
else if (h>=STORAGE_MIN_HEIGHT && wall_distance(s,avg)<NEAR_WALL) label="STORAGE";
if(mode && !same(label,"SCREEN")) continue;
if(!mode && same(label,"OTHER") && height>TABLE_MAX_HEIGHT) continue;
if (s->nvolumes>=MAX_VOLUMES) { warn(s,"furniture limit reached");break; }
Vec centre=vec(avg.x+co*(u0+u1)*.5f-si*(v0+v1)*.5f,top,
avg.z+si*(u0+u1)*.5f+co*(v0+v1)*.5f);
/* ROOM_FORMAT yaw is an active right-handed rotation about +Y. */
s->volumes[s->nvolumes++]=(Volume){centre,-yaw*180/PI,w,d,h,label};
}
}
}
/* A hint is a fallback for missing walls, never a constraint on observed geometry. */
static const char *hint(Scan *s);
const char *make_model(Scan *s)
{
int found;
s->floor=plane_height(s,0,&found);
if (!found) warn(s,"floor not seen; using STAGE floor");
s->ceiling=plane_height(s,1,&found);
if (!found) warn(s,"ceiling not seen");
usize bytes=(usize)s->count*sizeof(int);
int *ids=io_alloc(bytes),*queue=io_alloc(bytes);
if (!ids || !queue) { io_free(ids,bytes);io_free(queue,bytes);return "model allocation failed"; }
int n=0;
for (int i=0;i<s->count;i++) if (s->cells[i].weight) ids[n++]=i;
if (!n) { io_free(ids,bytes);io_free(queue,bytes);return "no surfaces in reconstruction bounds"; }
walls(s,ids,n);outline(s,ids,n);
const char *error=hint(s);
if (!error) furniture(s,ids,n,queue);
float perimeter=0,observed=0;
for (int i=0;i<s->noutline;i++) {
float d=root(distance2(s->outline[i],s->outline[(i+1)%s->noutline]));perimeter+=d;
if (!s->invisible[i]) observed+=d;
}
s->coverage=perimeter ? 100*observed/perimeter : 0;
if (s->coverage<LOW_COVERAGE_PERCENT) warn(s,"low wall coverage; scan all walls");
io_free(ids,bytes);io_free(queue,bytes);
return error;
}
static int number(char **cursor, float *value)
{
char *p=*cursor;while (*p==' ' || *p=='\t' || *p=='\r') p++;
int sign=1,digits=0;if (*p=='-' || *p=='+') { if (*p=='-') sign=-1;p++; }
float v=0,scale=1;
while (*p>='0' && *p<='9') { v=v*10+*p++-'0';digits++;if(v>10000) return 0; }
if (*p=='.') { p++;while (*p>='0' && *p<='9') { scale*=.1f;v+=(*p++-'0')*scale;digits++; } }
if(*p=='e' || *p=='E') {
p++;int exponent=0,negative=0,edigits=0;
if(*p=='-' || *p=='+') { negative=*p=='-';p++; }
while(*p>='0' && *p<='9') { exponent=exponent*10+*p++-'0';edigits++;if(exponent>38) return 0; }
if(!edigits) return 0;
while(exponent--) v=negative ? v*.1f : v*10;
}
if (!digits || !finite(v) || v>10000) return 0;
*value=sign*v;*cursor=p;return 1;
}
static const char *hint(Scan *s)
{
if (!s->opt.hint) return 0;
int fd=io_open(s->opt.hint,0);
if (fd<0) return "cannot open outline hint";
char data[8192];usize size=0;
for (;;) {
long r=io_read(fd,data+size,sizeof(data)-1-size);
if (r<0 || (r>0 && size+(usize)r==sizeof(data)-1)) { io_close(fd);return "outline hint too large or unreadable"; }
if (!r) break;
size+=(usize)r;
}
io_close(fd);data[size]=0;
Pt pts[MAX_OUTLINE];int n=0;char *p=data;
while (*p) {
while (*p==' ' || *p=='\t' || *p=='\r' || *p=='\n') p++;
if (!*p) break;
if (*p=='#') { while (*p && *p!='\n') p++;continue; }
if (n==MAX_OUTLINE || !number(&p,&pts[n].x) || !number(&p,&pts[n].z)) return "invalid outline hint";
n++;while (*p==' ' || *p=='\t' || *p=='\r') p++;
if (*p && *p!='\n' && *p!='#') return "invalid outline hint";
if (*p=='#') while (*p && *p!='\n') p++;
}
if (n<3) return "outline hint needs at least three points";
Pt centre={0};for (int i=0;i<n;i++) { centre.x+=pts[i].x/n;centre.z+=pts[i].z/n; }
float mean=0,variance=0;
for (int i=0;i<n;i++) mean+=root(distance2(pts[i],centre))/n;
for (int i=0;i<n;i++) { float r=root(distance2(pts[i],centre))-mean;variance+=r*r/n; }
if (n>=DEFAULT_CIRCLE_MIN_POINTS && root(variance)<mean*DEFAULT_CIRCLE_RADIAL_CV) {
warn(s,"default circle hint ignored");return 0;
}
int missing=0;for(int i=0;i<s->noutline;i++) missing+=s->invisible[i];
if (!missing) return 0;
Pt saved[MAX_OUTLINE];int inv[MAX_OUTLINE],old=s->noutline;
memcpy(saved,s->outline,sizeof(saved));memcpy(inv,s->invisible,sizeof(inv));
float area=0;for(int i=0;i<n;i++) area+=pts[i].x*pts[(i+1)%n].z-pts[(i+1)%n].x*pts[i].z;
s->noutline=n;
for(int i=0;i<n;i++) { s->outline[i]=pts[area>0 ? i : n-1-i];s->invisible[i]=1; }
if (!valid_polygon(s)) {
memcpy(s->outline,saved,sizeof(saved));memcpy(s->invisible,inv,sizeof(inv));s->noutline=old;
return "invalid outline hint polygon";
}
/* Only use a hint compatible with every observed wall. Chaperone can be an inset play area. */
for(int i=0;i<s->nplanes;i++) {
Plane *w=&s->planes[i];int match=0;
for(int k=0;k<n;k++) {
Pt a=s->outline[k],b=s->outline[(k+1)%n];
if(ab(a.x*w->n.x+a.z*w->n.z-w->d)<s->opt.gap &&
ab(b.x*w->n.x+b.z*w->n.z-w->d)<s->opt.gap) { match=1;s->invisible[k]=0; }
}
if (!match) {
memcpy(s->outline,saved,sizeof(saved));memcpy(s->invisible,inv,sizeof(inv));s->noutline=old;
warn(s,"hint disagrees with observed walls; ignored");return 0;
}
}
s->hint_used=1;warn(s,"incomplete walls closed using outline hint");return 0;
}
-145
View File
@@ -1,145 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "platform.h"
#ifdef FP_HOSTED
#define _DEFAULT_SOURCE 1
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/random.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
int io_open(const char *p, int w) { return open(p, w ? O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW : O_RDONLY, 0600); }
long io_read(int fd, void *p, usize n)
{
long r;
do r = read(fd, p, n); while (r < 0 && errno == EINTR);
return r;
}
static long put(int fd, const void *p, usize n)
{
long r;
do r = write(fd, p, n); while (r < 0 && errno == EINTR);
return r;
}
int io_close(int fd) { return close(fd); }
int io_rename(const char *a, const char *b) { return rename(a, b); }
void io_remove(const char *p) { unlink(p); }
int io_mkdir(const char *p) { return mkdir(p, 0700) == 0 || errno == EEXIST; }
void *io_alloc(usize n)
{
void *p = mmap(0, n, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
return p == MAP_FAILED ? 0 : p;
}
void io_free(void *p, usize n) { if (p) munmap(p, n); }
int io_random(void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = getrandom((u8 *)p + done, n - done, 0);
if (r < 0 && errno == EINTR) continue;
if (r <= 0) return 0;
done += (usize)r;
}
return 1;
}
i64 io_time(int mono)
{
struct timespec t;
if (clock_gettime(mono ? CLOCK_MONOTONIC : CLOCK_REALTIME, &t)) return 0;
return (i64)t.tv_sec * 1000000000 + t.tv_nsec;
}
#else
/* asm-generic Linux syscall numbers: this target has no libc or UAPI dependency. */
static long call(long n, long a, long b, long c, long d, long e, long f)
{
register long x8 __asm__("x8") = n;
register long x0 __asm__("x0") = a;
register long x1 __asm__("x1") = b;
register long x2 __asm__("x2") = c;
register long x3 __asm__("x3") = d;
register long x4 __asm__("x4") = e;
register long x5 __asm__("x5") = f;
__asm__ volatile("svc #0" : "+r"(x0) : "r"(x8), "r"(x1), "r"(x2), "r"(x3), "r"(x4), "r"(x5) : "memory", "cc");
return x0;
}
int io_open(const char *p, int w) { return (int)call(56, -100, (long)p, w ? 1|64|128|131072 : 0, 0600, 0, 0); }
long io_read(int fd, void *p, usize n)
{
long r;
do r = call(63, fd, (long)p, (long)n, 0, 0, 0); while (r == -4);
return r;
}
static long put(int fd, const void *p, usize n)
{
long r;
do r = call(64, fd, (long)p, (long)n, 0, 0, 0); while (r == -4);
return r;
}
int io_close(int fd) { return (int)call(57, fd, 0, 0, 0, 0, 0); }
int io_rename(const char *a, const char *b) { return (int)call(38, -100, (long)a, -100, (long)b, 0, 0); }
void io_remove(const char *p) { call(35, -100, (long)p, 0, 0, 0, 0); }
int io_mkdir(const char *p)
{
long r = call(34, -100, (long)p, 0700, 0, 0, 0);
return r == 0 || r == -17;
}
void *io_alloc(usize n)
{
long p = call(222, 0, (long)n, 3, 0x22, -1, 0);
return (unsigned long)p > (unsigned long)-4096 ? 0 : (void *)p;
}
void io_free(void *p, usize n) { if (p) call(215, (long)p, (long)n, 0, 0, 0, 0); }
int io_random(void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = call(278, (long)((u8 *)p + done), (long)(n - done), 0, 0, 0, 0);
if (r == -4) continue;
if (r <= 0) return 0;
done += (usize)r;
}
return 1;
}
i64 io_time(int mono)
{
struct { long sec, ns; } t = {0, 0};
call(113, mono ? 1 : 0, (long)&t, 0, 0, 0, 0);
return (i64)t.sec * 1000000000 + t.ns;
}
void *memcpy(void *d, const void *s, usize n)
{
u8 *a = d;
const u8 *b = s;
while (n--) *a++ = *b++;
return d;
}
void *memset(void *d, int c, usize n)
{
u8 *a = d;
while (n--) *a++ = (u8)c;
return d;
}
extern int main(int argc, char **argv);
__attribute__((used, noreturn)) void start_c(long *sp)
{
int code = main((int)sp[0], (char **)(sp + 1));
for (;;) call(94, code, 0, 0, 0, 0, 0);
}
__asm__(".text\n.global _start\n.type _start,%function\n_start:\n"
"mov x29,#0\nmov x30,#0\nmov x0,sp\nbl start_c\nbrk #0\n");
#endif
int io_write(int fd, const void *p, usize n)
{
while (n) {
long r = put(fd, p, n);
if (r <= 0) return 0;
p = (const u8 *)p + r;
n -= (usize)r;
}
return 1;
}
-25
View File
@@ -1,25 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef FP_ROOMSCAN_PLATFORM_H
#define FP_ROOMSCAN_PLATFORM_H
/* The reconstruction sources use only this boundary, on Linux and in hosted tests. */
typedef __SIZE_TYPE__ usize;
typedef unsigned char u8;
typedef unsigned int u32;
typedef unsigned long long u64;
typedef long long i64;
int io_open(const char *path, int write);
long io_read(int fd, void *data, usize size);
int io_write(int fd, const void *data, usize size);
int io_close(int fd);
int io_rename(const char *old, const char *new);
void io_remove(const char *path);
int io_mkdir(const char *path);
void *io_alloc(usize size);
void io_free(void *data, usize size);
int io_random(void *data, usize size);
i64 io_time(int monotonic);
void *memcpy(void *dst, const void *src, usize size);
void *memset(void *dst, int value, usize size);
#endif
-93
View File
@@ -1,93 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef FP_ROOMSCAN_H
#define FP_ROOMSCAN_H
#include "platform.h"
/* Resource limits are independent of dump length. Two passes avoid retaining frames. */
#define MAX_FRAME_VERTICES 1000000u
#define MAX_FRAME_INDICES 3000000u
#define MAX_CELLS 4200000u
#define MAX_SAMPLES 32768
#define MAX_PLANES 32
#define MAX_OUTLINE 96
#define MAX_VOLUMES 128
#define MAX_TRIANGLES 200000
#define MAX_MESH_VERTICES 600000
#define PI 3.14159265358979323846f
typedef struct { float x, y, z; } Vec;
typedef struct { float x, z; } Pt;
typedef struct {
Vec p, n;
float weight, sdf, sw;
int mark;
unsigned views, last_frame;
} Cell;
typedef struct {
Vec n;
float d, lo, hi, bottom, top, area;
int used;
} Plane;
typedef struct { Vec p; float yaw, w, d, h; const char *label; } Volume;
typedef struct {
const char *input, *out, *hint;
u64 universe;
float voxel, ceiling_default, truncation, max_edge, grazing, range, depth_jump, depth_ratio, min_views;
float patch_residual;
float plane_distance, min_plane_area, wall_height, snap_degrees;
float floor_band, ceiling_min, min_volume, cluster_gap, gap, horizontal;
} Options;
typedef struct {
Options opt;
Cell *cells;
int nx, ny, nz, count;
Vec origin;
Vec samples[MAX_SAMPLES];
int nsamples;
u64 seen, accepted, rejected, clipped, far_vertices, discontinuities;
unsigned frames, skipped_frames, fuse_frame;
Plane planes[MAX_PLANES];
int nplanes;
Pt outline[MAX_OUTLINE];
int invisible[MAX_OUTLINE], noutline;
Volume volumes[MAX_VOLUMES];
int nvolumes;
float floor, ceiling, coverage;
const char *warnings[24];
int nwarnings, hint_used;
u32 rng;
Vec *vertices;
u32 *indices;
int nv, ni;
i64 t0, tread, tfuse, tmodel, tmesh;
} Scan;
static inline float ab(float x) { return x < 0 ? -x : x; }
static inline float mn(float a, float b) { return a < b ? a : b; }
static inline float mx(float a, float b) { return a > b ? a : b; }
static inline float clamp(float x, float a, float b) { return mn(mx(x, a), b); }
static inline Vec vec(float x, float y, float z) { return (Vec){x,y,z}; }
static inline Vec add(Vec a, Vec b) { return vec(a.x+b.x,a.y+b.y,a.z+b.z); }
static inline Vec sub(Vec a, Vec b) { return vec(a.x-b.x,a.y-b.y,a.z-b.z); }
static inline Vec mul(Vec a, float b) { return vec(a.x*b,a.y*b,a.z*b); }
static inline float dot(Vec a, Vec b) { return a.x*b.x+a.y*b.y+a.z*b.z; }
static inline Vec cross(Vec a, Vec b) { return vec(a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x); }
static inline int finite(float x) { return x == x && ab(x) < 1e20f; }
float root(float x);
float sine(float x);
float cosine(float x);
float angle(float y, float x);
Vec unit(Vec p);
int flo(float x);
u32 random_next(Scan *s);
void warn(Scan *s, const char *message);
int cell_index(Scan *s, Vec p);
Vec grid_point(Scan *s, int x, int y, int z);
const char *read_dump(Scan *s, int fuse);
const char *init_grid(Scan *s);
void finish_fusion(Scan *s);
const char *make_model(Scan *s);
const char *make_mesh(Scan *s);
int same(const char *a, const char *b);
#endif
-2
View File
@@ -7,8 +7,6 @@
// Meta's OVRPlugin finds xrGetInstanceProcAddr with dlopen("libopenxr_loader.so") + dlsym, so FramePort points that
// string in libOVRPlugin.so at this library instead (patches/frame/adapter.py). Names the adapter emulates are served
// by the adapter (framebridge_extension_proc); everything else goes to overport's xrGetInstanceProcAddr unchanged.
// The adapter also routes scene discovery/persistence/mesh and the core locate/event functions that consume
// emulated spaces. Keep this name-agnostic: another fixed table here would repeat the dispatcher's limitation.
//
// Haptics (GitHub #9, found by Klownicle in Lucky's Tale): overport's dispatcher converts an
// XrHapticAmplitudeEnvelopeVibrationFB with its nanosecond duration taken as seconds, so one vibration allocates an
-105
View File
@@ -1,105 +0,0 @@
"""Render the in-headset room-scan prompts (SteamVR overlay images) into artifacts/roomscan/.
The Frame has no imaging library, so the agent shows pre-rendered PNGs through IVROverlay while it records the
passthrough depth mesh (agent `room_capture`). One image per state; "look around" comes in 13 variants (0-12 of 12
directions seen) so the progress ring can advance without drawing on the Frame. Colours = the Portal theme.
uv run python scripts/mr/render_prompts.py [--check]
"""
from __future__ import annotations
import argparse
import hashlib
import io
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "artifacts" / "roomscan"
W, H = 1024, 400
BG, SURFACE, TEXT, TEXT_2 = (13, 14, 18, 235), (29, 31, 39, 255), (236, 238, 243, 255), (167, 172, 186, 255)
ACCENT, SECONDARY, OK = (255, 138, 31, 255), (58, 168, 255, 255), (74, 222, 128, 255)
DIRECTIONS = 12
PROMPTS = {
"start": ("Turn on passthrough", "The scan runs while you can see your room:\n"
"open the quick access menu and switch passthrough on."),
"ceiling": ("Look up at the ceiling", "Hold it for a few seconds,\n"
"then look back down and keep turning."),
"floor": ("Look down at the floor", "Hold it for a few seconds,\nthen keep turning."),
"done": ("Room scanned", "You can take the headset off or start a mixed reality game."),
"failed": ("Scan stopped", "The headset's cameras sent no room view.\nStart the scan again from FramePort."),
}
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
for name in ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf",):
for base in ("/usr/share/fonts/truetype/dejavu", "C:/Windows/Fonts", "/Library/Fonts"):
p = Path(base) / name
if p.is_file():
return ImageFont.truetype(str(p), size)
return ImageFont.load_default(size=size)
def panel(title: str, body: str, seen: int | None = None, accent=ACCENT) -> Image.Image:
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle((4, 4, W - 4, H - 4), radius=48, fill=BG, outline=SURFACE, width=4)
d.rounded_rectangle((4, 4, 20, H - 4), radius=8, fill=accent) # portal edge
x = 64
if seen is not None: # progress ring: one segment per 30 degree direction seen
cx, cy, r = 150, H // 2, 92
for i in range(DIRECTIONS):
a0 = -90 + i * 360 / DIRECTIONS + 3
d.arc((cx - r, cy - r, cx + r, cy + r), a0, a0 + 360 / DIRECTIONS - 6,
fill=(SECONDARY if i < seen else SURFACE), width=26)
d.text((cx, cy), f"{seen}/{DIRECTIONS}", font=font(44, True), fill=TEXT, anchor="mm")
x = 290
d.text((x, 108), title, font=font(54, True), fill=TEXT, anchor="lm")
y = 180
for line in body.split("\n"):
d.text((x, y), line, font=font(32), fill=TEXT_2, anchor="lm")
y += 48
return img
def images() -> dict[str, bytes]:
out = {}
for key, (title, body) in PROMPTS.items():
out[key] = panel(title, body, accent=OK if key == "done" else ACCENT)
for n in range(DIRECTIONS + 1):
out[f"look_{n:02d}"] = panel("Look around the room", "Turn slowly until the ring is full:\n"
"every wall and your furniture.", seen=n, accent=SECONDARY)
data = {}
for key, img in out.items():
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
data[f"{key}.png"] = buf.getvalue()
return data
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--check", action="store_true", help="fail if artifacts/roomscan is out of date")
args = ap.parse_args()
data = images()
if args.check:
stale = [n for n, b in data.items() if not (OUT / n).is_file() or (OUT / n).read_bytes() != b]
if stale:
print("out of date:", ", ".join(stale))
return 1
return 0
OUT.mkdir(parents=True, exist_ok=True)
for name, b in data.items():
p = OUT / name
if not p.is_file() or p.read_bytes() != b:
p.write_bytes(b)
print(f"{len(data)} prompts in {OUT.relative_to(ROOT)} "
f"({sum(map(len, data.values())) // 1024} KiB, {hashlib.sha256(b''.join(data.values())).hexdigest()[:12]})")
return 0
if __name__ == "__main__":
sys.exit(main())
-218
View File
@@ -1,218 +0,0 @@
#!/usr/bin/env python3
"""Render deterministic, noisy head-centred depth meshes into the FPRS v1 contract (stdlib only)."""
from __future__ import annotations
import argparse
import math
import random
import struct
from pathlib import Path
BOX_OUTLINE = [(-2, -1.5), (2, -1.5), (2, 1.5), (-2, 1.5)]
L_OUTLINE = [(-2, -2), (2, -2), (2, 0), (0, 0), (0, 2), (-2, 2)]
TABLE = ((-.6, 0, -.25), (1.2, .75, .7))
COUCH = ((.6, 0, .95), (1.6, .65, .65))
ROOMVIEW_WIDTH, ROOMVIEW_HEIGHT = 132, 128
ROOMVIEW_NEAR, ROOMVIEW_FAR = .3, 4.0
def fan(width, height, roomview):
"""RoomView camera axes are +Z forward / -Y image up; poses convert them to STAGE."""
sx, sy = (1.96, 1.94) if roomview else (math.tan(math.radians(55)), math.tan(math.radians(38)))
for row in range(height):
for col in range(width):
x = (2 * col / (width - 1) - 1) * sx
y = (2 * row / (height - 1) - 1) * sy
if not roomview:
y = -y
length = math.sqrt(x * x + y * y + 1)
yield x / length, y / length, (1 if roomview else -1) / length
def smooth_depths(values, width, height, radius=3):
"""Separable box smoothing models soft stereo boundaries, without a NumPy/scipy dependency."""
result = list(values)
for vertical in (False, True):
source, result = result, [0.0] * len(values)
lines, length, step = (width, height, width) if vertical else (height, width, 1)
for line in range(lines):
start = line if vertical else line * width
prefix = [0.0]
for i in range(length):
prefix.append(prefix[-1] + source[start + i * step])
for i in range(length):
lo, hi = max(0, i - radius), min(length, i + radius + 1)
result[start + i * step] = (prefix[hi] - prefix[lo]) / (hi - lo)
return result
def inside(x, z, polygon):
odd = False
for (ax, az), (bx, bz) in zip(polygon, polygon[1:] + polygon[:1], strict=False):
if (az > z) != (bz > z) and x < (bx - ax) * (z - az) / (bz - az) + ax:
odd = not odd
return odd
def rotate(v, q):
x, y, z = v
qx, qy, qz, qw = q
tx, ty, tz = 2 * (qy * z - qz * y), 2 * (qz * x - qx * z), 2 * (qx * y - qy * x)
return (x + qw * tx + qy * tz - qz * ty,
y + qw * ty + qz * tx - qx * tz,
z + qw * tz + qx * ty - qy * tx)
def cast(eye, direction, polygon, ceiling, furniture, missing_wall):
ex, ey, ez = eye
dx, dy, dz = direction
nearest = 5.0
hit = False
for height in (0, ceiling):
if abs(dy) > 1e-8:
t = (height - ey) / dy
if .3 < t < nearest and inside(ex + t * dx, ez + t * dz, polygon):
nearest, hit = t, True
for index, ((ax, az), (bx, bz)) in enumerate(zip(polygon, polygon[1:] + polygon[:1], strict=False)):
if index == missing_wall:
continue
sx, sz = bx - ax, bz - az
det = dx * sz - dz * sx
if abs(det) < 1e-8:
continue
t = ((ax - ex) * sz - (az - ez) * sx) / det
u = ((ax - ex) * dz - (az - ez) * dx) / det
if .3 < t < nearest and 0 <= u <= 1 and 0 <= ey + t * dy <= ceiling:
nearest, hit = t, True
for (cx, bottom, cz), (width, height, depth) in furniture:
near, far = -1e20, 1e20
for pos, ray, lo, hi in zip(eye, direction,
(cx - width / 2, bottom, cz - depth / 2),
(cx + width / 2, bottom + height, cz + depth / 2), strict=False):
if abs(ray) < 1e-8:
if not lo <= pos <= hi:
far = -1e20
break
else:
a, b = (lo - pos) / ray, (hi - pos) / ray
near, far = max(near, min(a, b)), min(far, max(a, b))
if .3 < near < nearest and near <= far:
nearest, hit = near, True
return nearest if hit else None
def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise=.004,
holes=.025, smear=.012, floor_bias=.008, no_ceiling=False, seed=7,
yaw=0.0, offset=(0.0, 0.0, 0.0), missing_wall=-1, objects=True, profile="roomview",
invalid_start=None, smoothing=3):
"""Each frame has its own moving pose; no ground-truth room data is put in the dump."""
rng = random.Random(seed)
roomview = profile == "roomview"
width = width or (ROOMVIEW_WIDTH if roomview else 56)
height = height or (ROOMVIEW_HEIGHT if roomview else 40)
invalid_start = (2 if roomview else 0) if invalid_start is None else invalid_start
polygon = BOX_OUTLINE if shape == "box" else L_OUTLINE
ceiling = 2.6
furniture = [TABLE, COUCH] if objects and shape == "box" else []
rotation = (0, math.sin(yaw / 2), 0, math.cos(yaw / 2))
rays = list(fan(width, height, roomview))
with path.open("wb") as out:
out.write(struct.pack("<4sI", b"FPRS", 1))
for frame in range(frames):
heading = 2 * math.pi * frame / max(frames, 1)
pitch = -.85 if no_ceiling else (-.60, 0, .70)[frame % 3]
if roomview and no_ceiling:
pitch = -1.2
# The L's two arms need viewpoints on both sides of the re-entrant corner.
centre = (-.65, -.65) if shape == "l" else (0, 0)
eye = (centre[0] + .35 * math.cos(heading), 1.6 + .04 * math.sin(heading * 2),
centre[1] + .35 * math.sin(heading))
if roomview:
eye = eye[0], 1.3 + .4 * math.sin(heading), eye[2]
sy, cy = math.sin(heading / 2), math.cos(heading / 2)
sp, cp = math.sin(pitch / 2), math.cos(pitch / 2)
q = (cy * sp, sy * cp, -sy * sp, cy * cp)
if roomview:
q = q[3], q[2], -q[1], -q[0] # head rotation composed with camera's 180 degree X rotation
depths = [cast(eye, rotate(ray, q), polygon, ceiling, furniture, missing_wall) for ray in rays]
if roomview:
# Smooth both valid depth and inpainted far pixels. Low-frequency noise survives the filter,
# while isolated high-frequency speckle is small, as in a stereo patch fit.
coarse_width = (width + 11) // 12
coarse = [rng.gauss(0, .012) for _ in range(coarse_width * ((height + 11) // 12))]
depths = [(depth if depth is not None else 8 + rng.random() * 2)
+ coarse[(i // width // 12) * coarse_width + (i % width // 12)]
for i, depth in enumerate(depths)]
depths = smooth_depths(depths, width, height, smoothing)
vertices, valid = [], []
for index, (ray, depth) in enumerate(zip(rays, depths, strict=False)):
keep = depth is not None and rng.random() >= holes
valid.append(keep or roomview)
if roomview and not keep:
depth = 8 + rng.random() * 2
if depth is None:
depth = 0
else:
# Stereo edges blend two depths, producing stretched grazing triangles.
other = depths[index + 1] if (index + 1) % width and index + 1 < len(depths) else depth
if other is not None and abs(other - depth) > .3 and rng.random() < smear:
depth = (depth + other) / 2
world_ray = rotate(ray, q)
if eye[1] + depth * world_ray[1] < .15:
depth += floor_bias * rng.random()
depth += rng.gauss(0, noise)
vertices.extend(c * depth for c in ray)
indices = []
for row in range(height - 1):
for col in range(width - 1):
a = row * width + col
for triangle in ((a, a + width, a + 1), (a + 1, a + width, a + width + 1)):
if all(valid[i] for i in triangle):
indices.extend(triangle)
# Compose the global room transform with each independent mesh -> room pose.
pose_eye = tuple(a + b for a, b in zip(rotate(eye, rotation), offset, strict=False))
ax, ay, az, aw = rotation
bx, by, bz, bw = q
pose_q = (aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz)
if frame < invalid_start:
pose_eye = (float("nan"),) * 3
out.write(struct.pack("<d7fII", frame / (3 if roomview else 15),
*pose_eye, *pose_q, len(rays), len(indices)))
out.write(struct.pack(f"<{len(vertices)}f", *vertices))
out.write(struct.pack(f"<{len(indices)}I", *indices))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("out", type=Path)
ap.add_argument("--shape", choices=("box", "l"), default="box")
ap.add_argument("--frames", type=int, default=30)
ap.add_argument("--profile", choices=("roomview", "legacy"), default="roomview")
ap.add_argument("--width", type=int)
ap.add_argument("--height", type=int)
ap.add_argument("--invalid-start", type=int, help="untracked initial frames (RoomView default: 2)")
ap.add_argument("--smoothing", type=int, default=3, help="RoomView depth filter radius in pixels")
ap.add_argument("--noise", type=float, default=.004)
ap.add_argument("--holes", type=float, default=.025)
ap.add_argument("--smear", type=float, default=.012)
ap.add_argument("--floor-bias", type=float, default=.008)
ap.add_argument("--no-ceiling", action="store_true")
ap.add_argument("--no-objects", action="store_true")
ap.add_argument("--missing-wall", type=int, default=-1)
ap.add_argument("--yaw", type=float, default=0, help="global room yaw in degrees")
ap.add_argument("--seed", type=int, default=7)
args = ap.parse_args()
if args.frames < 1 or (args.width is not None and args.width < 3) or (args.height is not None and args.height < 3):
ap.error("positive frame count and a grid of at least 3 x 3 required")
render(args.out, shape=args.shape, frames=args.frames, width=args.width, height=args.height,
noise=args.noise, holes=args.holes, smear=args.smear, floor_bias=args.floor_bias,
no_ceiling=args.no_ceiling, seed=args.seed, yaw=math.radians(args.yaw),
missing_wall=args.missing_wall, objects=not args.no_objects, profile=args.profile,
invalid_start=args.invalid_start, smoothing=args.smoothing)
if __name__ == "__main__":
main()
+25
View File
@@ -135,6 +135,9 @@ class FakeFS:
return {"shots": shots, "total": len(shots), "games": list(games.values())}
if command == "take_screenshot": # the Screenshots tab's button: a new shot of the running game
return self.take_screenshot()
if command == "saves_info": # Restore saves…: what the Frame has now, and the copy from the last restore
return {"package": args.get("package"), "files": 14, "bytes": 2_310_000, "skipped": [],
"running": False, "version": "1.0", "rollback": {"size": 812_000, "time": time.time() - 86400}}
raise RuntimeError(f"fake Frame: {command} not available")
@property
@@ -598,6 +601,28 @@ def scan_job(app, folder: str, entries: list[dict], pace: float = 1.0):
return app.submit(f"Scan {folder.rstrip('/').rsplit('/', 1)[-1]}", run, None, "scan")
def fake_save_backups(pkg: str) -> None:
"""Two pretend save backups of `pkg` on this PC (yesterday's and last week's), for the Restore saves… dialog."""
import io
import json
import tarfile
from frameport.install import saves
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
for days, n, size in ((1, 14, 2_300_000), (7, 11, 1_900_000)):
created = time.time() - days * 86400
path = d / time.strftime("%Y%m%d-%H%M%S.tar.gz", time.localtime(created))
manifest = {"format": 1, "package": pkg, "created": created, "version": "1.0",
"files": [{"path": f"external/files/save{i}.dat", "size": size // n} for i in range(n)]}
raw = json.dumps(manifest).encode()
with tarfile.open(path, "w:gz") as tar:
ti = tarfile.TarInfo(saves.MANIFEST)
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
def attach_fake_frame(app: FramePortApp, monitor_session=None) -> None:
"""Connect `app` to a FakeTarget (the shared monitor stream, used by the Monitor tab and the Frame card, too)."""
app.monitor_hub.session_factory = monitor_session or FakeMonitorSession
+5 -21
View File
@@ -27,6 +27,7 @@ from showcase.fakes import ( # noqa: E402,F401
add_fake_linux_apps,
attach_fake_frame,
fake_live_stream,
fake_save_backups,
install_fakes,
open_monitor_details,
open_type_tab,
@@ -140,6 +141,7 @@ def main() -> int:
("tools", lambda a: a.go("settings")),
# 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"))]
if game:
steps.insert(1, ("game", lambda a: a.open_game(game)))
@@ -153,6 +155,9 @@ def main() -> int:
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
# discussion #352: pick a save backup (two pretend ones) to restore, "Undo last restore" from the Frame
steps.append(("restore-saves-dialog", lambda a: (a.page.pop_dialog(), fake_save_backups(game),
a.restore_saves(game))))
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
@@ -528,27 +533,6 @@ def main() -> int:
app = FramePortApp(page)
if args.fake_frame:
attach_fake_frame(app)
# The Room card uses local fixtures too; --fake-frame never contacts a headset.
from frameport.mr.room import box_room, dumps, summary
room = box_room(4, 3)
room.source, room.universe = "scan", 7
room_answer = dict(rooms=[], universe=7, room=dict(universe=7, source="scan", summary=summary(room)),
room_text=dumps(room), aligned=True, default_circle=False, scanner_installed=True)
original_agent = app.target.frame.agent
def room_agent(command, **kwargs):
if command == "room_capture":
return {"error": "not implemented yet"}
if command == "room_delete":
room_answer.update(room=None, room_text=None, aligned=False)
if command == "room_from_boundary":
room.source = "boundary"
room_answer.update(room=dict(universe=7, source="boundary", summary=summary(room)),
room_text=dumps(room), aligned=True)
if command.startswith("room_"):
return dict(room_answer)
return original_agent(command, **kwargs)
app.target.frame.agent = room_agent
except Exception: # noqa: BLE001
ERRORS.append("startup: " + traceback.format_exc())
return
+1 -1
View File
@@ -241,7 +241,7 @@
},
"adapter.scene_emul": {
"anchor": "adapter-scene_emul",
"title": "Use room layout",
"title": "Pretend room layout",
"group": "Game settings"
},
"adapter.scene_height": {
+160 -75
View File
@@ -22,6 +22,7 @@ Errors are one line on stderr; FRAMEPORT_DEBUG=1 shows the traceback.
frameport diag report <pkg> # zip + prefilled GitHub problem report
frameport share-recipe <pkg> --status works # prefilled GitHub issue submitting a working recipe
frameport open-link "<install link>" # FrameDrop button / frameport:// link: download, add, install
frameport saves backup <pkg>|--all # Quest games' saves from the Frame to this PC (list, restore, undo)
"""
from __future__ import annotations
@@ -50,7 +51,9 @@ tools_app = typer.Typer(help="Manage the portable toolchain.")
frame_app = typer.Typer(help="Find and connect to a Steam Frame.")
pc_app = typer.Typer(help="This PC as a target for PC VR games (Steam and SteamVR; Revive for Oculus games).")
diag_app = typer.Typer(help="Diagnostics bundles and problem reports.")
saves_app = typer.Typer(help="Back up Quest games' saves from the Frame to this PC and restore them.")
app.add_typer(tools_app, name="tools")
app.add_typer(saves_app, name="saves")
app.add_typer(frame_app, name="frame")
app.add_typer(pc_app, name="pc")
app.add_typer(diag_app, name="diag")
@@ -227,13 +230,78 @@ def tools_import_keys(dirs: list[Path] = typer.Argument(..., help="folders with
# ------------------------------------------------------------------------------------------ library
@app.command()
def scan(path: Path = typer.Argument(..., help="a folder with game backups (APKs or PC VR game folders), or an APK")):
"""Find games under PATH, analyze them and suggest patches."""
"""Find games under PATH, analyze them and suggest patches. A folder is remembered for `frameport rescan`
(`frameport folders` lists them). Games of the library found here after a move get their new place."""
from .core import folders
if path.is_dir():
folders.remember(path)
_scan(path, only_new=False)
def _scan(path: Path, only_new: bool) -> None:
rep = printing_reporter(verbose=False)
for g in pipeline.add_path(path, rep):
report = pipeline.ScanReport()
for g in pipeline.add_path(path, rep, only_new=only_new, report=report):
r = g["recipe"]
typer.echo(f"{g['package']:40} {g.get('title', '')[:34]:34} {r['status']:11} {r['source']}")
for warning in pipeline.analysis_warnings(g):
typer.echo(f" ! {warning}")
labels = {"moved": "moved here (location updated)", "updated": "another version here: now used",
"other_version": "another version here: left alone (frameport new-version <package> <path>)",
"other_copy": "another copy here: the library keeps its own"}
for kind, label in labels.items():
for item in getattr(report, kind):
typer.echo(f"{item['package']:40} {item['title'][:34]:34} {label}: {item['path']}")
if report.moved:
typer.echo(f"Updated the location of {len(report.moved)} game(s)")
@app.command()
def rescan():
"""Scan the library folders again (`frameport folders`) for new games and for games that moved there."""
from .core import folders
roots = folders.rescan_roots()
if not roots:
typer.echo("no folders to rescan yet: frameport scan <folder> or frameport folders --add <folder>")
return
for root in roots:
typer.echo(f"== {root}")
_scan(Path(root), only_new=True)
@app.command()
def folders(add: list[Path] = typer.Option([], "--add", help="a folder to scan from now on (repeatable)"),
remove: list[Path] = typer.Option([], "--remove", help="stop scanning this folder; its games stay in "
"the library (repeatable)"),
as_json: bool = typer.Option(False, "--json", help=JSON_HELP)):
"""The library folders `frameport rescan` (and the GUI's Rescan folders) look in: status, add, remove."""
import dataclasses
import json
from .core import folders as lib_folders
for p in add:
try:
lib_folders.add(p)
except ValueError as exc:
raise typer.BadParameter(str(exc)) from None
typer.echo(f"added {p}")
for p in remove:
if lib_folders.remove(p):
typer.echo(f"removed {p} (its games stay in the library)")
else:
typer.echo(f"{p} isn't a library folder", err=True)
raise typer.Exit(1)
status = lib_folders.status()
if as_json:
typer.echo(json.dumps([dataclasses.asdict(s) for s in status], indent=1))
return
if not status:
typer.echo("no library folders yet (frameport scan <folder> or frameport folders --add <folder>)")
for s in status:
typer.echo(f"{'ok' if s.exists else 'MISSING':8} {s.games:4} game(s) {s.path}")
@app.command("catalog-update")
@@ -709,93 +777,110 @@ 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")
# ------------------------------------------------------------------------------------------ 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)."""
import re
try:
return _pkgs(package, False)[0]
except typer.BadParameter:
if re.match(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$", package):
return package
raise
@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
@saves_app.command("backup")
def saves_backup(package: Optional[str] = typer.Argument(None, help="the game (package or part of its title)"),
all_: bool = typer.Option(False, "--all", help="every Quest game installed on the Frame"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Copy a Quest game's saves from the Frame to this PC (caches and big downloaded content stay out). The newest
backups are kept (10 per game, setting saves.keep). The game must be closed."""
from .install import saves
typer.echo(json.dumps(rooms.status(_target(frame).frame), indent=2))
target = _target(frame)
if all_:
pkgs = [g["package"] for g in target.installed() if g.get("kind") not in ("pcvr", "linux")
and not g.get("overlay")]
elif package:
pkgs = [_save_pkg(package)]
else:
raise typer.BadParameter("give a package or --all")
failed = 0
for pkg in pkgs:
try:
b = saves.backup(target.frame, pkg, printing_reporter(verbose=False))
except saves.SavesError as exc:
failed += 1
typer.echo(f"{pkg}: not backed up: {exc}", err=True)
continue
typer.echo(f"{pkg}: {b.files} file(s), {b.bytes / 2**20:.1f} MiB of saves -> {b.path}"
+ (f" ({b.skipped} large file(s) left out)" if b.skipped else ""))
if failed:
raise typer.Exit(1)
@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
@saves_app.command("list")
def saves_list(package: str = typer.Argument(..., help="the game (package or part of its title)"),
as_json: bool = typer.Option(False, "--json", help=JSON_HELP)):
"""This PC's backups of a game, newest first (the number is what `saves restore` takes)."""
from .install import saves
typer.echo(json.dumps(rooms.from_boundary(_target(frame).frame, height), indent=2))
pkg = _save_pkg(package)
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],
indent=1))
return
if not found:
typer.echo(f"{pkg}: no backups yet (frameport saves backup {pkg}); folder: {saves.backups_dir(pkg)}")
return
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}")
@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
@saves_app.command("restore")
def saves_restore(package: str = typer.Argument(..., help="the game (package or part of its title)"),
backup: Optional[str] = typer.Argument(None, help="a number from `saves list` (default 1 = the "
"newest) or a backup file"),
yes: bool = typer.Option(False, "--yes", "-y", help="don't ask"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Put a backup's saves back on the Frame. The game's current saves there are replaced; the Frame keeps a copy of
them (`saves undo` puts them back). The game must be closed."""
from .install import saves
typer.echo(json.dumps(rooms.put(_target(frame).frame, path), indent=2))
pkg = _save_pkg(package)
if backup and not backup.isdigit():
path = Path(backup)
if not path.is_file():
raise typer.BadParameter(f"no such file: {backup}")
else:
found = saves.list_backups(pkg)
n = int(backup or 1)
if not 1 <= n <= len(found):
raise typer.BadParameter(f"{pkg} has {len(found)} backup(s) (frameport saves list {pkg})")
path = found[n - 1].path
if not yes:
typer.confirm(f"Replace {pkg}'s saves on the Frame with {path.name}?", abort=True)
r = saves.restore(_target(frame).frame, pkg, path, printing_reporter(verbose=False))
typer.echo(f"{pkg}: restored {r.get('restored', 0)} file(s), removed {r.get('removed', 0)}; the saves from "
f"before are kept on the Frame (frameport saves undo {pkg})")
@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
@saves_app.command("undo")
def saves_undo(package: str = typer.Argument(..., help="the game (package or part of its title)"),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Put back the saves the last `saves restore` replaced (they're kept on the Frame)."""
from .install import saves
typer.echo(str(rooms.export(_target(frame).frame, directory)))
pkg = _save_pkg(package)
r = saves.undo_restore(_target(frame).frame, pkg)
typer.echo(f"{pkg}: the saves from before the last restore are back ({r.get('restored', 0)} file(s))")
@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
@frame_app.command("info")
def frame_info(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""The Frame's SteamOS build, runtimes, storage and installed games (JSON)."""
+120
View File
@@ -0,0 +1,120 @@
"""Library folders (GitHub discussion #351): the folders "Rescan folders" scans again (library setting `scan.roots`).
Every "Scan a folder…" remembers its folder; Settings → Library folders and `frameport folders` list them with their
status and let the user add or remove one. Removing a folder only stops it being scanned: the games already in the
library stay. No Flet here (the CLI uses it too)."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from . import library
SETTING = "scan.roots"
@dataclass
class FolderStatus:
path: str
exists: bool
games: int # library games whose files (APK, data or game folder) are in it
def roots() -> list[str]:
"""The remembered folders, in the order they were added (missing ones too)."""
return [str(r) for r in (library.setting(SETTING) or [])]
def _same(a: str, b: str) -> bool:
if a == b:
return True
try:
return Path(a).resolve() == Path(b).resolve()
except OSError:
return False
def remember(path: str | Path) -> list[str]:
"""Add a folder to the list (a scanned one): moved to the end if it's there already. Returns the new list."""
path = str(path)
return library.update_setting(SETTING, lambda cur: [r for r in (cur or []) if not _same(str(r), path)] + [path],
[])
def add(path: str | Path) -> list[str]:
"""Settings/CLI "Add folder…": like remember, but only an existing folder."""
p = Path(path).expanduser()
if not p.is_dir():
raise ValueError(f"{p} isn't a folder")
if any(_same(r, str(p)) for r in roots()):
return roots()
return remember(p)
def remove(path: str | Path) -> bool:
"""Stop scanning a folder (its games stay in the library). False if it wasn't in the list."""
path = str(path)
found = {"hit": False}
def drop(cur):
keep = [r for r in (cur or []) if not _same(str(r), path)]
found["hit"] = len(keep) != len(cur or [])
return keep
library.update_setting(SETTING, drop, [])
return found["hit"]
def game_paths(entry: dict) -> list[Path]:
"""Where a library game's files are (APK, data folder, game folder)."""
out = []
for key in ("apk", "data_dir", "game_dir"):
if entry.get(key):
out.append(Path(entry[key]))
return out
def _inside(p: Path, folder: Path) -> bool:
try:
return p == folder or p.is_relative_to(folder)
except (OSError, ValueError):
return False
def games_in(folder: str | Path, games: list[dict] | None = None) -> list[dict]:
"""Library games whose files are in the folder (by path, no disk access)."""
folder = Path(folder)
games = library.games() if games is None else games
return [g for g in games if g.get("kind") != "linux" and any(_inside(p, folder) for p in game_paths(g))]
def status() -> list[FolderStatus]:
"""Each remembered folder: whether it's there now and how many library games come from it. Touches the disk
(exists): call it off the UI thread."""
games = library.games()
out = []
for r in roots():
try:
exists = Path(r).is_dir()
except OSError:
exists = False
out.append(FolderStatus(r, exists, len(games_in(r, games))))
return out
def rescan_roots() -> list[str]:
"""Folders "Rescan folders" scans: the remembered ones that exist now. Before anything was remembered (libraries
from older versions), the folders the library's games came from (one level up from each game folder). A folder
inside another one is scanned with it."""
saved = library.setting(SETTING)
if saved is not None:
found = [r for r in saved if Path(r).exists()]
else:
seen = set()
for g in library.games():
if g.get("kind") == "linux": # added one by one (its origin may be e.g. Downloads)
continue
origin = g.get("origin") or (str(Path(g["apk"]).parent) if g.get("apk") else None)
if origin and Path(origin).parent.exists():
seen.add(str(Path(origin).parent))
found = sorted(seen)
return [r for r in found if not any(o != r and Path(r).is_relative_to(o) for o in found)]
-15
View File
@@ -307,21 +307,6 @@ def _migrate(data: dict) -> bool:
r.setdefault("reasons", {}).setdefault(pid, s.reason)
done.append("quest_binary_fixes_v2")
changed = True
if "scene_emul_all" not in done:
# Optional MR modes and anchor-only games need scene APIs too. Keep explicitly edited recipes intact.
from ..patches.applicability import uses_scene
for g in (data.get("games") or {}).values():
a, r = g.get("analysis"), g.get("recipe")
if (not isinstance(a, dict) or not isinstance(r, dict) or r.get("source") == "user"
or g.get("kind") in ("linux", "rift")):
continue
if uses_scene(analysis_from_dict(a)):
r.setdefault("patches", {})["adapter.scene_emul"] = {"value": 1}
r.setdefault("reasons", {})["adapter.scene_emul"] = (
"Scene and spatial anchor APIs use the saved room on the Frame.")
done.append("scene_emul_all")
changed = True
if "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():
+13 -19
View File
@@ -497,31 +497,25 @@ def ensure_helper(frame) -> bool:
if local is None:
return False
try:
sync_helper(frame, local, HELPER)
from ..frame.connection import sh_quote
remote = f"{frame.home}/{HELPER_REMOTE}"
want = hashlib.sha256(local.read_bytes()).hexdigest()
_, out, _ = frame.run(f"sha256sum {sh_quote(remote)} 2>/dev/null")
if out.split()[:1] == [want]:
return True
frame.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
tmp = f"{remote}.new-{os.getpid()}-{threading.get_ident()}" # own name: two starts at once don't collide
frame.put(local, tmp, resume=False)
code, _, err = frame.run(f"chmod 755 {sh_quote(tmp)} && mv -f {sh_quote(tmp)} {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
return True
except Exception as exc: # noqa: BLE001
log.warning("live view: couldn't put %s on the Frame (%s); using the software encoder", HELPER, exc)
return False
def sync_helper(frame, local: Path, name: str) -> str:
"""Install a bundled host helper by digest, atomically. Shared by live view and the room scanner."""
from ..frame.connection import sh_quote
remote = f"{frame.home}/.local/share/frameport/bin/{name}"
want = hashlib.sha256(local.read_bytes()).hexdigest()
_, out, _ = frame.run(f"sha256sum {sh_quote(remote)} 2>/dev/null")
if out.split()[:1] == [want]:
return remote
frame.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
tmp = f"{remote}.new-{os.getpid()}-{threading.get_ident()}" # own name: two starts at once don't collide
frame.put(local, tmp, resume=False)
code, _, err = frame.run(f"chmod 755 {sh_quote(tmp)} && mv -f {sh_quote(tmp)} {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
return remote
STOP_GRACE = 2.0 # s to wait for the script to end after stdin EOF before killing its process group
DROP_WARN = 0.05 # share of a stats window's frame slots that were late or skipped before the panel warns
-169
View File
@@ -1,169 +0,0 @@
"""Room storage and offline scanner operations on a connected Frame. No Flet."""
from __future__ import annotations
import tempfile
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from ..core.paths import artifacts_dir, user_data_dir
from ..frame.connection import sh_quote
from ..mr.room import dumps, parse, preview_svg, read_mesh
from .livestream import sync_helper
def status(frame) -> dict:
return frame.agent("room_status")
def delete(frame, universe: int | None = None) -> dict:
return frame.agent("room_delete", **({"universe": universe} if universe is not None else {}))
def from_boundary(frame, height: float = 2.5) -> dict:
return frame.agent("room_from_boundary", height=height)
def capture(frame, seconds: float = 90, wait: float = 180) -> dict:
"""Start a scan in the headset (the agent records the passthrough mesh, then builds the room)."""
ensure_scanner(frame)
ensure_prompts(frame)
result = frame.agent("room_capture", seconds=seconds, wait=wait)
if result.get("error"):
raise RuntimeError(result["error"])
return result
def capture_status(frame) -> dict:
return frame.agent("room_capture_status")
SCAN_DONE = ("done", "failed", "none")
def scan(frame, seconds: float = 90, wait: float = 180, progress=None, poll: float = 2.0,
sleep=time.sleep) -> dict:
"""capture() and wait for the result. progress(status) gets every status the agent reports (state waiting ->
recording -> building -> done|failed; directions seen, looked_up). Returns the final status; raises on failure."""
capture(frame, seconds, wait)
deadline = time.monotonic() + wait + seconds + 600 # + building time
while True:
st = capture_status(frame)
if progress:
progress(st)
if st.get("state") in SCAN_DONE:
break
if time.monotonic() > deadline:
raise RuntimeError("The room scan didn't finish")
sleep(poll)
if st.get("state") != "done":
raise RuntimeError(st.get("error") or "The room scan didn't finish")
return st
def ensure_prompts(frame) -> str:
"""The in-headset scan prompts (artifacts/roomscan/*.png) next to the scanner, synced by digest in one go."""
import hashlib
local = artifacts_dir() / "roomscan"
remote = f"{frame.home}/.local/share/frameport/bin/roomscan-prompts"
files = sorted(local.glob("*.png"))
if not files:
return remote
want = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in files}
_, out, _ = frame.run(f"mkdir -p {sh_quote(remote)} && cd {sh_quote(remote)} && sha256sum -- *.png 2>/dev/null")
have = {}
for line in out.splitlines():
digest, _, name = line.partition(" ")
have[name.strip()] = digest
for f in files:
if have.get(f.name) != want[f.name]:
frame.put(f, f"{remote}/{f.name}", resume=False)
return remote
@contextmanager
def _upload_dir(frame):
remote = f"{frame.home}/.local/share/frameport/rooms/.incoming-{uuid.uuid4().hex}"
code, _, err = frame.run(f"mkdir -p {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
try:
yield remote
finally:
frame.run(f"rm -rf -- {sh_quote(remote)}")
def put(frame, path: Path) -> dict:
path = Path(path)
room = parse(path.read_text(encoding="utf-8"))
mesh_path = None
if room.mesh:
mesh_path = path.parent / room.mesh.filename
mesh = read_mesh(mesh_path.read_bytes())
if (mesh.vertex_count, mesh.index_count) != (room.mesh.vertex_count, room.mesh.index_count):
raise ValueError("room mesh counts do not match room.txt")
staging = user_data_dir() / "rooms"
staging.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="import-", dir=staging) as local, _upload_dir(frame) as remote:
canonical = Path(local) / "room.txt"
canonical.write_text(dumps(room), encoding="utf-8")
frame.put(canonical, f"{remote}/room.txt", resume=False)
if mesh_path:
frame.put(mesh_path, f"{remote}/{room.mesh.filename}", resume=False)
return frame.agent("room_put", path=f"{remote}/room.txt")
def export(frame, directory: Path) -> Path:
st = status(frame)
if not st.get("room_text"):
raise ValueError("No room is stored on the Frame")
room = parse(st["room_text"])
directory = Path(directory)
mesh_data = None
if room.mesh:
remote = f"{frame.home}/.local/share/frameport/rooms/{room.universe}/{room.mesh.filename}"
with frame.sftp.open(remote, "rb") as f:
mesh_data = f.read(64 * 1024 * 1024)
mesh = read_mesh(mesh_data)
if (mesh.vertex_count, mesh.index_count) != (room.mesh.vertex_count, room.mesh.index_count):
raise ValueError("room changed during export; try again")
directory.mkdir(parents=True, exist_ok=True)
if mesh_data is not None:
(directory / room.mesh.filename).write_bytes(mesh_data)
path = directory / "room.txt"
path.write_text(dumps(room), encoding="utf-8")
return path
def ensure_scanner(frame) -> str:
local = artifacts_dir() / "linux-arm64-bin" / "fp_roomscan"
if not local.is_file():
raise RuntimeError("This build does not include the room scanner yet")
return sync_helper(frame, local, "fp_roomscan")
def build(frame, dump: Path, universe: int | None = None) -> dict:
dump = Path(dump)
with dump.open("rb") as f:
if f.read(8) != b"FPRS\x01\0\0\0":
raise ValueError("expected FPRS version 1 scan dump")
ensure_scanner(frame)
with _upload_dir(frame) as remote:
frame.put(dump, f"{remote}/scan.fprs", resume=False)
return frame.agent("room_build", dump=f"{remote}/scan.fprs",
**({"universe": universe} if universe is not None else {}))
def show(frame, path: Path) -> dict:
from ..mr.room import summary
st = status(frame)
if not st.get("room_text"):
raise ValueError("No room is stored on the Frame")
room = parse(st["room_text"])
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(preview_svg(room), encoding="utf-8")
return summary(room)
+301
View File
@@ -0,0 +1,301 @@
"""Save backups of Quest games (GitHub discussion #352: Steam doesn't sync them).
The Frame agent streams a game's saves as one tar.gz over an SSH exec channel (`_backup_saves`: its internal
/data/data/<pkg> folder and its /sdcard/Android/data/<pkg> folder, caches and big content left out; the archive's
last member `frameport-saves.json` lists every file with size and sha256). Backups live on this PC in
<user data>/save-backups/<pkg>/<YYYYmmdd-HHMMSS>.tar.gz with a <same>.json sidecar (the manifest without the file
list); the newest `keep_count()` per game are kept (setting `saves.keep`). Restoring streams one back
(`_restore_saves`): the agent checks the whole archive first (paths, links, sizes, hashes), keeps the Frame's current
saves as <base>/saves-before-restore.tar.gz (one copy: `undo_restore`), then swaps the files in. Both refuse while the
game runs. No Flet here: the GUI and the CLI share it.
"""
from __future__ import annotations
import json
import re
import tarfile
import time
from dataclasses import dataclass
from pathlib import Path
from ..core import library
from ..core.events import Reporter
from ..core.paths import user_data_dir
KEEP_DEFAULT = 10
KEEP_CHOICES = (3, 5, 10, 20, 50)
MANIFEST = "frameport-saves.json"
_NAME = re.compile(r"^\d{8}-\d{6}(-\d+)?\.tar\.gz$")
_PKG = re.compile(r"^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$")
CHUNK = 1 << 16
EXEC_TIMEOUT = 600 # s without data from the Frame (a dead link must not hang a job)
class SavesError(RuntimeError):
"""The Frame refused or failed (its own sentence, e.g. "the game is running")."""
@dataclass
class Backup:
package: str
path: Path
created: float
size: int # the archive on this PC
files: int = 0
bytes: int = 0 # the save files' size (uncompressed)
version: str = ""
title: str = ""
skipped: int = 0 # large files left out
@property
def sidecar(self) -> Path:
return sidecar_of(self.path)
def root_dir() -> Path:
return user_data_dir() / "save-backups"
def backups_dir(pkg: str) -> Path:
if not _PKG.match(pkg):
raise ValueError(f"bad package name {pkg!r}")
return root_dir() / pkg
def sidecar_of(archive: Path) -> Path:
return archive.with_name(archive.name[: -len(".tar.gz")] + ".json")
def keep_count() -> int:
try:
n = int(library.setting("saves.keep", KEEP_DEFAULT))
except (TypeError, ValueError):
n = KEEP_DEFAULT
return max(1, n)
def read_manifest(archive: Path) -> dict:
"""The manifest inside a backup (reads the whole archive: also checks it's complete). ValueError if it isn't a
FramePort save backup."""
try:
with tarfile.open(archive, "r:gz") as tar:
found = None
for m in tar:
if m.name == MANIFEST and m.isreg():
found = json.loads(tar.extractfile(m).read().decode("utf-8"))
except (OSError, tarfile.TarError, EOFError, ValueError) as exc:
raise ValueError(f"{archive.name} isn't a readable save backup ({exc})") from None
if not isinstance(found, dict) or found.get("format") != 1 or not found.get("package"):
raise ValueError(f"{archive.name} isn't a FramePort save backup")
return found
def _sidecar_data(manifest: dict, archive: Path) -> dict:
files = manifest.get("files") or []
return {"package": manifest.get("package"), "created": manifest.get("created"),
"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")}
def _from_sidecar(archive: Path, data: dict) -> Backup:
return Backup(package=str(data.get("package") or archive.parent.name), path=archive,
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 []))
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)
out = []
for archive in sorted(d.glob("*.tar.gz")) if d.is_dir() else []:
if not _NAME.match(archive.name):
continue
try:
data = json.loads(sidecar_of(archive).read_text(encoding="utf-8"))
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:
out.append(_from_sidecar(archive, data))
except OSError:
continue
return sorted(out, key=lambda b: b.created, reverse=True)
def has_backups(pkg: str) -> bool:
try:
d = backups_dir(pkg)
except ValueError:
return False
return d.is_dir() and any(_NAME.match(p.name) for p in d.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:]:
if protect is not None and b.path == protect:
continue
for p in (b.path, b.sidecar):
p.unlink(missing_ok=True)
gone.append(b.path)
return gone
def delete(backup: Backup) -> None:
backup.path.unlink(missing_ok=True)
backup.sidecar.unlink(missing_ok=True)
# ------------------------------------------------------------------------------------------ the Frame side
def _quote(s: str) -> str:
return "'" + s.replace("'", "'\"'\"'") + "'"
def _exec(frame, command: str, args: dict):
remote = frame.ensure_agent()
return frame.client.exec_command(f"python3 {_quote(remote)} {command} {_quote(json.dumps(args))}",
timeout=EXEC_TIMEOUT)
def _last_json(text: str) -> dict | None:
for line in reversed(text.splitlines()):
line = line.strip()
if line.startswith("{"):
try:
return json.loads(line)
except ValueError:
return None
return None
def info(frame, pkg: str) -> dict:
"""{files, bytes, skipped, running, version, rollback: {size, time} | None} of the game's saves on the Frame."""
return frame.agent("saves_info", package=pkg)
def _new_name(d: Path) -> Path:
stamp = time.strftime("%Y%m%d-%H%M%S")
path, n = d / f"{stamp}.tar.gz", 1
while path.exists() or path.with_name(path.name + ".part").exists():
path, n = d / f"{stamp}-{n}.tar.gz", n + 1
return path
def backup(frame, pkg: str, reporter: Reporter | None = None, keep: int | 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")
try:
expected = 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})
received = 0
try:
stdin.channel.shutdown_write()
with open(part, "wb") as f:
while True:
rep.check_cancel()
chunk = stdout.read(CHUNK)
if not chunk:
break
f.write(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")
code = stdout.channel.recv_exit_status()
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})")
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()
except Exception: # noqa: BLE001
pass
part.unlink(missing_ok=True)
raise
data = _sidecar_data(manifest, dest)
sidecar_of(dest).write_text(json.dumps(data, indent=1), encoding="utf-8")
result = _from_sidecar(dest, data)
prune(pkg, keep, protect=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 ""))
return result
def restore(frame, pkg: str, archive: Path, reporter: Reporter | None = None) -> dict:
"""Send a backup to the Frame; its current saves are kept there as a rollback copy (`undo_restore`)."""
rep = reporter or Reporter()
archive = Path(archive)
manifest = read_manifest(archive) # a damaged or foreign file fails here, before the Frame is touched
if manifest.get("package") != pkg:
raise SavesError(f"this backup is of {manifest.get('package')}, not {pkg}")
rep.stage("saves", "Restoring saves")
size = archive.stat().st_size
stdin, stdout, stderr = _exec(frame, "_restore_saves", {"package": pkg})
sent = 0
try:
with open(archive, "rb") as f:
while True:
rep.check_cancel()
chunk = f.read(CHUNK)
if not chunk:
break
try:
stdin.write(chunk)
except OSError:
break # the Frame stopped reading (refused): its reply says why
sent += len(chunk)
rep.progress(min(0.95, sent / max(1, size)), f"{sent / 2**20:.1f} MiB")
try:
stdin.channel.shutdown_write()
except OSError:
pass
except BaseException:
try:
stdout.channel.close() # a cancelled restore: the agent sees a cut-off archive and changes nothing
except Exception: # noqa: BLE001
pass
raise
out = stdout.read().decode("utf-8", "replace")
code = stdout.channel.recv_exit_status()
reply = _last_json(out) or {}
if not reply.get("ok"):
err = stderr.read().decode("utf-8", "replace").strip()
raise SavesError(reply.get("error") or f"the Frame couldn't restore the saves (exit {code}) {err[-300:]}")
rep.progress(1.0, "")
return reply["result"]
def undo_restore(frame, pkg: str) -> dict:
"""Put back the saves the last restore replaced (the Frame's rollback copy; the replaced ones become the new
rollback copy)."""
stdin, stdout, stderr = _exec(frame, "_restore_saves", {"package": pkg, "rollback": True})
try:
stdin.channel.shutdown_write()
except OSError:
pass
reply = _last_json(stdout.read().decode("utf-8", "replace")) or {}
stdout.channel.recv_exit_status()
if not reply.get("ok"):
raise SavesError(reply.get("error") or "the Frame couldn't put the saves back")
return reply["result"]
+82 -35
View File
@@ -5,6 +5,7 @@
],
" (connect the Frame first)": "",
" (list cut off)": "",
" (newest)": "",
" (update: {latest})": "",
" ({len} can't be installed there)": "",
" + Revive": "",
@@ -31,7 +32,6 @@
"(top folder)": "",
"+ {base_count} standard patches": "",
", and your signing keys were saved to {backup}.": "",
", ceiling seen": "",
", {n} already there": [
"",
""
@@ -102,6 +102,7 @@
"Add a tag": "",
"Add a Windows program…": "",
"Add an APK…": "",
"Add folder…": "",
"Add from a link…": "",
"Add games": "",
"Add this tag": "",
@@ -194,7 +195,17 @@
"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: {title}": "",
"Back up the signing keys ({len}) to {shown_dir} first": "",
"Backed up the saves of {n} game": [
"",
""
],
"Backed up {done} of {total} games' saves; not: {failed}": "",
"Backing up {title}": "",
"Banner": "",
"Battery": "",
"Before a swapchain image is handed to the Frame, wait until the game's GPU work on it has finished, for games 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).": "",
@@ -207,7 +218,6 @@
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
"Browse this game's files.": "",
"Build page": "",
"Building the room…": "",
"Built for PCs (x86_64): runs through a translator, slower": "",
"Built for the Frame (arm64)": "",
"Cable connected.": "",
@@ -239,12 +249,15 @@
"Check now": "",
"Check program": "",
"Check that the Frame and this PC are on the same network.": "",
"Checking folders…": "",
"Checking for updates": "",
"Checking the saves on the Frame…": "",
"Checking this PC…": "",
"Checking tools…": "",
"Checking which app opens these links…": "",
"checksum checked": "",
"Check…": "",
"Choose a backup to put back on the Frame. Close the game first.": "",
"Choose a file": "",
"Choose a folder": "",
"Choose a folder with Quest, Android or PC VR games. FramePort finds them and gets them ready.": "",
@@ -318,7 +331,7 @@
"Copy this theme as a file": "",
"Corrects graphics code the Frame's drivers reject (black screen with sound).": "",
"Corrects specific shaders that hang the game on the Frame. Comes from a game's recipe.": "",
"Couldn't check room data: {error}": "",
"Couldn't check the saves on the Frame: {error}": "",
"Couldn't check this PC": "",
"Couldn't connect: {exc}": "",
"Couldn't copy the screenshot: {error}": "",
@@ -358,8 +371,6 @@
"Default": "",
"Default (30% radius)": "",
"Delete": "",
"Delete room": "",
"Delete room?": "",
"Delete {names}?": "",
"Delete {n} items…": "",
"Delete {n} screenshot?": [
@@ -456,6 +467,7 @@
"Every 5 s, logs per located space how far the requested times are from the frame's predicted display time, and the runtime clock's offset from the monotonic clock. No effect on the game.": "",
"Every key goes through": "",
"Every N seconds, saves the left-eye image the game submits (a quarter of its size) as fb_snap_0-7.ppm in the game's files folder on the Frame. Launch tests run without anyone wearing the headset, so the headset view stays black; this shows what the game itself draws. OpenGL ES games only.": "",
"Every Scan a folder… adds its folder here. Rescan folders looks in them for new games, and for games you moved: a game whose files are gone from their old place is pointed at the same copy in its new place. Another version found there is only taken by Scan a folder…; Rescan leaves it for you to choose (Update with a newer version… on the game's page). Removing a folder only stops it being scanned: its games stay in the library.": "",
"Every shot you take in the headset": "",
"Every uploaded file is present with the right size.": "",
"Every {n}": "",
@@ -477,6 +489,7 @@
"Eye-tracked": "",
"failed": "",
"Failed": "",
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (for example Demeter).": "",
"Fan": "",
"File": "",
"file": "",
@@ -633,7 +646,6 @@
"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)": "",
@@ -749,7 +761,6 @@
"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,7 +868,7 @@
"Lets you type into this app's text fields on the Frame (they'd close at once).": "",
"Letters, Enter, Backspace, arrows, Esc and shortcuts reach the headset.": "",
"Library": "",
"Lines up with the current play area": "",
"Library folders": "",
"Link": "",
"Linux": "",
"Linux app": "",
@@ -921,7 +932,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 use walls and furniture from a saved room. Use your play area for walls now, or import a recorded scan. Headset scanning is not available yet.": "",
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
"Modem": "",
"Monitor": "",
"Monitor is using {pct}% of the Frame's CPU": "",
@@ -959,11 +970,13 @@
"No": "",
"No activity": "",
"no answer from the Frame": "",
"No backups of {title} on this PC yet. Back up its saves first (game menu → Back up saves).": "",
"no checksum": "",
"No dev build is published right now": "",
"No file at that path": "",
"No folder at that path": "",
"No folders to rescan yet — add games with Scan a folder…": "",
"No folders yet. Add one here, or use Scan a folder… in the Library.": "",
"No Frame connected: the game goes to your library now and installs when the Frame connects.": "",
"No FramePort game is running. Start one, or switch the filter to see everything.": "",
"No Frames found. New Frame? Use Start setup above. Otherwise turn on Developer Mode and keep the Frame on this network.": "",
@@ -971,12 +984,12 @@
"No keyboard? Open {page} in Chromium on the Frame, copy the line and paste it into Konsole. This PC shows there as \"{words}\".": "",
"No limit": "",
"No matching files": "",
"No new games in that folder": "",
"No new games in {name}": "",
"No other Frames remembered yet.": "",
"No process matches.": "",
"no reason given": "",
"No release notes.": "",
"No room saved yet": "",
"No screenshots yet": "",
"No Unreal crash reporter": "",
"no viewer open": "",
@@ -987,6 +1000,7 @@
"Not connected: {error}": "",
"Not enough space on the Frame": "",
"Not enough space on the Frame ({detail}). Uninstall games you don't play or free up space on the Steam Frame page.": "",
"Not found (moved, deleted or on a drive that isn't connected)": "",
"Not from a FramePort game": "",
"Not installed": "",
"Not installed yet (needs Developer Mode)": "",
@@ -1029,6 +1043,7 @@
"ON AIR": "",
"On Frame": "",
"On the desktop": "",
"On the Frame now: {line}": "",
"On the Frame, open Konsole (SteamVR dashboard → Launch a program → Desktop, then app menu → System → Konsole) and run:": "",
"On the Frame, open Settings → Developer → Pair new host and approve FramePort there.": "",
"On the Frame, open Settings → Developer → Pair new host and approve FramePort.": "",
@@ -1047,11 +1062,13 @@
"Only internal storage": "",
"Open": "",
"Open \"Install with FrameDrop\" buttons (framedrop:// links) in FramePort": "",
"Open backups folder": "",
"Open frameport:// links in FramePort": "",
"Open game": "",
"Open game page": "",
"Open GitHub issue": "",
"Open in browser": "",
"Open save backups": "",
"Open the Monitor": "",
"Open {scheme}:// links with FramePort?": "",
"Open {title}": "",
@@ -1149,6 +1166,7 @@
"Power": "",
"Power ICs": "",
"Prepare {title}": "",
"Pretend room layout": "",
"Pretty smooth": "",
"Prevents a start-up crash in some Unreal games.": "",
"Previous": "",
@@ -1166,9 +1184,9 @@
"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.": "",
"Puts settings files next to the game on the Frame, for example to turn off an unsupported effect.": "",
"Quality": "",
"Quest": "",
@@ -1208,7 +1226,6 @@
"Reduces shading away from the full-resolution area, following each eye's gaze. Default area size keeps the runtime's current profile unchanged. Custom radius is a fraction of the shorter eye-image dimension; the transition ends at twice that radius. Larger areas retain more detail but save less GPU work. Fixed stops the area following your eyes. The optional test darkens actual coarse-shaded fragments; rebuild/reinstall to toggle it. Tested on RE4, Deadpool and Asgard's Wrath 2. The Climb 2 gets a version-guarded private bridge at 1.0 scale; Batman gets its hash-verified allocation fix (unsupported OVRPlugin versions are refused). Other games may need additional fixes; turn this off if artifacts appear.": "",
"Refresh": "",
"Refresh rate": "",
"Refresh room": "",
"Refresh store details": "",
"Reinstall anyway…": "",
"Reinstall on Frame": "",
@@ -1221,6 +1238,7 @@
"Remembered": "",
"Remove": "",
"Remove converted copies now": "",
"Remove folder": "",
"Remove from library…": "",
"Remove localized app names": "",
"Remove tag": "",
@@ -1263,13 +1281,19 @@
"Repairs shaders that hang the graphics chip at certain effects, from the game's recipe (for example Vader Immortal's lightspeed jump).": "",
"Repeat xrLocateViews queries for the same display time get the first, fully tracked answer again. Games that ask several times per frame got slightly different poses and rendered parts of the frame with different heads: judder (for example I Am Cat; proposed by Klownicle, GitHub #8).": "",
"Replace application label and icon": "",
"Replace the saves on the Frame?": "",
"Replaces libvrapi.so with a VrApi→OpenXR bridge (Android-XR-Bridge/OVRPort fork, GPL-3.0, patched for the Frame: GLES sessions, cylinder→quad layers, sRGB format fallback, 30 s VR-mode deadline, emulated time conversion). Needed when the engine calls VrApi itself instead of through OVRPlugin (for example The Climb 2, Path of the Warrior); OVRPort cannot translate those.": "",
"Report a problem": "",
"Report a problem · {title}": "",
"Report a problem…": "",
"Reports Frame controllers as Oculus Touch and hides synthetic hand tracking. Set 0 for games that require hand tracking (for example Silhouette).": "",
"Rescan folders": "",
"Rescanning {n} folder for new games": [
"Rescan folders in the Library looks in these folders for new games and for games that moved there.": "",
"Rescan folders won't look in {path} any more. The {n} game from it stays in your library (nothing is deleted).": [
"",
""
],
"Rescanning {n} folder for new and moved games": [
"",
""
],
@@ -1283,6 +1307,10 @@
"Restart the Frame?": "",
"Restart the Frame…": "",
"Restarting": "",
"Restore": "",
"Restore saves · {title}": "",
"Restore saves: {title}": "",
"Restore saves…": "",
"Resume": "",
"Resuming — copied files are skipped": "",
"Retry refused picture formats": "",
@@ -1294,14 +1322,8 @@
"Revive {value} · {value2} backend": "",
"Revive: SteamVR (OpenVR) backend on PC": "",
"Rift": "",
"Room": "",
"Room depth": "",
"Room floor plan": "",
"Room height": "",
"Room positions belong to the play area where they were recorded. If SteamVR changes it, scan again or use the new boundary to line up virtual walls with your room.": "",
"Room scan saved": "",
"Room scanned": "",
"Room setup": "",
"Room width": "",
"Rotate panels 180°": "",
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.": "",
@@ -1319,9 +1341,9 @@
"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": "",
"Save the screenshots to which folder on this PC?": "",
"Saved as a known-good recipe": "",
"Saved as known-good. Check the GitHub issue that opened and submit it.": "",
@@ -1329,6 +1351,7 @@
"Saved {name} — drag it into the GitHub issue that opened": "",
"Saved. Restart the game to use the new settings.": "",
"Saved. They apply when the game is installed.": "",
"Saves": "",
"Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.": "",
"saves and settings": "",
"Saves the first N UI panels that FrameBridge turns upright (Vulkan games whose panels are stored upside down), before and after the flip, as fb_flip_<n>_raw.ppm / fb_flip_<n>_upright.ppm in the game's files folder on the Frame. Shows what the runtime gets without anyone wearing the headset.": "",
@@ -1337,13 +1360,8 @@
"Saves what the game draws every few seconds, for testing without the headset.": "",
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.": "",
"Scan a folder…": "",
"Scan room in the headset": "",
"Scan your room for mixed reality": "",
"Scan {name}": "",
"Scanner installed": "",
"Scanning: {directions} of 12 directions seen{up}": "",
"Scans your game folders again and adds new games. Games already in the library keep their recipe, tags and art.": "",
"Scene games get your saved room. Without one, they get a simple room from the play area.": "",
"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.": "",
"Screenshot copied. Paste it anywhere.": "",
"Screenshot taken": "",
"Screenshots": "",
@@ -1363,10 +1381,7 @@
"Select all": "",
"Select all shown": "",
"Select games to install": "",
"Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved room, use a simple play-area box (for example Demeter).": "",
"Set by you.": "",
"Set up a play area in SteamVR first. Valve's default circle does not describe your room.": "",
"Set up a room in your current play area": "",
"Set up for FramePort": "",
"Set up with a USB cable": "",
"Set up your Frame": "",
@@ -1480,6 +1495,7 @@
"Steady controller input after focus": "",
"Steam": "",
"Steam and SteamVR": "",
"Steam doesn't sync Quest games' saves. This copies each installed Quest game's saves to this PC (caches and big downloaded content stay out). Restore one from the game's menu → Restore saves….": "",
"Steam for Windows not found": "",
"Steam Frame": "",
"Steam Frame patches": "",
@@ -1496,6 +1512,8 @@
"Stereo (3D) 360° layers: 0 = as the game sends them, 2 = flat (the left image to both eyes).": "",
"Stop": "",
"Stop objects popping in and out": "",
"Stop scanning this folder": "",
"Stop scanning this folder?": "",
"Stop using this key (the file stays)": "",
"Stopped: {why}": "",
"Stops a black picture in Minecraft launchers (for example QuestCraft).": "",
@@ -1586,6 +1604,7 @@
"The Frame's Steam library still shows the old artwork.": "",
"The Frame's Steam library still shows the old name.": "",
"the game failed its checks (see the list above)": "",
"The game's current saves on the Frame are replaced. FramePort keeps a copy of them on the Frame, so you can undo the last restore.": "",
"The game's files or settings changed. Update applies only what's needed; saves are kept.": "",
"The game's {kind} isn't at {path} any more. Add the game again from the folder that has it.": "",
"The headset is asleep: put it on, then take the screenshot.": "",
@@ -1606,7 +1625,6 @@
"The PC VR counterpart of OVRPort's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); without it the game runs as a flat window. On the Frame the launcher runs the game through FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive handle it there).": "",
"the previous version, kept as a backup": "",
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games (Gear VR, Oculus Go) run through FramePort's 32-bit bridge (experimental).": "",
"The scanner turns a recorded depth-mesh dump into a room. FramePort installs it when you build a recorded scan.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or turn off HTTPS scanning.": "",
"The store's name": "",
@@ -1684,6 +1702,10 @@
"Typed it, except characters the Frame's US keyboard layout doesn't have: {chars}": "",
"ufw is active. Allow the setup port with: sudo ufw allow {port}/tcp (remove it afterwards with: sudo ufw delete allow {port}/tcp)": "",
"unclear": "",
"Undo last restore ({when})": "",
"Undo restore": "",
"Undo restore: {title}": "",
"Undo the last restore?": "",
"Uninstall": "",
"Uninstall FramePort": "",
"Uninstall FramePort?": "",
@@ -1749,6 +1771,10 @@
"Update tools": "",
"Update with a newer APK…": "",
"Update with a newer version…": "",
"Updated the location of {n} game": [
"",
""
],
"Updates": "",
"Updates every game marked \"Update ready\", one after another.": "",
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
@@ -1771,8 +1797,6 @@
"Use controllers": "",
"Use FramePort": "",
"Use FramePort for these links": "",
"Use my play area": "",
"Use room layout": "",
"Use the new recipe": "",
"Use the repack's launcher (bundled Revive)": "",
"Use the setup command": "",
@@ -1780,17 +1804,16 @@
"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.": "",
"Vermillion: paint canvases in the scene": "",
"Version": "",
"version {version}": "",
"Very high": "",
"Vibration strength": "",
"Video panels that an Android media player draws into (XR_KHR_android_surface_swapchain, for example I Am Monkey's intro video), which the Frame's runtime refuses, get an Android Surface from FrameBridge: each video frame is copied into an ordinary swapchain the panel shows. Without it these panels are missing and a game waiting for its video stays black. 0 = leave them to the runtime.": "",
@@ -1948,6 +1971,22 @@
"",
""
],
"{n} game has another version there (left alone: Update with a newer version… on its page)": [
"",
""
],
"{n} game in the library": [
"",
""
],
"{n} game is already in the library from another folder": [
"",
""
],
"{n} game now uses the other version found there": [
"",
""
],
"{n} game ran into a problem": [
"",
""
@@ -1964,6 +2003,10 @@
"",
""
],
"{n} large file left out": [
"",
""
],
"{n} line": [
"",
""
@@ -1997,6 +2040,7 @@
"{rate}/s": "",
"{remaining} more after this": "",
"{size:.1f} GiB free": "",
"{size} of saves": "",
"{title} couldn't be added to the Frame's Steam library: {why}": "",
"{title} ended.": "",
"{title} failed: {error}": "",
@@ -2006,6 +2050,7 @@
"{title} joins the Frame's Steam library once the running game closes": "",
"{title} joins the Frame's Steam library when you're back in Gaming Mode — start it there": "",
"{title} moved to {label}": "",
"{title}'s saves on the Frame are replaced by the backup from {when}. The Frame keeps a copy of the saves it has now, so you can undo this.": "",
"{title}: analyzed again": "",
"{title}: checks failed": "",
"{title}: details from {value}": "",
@@ -2016,7 +2061,10 @@
"{title}: newer version added": "",
"{title}: newer version added. Press Update to install it.": "",
"{title}: ready": "",
"{title}: saves backed up ({line})": "",
"{title}: saves from {when} restored ({n} files)": "",
"{title}: Steam artwork updated on the Frame": "",
"{title}: the saves from before the last restore are back": "",
"{title}: {line}": "",
"{used} of {total}": "",
"{used}/{max} kernel keys used": "",
@@ -2027,7 +2075,6 @@
"{value} failed ({returncode})": "",
"{value} installed, but didn't start": "",
"{value} took too long — update by hand: {join}": "",
"{walls} walls · {area:.1f} m² · {height:.1f} m high": "",
"• On the Frame (if selected below): {n} game ({size}), its Steam entry and FramePort's files": [
"",
""
-1
View File
@@ -1 +0,0 @@
"""Portable room files shared by the CLI and GUI."""
-308
View File
@@ -1,308 +0,0 @@
"""FramePort room format v1, in SteamVR STAGE coordinates (metres, +Y up).
Unknown text records are ignored so a newer scanner can work with an older app.
Known records are validated before upload; mesh paths never escape the room folder.
"""
from __future__ import annotations
import math
import re
import struct
import time
import uuid
from collections import Counter
from dataclasses import dataclass, field
from html import escape
def _id() -> str:
return uuid.uuid4().hex
@dataclass
class Wall:
uuid: str
x0: float
z0: float
x1: float
z1: float
y0: float = 0
y1: float = 2.5
label: str = "WALL_FACE" # INVISIBLE_WALL_FACE: an edge the scanner closed without seeing a wall
WALL_LABELS = ("WALL_FACE", "INVISIBLE_WALL_FACE")
@dataclass
class Volume:
uuid: str
label: str
cx: float
cz: float
top_y: float
yaw_deg: float
w: float
d: float
h: float
@dataclass
class Plane:
uuid: str
label: str
px: float
py: float
pz: float
qx: float
qy: float
qz: float
qw: float
w: float
h: float
@dataclass
class Mesh:
uuid: str = field(default_factory=_id)
filename: str = "room-mesh.bin"
vertex_count: int = 0
index_count: int = 0
vertices: list[tuple[float, float, float]] = field(default_factory=list)
indices: list[int] = field(default_factory=list)
def __post_init__(self):
if self.vertices:
self.vertex_count = len(self.vertices)
if self.indices:
self.index_count = len(self.indices)
@dataclass
class Room:
universe: int = 0
source: str = "manual"
time: int = field(default_factory=lambda: int(time.time()))
uuid: str = field(default_factory=_id)
floor_uuid: str = field(default_factory=_id)
ceiling_uuid: str = field(default_factory=_id)
floor: float = 0
ceiling: float = 2.5
outline: list[tuple[float, float]] = field(default_factory=list)
walls: list[Wall] = field(default_factory=list)
volumes: list[Volume] = field(default_factory=list)
planes: list[Plane] = field(default_factory=list)
mesh: Mesh | None = None
play_area: list[tuple[float, float]] = field(default_factory=list)
def _area(points) -> float:
return sum(x * b - a * z for (x, z), (a, b) in zip(points, points[1:] + points[:1], strict=True)) / 2
def _uuid(value: str) -> str:
if not re.fullmatch(r"[0-9a-f]{32}", value):
raise ValueError("UUID must be 32 lowercase hex digits")
return value
def _numbers(values):
result = [float(v) for v in values]
if not all(math.isfinite(v) for v in result):
raise ValueError("coordinates must be finite")
return result
def _finish(room: Room) -> Room:
if not room.outline and room.walls:
xs = [x for w in room.walls for x in (w.x0, w.x1)]
zs = [z for w in room.walls for z in (w.z0, w.z1)]
room.outline = [(min(xs), min(zs)), (max(xs), min(zs)),
(max(xs), max(zs)), (min(xs), max(zs))]
if len(room.outline) < 3 or abs(_area(room.outline)) < 1e-8:
raise ValueError("room needs an outline with nonzero area or walls")
if _area(room.outline) < 0:
room.outline.reverse()
if room.ceiling <= room.floor:
raise ValueError("ceiling must be above floor")
if not room.walls:
room.walls = [Wall(_id(), *a, *b, room.floor, room.ceiling)
for a, b in zip(room.outline, room.outline[1:] + room.outline[:1], strict=True)]
edges = list(zip(room.outline, room.outline[1:] + room.outline[:1], strict=True))
for wall in room.walls:
if wall.y1 <= wall.y0 or (wall.x0, wall.z0) == (wall.x1, wall.z1):
raise ValueError("wall must have positive height and length")
if ((wall.x1, wall.z1), (wall.x0, wall.z0)) in edges:
wall.x0, wall.x1 = wall.x1, wall.x0
wall.z0, wall.z1 = wall.z1, wall.z0
ids = [room.uuid, room.floor_uuid, room.ceiling_uuid]
ids += [v.uuid for v in [*room.walls, *room.volumes, *room.planes]]
if room.mesh:
ids.append(room.mesh.uuid)
if len(ids) != len(set(ids)):
raise ValueError("entity UUIDs must be unique")
return room
def parse(text: str) -> Room:
"""Read v1 text, tolerating comments, blank lines and future record types."""
lines = [line.split("#", 1)[0].split() for line in text.splitlines()]
lines = [line for line in lines if line]
if not lines or lines[0] != ["frameport-room", "1"]:
raise ValueError("expected frameport-room 1")
room, ceiling_seen = Room(), False
for parts in lines[1:]:
kind, values = parts[0], parts[1:]
try:
if kind in ("universe", "time"):
if len(values) != 1 or not values[0].isdigit():
raise ValueError("expected a nonnegative integer")
n = int(values[0])
if kind == "universe" and n >= 2**64:
raise ValueError("universe exceeds u64")
setattr(room, kind, n)
elif kind == "source":
if len(values) != 1 or values[0] not in ("scan", "boundary", "box", "manual"):
raise ValueError("invalid source")
room.source = values[0]
elif kind == "room":
if len(values) != 1:
raise ValueError("expected UUID")
room.uuid = _uuid(values[0])
elif kind in ("floor", "ceiling"):
ident, height = values
setattr(room, kind + "_uuid", _uuid(ident))
setattr(room, kind, _numbers([height])[0])
ceiling_seen |= kind == "ceiling"
elif kind == "outline":
n = int(values[0])
coords = _numbers(values[1:])
if n < 3 or len(coords) != n * 2:
raise ValueError("outline count does not match coordinates")
room.outline = list(zip(coords[::2], coords[1::2], strict=True))
elif kind == "wall":
if len(values) not in (7, 8) or (len(values) == 8 and values[7] not in WALL_LABELS):
raise ValueError("expected UUID, 6 coordinates and an optional wall label")
room.walls.append(Wall(_uuid(values[0]), *_numbers(values[1:7]), *values[7:]))
elif kind in ("volume", "plane"):
count = 9 if kind == "volume" else 11
if len(values) != count or not re.fullmatch(r"[A-Z][A-Z0-9_]*", values[1]):
raise ValueError("invalid label or coordinate count")
obj = (Volume if kind == "volume" else Plane)(_uuid(values[0]), values[1], *_numbers(values[2:]))
if min(obj.w, obj.h, obj.d if kind == "volume" else obj.w) <= 0:
raise ValueError("dimensions must be positive")
if kind == "plane" and sum(q*q for q in (obj.qx, obj.qy, obj.qz, obj.qw)) < 1e-12:
raise ValueError("plane quaternion is zero")
(room.volumes if kind == "volume" else room.planes).append(obj)
elif kind == "mesh":
ident, filename, nv, ni = values
if not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]*", filename) or filename in ("room.txt", "scan.log"):
raise ValueError("mesh must be a sidecar filename")
nv, ni = int(nv), int(ni)
if nv < 0 or ni < 0 or ni % 3 or ni > 600000:
raise ValueError("invalid mesh counts")
room.mesh = Mesh(_uuid(ident), filename, nv, ni)
except (ValueError, IndexError, TypeError) as exc:
raise ValueError(f"{kind}: {exc}") from exc
if not ceiling_seen:
room.ceiling = room.floor + 2.5
return _finish(room)
def dumps(room: Room) -> str:
"""Write canonical text; validate programmatically constructed rooms too."""
_finish(room)
def row(*values):
return " ".join(format(v, ".9g") if isinstance(v, float) else str(v) for v in values)
rows = ["frameport-room 1", row("universe", room.universe), row("source", room.source), row("time", room.time),
row("room", room.uuid), row("floor", room.floor_uuid, room.floor),
row("ceiling", room.ceiling_uuid, room.ceiling),
row("outline", len(room.outline), *(v for p in room.outline for v in p))]
for w in room.walls: # the label only when it isn't the default (older readers know 7 fields)
rows.append(row("wall", w.uuid, w.x0, w.z0, w.x1, w.z1, w.y0, w.y1,
*([w.label] if w.label != "WALL_FACE" else [])))
for kind, objects in (("volume", room.volumes), ("plane", room.planes)):
rows.extend(row(kind, *vars(obj).values()) for obj in objects)
if room.mesh:
m = room.mesh
rows.append(row("mesh", m.uuid, m.filename, m.vertex_count, m.index_count))
text = "\n".join(rows) + "\n"
parse(text)
return text
def read_mesh(data: bytes) -> Mesh:
if len(data) < 16:
raise ValueError("truncated mesh header")
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
if magic != b"FPRM" or version != 1:
raise ValueError("expected FPRM version 1")
if ni % 3 or ni > 600000 or len(data) != 16 + nv * 12 + ni * 4:
raise ValueError("invalid mesh length or triangle count")
vertices = list(struct.iter_unpack("<fff", data[16:16 + nv * 12]))
indices = [v[0] for v in struct.iter_unpack("<I", data[16 + nv * 12:])]
if any(not math.isfinite(x) for p in vertices for x in p) or any(i >= nv for i in indices):
raise ValueError("invalid mesh coordinate or index")
return Mesh(vertex_count=nv, index_count=ni, vertices=vertices, indices=indices)
def write_mesh(mesh: Mesh) -> bytes:
data = struct.pack("<4sIII", b"FPRM", 1, len(mesh.vertices), len(mesh.indices))
data += b"".join(struct.pack("<fff", *p) for p in mesh.vertices)
data += b"".join(struct.pack("<I", i) for i in mesh.indices)
read_mesh(data)
return data
def from_boundary(outline_xz, height: float = 2.5) -> Room:
coords = [tuple(_numbers(p)) for p in outline_xz]
if any(len(p) != 2 for p in coords) or not math.isfinite(height) or height <= 0:
raise ValueError("invalid boundary or height")
return _finish(Room(source="boundary", ceiling=height, outline=coords))
def box_room(width: float, depth: float, height: float = 2.5) -> Room:
if not all(math.isfinite(v) and v > 0 for v in (width, depth, height)):
raise ValueError("room dimensions must be positive and finite")
w, d = width / 2, depth / 2
room = from_boundary([(-w, -d), (w, -d), (w, d), (-w, d)], height)
room.source = "box"
return room
def summary(room: Room) -> dict:
return {"walls": len(room.walls), "volumes": dict(Counter(v.label for v in room.volumes)),
"planes": len(room.planes), "floor_area": abs(_area(room.outline)),
"ceiling_height": room.ceiling - room.floor}
def preview_svg(room: Room) -> str:
"""Self-contained top-down preview; text labels are escaped, never interpreted as markup."""
points = room.outline + room.play_area
xmin, xmax = min(x for x, _ in points), max(x for x, _ in points)
zmin, zmax = min(z for _, z in points), max(z for _, z in points)
scale = 440 / max(xmax-xmin, zmax-zmin, .1)
def point(x, z):
return f"{30+(x-xmin)*scale:.2f},{30+(zmax-z)*scale:.2f}"
def polygon(poly):
return " ".join(point(x, z) for x, z in poly)
out = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" role="img">',
'<title>Room floor plan</title>',
f'<polygon points="{polygon(room.outline)}" fill="#17232f" stroke="#f49a42" stroke-width="3"/>']
for wall in room.walls:
x0, y0 = point(wall.x0, wall.z0).split(",")
x1, y1 = point(wall.x1, wall.z1).split(",")
out.append(f'<line x1="{x0}" y1="{y0}" x2="{x1}" y2="{y1}" stroke="#f49a42" stroke-width="3"/>')
for v in room.volumes:
angle = math.radians(v.yaw_deg)
c, s = math.cos(angle), math.sin(angle)
corners = [(v.cx+c*x+s*z, v.cz-s*x+c*z)
for x, z in [(-v.w/2, -v.d/2), (v.w/2, -v.d/2), (v.w/2, v.d/2), (-v.w/2, v.d/2)]]
x, y = point(v.cx, v.cz).split(",")
out += [f'<polygon points="{polygon(corners)}" fill="#275772" stroke="#65bcec"/>',
f'<text x="{x}" y="{y}" text-anchor="middle" fill="#ffffff" font-size="12">{escape(v.label)}</text>']
if room.play_area:
out.append(f'<polygon points="{polygon(room.play_area)}" fill="none" stroke="#65bcec" stroke-dasharray="6 4"/>')
return "\n".join(out + ["</svg>"])
-1
View File
@@ -34,7 +34,6 @@ def meta_audio_libs(a: Analysis) -> list[str]:
def uses_scene(a: Analysis) -> bool:
"""Scene layouts and spatial anchors both need the Meta scene API on the Frame."""
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
return any(p.endswith(("USE_SCENE", "USE_ANCHOR_API")) for p in perms)
+16 -12
View File
@@ -135,8 +135,8 @@ SETTINGS = [
"that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s "
"(compare both in one session)."),
("scene_emul", "int", 0, "Emulate Meta scene (room)",
"Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved "
"room, use a simple play-area box (for example Demeter)."),
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality "
"games that build their level from the room (for example Demeter)."),
("controller_models", "int", 0, "Steam Frame controller models",
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get "
"the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and "
@@ -341,8 +341,8 @@ UI: dict[str, dict] = {
"passthrough_emul": dict(group="room", level="common", label="See your room",
help="Shows the Frame's cameras where the game expects passthrough.",
control=("switch",)),
"scene_emul": dict(group="room", level="common", label="Use room layout",
help="Scene games get your saved room. Without one, they get a simple room from the play area.",
"scene_emul": dict(group="room", level="common", label="Pretend room layout",
help="Mixed-reality games that build their level from your room get a simple room with walls.",
control=("switch",)),
"scene_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")),
@@ -448,10 +448,8 @@ UI: dict[str, dict] = {
# settings that need the xrshim (frame/adapter.py: OVRPlugin's own arm64 OpenXR lookups routed to the adapter), so
# they don't work for 32-bit games on the bridge. surface_native: Batman's native video renderer hooks Vulkan
# functions OVRPort's dispatcher doesn't know. scene_emul: the dispatcher refuses every scene/anchor function
# ("Unknown proc addr: xrQuerySpacesFB", Pinball FX VR 2026-10-16), so the emulated room only reaches the game through
# the shim (Demeter/NOPE only got it because haptic_fix happened to be on)
XRSHIM_SETTINGS = ("controller_models", "haptic_fix", "surface_native", "scene_emul")
# functions OVRPort's dispatcher doesn't know
XRSHIM_SETTINGS = ("controller_models", "haptic_fix", "surface_native")
class AdapterSetting(Patch):
@@ -474,7 +472,7 @@ class AdapterSetting(Patch):
return UI.get(self.key, {}).get("help") or super().summary
def detect(self, a):
from .applicability import uses_equirect_layers, uses_render_models, uses_scene
from .applicability import needs_scene, uses_equirect_layers, uses_render_models, uses_room_model
if self.key == "controller_fix" and a.extra.get("hand_tracking_only"):
return Suggestion(True, "Hand tracking is required by the game: pass hands through instead of reporting "
@@ -510,9 +508,15 @@ class AdapterSetting(Patch):
# OVRPort's patch_disable_space_warp doesn't reach UE5). Off until confirmed in more games.
return Suggestion(False, "Unreal Engine 5 game with Meta's space warp: turn on if the picture flickers or "
"smears (for example Into The Radius 2).", {"value": 1})
if self.key == "scene_emul" and uses_scene(a):
return Suggestion(True, "The game uses Meta's scene or spatial anchor APIs: serve the saved room on "
"the Frame, or a simple play-area room until one is scanned.", {"value": 1})
if self.key == "scene_emul" and needs_scene(a):
return Suggestion(True, "Mixed-reality game that builds its level from the room model: emulate a "
"guardian-sized room (for example Demeter).", {"value": 1})
if self.key == "scene_emul" and uses_room_model(a):
# also VR games that only ask for the room (VR HOT's room setup retried forever without it)
return Suggestion(True, "The game asks Meta's system for your room layout (scene), which the Frame "
"doesn't have: emulate a room from the play area (for example VR HOT's room "
"setup).",
{"value": 1})
return None
def applies(self, a):
+221 -18
View File
@@ -28,15 +28,115 @@ from .sources import quest_dump, rift_dump
from .targets.base import PC_LABEL, Target
@dataclasses.dataclass
class ScanReport:
"""What a scan did with games already in the library that it found at another place (GitHub discussion #351).
Each item: {"package", "title", "path": the copy found, "old": where the library had it}.
moved: the library's copy is gone and the found one is the same build: the entry now points at it (title, tags,
art, recipe, builds and installs stay). updated: the library's copy is gone and the found one is another version:
taken like "Update with a newer APK…" (only by Scan a folder…, which the user pointed at that folder).
other_version: the same on Rescan folders: left alone, the user decides. other_copy: the library's copy is still
there: it stays."""
moved: list[dict] = dataclasses.field(default_factory=list)
updated: list[dict] = dataclasses.field(default_factory=list)
other_version: list[dict] = dataclasses.field(default_factory=list)
other_copy: list[dict] = dataclasses.field(default_factory=list)
def handled(self) -> set[str]:
return {i["package"] for i in self.moved + self.updated + self.other_version + self.other_copy}
def note(self, kind: str, entry: dict, path, reporter: Reporter | None = None) -> None:
item = {"package": entry["package"], "title": entry.get("title") or entry["package"], "path": str(path),
"old": entry.get("apk") or entry.get("game_dir")}
getattr(self, kind).append(item)
if reporter:
reporter.log({
"moved": "{title}: moved, now uses {path}",
"updated": "{title}: another version found at {path}; the library now uses it (title, tags, art and "
"your own recipe stay)",
"other_version": "{title}: another version found at {path}; left alone (to switch to it: \"Update "
"with a newer APK…\" or \"…version…\" on the game's page, or Scan a folder… on "
"that folder)",
"other_copy": "{title}: another copy at {path} is left out (the library keeps {old})",
}[kind].format(**item))
def _same_path(a, b) -> bool:
try:
return Path(a).resolve() == Path(b).resolve()
except OSError:
return str(a) == str(b)
def source_present(entry: dict) -> bool:
"""The game's own files are where the library says (Quest/Android: APK + data; PC VR: the game folder)."""
try:
if is_rift(entry) or is_linux(entry):
return bool(entry.get("game_dir")) and Path(entry["game_dir"]).is_dir()
check_source(entry)
return True
except Exception: # noqa: BLE001 - missing, or on a drive that isn't connected
return False
def _missing_note(entry: dict, reporter: Reporter | None) -> None:
if reporter:
reporter.log(f"{entry.get('title') or entry['package']}: its files aren't at "
f"{entry.get('apk') or entry.get('game_dir')} any more (moved, deleted, or on a drive that "
"isn't connected)")
def same_quest_build(entry: dict, apk: Path) -> bool:
"""The APK is the build the library entry was made from: the same SHA-256 (recorded when it was added), else the
same versionName."""
if entry.get("source_sha256"):
try:
return builder.sha256(Path(apk)) == entry["source_sha256"]
except OSError:
return False
ver = (entry.get("analysis") or {}).get("version")
return bool(ver) and quest_dump.version_name(Path(apk)) == ver
def relink_quest(package: str, src: SourceGame) -> dict | None:
"""Point a Quest/Android entry at the same build in another place: only where its files are changes."""
from .core.models import data_manifest
manifest = data_manifest(src.data_dir, src.data_files)
fields = dict(name=src.name, apk=str(src.apk), data_dir=str(src.data_dir) if src.data_dir else None,
data_files=src.data_files, data_bytes=sum(manifest.values()), data_manifest=manifest,
origin=str(src.origin) if src.origin else None)
return library.update_game(package, lambda g: g.update(fields))
def _known_quest(src: SourceGame, old: dict, reporter: Reporter | None, allow_update: bool,
report: ScanReport) -> None:
"""A Quest/Android game of the library found at another place than the library's copy (see ScanReport)."""
if source_present(old):
report.note("other_copy", old, src.apk, reporter)
return
_missing_note(old, reporter)
if same_quest_build(old, src.apk):
relink_quest(old["package"], src)
report.note("moved", old, src.apk, reporter)
elif allow_update:
replace_apk(old["package"], src.apk, reporter, found=src)
report.note("updated", old, src.apk, reporter)
else:
report.note("other_version", old, src.apk, reporter)
def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_rift: bool = False,
art: bool = False, only_new: bool = False) -> list[dict]:
art: bool = False, only_new: bool = False, report: ScanReport | None = None) -> list[dict]:
"""Scan a file/folder (recursively), analyze every game found (Quest APKs and Rift game folders) and store it with
a suggested recipe. on_added(entry) is called as each game lands in the library (the GUI streams cards in).
force_rift: `path` is one Rift game folder (added even if no VR runtime is detected). art: fetch artwork too.
only_new: skip Quest APKs already in the library (a rescan for games added since; Rift folders already skip
unchanged games)."""
unchanged games). A game of the library found at another place (moved, a second copy, another version) is put in
`report` (ScanReport), not in the returned list."""
path = Path(path)
added = []
report = report if report is not None else ScanReport()
known_apks = {str(Path(g["apk"]).resolve()) for g in library.games() if g.get("apk")} if only_new else set()
# entries made from AXRB's PC-patched copies (no OBBs; older FramePort scanned them) get the original download
known_packages = {g["package"] for g in library.games()
@@ -45,6 +145,8 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
def done(entry):
if only_new and entry["package"] in known_packages: # already in the library (e.g. an unchanged Rift folder)
return
if entry["package"] in report.handled(): # moved, another copy or version: reported, not "added"
return
added.append(entry)
if on_added:
on_added(entry)
@@ -54,12 +156,21 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
reporter.check_cancel()
if only_new and str(Path(src.apk).resolve()) in known_apks:
continue
if only_new: # another copy of a game already in the library (e.g. a patched build): leave it alone
try:
pkg = quest_dump._package_of(src.apk)
except Exception: # noqa: BLE001
pkg = None
old = library.game(pkg) if pkg else None
if old and old.get("apk") and not is_rift(old) and not is_linux(old) \
and not _same_path(old["apk"], src.apk) and not quest_dump._axrb_patched(Path(old["apk"])):
try:
if quest_dump._package_of(src.apk) in known_packages:
continue
except Exception: # noqa: BLE001
pass
_known_quest(src, old, reporter, not only_new, report)
except Exception as exc: # noqa: BLE001 - keep scanning
if reporter:
reporter.log(f"skipped {src.apk.name}: {exc}")
continue
if only_new and pkg in known_packages: # (e.g. an entry without an APK)
continue
try:
entry = add_game(src, reporter)
if art:
@@ -87,7 +198,8 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
reporter.check_cancel()
reporter.progress(i / max(len(folders), 1), f"{i + 1} / {len(folders)} · {folder.name}")
try:
entry = add_rift_game(folder, reporter, tree=trees.get(folder), force=force_rift, art=art)
entry = add_rift_game(folder, reporter, tree=trees.get(folder), force=force_rift, art=art,
report=report, allow_update=not only_new)
if entry and art and not entry.get("details"):
fetch_details(entry["package"], reporter)
entry = library.game(entry["package"])
@@ -96,6 +208,8 @@ def add_path(path: Path, reporter: Reporter | None = None, on_added=None, force_
except Exception as exc:
if reporter:
reporter.log(f"skipped {folder.name}: {exc}")
if reporter and report.moved:
reporter.log(f"Updated the location of {len(report.moved)} game(s)")
return added
@@ -136,12 +250,70 @@ def _existing_rift(folder: Path) -> dict | None:
return None
def _rift_program(entry: dict) -> tuple[str, int] | None:
"""(program path in the game folder, its size) from a PC VR entry's fingerprint."""
fp = (((entry.get("analysis") or {}).get("extra") or {}).get("fingerprint")) or ""
parts = fp.rsplit(":", 3)
if len(parts) == 4 and parts[0] and parts[1].isdigit():
return parts[0], int(parts[1])
return None
def _file_size(path: Path) -> int | None:
try:
return path.stat().st_size if path.is_file() else None
except OSError:
return None
def _same_rift_build(entry: dict, folder: Path) -> bool:
"""The folder holds the build the entry was made from: its program at the same place with the same size."""
prog = _rift_program(entry)
if prog:
return _file_size(folder / prog[0]) == prog[1]
return bool(entry.get("exe")) and _file_size(folder / entry["exe"]) is not None
def _moved_rift(folder: Path, tree) -> dict | None:
"""The library's PC VR entry this folder is another copy of (it was found elsewhere before): the same game id,
else an entry whose folder is gone and whose program (path and size) is in this folder."""
from .analysis import rift
rifts = [g for g in library.games() if g.get("kind") == "rift" and g.get("game_dir")
and not _same_path(g["game_dir"], folder)]
same = library.game(rift.game_id(folder, getattr(tree, "manifest", None)))
if same is not None and any(g["package"] == same["package"] for g in rifts):
return same
gone = [g for g in rifts if (p := _rift_program(g)) and _file_size(folder / p[0]) == p[1]
and not source_present(g)]
return gone[0] if len(gone) == 1 else None
def _relink_rift(entry: dict, folder: Path) -> dict | None:
"""Point a PC VR entry at the same build in another folder: only where its files are changes."""
from .analysis import rift
new_fp = rift.fingerprint(folder, entry.get("exe") or "")
def change(g: dict) -> None:
g.update(game_dir=str(folder), origin=str(folder.parent), name=folder.name)
extra = (g.get("analysis") or {}).setdefault("extra", {}) if isinstance(g.get("analysis"), dict) else {}
extra["folder"] = str(folder)
old_fp = extra.get("fingerprint") or ""
# (copies may get a new date; a fingerprint of an older analysis stays so the next scan analyzes it again)
if new_fp and old_fp.rsplit(":", 1)[-1] == new_fp.rsplit(":", 1)[-1]:
extra["fingerprint"] = new_fp
return library.update_game(entry["package"], change)
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
force: bool = False, art: bool = False, replace: dict | None = None) -> dict | None:
force: bool = False, art: bool = False, replace: dict | None = None,
report: ScanReport | None = None, allow_update: bool = True) -> dict | None:
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
name."""
name. A game of the library whose folder moved here gets this folder (report: ScanReport; allow_update: another
version of it may replace it, see ScanReport)."""
from .analysis import rift
from .recommend import catalog
@@ -152,6 +324,27 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
return None
old = replace or _existing_rift(folder)
if old is None and exe is None: # (set_exe passes the entry's own folder)
tree = tree or rift.walk(folder)
other = _moved_rift(folder, tree)
if other is not None:
report = report if report is not None else ScanReport()
if source_present(other):
report.note("other_copy", other, folder, reporter)
return None
_missing_note(other, reporter)
if not _same_rift_build(other, folder):
if not allow_update:
report.note("other_version", other, folder, reporter)
return None
replace_rift_game(other["package"], folder, reporter)
report.note("updated", other, folder, reporter)
return library.game(other["package"])
_relink_rift(other, folder)
report.note("moved", other, folder, reporter)
if art and not _has_art(other["package"]):
_rift_art(library.game(other["package"]), reporter)
return library.game(other["package"])
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
if replace is None:
@@ -638,13 +831,16 @@ def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
old = library.game(a.package) or {}
# a name the user chose (Rename…, an install link) stays when the game is scanned again
names = {"auto_title": recipe.title or a.label} if old.get("title_locked") else {"title": recipe.title or a.label}
# a recipe the user edited stays when the game is scanned again (only the suggestion is refreshed, like Analyze
# again); a derived one follows the new analysis
own = {} if (old.get("recipe") or {}).get("source") == "user" else \
{"recipe": library.recipe_to_dict(recipe), "status": recipe.status}
return library.upsert_game(
a.package, **names, name=src.name, apk=str(src.apk),
data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files,
data_bytes=sum(manifest.values()), data_manifest=manifest, source_sha256=builder.sha256(src.apk),
origin=str(src.origin),
analysis=a.to_dict(), recipe=library.recipe_to_dict(recipe), suggested=library.recipe_to_dict(recipe),
status=recipe.status,
analysis=a.to_dict(), suggested=library.recipe_to_dict(recipe), **own,
)
@@ -661,16 +857,17 @@ class WrongPackage(ValueError):
"""The picked APK is another app than the game it should update."""
def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> dict:
def replace_apk(package: str, apk: Path, reporter: Reporter | None = None, found: SourceGame | None = None) -> dict:
"""A newer APK for a Quest/Android game already in the library (GitHub #161): the entry points at it from now on.
Its title, art, tags and the user's own recipe stay (a suggested recipe follows the new analysis, like Analyze
again). The game data stays where it was unless the new APK's folder brings its own. Returns {"entry", "older":
the new versionCode is lower than the old one's, "old_version", "new_version"}."""
again). The game data stays where it was unless the new APK's folder brings its own. found: the game as a scan
found it (its data folder). Returns {"entry", "older": the new versionCode is lower than the old one's,
"old_version", "new_version"}."""
entry = library.game(package)
if entry is None or is_rift(entry) or is_linux(entry):
raise ValueError("only Quest/Android games can get a newer APK")
apk = Path(apk)
src = quest_dump.from_path(apk)
src = found or quest_dump.from_path(apk)
new_pkg, new_vc = quest_dump._apk_ids(apk)
if src is None or new_pkg is None:
raise ValueError(f"{apk.name} isn't a readable APK")
@@ -685,8 +882,14 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
a = analyze(apk, data_bytes=src.data_bytes())
a.extra["lang_packs"] = langpacks.find_tags(_data_folder(src))
a.extra["asset_files"] = langpacks.find_content_files(_data_folder(src))
library.upsert_game(package, apk=str(apk), data_dir=str(src.data_dir) if src.data_dir else None,
data_files=src.data_files, data_bytes=src.data_bytes())
fields = dict(apk=str(apk), data_dir=str(src.data_dir) if src.data_dir else None, data_files=src.data_files,
data_bytes=src.data_bytes(), source_sha256=builder.sha256(apk))
if found is not None: # found by a scan: where it was found
from .core.models import data_manifest
fields.update(name=src.name, origin=str(src.origin) if src.origin else entry.get("origin"),
data_manifest=data_manifest(src.data_dir, src.data_files))
library.upsert_game(package, **fields)
stored = _store_analysis(package, a)
return {"entry": stored, "older": bool(old_vc and new_vc and new_vc < old_vc), "old_version": old_vc,
"new_version": new_vc}
+10
View File
@@ -45,6 +45,16 @@ def _apk_ids(apk: Path) -> tuple[str | None, int | None]:
return a.package, vc
def version_name(apk: Path) -> str | None:
"""The APK's versionName (None if it can't be read)."""
try:
from pyaxmlparser import APK
return APK(str(apk)).version_name or None
except Exception:
return None
def _package_of(apk: Path) -> str | None:
return _apk_ids(apk)[0]
+86 -26
View File
@@ -96,6 +96,24 @@ def reduce_motion_setting() -> bool:
return bool(library.setting(REDUCE_MOTION, False))
def scan_report_lines(report: pipeline.ScanReport) -> list[str]:
"""A scan's summary of games already in the library that it found elsewhere (pipeline.ScanReport)."""
out = []
if report.moved:
out.append(tr_n("Updated the location of {n} game", "Updated the location of {n} games", len(report.moved)))
if report.updated:
out.append(tr_n("{n} game now uses the other version found there",
"{n} games now use the other versions found there", len(report.updated)))
if report.other_version:
out.append(tr_n("{n} game has another version there (left alone: Update with a newer version… on its page)",
"{n} games have another version there (left alone: Update with a newer version… on their "
"pages)", len(report.other_version)))
if report.other_copy:
out.append(tr_n("{n} game is already in the library from another folder",
"{n} games are already in the library from other folders", len(report.other_copy)))
return out
SIDEBAR_WIDTH = 236 # logical px before the UI scale (content_insets)
@@ -1168,13 +1186,17 @@ class FramePortApp:
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
"remove": lambda e: self.remove_from_library(pkg),
"backup_saves": lambda e: self.backup_saves(pkg),
"restore_saves": lambda e: self.restore_saves(pkg),
"saves_folder": lambda e: self.open_saves_folder(pkg),
}
enabled = (lambda opts: [(label, icon, handler) for label, icon, handler, disabled, _ in opts
if handler and not disabled])
busy = bool(job)
on_frame = connected and C.install_state(g, self.frame_info) in ("installed", "outdated")
st = MenuState(
quick=quick, connected=connected, job=busy,
on_frame=connected and C.install_state(g, self.frame_info) in ("installed", "outdated"),
quick=quick, connected=connected, job=busy, on_frame=on_frame,
saves=on_frame and not rift and not linux,
on_pc=rift and pkg in self.pc_installs(),
plays=[] if busy or not quick else enabled(self.play_options(g)),
installs=[] if busy or not quick else enabled(self.install_options(g)),
@@ -1765,6 +1787,30 @@ class FramePortApp:
return tr("{title}: Steam artwork updated on the Frame").format(title=self._title(pkg))
return self.submit(tr("Update Steam art: {title}").format(title=self._title(pkg)), run, pkg, "art")
# Save backups (discussion #352): views/saves_dialog.py, install/saves.py
def backup_saves(self, pkg: str) -> Job:
from .views import saves_dialog
return saves_dialog.backup_saves(self, pkg)
def restore_saves(self, pkg: str) -> None:
from .views import saves_dialog
saves_dialog.restore_dialog(self, pkg)
def open_saves_folder(self, pkg: str) -> None:
from .views import saves_dialog
saves_dialog.open_folder(pkg)
def backup_all_saves(self) -> Job | None:
"""Frame page: one job backing up every installed Quest game's saves."""
from .views import saves_dialog
pkgs = [d.get("package") for d in (self.frame_info or {}).get("installed", [])
if d.get("package") and d.get("kind") not in ("pcvr", "linux") and not d.get("overlay")]
return saves_dialog.backup_all(self, pkgs)
def move_game(self, pkg: str) -> None:
"""Move to…: pick another drive of the Frame (microSD card, internal storage) for an installed game; the copy
runs as a Frame job (GitHub #90)."""
@@ -1842,13 +1888,28 @@ class FramePortApp:
"The game folder stays.").format(title=title)
delete_local, extra = self._delete_local_option(pkg)
delete_data = None
delete_data = backup_first = None
if to == "frame": # GitHub #130: what the game stored (saves, mods, downloads) used to survive every uninstall
delete_data, row = C.checkbox(tr("Also delete its saves and everything it stored on the Frame (mods, "
"downloaded content)"), value=False, active_color=T.ERROR)
extra = row if extra is None else ft.Column([row, extra], spacing=T.px(8), tight=True)
rows = [row]
if (library.game(pkg) or {}).get("kind") not in ("rift", "linux"): # discussion #352
backup_first, backup_row = C.checkbox(tr("Back up its saves to this PC before deleting them"),
value=True, active_color=T.ACCENT, disabled=True)
def follow(e):
backup_first.disabled = not delete_data.value
C.update(backup_first)
delete_data.on_change = follow
rows.append(ft.Container(backup_row, padding=ft.Padding(T.S5, 0, 0, 0)))
rows += [extra] if extra is not None else []
extra = ft.Column(rows, spacing=T.px(8), tight=True) if len(rows) > 1 else row
def run(job: Job):
if delete_data and delete_data.value and backup_first and backup_first.value:
from ..install import saves
saves.backup(self._target_for(to).frame, pkg, job.reporter) # fails = nothing deleted
self._target_for(to).uninstall(pkg, keep_data=not (delete_data and delete_data.value))
self._pc_cache = None
if delete_local and delete_local.value:
@@ -2235,36 +2296,29 @@ class FramePortApp:
return self.submit(tr("Add {name}").format(name=name), run, None, "task")
def scan_roots(self) -> list[str]:
"""Folders to rescan: the ones scanned before (remembered from now on), else the folders the library's games
came from (one level up from each game folder)."""
roots = [r for r in library.setting("scan.roots", []) if Path(r).exists()]
if not roots:
found = set()
for g in library.load().get("games", {}).values():
if g.get("kind") == "linux": # added one by one (its origin may be e.g. Downloads)
continue
origin = g.get("origin") or (str(Path(g["apk"]).parent) if g.get("apk") else None)
if origin and Path(origin).parent.exists():
found.add(str(Path(origin).parent))
roots = sorted(found)
# a folder inside another one is scanned with it (scanning it again would only repeat work)
return [r for r in roots if not any(o != r and Path(r).is_relative_to(o) for o in roots)]
"""Folders "Rescan folders" scans (core/folders.py; Settings → Library folders lists and edits them)."""
from ..core import folders
return folders.rescan_roots()
def rescan(self, e=None) -> None:
"""Scan the library's folders again for games added since (unchanged games aren't analyzed again)."""
"""Scan the library's folders again for games added since (unchanged games aren't analyzed again) and for games
whose files moved there (their entries are pointed at the new place)."""
roots = self.scan_roots()
if not roots:
self.toast(tr("No folders to rescan yet — add games with Scan a folder…"))
return
for r in roots:
self.scan(r, only_new=True)
self.toast(tr_n("Rescanning {n} folder for new games", "Rescanning {n} folders for new games", len(roots)))
self.toast(tr_n("Rescanning {n} folder for new and moved games",
"Rescanning {n} folders for new and moved games", len(roots)))
def scan(self, path: str, single: bool = False, only_new: bool = False) -> Job:
last = {"t": 0.0}
if not single: # remembered for "Rescan folders"
roots = [r for r in library.setting("scan.roots", []) if r != str(path)]
library.set_setting("scan.roots", roots + [str(path)])
if not single and Path(path).is_dir(): # remembered for "Rescan folders" (Settings → Library folders)
from ..core import folders
folders.remember(path)
def added_one(entry: dict):
from ..artwork import thumbs
@@ -2282,17 +2336,23 @@ class FramePortApp:
def run(job: Job):
rep = job.reporter
rep.stage(tr("Looking for games"))
report = pipeline.ScanReport()
added = pipeline.add_path(Path(path), rep, on_added=added_one, force_rift=single, art=True,
only_new=only_new)
only_new=only_new, report=report)
if self.route[0] == "welcome" and added:
library.set_setting("ui.welcome_done", True)
self.route = ("library",)
n = len(added)
if report.handled():
self.refresh_view() # moved games: their pages show the new place
if only_new:
return tr_n("{n} new game in {name}", "{n} new games in {name}", n,
text = tr_n("{n} new game in {name}", "{n} new games in {name}", n,
name=Path(path).name) if added else \
tr("No new games in {name}").format(name=Path(path).name)
return tr_n("Added {n} game", "Added {n} games", n) if added else tr("No games found in that folder")
else:
text = tr_n("Added {n} game", "Added {n} games", n) if added else \
(tr("No new games in that folder") if report.handled() else tr("No games found in that folder"))
return " · ".join([text] + scan_report_lines(report))
return self.submit(tr("Scan {name}").format(name=Path(path).name), run, None, "scan")
# ------------------------------------------------------------------ executable choice / artwork
+10 -8
View File
@@ -19,12 +19,6 @@ class _Translated(dict):
HELP: dict[str, str] = _Translated({
"mr_room": "Mixed-reality games use walls and furniture from a saved room. Use your play area for walls now, "
"or import a recorded scan. Headset scanning is not available yet.",
"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.",
# ---- game status / library
"status": "How well the game runs on the Steam Frame. \"Works\" and \"Works with issues\" were tested on a Frame; "
"\"Untested\" is a suggestion nobody has confirmed yet. \"Can't run\" means a known blocker.",
@@ -117,8 +111,14 @@ HELP: dict[str, str] = _Translated({
"app_updates": "FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, "
"then restarts FramePort; your games and settings stay.",
"update_all": "Updates every game marked \"Update ready\", one after another.",
"rescan": "Scans your game folders again and adds new games. Games already in the library keep their recipe, "
"tags and art.",
"rescan": "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.",
"library_folders": "Every Scan a folder… adds its folder here. Rescan folders looks in them for new games, and "
"for games you moved: a game whose files are gone from their old place is pointed at the same "
"copy in its new place. Another version found there is only taken by Scan a folder…; Rescan "
"leaves it for you to choose (Update with a newer version… on the game's page). Removing a "
"folder only stops it being scanned: its games stay in the library.",
"files": "Upload, download, rename and delete files on the Frame. Videos, Downloads and Documents are shared by "
"every Quest game; each game also has its own storage.",
"live_view": "Shows the headset's view with sound, whatever is running. It plays here where possible, else in "
@@ -126,6 +126,8 @@ HELP: dict[str, str] = _Translated({
"type_on_frame": "This PC's keyboard types on the Frame while this tab is open. In the headset, select a text "
"field, then type here.",
"free_space": "Deletes copies kept from earlier installs and unfinished uploads. Games and saves stay.",
"save_backups": "Steam doesn't sync Quest games' saves. This copies each installed Quest game's saves to this PC "
"(caches and big downloaded content stay out). Restore one from the game's menu → Restore saves….",
"install_drive": "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.",
"move_game": "Copies the game and its saves to the other drive, checks the copy, then removes the old one. The "
+10 -1
View File
@@ -36,6 +36,7 @@ class MenuState:
movable: bool = False # the Frame knows which drive it's on (agent v63+, microSD): Move to…
android: bool = False # a Quest/Android game (not PC VR, not a Linux app): it can get a newer APK
new_version: bool = False # a Linux app or PC VR game: it can get a newer version (file or folder)
saves: bool = False # a Quest/Android game installed on the connected Frame: back up / restore saves
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
@@ -62,6 +63,13 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
# the new build only; the Frame's game data stays (e.g. changed OBBs a normal update would replace)
top.append((tr("Update app only…"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, act("update_app")))
saves: list = [] # GitHub discussion #352: Steam doesn't sync Quest games' saves
if st.saves and not job:
saves += [(tr("Back up saves"), ft.Icons.BACKUP_ROUNDED, act("backup_saves")),
(tr("Restore saves…"), ft.Icons.RESTORE_ROUNDED, act("restore_saves"))]
if st.android:
saves.append((tr("Open backups folder"), ft.Icons.FOLDER_OPEN_ROUNDED, act("saves_folder")))
artwork = [(tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")),
(tr("Find artwork…"), ft.Icons.IMAGE_SEARCH_ROUNDED, act("find_art")),
(tr("Use your own artwork…"), ft.Icons.UPLOAD_FILE_OUTLINED, act("custom_art"))]
@@ -87,7 +95,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
danger = [(tr("Remove from library…"), ft.Icons.REMOVE_CIRCLE_OUTLINE_ROUNDED, act("remove"))]
out: list = list(top)
for head, items in ((tr("Name and artwork"), artwork), (tr("Recipe"), recipe), (tr("Troubleshoot"), trouble)):
for head, items in ((tr("Saves"), saves), (tr("Name and artwork"), artwork), (tr("Recipe"), recipe),
(tr("Troubleshoot"), trouble)):
if items:
out += [None, Header(head), *items]
return out + [None, *danger]
+7 -131
View File
@@ -19,45 +19,6 @@ if TYPE_CHECKING:
from ..app import FramePortApp
def room_state(app, force: bool = False) -> dict:
"""Cache per connected Frame; all SSH and preview writes happen on the background worker."""
target = app.target if app.frame_state == "connected" else None
cached = getattr(app, "_room_state", None)
if cached is None or cached["target"] is not target:
cached = app._room_state = dict(target=target, status=None, preview=None, error=None, loading=False, time=0)
if target is None or cached["loading"] or (not force and time.monotonic() - cached["time"] < 30):
return cached
cached["loading"] = True
def fetch():
import hashlib
from ...core.paths import user_data_dir
from ...install import rooms
from ...mr.room import parse, preview_svg
try:
status = rooms.status(target.frame)
preview = None
if status.get("room_text"):
svg = preview_svg(parse(status["room_text"]))
name = hashlib.sha256(svg.encode()).hexdigest()[:24] + ".svg"
path = user_data_dir() / "room-previews" / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(svg, encoding="utf-8")
preview = "room-previews/" + name
cached.update(status=status, preview=preview, error=None)
except Exception as exc: # noqa: BLE001 - a failed room query must not hide the rest of the Frame page
cached.update(status=None, preview=None, error=explain(exc))
finally:
cached.update(loading=False, time=time.monotonic())
if app.target is target and app.frame_state == "connected":
app.refresh_view()
app.run_bg(fetch)
return cached
class FrameView:
def __init__(self, app: FramePortApp):
self.app = app
@@ -220,93 +181,6 @@ class FrameView:
return C.section(tr("Storage"), C.card(ft.Column([pick, rows], spacing=T.S3), padding=T.S4),
help="install_drive")
def room_card(self) -> ft.Control:
app = self.app
cached = room_state(app)
status = cached["status"] or {}
room = status.get("room")
busy = cached["loading"] or bool(cached.get("working"))
scanned = bool(room and room.get("source") == "scan")
detail = (tr("Room scan saved") if scanned else tr("Using your play area; furniture is not included") if room
else tr("No room saved yet"))
rows = [C.Check(None if cached["loading"] and not status else True if scanned else "warn",
tr("Room scanned"), detail, "mr_room"),
C.Check(None if cached["loading"] and not status else True if status.get("aligned") else "warn",
tr("Lines up with the current play area"),
tr("Same play area") if status.get("aligned")
else tr("Set up a room in your current play area"),
"mr_alignment"),
C.Check(None if cached["loading"] and not status else bool(status.get("scanner_installed")),
tr("Scanner installed"), tr("Ready") if status.get("scanner_installed")
else tr("Installed when you build a recorded scan"), "mr_scanner")]
contents = [C.checklist(rows)]
scan = cached.get("scan")
if scan:
line = {"waiting": tr("Put on the headset and turn on passthrough. The scan starts by itself."),
"recording": tr("Scanning: {directions} of 12 directions seen{up}").format(
directions=scan.get("directions", 0),
up=tr(", ceiling seen") if scan.get("looked_up", 0) >= 5 else ""),
"building": tr("Building the room…")}.get(scan.get("state"), "")
if line:
contents.append(C.callout(line, "info"))
if cached.get("error"):
contents.append(C.callout(tr("Couldn't check room data: {error}").format(error=cached["error"]), "warn"))
if cached.get("preview"):
contents.append(ft.Image(src=cached["preview"], width=T.px(300), height=T.px(240),
fit=ft.BoxFit.CONTAIN, semantics_label=tr("Room floor plan")))
if room:
counts = room.get("summary") or {}
contents.append(C.meta(tr("{walls} walls · {area:.1f} m² · {height:.1f} m high").format(
walls=counts.get("walls", 0), area=counts.get("floor_area", 0),
height=counts.get("ceiling_height", 0))))
default = status.get("default_circle")
boundary_hint = tr("Use your SteamVR play area as walls, without furniture.") if default is False else tr(
"Set up a play area in SteamVR first. Valve's default circle does not describe your room.")
def operate(action):
if cached["loading"] or cached.get("working"):
return
cached["working"] = True
app.refresh_view()
target = app.target
def scan_progress(st): # every agent status while the player scans in the headset
cached["scan"] = st
if app.target is target:
app.refresh_view()
def work():
from ...install import rooms
try:
if action == "capture":
rooms.scan(target.frame, progress=scan_progress)
app.toast(tr("Room scanned"))
else:
{"boundary": rooms.from_boundary, "delete": rooms.delete}[action](target.frame)
except Exception as exc: # noqa: BLE001
app.toast(explain(exc), error=True)
finally:
cached["working"] = False
cached["scan"] = None
if app.target is target:
room_state(app, force=True)
app.refresh_view()
app.run_bg(work)
def remove(e):
C.confirm(app.page, tr("Delete room?"), tr("Games will use a simple room until you save another room."),
tr("Delete room"), lambda: operate("delete"), danger=True)
contents.append(ft.Row([
C.secondary(tr("Use my play area"), ft.Icons.CROP_FREE_ROUNDED, lambda e: operate("boundary"),
disabled=busy or default is not False, tooltip=C.tip(boundary_hint)),
C.secondary(tr("Scan room in the headset"), ft.Icons.VIEW_IN_AR_ROUNDED, lambda e: operate("capture"),
disabled=busy),
C.ghost(tr("Delete room"), ft.Icons.DELETE_OUTLINE_ROUNDED, remove, disabled=busy or not room),
], spacing=T.S2, wrap=True))
return C.section(tr("Room"), C.card(ft.Column(contents, spacing=T.S3), padding=T.S4), help="mr_room",
action=C.ghost(tr("Refresh room"), ft.Icons.REFRESH_ROUNDED,
lambda e: room_state(app, force=True), disabled=busy))
def installed(self, info: dict) -> ft.Control:
"""The list fills in the background (icon thumbnails may need creating the first time)."""
items = info.get("installed") or []
@@ -318,11 +192,13 @@ class FrameView:
padding=T.S5)
send = C.ghost(tr("Files"), ft.Icons.FOLDER_OPEN_ROUNDED, lambda e: self.app.go("files"),
tooltip=C.tip(HELP["files"]))
quest = any(d.get("kind") not in ("pcvr", "linux") and not d.get("overlay") for d in items)
backup = [C.ghost(tr("Back up all saves"), ft.Icons.BACKUP_ROUNDED, lambda e: self.app.backup_all_saves(),
tooltip=C.tip(HELP["save_backups"]))] if quest else []
free = C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED, lambda e: self.app.cleanup_frame(),
tooltip=C.tip(HELP["free_space"]))
return C.section(tr("Installed games ({len})").format(len=len(items)), body,
action=ft.Row([send, C.ghost(tr("Free up space…"), ft.Icons.CLEANING_SERVICES_OUTLINED,
lambda e: self.app.cleanup_frame(),
tooltip=C.tip(HELP["free_space"]))], spacing=T.S2, tight=True)
if items else send)
action=ft.Row([send, *backup, free], spacing=T.S2, tight=True) if items else send)
def _fill_installed(self, items: list[dict], col: ft.Column) -> None:
def settings_slot() -> ft.Control:
@@ -688,5 +564,5 @@ class FrameView:
return self.wizard()
return ft.Column([
app.top_bar(tr("Steam Frame"), tr("What your Frame is ready for and what's installed")),
self.device_card(info), self.readiness(info), self.room_card(), self.storage(info), self.installed(info),
self.device_card(info), self.readiness(info), self.storage(info), self.installed(info),
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
-13
View File
@@ -332,19 +332,6 @@ class GameView:
session = self.session_callout()
if session is not None:
out.append(session)
if not self.rift and not self.linux and self.app.frame_state == "connected":
from ...patches.applicability import uses_scene
from .frame import room_state
if uses_scene(library.analysis_from_dict(g["analysis"])):
state = room_state(self.app)
if state["status"] is not None and not state["status"].get("room"):
out.append(C.callout(ft.Column([
C.body(tr("Scan your room for mixed reality"), T.TEXT),
C.body(tr(HELP["mr_room"])),
C.secondary(tr("Room setup"), ft.Icons.VIEW_IN_AR_ROUNDED,
lambda e: self.app.go("frame")),
], spacing=T.S2), "info"))
extra = g["analysis"].get("extra", {})
missing =C.missing_libraries(g, self.app.frame_info) if self.linux else []
if missing:
+164
View File
@@ -0,0 +1,164 @@
"""Save backups of Quest games (GitHub discussion #352): "Back up saves" (a job), "Restore saves…" (pick a backup,
confirm; the Frame keeps the replaced saves for one undo), "Open backups folder", and "Back up all saves" on the Frame
page. The work itself is install/saves.py (shared with the CLI)."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import flet as ft
from ...errors import explain
from ...i18n import fmt_size, tr, tr_n
from ...install import saves
from .. import components as C
from .. import theme as T
if TYPE_CHECKING:
from ..app import FramePortApp
from ..jobs import Job
def _frame(app: FramePortApp):
return app._target_for("frame").frame
def when(t: float) -> str:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(t))
def backup_line(b: saves.Backup) -> str:
""""12 files · 3.4 MB of saves · version 1.2" (+ large files left out)."""
parts = [tr_n("{n} file", "{n} files", b.files), tr("{size} of saves").format(size=fmt_size(b.bytes))]
if b.version:
parts.append(tr("version {version}").format(version=b.version))
if b.skipped:
parts.append(tr_n("{n} large file left out", "{n} large files left out", b.skipped))
return " · ".join(parts)
def backup_saves(app: FramePortApp, pkg: str) -> Job:
"""Back up the game's saves from the Frame to this PC (a job)."""
title = app._title(pkg)
def run(job: Job):
b = saves.backup(_frame(app), pkg, job.reporter)
return tr("{title}: saves backed up ({line})").format(title=title, line=backup_line(b))
return app.submit(tr("Back up saves: {title}").format(title=title), run, pkg, "tool-frame")
def backup_all(app: FramePortApp, packages: list[str]) -> Job | None:
"""One job backing up every installed Quest game's saves (a game that runs or fails is reported, the rest go
on)."""
if not packages:
return None
def run(job: Job):
done, failed = 0, []
for i, pkg in enumerate(packages):
job.reporter.check_cancel()
job.reporter.log(tr("Backing up {title}").format(title=app._title(pkg)))
try:
saves.backup(_frame(app), pkg, job.reporter)
done += 1
except saves.SavesError as exc:
failed.append(f"{app._title(pkg)}: {explain(exc)}")
job.reporter.log(failed[-1])
job.reporter.progress((i + 1) / len(packages))
if failed:
return tr("Backed up {done} of {total} games' saves; not: {failed}").format(
done=done, total=len(packages), failed="; ".join(failed[:3]) + (" …" if len(failed) > 3 else ""))
return tr_n("Backed up the saves of {n} game", "Backed up the saves of {n} games", done)
return app.submit(tr("Back up all saves"), run, None, "tool-frame")
def open_folder(pkg: str) -> None:
from ...core import winhost
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
winhost.open_folder(d)
def restore_dialog(app: FramePortApp, pkg: str) -> None:
"""Pick one of the game's backups and restore it on the Frame (after a confirmation); undo the last restore."""
title = app._title(pkg)
found = saves.list_backups(pkg)
by_path = {str(b.path): b for b in found}
group = ft.RadioGroup(value=str(found[0].path) if found else None, content=ft.Column([
ft.Row([ft.Radio(value=str(b.path), active_color=T.ACCENT),
ft.Column([C.body(when(b.created) + (tr(" (newest)") if i == 0 else ""), T.TEXT),
C.meta(backup_line(b))], spacing=T.px(2), expand=True)],
spacing=T.S2)
for i, b in enumerate(found)], spacing=T.S2, scroll=ft.ScrollMode.AUTO, height=min(T.px(300),
T.px(52) * len(found))))
undo_row = ft.Row([C.meta(tr("Checking the saves on the Frame…"))], spacing=T.S2,
vertical_alignment=ft.CrossAxisAlignment.CENTER)
def restore(e):
b = by_path.get(group.value or "")
if not b:
return
def go():
app.page.pop_dialog()
def run(job: Job):
r = saves.restore(_frame(app), pkg, b.path, job.reporter)
return tr("{title}: saves from {when} restored ({n} files)").format(
title=title, when=when(b.created), n=r.get("restored", 0))
app.submit(tr("Restore saves: {title}").format(title=title), run, pkg, "tool-frame")
C.confirm(app.page, tr("Replace the saves on the Frame?"),
tr("{title}'s saves on the Frame are replaced by the backup from {when}. The Frame keeps a copy of "
"the saves it has now, so you can undo this.").format(title=title, when=when(b.created)),
tr("Restore"), go, danger=True)
def undo(e):
def go():
app.page.pop_dialog()
def run(job: Job):
saves.undo_restore(_frame(app), pkg)
return tr("{title}: the saves from before the last restore are back").format(title=title)
app.submit(tr("Undo restore: {title}").format(title=title), run, pkg, "tool-frame")
C.confirm(app.page, tr("Undo the last restore?"),
tr("Puts back the saves {title} had before the last restore. The saves it has now become the copy "
"you can undo to.").format(title=title), tr("Undo restore"), go, danger=True)
def fill():
try:
info = saves.info(_frame(app), pkg)
except Exception as exc: # noqa: BLE001
undo_row.controls = [C.meta(tr("Couldn't check the saves on the Frame: {error}").format(
error=explain(exc)))]
C.update(undo_row)
return
now = tr("On the Frame now: {line}").format(line=" · ".join(
[tr_n("{n} file", "{n} files", int(info.get("files") or 0)),
tr("{size} of saves").format(size=fmt_size(int(info.get("bytes") or 0)))]))
rows: list[ft.Control] = [ft.Container(C.meta(now), expand=True)]
rb = info.get("rollback")
if rb:
rows.append(C.ghost(tr("Undo last restore ({when})").format(when=when(rb.get("time") or 0)),
ft.Icons.UNDO_ROUNDED, on_click=undo))
undo_row.controls = rows
C.update(undo_row)
restore_btn = C.primary(tr("Restore"), ft.Icons.RESTORE_ROUNDED, on_click=restore, disabled=not found)
if found:
listing: ft.Control = group
else:
listing = C.callout(tr("No backups of {title} on this PC yet. Back up its saves first (game menu → Back up "
"saves).").format(title=title))
content = ft.Column([
C.body(tr("Choose a backup to put back on the Frame. Close the game first."), T.TEXT_2),
listing,
C.callout(tr("The game's current saves on the Frame are replaced. FramePort keeps a copy of them on the Frame, "
"so you can undo the last restore."), "warn"),
undo_row,
], spacing=T.S3, tight=True)
app.page.show_dialog(C.dialog(
tr("Restore saves · {title}").format(title=title), content, size="m",
actions=[C.ghost(tr("Open backups folder"), ft.Icons.FOLDER_OPEN_ROUNDED, on_click=lambda e: open_folder(pkg)),
C.ghost(tr("Cancel"), on_click=lambda e: app.page.pop_dialog()), restore_btn]))
app.run_bg(fill)
+91 -1
View File
@@ -23,12 +23,14 @@ TOOL_WHY = {"java": tr("Runs OVRPort and apksigner"), "overport": tr("Converts Q
"apksigner": tr("Signs rebuilt games"), "revive": tr("Runs Oculus PC games on SteamVR")}
# the page's sections in order (the index on the left lists them): Appearance near the top (it's the most used),
# "Uninstall FramePort" last
SECTIONS = ("appearance", "updates", "links", "installing", "tools", "pc", "data", "feedback", "about", "remove")
SECTIONS = ("appearance", "updates", "links", "folders", "installing", "tools", "pc", "data", "feedback", "about",
"remove")
def section_titles() -> dict[str, str]:
"""Index labels of SECTIONS (the sections' own headings may be longer)."""
return {"appearance": tr("Appearance"), "updates": tr("Updates"), "links": tr("Install links"),
"folders": tr("Library folders"),
"installing": tr("Installing"), "tools": tr("Tools"), "pc": tr("This PC"), "data": tr("Data"),
"feedback": tr("Problems and feedback"), "about": tr("About"), "remove": tr("Uninstall FramePort")}
@@ -135,6 +137,91 @@ class SettingsView:
ft.Container(height=T.S2), self.ssh_keys()],
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
def library_folders(self) -> ft.Control:
"""Settings → Library folders: the folders "Rescan folders" scans (core/folders.py, GitHub discussion #351).
Status (is it there, how many games come from it) is read off the UI thread."""
from ...core import folders
app = self.app
rows = ft.Column(spacing=T.S2)
def row(st: folders.FolderStatus) -> ft.Control:
games = tr_n("{n} game in the library", "{n} games in the library", st.games)
if st.exists:
icon, color, state = ft.Icons.FOLDER_OUTLINED, T.TEXT_2, games
else:
icon, color = ft.Icons.FOLDER_OFF_OUTLINED, T.WARN
state = tr("Not found (moved, deleted or on a drive that isn't connected)") + " · " + games
return ft.Row([
C.as_icon(icon, T.px(18), color),
ft.Column([C.body(Path(st.path).name or st.path, T.TEXT, max_lines=1,
overflow=ft.TextOverflow.ELLIPSIS),
C.meta(st.path, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS, tooltip=st.path),
C.meta(state, T.WARN if not st.exists else None)],
spacing=0, expand=True),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Stop scanning this folder"),
on_click=lambda e, s=st: remove(s)),
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
def fill(): # (run_bg: a folder on a sleeping network drive can take a while to answer)
items = folders.status()
rows.controls = [row(s) for s in items] or \
[C.meta(tr("No folders yet. Add one here, or use Scan a folder… in the Library."))]
C.update(rows)
def remove(st: folders.FolderStatus):
def ok():
folders.remove(st.path)
app.run_bg(fill)
C.confirm(app.page, tr("Stop scanning this folder?"),
tr_n("Rescan folders won't look in {path} any more. The {n} game from it stays in your library "
"(nothing is deleted).",
"Rescan folders won't look in {path} any more. The {n} games from it stay in your library "
"(nothing is deleted).", st.games, path=st.path),
tr("Remove folder"), ok)
async def add(e):
path = await C.pick_dir(app.page, dialog_title=tr("Folder with games (APKs or PC VR games)"))
if not path:
return
try:
folders.add(path)
except ValueError as exc:
app.toast(str(exc), error=True)
return
app.run_bg(fill)
app.scan(path, only_new=True) # its games (new ones, and ones that moved there) come in now
rows.controls = [C.meta(tr("Checking folders…"))]
app.run_bg(fill)
return ft.Column([
C.meta(tr("Rescan folders in the Library looks in these folders for new games and for games that "
"moved there.")),
rows,
ft.Row([C.secondary(tr("Add folder…"), ft.Icons.CREATE_NEW_FOLDER_OUTLINED, add),
C.ghost(tr("Rescan folders"), ft.Icons.REFRESH_ROUNDED, app.rescan)],
spacing=T.S2, run_spacing=T.S2, wrap=True),
], spacing=T.S3, horizontal_alignment=ft.CrossAxisAlignment.START)
def save_backups(self) -> ft.Control:
"""Settings → Data: how many save backups to keep per game (install/saves.py, setting saves.keep)."""
from ...core import library, winhost
from ...install import saves
def changed(e):
library.set_setting("saves.keep", int(e.control.value))
def open_root(e):
saves.root_dir().mkdir(parents=True, exist_ok=True)
winhost.open_folder(saves.root_dir())
keep = C.dropdown(label=tr("Save backups to keep per game"), value=str(saves.keep_count()), width=T.px(260),
options=[ft.DropdownOption(str(n), str(n))
for n in sorted({*saves.KEEP_CHOICES, saves.keep_count()})],
on_select=changed)
return ft.Row([keep, C.help_icon("save_backups"),
C.ghost(tr("Open save backups"), ft.Icons.FOLDER_OPEN_ROUNDED, open_root)],
spacing=T.S2, wrap=True, vertical_alignment=ft.CrossAxisAlignment.CENTER)
def ssh_keys(self) -> ft.Control:
"""Settings → Installing: private key files FramePort logs in with besides its own (frame.connection
EXTRA_KEYS_SETTING; the Frame connection and OpenSSH uploads both use them, GitHub #242)."""
@@ -496,6 +583,8 @@ class SettingsView:
"appearance": C.section(tr("Appearance"), C.card(self.appearance(), padding=T.S4), help="ui_scale"),
"updates": C.section(tr("Updates"), C.card(self.updates_card(), padding=T.S4), help="app_updates"),
"links": C.section(tr("Install links"), C.card(self.links_card(), padding=T.S4), help="install_links"),
"folders": C.section(tr("Library folders"), C.card(self.library_folders(), padding=T.S4),
help="library_folders"),
"installing": C.section(tr("Installing"), C.card(self.installing(), padding=T.S4), help="launch_test"),
"tools": C.section(tr("Tools"), C.card(self.tools, padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
subtitle=tr("FramePort keeps its own copies; nothing is installed system-wide"),
@@ -512,6 +601,7 @@ class SettingsView:
C.kv(tr("Catalog"), tr("{len} tested recipes (built in, online and yours)")
.format(len=len(catalog.load())), "catalog"),
self.catalog_updates(),
self.save_backups(),
], spacing=T.S2))),
# text above, buttons below (side by side, the buttons squeezed the text in a narrow window)
"feedback": C.section(tr("Problems and feedback"), C.card(ft.Column([
-342
View File
@@ -1,342 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// Compile the actual emulation with a fake runtime. No Android, SteamVR or device is involved.
#define _GNU_SOURCE
#include <openxr/openxr.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#define LOG(...) do { fprintf(stderr, __VA_ARGS__); fputc('\n', stderr); } while (0)
#define CHECK(c) do { if (!(c)) { fprintf(stderr, "FAIL %d: %s\n", __LINE__, #c); return 1; } } while (0)
#define NEAR(a,b) (fabsf((a)-(b)) < 0.0001f)
static XrInstance active_instance = (XrInstance)(uintptr_t)1;
static float scene_width, scene_depth, scene_height = 2.5f;
#define STAGE ((XrSpace)(uintptr_t)42)
#define LOCAL ((XrSpace)(uintptr_t)43)
#define SESSION ((XrSession)(uintptr_t)1)
static XrResult XRAPI_CALL fake_create(XrSession s, const XrReferenceSpaceCreateInfo *info, XrSpace *out) {
(void)s; (void)info; *out = STAGE; return XR_SUCCESS;
}
static XrResult XRAPI_CALL fake_bounds(XrSession s, XrReferenceSpaceType type, XrExtent2Df *out) {
(void)s; (void)type; *out = (XrExtent2Df){1,1}; return XR_SUCCESS;
}
static XrResult XRAPI_CALL fake_locate(XrSpace s, XrSpace base, XrTime time, XrSpaceLocation *out) {
(void)base; (void)time;
if (s != STAGE && s != LOCAL) return XR_ERROR_HANDLE_INVALID;
out->pose = s == STAGE ? (XrPosef){{0,0,0,1},{0,0,0}} : (XrPosef){{0,0.70710678f,0,0.70710678f},{10,0,5}};
out->locationFlags = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_POSITION_VALID_BIT |
XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT | XR_SPACE_LOCATION_POSITION_TRACKED_BIT;
return XR_SUCCESS;
}
static PFN_xrVoidFunction lookup(XrInstance i, const char *name) {
(void)i;
if (!strcmp(name,"xrCreateReferenceSpace")) return (PFN_xrVoidFunction)fake_create;
if (!strcmp(name,"xrGetReferenceSpaceBoundsRect")) return (PFN_xrVoidFunction)fake_bounds;
if (!strcmp(name,"xrLocateSpace")) return (PFN_xrVoidFunction)fake_locate;
return NULL;
}
#include "scene_emu.c"
static int check_boundary(int i, unsigned count) {
XrBoundary2DFB b = {.type=XR_TYPE_BOUNDARY_2D_FB};
CHECK(emu_get_boundary_2d(SESSION,entities[i].handle,&b) == XR_SUCCESS && b.vertexCountOutput == count);
XrVector2f vertices[MAX_OUTLINE];
b.vertexCapacityInput=count-1; b.vertices=vertices;
CHECK(emu_get_boundary_2d(SESSION,entities[i].handle,&b) == XR_ERROR_SIZE_INSUFFICIENT);
b.vertexCapacityInput=count;
CHECK(emu_get_boundary_2d(SESSION,entities[i].handle,&b) == XR_SUCCESS);
for (unsigned k=0;k<count;++k) {
XrVector3f v = quat_rotate(entities[i].pose_in_stage.orientation,(XrVector3f){vertices[k].x,vertices[k].y,0});
if (i == ENT_FLOOR || i == ENT_CEILING) {
CHECK(NEAR(v.y,0));
XrVector3f n=quat_rotate(entities[i].pose_in_stage.orientation,(XrVector3f){0,0,1});
CHECK(NEAR(n.y,i==ENT_FLOOR?1:-1));
}
}
return 0;
}
static int check_mesh(int mesh_ok) {
int mesh=-1;
for(int i=0;i<entity_count;++i) if(entities[i].vertex_count) mesh=i;
CHECK((mesh >= 0) == mesh_ok);
if(mesh<0) return 0;
XrSpaceTriangleMeshMETA m={.type=XR_TYPE_SPACE_TRIANGLE_MESH_META};
CHECK(emu_get_triangle_mesh(entities[mesh].handle,NULL,&m)==XR_SUCCESS && m.vertexCountOutput==4 && m.indexCountOutput==6);
XrVector3f v[4]; uint32_t indices[6];
m.vertexCapacityInput=3; m.vertices=v;
CHECK(emu_get_triangle_mesh(entities[mesh].handle,NULL,&m)==XR_ERROR_SIZE_INSUFFICIENT);
m.vertexCapacityInput=4; m.indexCapacityInput=5; m.indices=indices;
CHECK(emu_get_triangle_mesh(entities[mesh].handle,NULL,&m)==XR_ERROR_SIZE_INSUFFICIENT);
m.indexCapacityInput=6;
CHECK(emu_get_triangle_mesh(entities[mesh].handle,NULL,&m)==XR_SUCCESS && indices[5]==3 && v[2].x==4);
m.vertexCapacityInput=0; m.vertices=NULL;
CHECK(emu_get_triangle_mesh(entities[mesh].handle,NULL,&m)==XR_SUCCESS);
return 0;
}
static int check_discovery(void) {
XrSpaceFilterComponentMETA component={XR_TYPE_SPACE_FILTER_COMPONENT_META,NULL,XR_SPACE_COMPONENT_TYPE_ROOM_LAYOUT_FB};
const XrSpaceFilterBaseHeaderMETA *fs[]={(const XrSpaceFilterBaseHeaderMETA *)&component};
XrSpaceDiscoveryInfoMETA info={XR_TYPE_SPACE_DISCOVERY_INFO_META,NULL,1,fs};
XrAsyncRequestIdFB req;
CHECK(emu_discover_spaces(SESSION,&info,&req)==XR_SUCCESS);
XrEventDataBuffer ev;
CHECK(pop_event(&ev) && ev.type==XR_TYPE_EVENT_DATA_SPACE_DISCOVERY_RESULTS_AVAILABLE_META);
CHECK(((XrEventDataSpaceDiscoveryResultsAvailableMETA *)&ev)->requestId==req);
CHECK(pop_event(&ev) && ev.type==XR_TYPE_EVENT_DATA_SPACE_DISCOVERY_COMPLETE_META);
CHECK(((XrEventDataSpaceDiscoveryCompleteMETA *)&ev)->requestId==req);
CHECK(((XrEventDataSpaceDiscoveryCompleteMETA *)&ev)->result==XR_SUCCESS && !pop_event(&ev));
XrSpaceDiscoveryResultsMETA results={.type=XR_TYPE_SPACE_DISCOVERY_RESULTS_META};
CHECK(emu_retrieve_discovery(SESSION,req,&results)==XR_SUCCESS && results.resultCountOutput==1);
XrSpaceDiscoveryResultMETA values[MAX_ENTITIES];
results.resultCapacityInput=MAX_ENTITIES; results.results=values;
CHECK(emu_retrieve_discovery(SESSION,req,&results)==XR_SUCCESS && values[0].space==entities[0].handle);
// UUID and component filters intersect; unknown filters do not restrict discovery.
XrUuidEXT uuid=entities[ENT_FLOOR].uuid;
XrSpaceFilterUuidMETA u={XR_TYPE_SPACE_FILTER_UUID_META,NULL,1,&uuid};
XrSpaceFilterBaseHeaderMETA unknown={(XrStructureType)123,NULL};
const XrSpaceFilterBaseHeaderMETA *both[]={(const XrSpaceFilterBaseHeaderMETA *)&u,(const XrSpaceFilterBaseHeaderMETA *)&component,&unknown};
info.filterCount=3; info.filters=both;
CHECK(emu_discover_spaces(SESSION,&info,&req)==XR_SUCCESS);
results.resultCapacityInput=0;
CHECK(emu_retrieve_discovery(SESSION,req,&results)==XR_SUCCESS && results.resultCountOutput==0);
component.componentType=XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB;
CHECK(emu_discover_spaces(SESSION,&info,&req)==XR_SUCCESS);
CHECK(emu_retrieve_discovery(SESSION,req,&results)==XR_SUCCESS && results.resultCountOutput==1);
info.filterCount=0; info.filters=NULL;
CHECK(emu_discover_spaces(SESSION,&info,&req)==XR_SUCCESS);
results.resultCapacityInput=1;
CHECK(emu_retrieve_discovery(SESSION,req,&results)==XR_ERROR_SIZE_INSUFFICIENT);
CHECK(emu_retrieve_discovery(SESSION,req+1000,&results)==XR_ERROR_VALIDATION_FAILURE);
// FB query uses the same UUIDs, two-call semantics and max-result limit.
XrSpaceUuidFilterInfoFB fb_u={XR_TYPE_SPACE_UUID_FILTER_INFO_FB,NULL,1,&uuid};
XrSpaceQueryInfoFB q={.type=XR_TYPE_SPACE_QUERY_INFO_FB,.maxResultCount=MAX_ENTITIES,.filter=(const XrSpaceFilterInfoBaseHeaderFB *)&fb_u};
CHECK(emu_query_spaces_filtered(SESSION,(const XrSpaceQueryInfoBaseHeaderFB *)&q,&req)==XR_SUCCESS);
XrSpaceQueryResultsFB fb_results={.type=XR_TYPE_SPACE_QUERY_RESULTS_FB};
CHECK(emu_retrieve_results(SESSION,req,&fb_results)==XR_SUCCESS && fb_results.resultCountOutput==1);
XrSpaceQueryResultFB fb_values[MAX_ENTITIES]; fb_results.results=fb_values; fb_results.resultCapacityInput=MAX_ENTITIES;
CHECK(emu_retrieve_results(SESSION,req,&fb_results)==XR_SUCCESS && uuid_equal(&fb_values[0].uuid,&uuid));
q.filter=NULL; q.maxResultCount=2;
CHECK(emu_query_spaces_filtered(SESSION,(const XrSpaceQueryInfoBaseHeaderFB *)&q,&req)==XR_SUCCESS);
fb_results.resultCapacityInput=1;
CHECK(emu_retrieve_results(SESSION,req,&fb_results)==XR_ERROR_SIZE_INSUFFICIENT && fb_results.resultCountOutput==2);
while(pop_event(&ev)) {}
// Queue exhaustion must return an error before accepting a request whose events would be lost.
for(int i=0;i<31;++i) CHECK(emu_discover_spaces(SESSION,&info,&req)==XR_SUCCESS);
CHECK(emu_discover_spaces(SESSION,&info,&req)==XR_ERROR_LIMIT_REACHED);
while(pop_event(&ev)) {}
return 0;
}
static int check_room(int expect_walls, int mesh_ok) {
CHECK(room_from_file && wall_count==expect_walls && room_universe==1234);
CHECK(!check_boundary(ENT_FLOOR,5) && !check_boundary(ENT_CEILING,5));
// Area centroid of this asymmetric five-point polygon, rather than its bounding rectangle centre.
CHECK(NEAR(entities[ENT_FLOOR].pose_in_stage.position.x,2));
CHECK(NEAR(entities[ENT_FLOOR].pose_in_stage.position.z,37.0f/21.0f));
XrRoomLayoutFB layout={.type=XR_TYPE_ROOM_LAYOUT_FB};
CHECK(emu_get_room_layout(SESSION,entities[0].handle,&layout)==XR_SUCCESS && layout.wallUuidCountOutput==(unsigned)expect_walls);
XrUuidEXT walls[MAX_ENTITIES]; layout.wallUuids=walls; layout.wallUuidCapacityInput=1;
CHECK(emu_get_room_layout(SESSION,entities[0].handle,&layout)==XR_ERROR_SIZE_INSUFFICIENT);
layout.wallUuidCapacityInput=MAX_ENTITIES;
CHECK(emu_get_room_layout(SESSION,entities[0].handle,&layout)==XR_SUCCESS);
XrVector3f normal=quat_rotate(entities[ENT_WALL0].pose_in_stage.orientation,(XrVector3f){0,0,1});
CHECK(NEAR(normal.x,0) && NEAR(normal.z,1));
CHECK(!check_boundary(ENT_WALL0,4));
for(int i=ENT_WALL0;i<ENT_WALL0+wall_count;++i) {
scene_entity *e=&entities[i];
XrVector3f n=quat_rotate(e->pose_in_stage.orientation,(XrVector3f){0,0,1});
XrVector3f tangent=quat_rotate(e->pose_in_stage.orientation,(XrVector3f){1,0,0});
CHECK(NEAR(n.x,-tangent.z) && NEAR(n.z,tangent.x));
CHECK(uuid_equal(&walls[i-ENT_WALL0],&e->uuid));
}
int volume=-1,plane=-1;
for(int i=0;i<entity_count;++i) {
if(entities[i].has_bounds3) volume=i;
if(!strcmp(entities[i].label,"WINDOW_FRAME")) plane=i;
}
CHECK(volume>=0 && plane>=0);
XrRect3DfFB box;
CHECK(emu_get_bounding_box_3d(SESSION,entities[volume].handle,&box)==XR_SUCCESS);
CHECK(NEAR(box.offset.x,-1) && NEAR(box.offset.y,-0.5f) && NEAR(box.offset.z,-0.75f));
CHECK(NEAR(box.extent.width,2) && NEAR(box.extent.height,1) && NEAR(box.extent.depth,0.75f));
normal=quat_rotate(entities[volume].pose_in_stage.orientation,(XrVector3f){0,0,1});
CHECK(NEAR(normal.y,1));
XrVector3f bottom=quat_rotate(entities[volume].pose_in_stage.orientation,(XrVector3f){0,0,-0.75f});
CHECK(NEAR(entities[volume].pose_in_stage.position.y+bottom.y,0));
XrVector3f axis=quat_rotate(entities[volume].pose_in_stage.orientation,(XrVector3f){1,0,0});
CHECK(NEAR(axis.x,0) && NEAR(axis.z,-1));
CHECK(NEAR(entities[plane].pose_in_stage.position.x,1) && NEAR(entities[plane].bounds.extent.height,1.2f));
// a current SDK names what it knows and accepts TABLE / invisible walls: labels as stored
XrSemanticLabelsSupportInfoFB support={XR_TYPE_SEMANTIC_LABELS_SUPPORT_INFO_FB,NULL,
XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_DESK_TO_TABLE_MIGRATION_BIT_FB|
XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_INVISIBLE_WALL_FACE_BIT_FB,
"TABLE,OTHER,FLOOR,CEILING,WALL_FACE,INVISIBLE_WALL_FACE,WINDOW_FRAME"};
XrSemanticLabelsFB labels={.type=XR_TYPE_SEMANTIC_LABELS_FB,.next=&support};
CHECK(emu_get_semantic_labels(SESSION,entities[volume].handle,&labels)==XR_SUCCESS && labels.bufferCountOutput==12);
char text[256]; labels.buffer=text; labels.bufferCapacityInput=2;
CHECK(emu_get_semantic_labels(SESSION,entities[volume].handle,&labels)==XR_ERROR_SIZE_INSUFFICIENT);
labels.bufferCapacityInput=sizeof(text);
CHECK(emu_get_semantic_labels(SESSION,entities[volume].handle,&labels)==XR_SUCCESS && !strcmp(text,"TABLE,OTHER"));
// an SDK without the migration bit gets DESK; one that doesn't know a label gets OTHER (deduplicated)
support.flags=0;
CHECK(emu_get_semantic_labels(SESSION,entities[volume].handle,&labels)==XR_SUCCESS && !strcmp(text,"OTHER"));
support.recognizedLabels="DESK,OTHER";
CHECK(emu_get_semantic_labels(SESSION,entities[volume].handle,&labels)==XR_SUCCESS && !strcmp(text,"DESK,OTHER"));
// no support info at all (first XR_FB_scene apps): the first label set only
labels.next=NULL;
CHECK(emu_get_semantic_labels(SESSION,entities[volume].handle,&labels)==XR_SUCCESS && !strcmp(text,"DESK,OTHER"));
labels.next=&support;
uint32_t n; XrSpaceComponentTypeFB types[8];
CHECK(emu_enumerate_components(entities[volume].handle,0,&n,NULL)==XR_SUCCESS && n==6); // incl. SHARABLE
CHECK(emu_enumerate_components(entities[volume].handle,1,&n,types)==XR_ERROR_SIZE_INSUFFICIENT);
CHECK(emu_enumerate_components(entities[volume].handle,8,&n,types)==XR_SUCCESS);
XrSpaceComponentStatusFB status={.type=XR_TYPE_SPACE_COMPONENT_STATUS_FB};
CHECK(emu_get_component_status(entities[volume].handle,XR_SPACE_COMPONENT_TYPE_BOUNDED_3D_FB,&status)==XR_SUCCESS && status.enabled);
// scene loaders of older Meta SDKs switch SHARABLE on for every anchor (Meta's runtime lists it everywhere)
XrSpaceComponentStatusSetInfoFB share={XR_TYPE_SPACE_COMPONENT_STATUS_SET_INFO_FB,NULL,XR_SPACE_COMPONENT_TYPE_SHARABLE_FB,XR_TRUE,0};
XrAsyncRequestIdFB share_req;
CHECK(emu_set_component_status(entities[ENT_WALL0].handle,&share,&share_req)==XR_SUCCESS);
XrEventDataBuffer share_ev;
CHECK(pop_event(&share_ev) && share_ev.type==XR_TYPE_EVENT_DATA_SPACE_SET_STATUS_COMPLETE_FB);
XrSpaceContainerFB container={.type=XR_TYPE_SPACE_CONTAINER_FB};
CHECK(emu_get_container(SESSION,entities[0].handle,&container)==XR_SUCCESS && container.uuidCountOutput==(unsigned)room_entity_count-1);
container.uuidCapacityInput=1; container.uuids=walls;
CHECK(emu_get_container(SESSION,entities[0].handle,&container)==XR_ERROR_SIZE_INSUFFICIENT);
container.uuidCapacityInput=MAX_ENTITIES;
CHECK(emu_get_container(SESSION,entities[0].handle,&container)==XR_SUCCESS);
CHECK(!check_mesh(mesh_ok));
return check_discovery();
}
static int create_test_anchor(XrSpace *out) {
XrSpatialAnchorCreateInfoFB info={XR_TYPE_SPATIAL_ANCHOR_CREATE_INFO_FB,NULL,LOCAL,{{0,0,0,1},{1,0,0}},1};
XrAsyncRequestIdFB req;
CHECK(emu_create_anchor(SESSION,&info,&req)==XR_SUCCESS);
XrEventDataBuffer ev;
CHECK(pop_event(&ev) && ev.type==XR_TYPE_EVENT_DATA_SPATIAL_ANCHOR_CREATE_COMPLETE_FB);
XrEventDataSpatialAnchorCreateCompleteFB *e=(void *)&ev;
CHECK(e->result==XR_SUCCESS);
*out=e->space;
int i=fake_space_index(*out); CHECK(i>=0);
CHECK(NEAR(entities[i].pose_in_stage.position.x,10) && NEAR(entities[i].pose_in_stage.position.z,4));
XrPosef pose; XrSpaceLocationFlags flags;
CHECK(locate_fake(*out,LOCAL,1,&pose,&flags) && NEAR(pose.position.x,1) && NEAR(pose.position.z,0));
CHECK(locate_fake(LOCAL,*out,1,&pose,&flags) && NEAR(pose.position.x,-1));
return 0;
}
static int check_persistence(const char *mode) {
XrEventDataBuffer ev; XrAsyncRequestIdFB req;
if(!strcmp(mode,"save") || !strcmp(mode,"save-failure")) {
CHECK(entity_count==ENT_FIXED_COUNT);
XrSpace spaces[2]; CHECK(!create_test_anchor(&spaces[0]) && !create_test_anchor(&spaces[1]));
XrUuidEXT first,second;
CHECK(emu_get_uuid(spaces[0],&first)==XR_SUCCESS && emu_get_uuid(spaces[1],&second)==XR_SUCCESS && !uuid_equal(&first,&second));
XrSpaceSaveInfoFB fb={XR_TYPE_SPACE_SAVE_INFO_FB,NULL,spaces[0],XR_SPACE_STORAGE_LOCATION_LOCAL_FB,XR_SPACE_PERSISTENCE_MODE_INDEFINITE_FB};
CHECK(emu_save_space(SESSION,&fb,&req)==XR_SUCCESS && pop_event(&ev));
CHECK(ev.type==XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB);
if(!strcmp(mode,"save-failure")) {
CHECK(((XrEventDataSpaceSaveCompleteFB *)&ev)->result==XR_ERROR_RUNTIME_FAILURE);
CHECK(!entities[fake_space_index(spaces[0])].saved); return 0;
}
CHECK(((XrEventDataSpaceSaveCompleteFB *)&ev)->result==XR_SUCCESS);
XrSpacesSaveInfoMETA meta={XR_TYPE_SPACES_SAVE_INFO_META,NULL,2,spaces};
CHECK(emu_save_spaces(SESSION,&meta,&req)==XR_SUCCESS && pop_event(&ev));
CHECK(ev.type==XR_TYPE_EVENT_DATA_SPACES_SAVE_RESULT_META && ((XrEventDataSpacesSaveResultMETA *)&ev)->result==XR_SUCCESS);
scene_on_create_session(SESSION);
CHECK(entity_count==ENT_FIXED_COUNT+2 && fake_space_index(spaces[0])<0);
CHECK(uuid_equal(&first,&entities[ENT_FIXED_COUNT].uuid) && uuid_equal(&second,&entities[ENT_FIXED_COUNT+1].uuid));
} else if(!strcmp(mode,"erase-failure")) {
CHECK(entity_count==ENT_FIXED_COUNT+2);
char path[4096],backup[4128]; CHECK(anchor_path(path,sizeof(path)));
snprintf(backup,sizeof(backup),"%s.backup",path);
CHECK(rename(path,backup)==0 && mkdir(path,0700)==0);
XrSpace handle=entities[ENT_FIXED_COUNT].handle;
XrSpacesEraseInfoMETA meta={XR_TYPE_SPACES_ERASE_INFO_META,NULL,1,&handle,0,NULL};
CHECK(emu_erase_spaces(SESSION,&meta,&req)==XR_SUCCESS && pop_event(&ev));
CHECK(ev.type==XR_TYPE_EVENT_DATA_SPACES_ERASE_RESULT_META && ((XrEventDataSpacesEraseResultMETA *)&ev)->result==XR_ERROR_RUNTIME_FAILURE);
CHECK(fake_space_index(handle)>=0 && entities[ENT_FIXED_COUNT].saved);
CHECK(rmdir(path)==0 && rename(backup,path)==0);
} else if(!strcmp(mode,"erase")) {
CHECK(entity_count==ENT_FIXED_COUNT+2);
XrSpace old=entities[ENT_FIXED_COUNT].handle;
XrSpaceEraseInfoFB fb={XR_TYPE_SPACE_ERASE_INFO_FB,NULL,old,XR_SPACE_STORAGE_LOCATION_LOCAL_FB};
CHECK(emu_erase_space(SESSION,&fb,&req)==XR_SUCCESS && pop_event(&ev));
CHECK(ev.type==XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB && ((XrEventDataSpaceEraseCompleteFB *)&ev)->result==XR_SUCCESS);
CHECK(fake_space_index(old)<0);
XrUuidEXT uuid=entities[ENT_FIXED_COUNT+1].uuid;
XrSpacesEraseInfoMETA meta={XR_TYPE_SPACES_ERASE_INFO_META,NULL,0,NULL,1,&uuid};
CHECK(emu_erase_spaces(SESSION,&meta,&req)==XR_SUCCESS && pop_event(&ev));
CHECK(ev.type==XR_TYPE_EVENT_DATA_SPACES_ERASE_RESULT_META && ((XrEventDataSpacesEraseResultMETA *)&ev)->result==XR_SUCCESS);
scene_on_create_session(SESSION); CHECK(entity_count==ENT_FIXED_COUNT);
} else if(!strcmp(mode,"reload")) {
CHECK(entity_count==ENT_FIXED_COUNT+2);
for(int i=ENT_FIXED_COUNT;i<entity_count;++i) CHECK(entities[i].saved && entities[i].is_anchor && NEAR(entities[i].pose_in_stage.position.z,4));
CHECK(!check_discovery());
XrUuidEXT uuid=entities[ENT_FIXED_COUNT].uuid;
XrSpaceFilterUuidMETA filter={XR_TYPE_SPACE_FILTER_UUID_META,NULL,1,&uuid};
const XrSpaceFilterBaseHeaderMETA *filters_[]={(const XrSpaceFilterBaseHeaderMETA *)&filter};
XrSpaceDiscoveryInfoMETA discover={XR_TYPE_SPACE_DISCOVERY_INFO_META,NULL,1,filters_};
CHECK(emu_discover_spaces(SESSION,&discover,&req)==XR_SUCCESS);
XrSpaceDiscoveryResultMETA value;
XrSpaceDiscoveryResultsMETA found={.type=XR_TYPE_SPACE_DISCOVERY_RESULTS_META,.resultCapacityInput=1,.results=&value};
CHECK(emu_retrieve_discovery(SESSION,req,&found)==XR_SUCCESS && found.resultCountOutput==1 && uuid_equal(&value.uuid,&uuid));
XrSpaceUuidFilterInfoFB fb_filter={XR_TYPE_SPACE_UUID_FILTER_INFO_FB,NULL,1,&uuid};
XrSpaceQueryInfoFB fb_query={.type=XR_TYPE_SPACE_QUERY_INFO_FB,.maxResultCount=10,.filter=(const XrSpaceFilterInfoBaseHeaderFB *)&fb_filter};
CHECK(emu_query_spaces_filtered(SESSION,(const XrSpaceQueryInfoBaseHeaderFB *)&fb_query,&req)==XR_SUCCESS);
XrSpaceQueryResultFB fb_value;
XrSpaceQueryResultsFB fb_found={.type=XR_TYPE_SPACE_QUERY_RESULTS_FB,.resultCapacityInput=1,.results=&fb_value};
CHECK(emu_retrieve_results(SESSION,req,&fb_found)==XR_SUCCESS && fb_found.resultCountOutput==1 && uuid_equal(&fb_value.uuid,&uuid));
while(pop_event(&ev)) {}
} else CHECK(entity_count==ENT_FIXED_COUNT);
return 0;
}
int main(int argc,char **argv) {
CHECK(argc>=2);
const char *mode=argv[1];
if(!strcmp(mode,"override")) { scene_width=3; scene_depth=4; scene_height=2.7f; }
emulate_scene=1;
scene_app_mesh=!getenv("SCENE_NO_MESH_EXT"); // the app enabled XR_META_spatial_entity_mesh (the usual case)
scene_on_create_session(SESSION);
CHECK(is_scene_extension("XR_META_spatial_entity_discovery") && is_scene_extension("XR_META_spatial_entity_persistence") && is_scene_extension("XR_META_spatial_entity_mesh"));
CHECK(scene_emulation("xrDiscoverSpacesMETA") && scene_emulation("xrGetSpaceTriangleMeshMETA") && !scene_emulation("garbage"));
if(!strcmp(mode,"room")) { CHECK(argc==4); CHECK(!check_room(atoi(argv[2]),atoi(argv[3]))); }
else if(!strcmp(mode,"derived")) {
CHECK(room_from_file && wall_count==4 && room_entity_count==7);
CHECK(!check_boundary(ENT_FLOOR,4) && !check_boundary(ENT_CEILING,4));
CHECK(NEAR(entities[ENT_FLOOR].pose_in_stage.position.x,2) && NEAR(entities[ENT_FLOOR].pose_in_stage.position.z,1.5f));
CHECK(NEAR(entities[ENT_CEILING].pose_in_stage.position.y,3.5f));
} else if(!strcmp(mode,"many")) {
CHECK(room_from_file && wall_count==300 && room_entity_count==303);
for(int i=0;i<entity_count;++i) {
CHECK(fake_space_index(entities[i].handle)==i);
for(int j=0;j<i;++j) CHECK(!uuid_equal(&entities[i].uuid,&entities[j].uuid));
}
CHECK(!check_discovery());
} else if(!strcmp(mode,"box") || !strcmp(mode,"override")) {
CHECK(!room_from_file && entity_count==ENT_FIXED_COUNT && wall_count==4);
CHECK(NEAR(entities[ENT_FLOOR].bounds.extent.width,scene_width?scene_width:1.5f));
CHECK(NEAR(entities[ENT_FLOOR].bounds.extent.height,scene_depth?scene_depth:1.5f));
CHECK(NEAR(entities[ENT_CEILING].pose_in_stage.position.y,scene_height));
CHECK(!check_boundary(ENT_FLOOR,4) && uuid_equal(&entities[0].uuid,&(XrUuidEXT){{0}})==0);
// walls go round the floor: -Z, +X, +Z, -X
CHECK(entities[ENT_WALL0].pose_in_stage.position.z<0 && entities[ENT_WALL0+1].pose_in_stage.position.x>0);
CHECK(entities[ENT_WALL0+2].pose_in_stage.position.z>0 && entities[ENT_WALL0+3].pose_in_stage.position.x<0);
PFN_xrGetReferenceSpaceBoundsRect bounds_fn=(PFN_xrGetReferenceSpaceBoundsRect)scene_emulation("xrGetReferenceSpaceBoundsRect");
XrExtent2Df ext={9,9};
CHECK(bounds_fn && bounds_fn(SESSION,XR_REFERENCE_SPACE_TYPE_STAGE,&ext)==XR_SPACE_BOUNDS_UNAVAILABLE && ext.width==0 && ext.height==0);
CHECK(bounds_fn(SESSION,XR_REFERENCE_SPACE_TYPE_LOCAL,&ext)==XR_SUCCESS && NEAR(ext.width,1));
CHECK(!check_discovery());
} else CHECK(!check_persistence(mode));
XrAsyncRequestIdFB request;
CHECK(emu_request_scene_capture(SESSION,NULL,&request)==XR_SUCCESS);
XrEventDataBuffer ev; CHECK(pop_event(&ev) && ev.type==XR_TYPE_EVENT_DATA_SCENE_CAPTURE_COMPLETE_FB);
reset_room();
for(int i=0;i<MAX_QUERIES;++i) free(queries[i].results);
return 0;
}
#ifdef SCENE_SHIM_TEST
// The shim loads this fake adapter by its production SONAME. Its scene functions are the real implementation.
__attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_proc(const char *name) {
if (!emulate_scene) { emulate_scene=1; scene_on_create_session(SESSION); }
return scene_emulation(name);
}
#endif
-37
View File
@@ -1,37 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
#include <openxr/openxr.h>
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>
#define CHECK(c) do { if (!(c)) { fprintf(stderr,"FAIL %d: %s\n",__LINE__,#c); return 1; } } while(0)
#ifdef FAKE_DISPATCH
// Stand in for OVRPort: refuse all scene names. Any successful scene lookup must have bypassed this table.
static void marker(void) {}
XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance,const char *name,PFN_xrVoidFunction *fn) {
(void)instance;
if (!strcmp(name,"testForwarded")) { *fn=marker; return XR_SUCCESS; }
*fn=NULL; return XR_ERROR_FUNCTION_UNSUPPORTED;
}
#else
int main(int argc,char **argv) {
CHECK(argc>2);
CHECK(dlopen("libopenxr_loader_generic.so",RTLD_NOW|RTLD_LOCAL));
void *shim=dlopen(argv[1],RTLD_NOW|RTLD_LOCAL); CHECK(shim);
PFN_xrGetInstanceProcAddr get=(PFN_xrGetInstanceProcAddr)dlsym(shim,"xrGetInstanceProcAddr"); CHECK(get);
XrInstance instance=(XrInstance)(uintptr_t)1;
PFN_xrVoidFunction fn=NULL;
for(int i=2;i<argc;++i) CHECK(get(instance,argv[i],&fn)==XR_SUCCESS && fn);
CHECK(get(instance,"testForwarded",&fn)==XR_SUCCESS && fn);
CHECK(get(instance,"unknown",&fn)==XR_ERROR_FUNCTION_UNSUPPORTED && !fn);
PFN_xrDiscoverSpacesMETA discover;
PFN_xrRetrieveSpaceDiscoveryResultsMETA retrieve;
CHECK(get(instance,"xrDiscoverSpacesMETA",&fn)==XR_SUCCESS); discover=(PFN_xrDiscoverSpacesMETA)fn;
CHECK(get(instance,"xrRetrieveSpaceDiscoveryResultsMETA",&fn)==XR_SUCCESS); retrieve=(PFN_xrRetrieveSpaceDiscoveryResultsMETA)fn;
XrSpaceDiscoveryInfoMETA info={.type=XR_TYPE_SPACE_DISCOVERY_INFO_META}; XrAsyncRequestIdFB req;
CHECK(discover((XrSession)(uintptr_t)1,&info,&req)==XR_SUCCESS);
XrSpaceDiscoveryResultsMETA results={.type=XR_TYPE_SPACE_DISCOVERY_RESULTS_META};
CHECK(retrieve((XrSession)(uintptr_t)1,req,&results)==XR_SUCCESS && results.resultCountOutput==7);
return 0;
}
#endif
-192
View File
@@ -1,192 +0,0 @@
"""Room commands and launcher synchronization, entirely on a temporary fake Frame."""
import json
import math
from pathlib import Path
from types import SimpleNamespace
import pytest
from test_agent import load_agent
from frameport.mr.room import Mesh, box_room, dumps, parse, write_mesh
def test_universe_log(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
assert a.room_universe_from_log("noise") is None
assert a.room_universe_from_log("chaperone universe 123\nSwitch to standing universe 456") == 456
assert a.room_universe_from_log("Switch to universe 7\nchaperone universe 424242") == 0
assert a.room_universe_from_log("Switch to universe: 9876543210987654321") == 9876543210987654321
def chaperone(a, points, universe=7):
quads = [[[x, 0, z], [b, 0, c], [b, 2.43, c], [x, 2.43, z]]
for (x, z), (b, c) in zip(points, points[1:] + points[:1], strict=True)]
path = Path(a.HOME) / ".config/openvr/config/chaperone_info.vrchap"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"universes": [{"universeID": str(universe), "collision_bounds": quads}]}))
def test_boundary_circle_and_status(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
circle = [(math.cos(i*math.pi/10), math.sin(i*math.pi/10)) for i in range(20)]
chaperone(a, circle)
assert a.cmd_room_status({})["default_circle"]
with pytest.raises(a.AgentError, match="default circle"):
a.cmd_room_from_boundary({})
chaperone(a, [(-2, -1), (2, -1), (2, 1), (-2, 1)])
status = a.cmd_room_from_boundary({})
assert status["aligned"] and status["universe"] == 7 and not status["default_circle"]
assert status["room"]["summary"]["floor_area"] == 8
assert parse(status["room_text"]).source == "boundary"
assert Path(a.ROOMS_DIR, "current").readlink() == Path("7")
logs = Path(a.STEAM, "logs/vrserver.txt")
logs.parent.mkdir(parents=True)
logs.write_text("chaperone universe 8")
assert not a.cmd_room_status({})["aligned"]
assert not a.cmd_room_status({})["scanner_installed"]
assert not a.cmd_room_delete({})["rooms"]
with pytest.raises(a.AgentError, match="scanner is not installed"): # capture needs fp_roomscan to build
a.cmd_room_capture({})
def test_store_mesh_validation_and_fallback(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
r = box_room(3, 4)
r.universe = 7
m = Mesh(vertices=[(0, 0, 0), (1, 0, 0), (0, 0, 1)], indices=[0, 1, 2])
r.mesh = Mesh(vertex_count=3, index_count=3)
with pytest.raises(a.AgentError, match="missing"):
a.room_store(dumps(r))
a.room_store(dumps(r), write_mesh(m))
r.universe = 8
a.room_store(dumps(r), write_mesh(m))
assert a.room_selected(7).endswith("/7")
assert a.room_selected(9).endswith("/8")
with pytest.raises(a.AgentError):
a.room_store(dumps(r), b"FPRM")
assert a.cmd_room_status({})["room"]["universe"] == 8
assert a.cmd_room_delete({"universe": 8})["room"]["universe"] == 7
with pytest.raises(a.AgentError):
a.cmd_room_delete({"universe": "../other"})
with pytest.raises(a.AgentError):
a.cmd_room_put({"path": str(tmp_path / "room.txt")})
folder = Path(a.ROOMS_DIR) / ".incoming"
folder.mkdir()
(folder / "room.txt").write_text(dumps(r))
(folder / "room-mesh.bin").write_bytes(write_mesh(m))
assert a.cmd_room_put({"path": str(folder / "room.txt")})["room"]["universe"] == 8
def test_launcher_sync_and_upgrade(monkeypatch, tmp_path, capsys):
a = load_agent(monkeypatch, tmp_path)
pkg = "com.test.scene"
anchor = Path(a.ANCHORS, pkg)
anchor.mkdir(parents=True)
base = tmp_path / "drive with space" / pkg
(base / "lepton-app").mkdir(parents=True)
dep = dict(package=pkg, base=str(base), appid=123, scene_room=True)
(anchor / "deployment.json").write_text(json.dumps(dep))
a.write_launcher(str(anchor), str(base), pkg, "Scene", 123, "/lepton", {}, scene_room=True)
text = (anchor / "launch.sh").read_text()
assert text.count("# frameport-room-sync") == 1
assert text.index("_room_sync") < text.index("\nsetsid ")
assert a.set_room_sync_line(text, pkg, True) == text
old = "\n".join(line for line in text.splitlines() if "# frameport-room-sync" not in line) + "\n"
(anchor / "launch.sh").write_text(old)
a.upgrade_launchers()
assert (anchor / "launch.sh").read_text().count("# frameport-room-sync") == 1
r = box_room(4, 3)
r.universe = 7
a.room_store(dumps(r))
a.room_sync_worker(pkg)
assert parse((base / "room.txt").read_text()).universe == 7
(base / "room-mesh.bin").write_bytes(b"stale")
a.room_sync_worker(pkg)
assert not (base / "room-mesh.bin").exists()
# Live settings take precedence, including disabling after a previous start.
(base / "settings.conf").write_text("scene_emul=0\n")
a.room_sync_worker(pkg)
assert not (base / "room.txt").exists()
(base / "settings.conf").write_text("scene_emul=1\n")
a.room_sync_worker(pkg)
assert (base / "room.txt").exists()
a.cmd_room_delete({})
a.room_sync_worker(pkg)
assert not (base / "room.txt").exists()
def test_build_offline_scanner(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
Path(a.ROOMS_DIR).mkdir(parents=True)
scanner = Path(a.ROOM_SCANNER)
scanner.parent.mkdir(parents=True)
scanner.touch(mode=0o755)
dump = Path(a.ROOMS_DIR, "scan.fprs")
dump.write_bytes(b"FPRS")
def run(argv, **kwargs):
assert argv[:3] == [a.ROOM_SCANNER, "--offline", str(dump)]
r = box_room(4, 3)
r.universe = int(argv[argv.index("--universe") + 1])
Path(argv[argv.index("--out") + 1], "room.txt").write_text(dumps(r))
return SimpleNamespace(returncode=0, stdout='{"triangles": 20}', stderr="scan completed")
monkeypatch.setattr(a.subprocess, "run", run)
result = a.cmd_room_build({"dump": str(dump), "universe": 8})
assert result["report"] == {"triangles": 20}
assert result["room"]["universe"] == 8
assert Path(a.ROOMS_DIR, "8/scan.log").read_text() == "scan completed"
def test_live_scene_setting_updates_only_the_quest_launcher(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
pkg = "com.test.live"
anchor = Path(a.ANCHORS, pkg)
anchor.mkdir(parents=True)
dep = dict(package=pkg, base=str(anchor), appid=123, scene_room=False)
(anchor / "deployment.json").write_text(json.dumps(dep))
a.write_launcher(str(anchor), str(anchor), pkg, "Live", 123, "/lepton", {})
path = anchor / "launch.sh"
assert "# frameport-room-sync" not in path.read_text()
(anchor / "settings.conf").write_text("scene_emul=1\n")
a.room_setting_changed(pkg, dep, {"scene_emul": 1})
assert "# frameport-room-sync" in path.read_text()
assert a.deployment(pkg)["scene_room"]
(anchor / "room.txt").write_text("stale")
(anchor / "settings.conf").write_text("scene_emul=0\n")
a.room_setting_changed(pkg, dep, {"scene_emul": 0})
assert "# frameport-room-sync" not in path.read_text()
assert not (anchor / "room.txt").exists()
assert not a.deployment(pkg)["scene_room"]
def test_mesh_sync_renames_sidecar_and_changes_current_room(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
pkg = "com.test.mesh"
anchor = Path(a.ANCHORS, pkg)
anchor.mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps(dict(package=pkg, base=str(anchor), scene_room=True)))
r = box_room(3, 4)
r.universe = 7
r.mesh = Mesh(filename="custom.bin", vertices=[(0, 0, 0), (1, 0, 0), (0, 0, 1)], indices=[0, 1, 2])
data = write_mesh(r.mesh)
a.room_store(dumps(r), data)
result = a.room_sync_worker(pkg)
assert result["room"]["universe"] == 7
got = parse((anchor / "room.txt").read_text())
assert got.mesh.filename == "room-mesh.bin"
assert (anchor / "room-mesh.bin").read_bytes() == data
assert not (anchor / "custom.bin").exists()
@pytest.mark.parametrize("settings, recipe, enabled", [
({"scene_emul": 1}, {}, True),
({}, {"adapter": {"scene_emul": 1}}, True),
({}, {"patches": ["adapter.scene_emul"]}, True),
({"scene_emul": 0}, {"patches": ["adapter.scene_emul"]}, False),
])
def test_finalize_records_resolved_scene_setting(monkeypatch, tmp_path, settings, recipe, enabled):
from test_game_update import PKG, installed_agent
a, base, _ = installed_agent(monkeypatch, tmp_path)
a.configure_install(dict(package=PKG, title="Example", settings=settings, recipe=recipe),
dict(base=str(base), anchor=str(base), appid=42, lepton="/lepton"), 0)
assert a.deployment(PKG)["scene_room"] is enabled
+9 -2
View File
@@ -55,12 +55,19 @@ def test_rewriter_on_doom3quest_shaders(tmp_path):
assert text.lstrip().startswith("#version 300 es")
vp = (out / "diffuseMapShaderVP.glsl").read_text()
assert "u_viewMatrices[(0u) ]" in vp and "#extension GL_OVR_multiview2 : enable" in vp
# optional: a real GLSL ES front end (glslangValidator: OpenGL ES 3.00 without SPIR-V)
# optional: a real GLSL ES front end (glslangValidator: OpenGL ES 3.00 without SPIR-V). Doom3Quest's sources start
# with a newline before #version (raw string literals); GL drivers accept that, glslang's ES profile doesn't, so the
# copy it checks starts at #version
validator = shutil.which("glslangValidator")
if validator:
checked = tmp_path / "checked"
checked.mkdir()
for f in rewritten:
stage = "vert" if f.stem.endswith("VP") else "frag"
subprocess.run([validator, "-S", stage, str(f)], check=True, capture_output=True)
copy = checked / f.name
copy.write_text(f.read_text().lstrip())
r = subprocess.run([validator, "-S", stage, str(copy)], capture_output=True, text=True)
assert r.returncode == 0, f"{f.name}: {r.stdout}{r.stderr}"
def _khronos_headers(dest: Path) -> Path | None:
+3 -4
View File
@@ -18,10 +18,10 @@ def test_mixed_reality_only_scene_game():
assert "frame.meta_permissions" in r.patches
def test_mixed_reality_with_anchors():
def test_mixed_reality_without_scene():
_, r = suggest(extra={"mr_only": True, "meta_permissions_used": ["com.oculus.permission.USE_ANCHOR_API"],
"size": 1})
assert "patch_force_passthrough" in r.patches and "adapter.scene_emul" in r.patches
assert "patch_force_passthrough" in r.patches and "adapter.scene_emul" not in r.patches
def test_hand_tracking_only():
@@ -102,8 +102,7 @@ def test_scene_emul_suggested_for_games_that_ask_for_the_room():
a = _analysis(extra={"meta_permissions_used": ["com.oculus.permission.USE_SCENE"], "mr_only": False})
s = p.detect(a)
assert s and s.recommended and s.params == {"value": 1}
assert p.detect(_analysis(extra={"meta_permissions_used": ["com.oculus.permission.USE_ANCHOR_API"]})).recommended
assert p.detect(_analysis()) is None
assert p.detect(_analysis(extra={"meta_permissions_used": ["com.oculus.permission.USE_ANCHOR_API"]})) is None
def test_pose_time_fix_suggested_for_unreal4_oculus_input():
+261
View File
@@ -0,0 +1,261 @@
"""GitHub discussion #351: the library folders list (Settings → Library folders, `frameport folders`) and scans that
find games of the library at a new place (moved: relinked in place; another version; another copy)."""
import shutil
import zipfile
from typer.testing import CliRunner
from frameport import cli, pipeline
from frameport.artwork import fetch as artwork
from frameport.core import folders, library
from frameport.core.events import Reporter
from frameport.patches import base
from frameport.sources import quest_dump
PKG = "com.example.questgame"
# ------------------------------------------------------------------------------------------ the folder list
def test_folder_list_add_remove_status(tmp_path):
a, b = tmp_path / "A", tmp_path / "B"
a.mkdir()
b.mkdir()
folders.remember(a)
folders.remember(b)
folders.remember(a) # scanned again: once, moved to the end
assert folders.roots() == [str(b), str(a)]
assert folders.add(b) == [str(b), str(a)] # already there: unchanged
library.upsert_game("g1", apk=str(a / "x" / "g.apk"))
library.upsert_game("g2", kind="rift", game_dir=str(a / "Game"))
library.upsert_game("g3", apk=str(tmp_path / "elsewhere.apk"))
shutil.rmtree(b)
st = {s.path: s for s in folders.status()}
assert (st[str(a)].exists, st[str(a)].games) == (True, 2)
assert (st[str(b)].exists, st[str(b)].games) == (False, 0)
assert folders.rescan_roots() == [str(a)] # a missing folder isn't scanned
assert folders.remove(str(a)) is True and folders.remove(str(a)) is False
assert library.game("g1") and library.game("g2") # its games stay
try:
folders.add(tmp_path / "nope")
raise AssertionError("a missing folder can't be added")
except ValueError:
pass
def test_rescan_roots_fallback_only_before_anything_was_remembered(tmp_path):
games = tmp_path / "Games"
(games / "One").mkdir(parents=True)
library.upsert_game("g1", apk=str(games / "One" / "g.apk"), origin=str(games / "One"))
assert folders.rescan_roots() == [str(games)] # an older library: where its games came from
folders.remember(games)
folders.remember(games / "One") # inside another one: scanned with it
assert folders.rescan_roots() == [str(games)]
folders.remove(games)
folders.remove(games / "One")
assert folders.rescan_roots() == [] # the user removed them all: no guessing them back
def test_cli_folders(tmp_path):
d = tmp_path / "Games"
d.mkdir()
runner = CliRunner()
res = runner.invoke(cli.app, ["folders", "--add", str(d)])
assert res.exit_code == 0 and str(d) in res.output and "ok" in res.output
assert folders.roots() == [str(d)]
res = runner.invoke(cli.app, ["folders", "--json"])
assert '"exists": true' in res.output
res = runner.invoke(cli.app, ["folders", "--remove", str(d)])
assert res.exit_code == 0 and folders.roots() == []
assert runner.invoke(cli.app, ["folders", "--remove", str(d)]).exit_code == 1
assert runner.invoke(cli.app, ["folders", "--add", str(tmp_path / "missing")]).exit_code != 0
assert "no folders to rescan" in runner.invoke(cli.app, ["rescan"]).output
# ------------------------------------------------------------------------------------------ Quest games that moved
def _apk(path, manifest, extra=b""):
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w") as z:
z.writestr("AndroidManifest.xml", manifest)
z.writestr("classes.dex", b"dex\n035\0" + extra)
return path
def _game(root, manifest, extra=b""):
apk = _apk(root / "Quest Game" / "game.apk", manifest, extra)
obb = root / "Quest Game" / PKG
obb.mkdir()
(obb / f"main.1.{PKG}.obb").write_bytes(b"o" * 10)
return apk
def _setup(tmp_path, monkeypatch, manifest):
base.load_all()
monkeypatch.setattr(quest_dump, "_apk_ids", lambda p: (PKG, 1))
monkeypatch.setattr(pipeline.rift_dump, "scan", lambda *a, **k: [])
_game(tmp_path / "Old", manifest)
[g] = pipeline.add_path(tmp_path / "Old")
r = library.recipe_from_dict(g["recipe"])
r.source, r.status = "user", "works"
r.patches["adapter.scale"] = {"value": 1.2}
library.upsert_game(PKG, title="My Game", title_locked=True, tags=["mine"], recipe=library.recipe_to_dict(r),
status="works", build={"ok": True, "sha256": "b"}, installs={"Frame": {"time": 1}})
art = artwork.artwork_dir(PKG)
(art / artwork.PICKED).write_text("custom")
return g
def _logs():
logs = []
rep = Reporter([lambda e: logs.append(e.message) if e.kind == "log" else None])
return rep, logs
def _kept_user_fields(g):
assert (g["title"], g["title_locked"], g["tags"]) == ("My Game", True, ["mine"])
assert g["recipe"]["source"] == "user" and g["recipe"]["patches"]["adapter.scale"] == {"value": 1.2}
assert g["status"] == "works" and g["build"] == {"ok": True, "sha256": "b"} and g["installs"]
assert (artwork.artwork_dir(PKG) / artwork.PICKED).is_file()
def test_rescan_relinks_a_moved_quest_game(tmp_path, monkeypatch, quest_manifest):
_setup(tmp_path, monkeypatch, quest_manifest)
shutil.move(tmp_path / "Old", tmp_path / "New") # e.g. another drive
rep, logs = _logs()
report = pipeline.ScanReport()
assert pipeline.add_path(tmp_path / "New", rep, only_new=True, report=report) == []
g = library.game(PKG)
assert [i["package"] for i in report.moved] == [PKG]
new = tmp_path / "New" / "Quest Game"
assert g["apk"] == str(new / "game.apk") and g["data_dir"] == str(new / PKG) and g["origin"] == str(new)
assert g["data_manifest"] == {f"main.1.{PKG}.obb": 10}
_kept_user_fields(g)
assert any("aren't at" in m and "drive" in m for m in logs) # the old place is gone (maybe unplugged)
assert any("Updated the location of 1 game" in m for m in logs)
pipeline.check_source(g) # buildable again
def test_full_scan_keeps_a_user_recipe_and_refreshes_a_derived_one(tmp_path, monkeypatch, quest_manifest):
g0 = _setup(tmp_path, monkeypatch, quest_manifest)
[g] = pipeline.add_path(tmp_path / "Old") # scanned again, same place
_kept_user_fields(g)
assert g["suggested"] == g0["suggested"]
stale = dict(g0["recipe"], patches={}, source="heuristics")
library.upsert_game(PKG, recipe=stale)
[g] = pipeline.add_path(tmp_path / "Old")
assert g["recipe"] == g["suggested"] # not the user's: follows the analysis
def test_another_version_isnt_swapped_silently(tmp_path, monkeypatch, quest_manifest):
_setup(tmp_path, monkeypatch, quest_manifest)
shutil.rmtree(tmp_path / "Old")
newer = _game(tmp_path / "New", quest_manifest, extra=b"v2") # other bytes: another build
report = pipeline.ScanReport()
pipeline.add_path(tmp_path / "New", only_new=True, report=report) # Rescan folders: the user decides
assert [i["package"] for i in report.other_version] == [PKG] and not report.moved
assert library.game(PKG)["apk"] == str(tmp_path / "Old" / "Quest Game" / "game.apk")
report = pipeline.ScanReport()
assert pipeline.add_path(tmp_path / "New", report=report) == [] # Scan a folder…: like Update with a newer APK…
g = library.game(PKG)
assert [i["package"] for i in report.updated] == [PKG]
assert g["apk"] == str(newer) and g["source_sha256"] == pipeline.builder.sha256(newer)
assert (g["title"], g["tags"], g["recipe"]["source"]) == ("My Game", ["mine"], "user")
def test_a_second_copy_leaves_the_existing_one(tmp_path, monkeypatch, quest_manifest):
_setup(tmp_path, monkeypatch, quest_manifest)
_game(tmp_path / "Copy", quest_manifest)
for only_new in (True, False):
report = pipeline.ScanReport()
assert pipeline.add_path(tmp_path / "Copy", only_new=only_new, report=report) == []
assert [i["package"] for i in report.other_copy] == [PKG]
g = library.game(PKG)
assert g["apk"] == str(tmp_path / "Old" / "Quest Game" / "game.apk")
_kept_user_fields(g)
def test_same_build_without_a_checksum_uses_the_version_name(tmp_path, monkeypatch):
apk = tmp_path / "x.apk"
apk.write_bytes(b"x")
monkeypatch.setattr(quest_dump, "version_name", lambda p: "1.2")
assert pipeline.same_quest_build({"analysis": {"version": "1.2"}}, apk)
assert not pipeline.same_quest_build({"analysis": {"version": "1.1"}}, apk)
assert not pipeline.same_quest_build({"source_sha256": "0" * 64, "analysis": {"version": "1.2"}}, apk)
def test_scan_summary_lines():
from frameport.ui.app import scan_report_lines
report = pipeline.ScanReport()
assert scan_report_lines(report) == []
report.note("moved", {"package": "a", "title": "A"}, "/x")
report.note("moved", {"package": "b", "title": "B"}, "/y")
report.note("other_version", {"package": "c"}, "/z")
lines = scan_report_lines(report)
assert lines[0] == "Updated the location of 2 games" and "another version" in lines[1]
# ------------------------------------------------------------------------------------------ PC VR games that moved
def _rift(tmp_path):
from test_rift import unity_game
unity_game(tmp_path / "Old")
[g] = pipeline.add_path(tmp_path / "Old")
pkg = g["package"]
r = library.recipe_from_dict(g["recipe"])
r.source = "user"
r.patches["pcvr.no_crash_reporter"] = {}
pipeline.set_recipe(pkg, r)
library.upsert_game(pkg, title="My Space", title_locked=True, tags=["mine"], exe_confirmed=True)
return pkg
def _rift_kept(g):
assert (g["title"], g["tags"], g["exe"]) == ("My Space", ["mine"], "Space Game.exe")
assert g["recipe"]["source"] == "user" and "pcvr.no_crash_reporter" in g["recipe"]["patches"]
def test_rescan_relinks_a_moved_rift_game(tmp_path):
pkg = _rift(tmp_path)
(tmp_path / "New").mkdir()
shutil.move(tmp_path / "Old" / "Space Game", tmp_path / "New" / "Space Game")
report = pipeline.ScanReport()
assert pipeline.add_path(tmp_path / "New", only_new=True, report=report) == []
g = library.game(pkg)
new = tmp_path / "New" / "Space Game"
assert [i["package"] for i in report.moved] == [pkg]
assert g["game_dir"] == str(new) and g["analysis"]["extra"]["folder"] == str(new)
_rift_kept(g)
assert len([x for x in library.games() if x.get("kind") == "rift"]) == 1 # no second entry
def test_a_renamed_rift_folder_is_found_by_its_program(tmp_path):
pkg = _rift(tmp_path)
(tmp_path / "New").mkdir()
shutil.move(tmp_path / "Old" / "Space Game", tmp_path / "New" / "Space Game (repack)")
report = pipeline.ScanReport()
pipeline.add_path(tmp_path / "New", only_new=True, report=report)
assert [i["package"] for i in report.moved] == [pkg]
assert library.game(pkg)["game_dir"] == str(tmp_path / "New" / "Space Game (repack)")
assert len([x for x in library.games() if x.get("kind") == "rift"]) == 1
def test_rift_copy_and_version(tmp_path):
pkg = _rift(tmp_path)
shutil.copytree(tmp_path / "Old" / "Space Game", tmp_path / "Copy" / "Space Game")
report = pipeline.ScanReport()
pipeline.add_path(tmp_path / "Copy", report=report) # the library's folder is still there: it stays
assert [i["package"] for i in report.other_copy] == [pkg]
assert library.game(pkg)["game_dir"] == str(tmp_path / "Old" / "Space Game")
shutil.rmtree(tmp_path / "Old")
exe = tmp_path / "Copy" / "Space Game" / "Space Game.exe"
exe.write_bytes(exe.read_bytes() + b"\0" * 512) # another build
report = pipeline.ScanReport()
pipeline.add_path(tmp_path / "Copy", only_new=True, report=report)
assert [i["package"] for i in report.other_version] == [pkg]
assert library.game(pkg)["game_dir"] == str(tmp_path / "Old" / "Space Game")
report = pipeline.ScanReport()
pipeline.add_path(tmp_path / "Copy", report=report)
g = library.game(pkg)
assert [i["package"] for i in report.updated] == [pkg] and g["source_id"]
assert g["game_dir"] == str(tmp_path / "Copy" / "Space Game")
_rift_kept(g)
+19 -2
View File
@@ -27,7 +27,7 @@ def _states():
"""(name, game, state) for the cases the menus have to handle."""
return [
("quest on Frame", QUEST, MenuState(connected=True, on_frame=True, plays=[_play()], installs=[_install()],
settings=True, selectable=True, android=True)),
settings=True, selectable=True, android=True, saves=True)),
("quest not installed", QUEST, MenuState(connected=True, installs=[_install("Install on Frame")],
settings=True, selectable=True, android=True)),
("rift on PC", RIFT, MenuState(on_pc=True, plays=[_play("this PC")], programs=True, selectable=True,
@@ -110,7 +110,7 @@ def test_sections_and_conditions():
st.quick = False
entries = menu_sections(g, st, _key)
heads = [e.label for e in entries if isinstance(e, Header)]
assert heads == ["Name and artwork", "Recipe", "Troubleshoot"]
assert heads == ["Saves", "Name and artwork", "Recipe", "Troubleshoot"]
assert _section_of(entries, "Analyze again") == "Troubleshoot"
assert _section_of(entries, "Update art on Frame") == "Name and artwork"
assert _section_of(entries, "Rename…") == "Name and artwork"
@@ -188,3 +188,20 @@ def test_newer_version_for_linux_apps_and_pc_vr_games():
assert ("Update with a newer version…" in full) == want, name
if g is not QUEST:
assert "Update with a newer APK…" not in quick + full, name
def test_save_backups_in_the_full_menu_of_quest_games():
"""Discussion #352: back up / restore a Quest game's saves (installed on the Frame, no job running); the backups
folder for every Quest game; nothing for PC VR games and Linux apps; never in the short right-click menu."""
for name, g, st in _states():
quick = [e[0] for e in _items(menu_sections(g, st, _key))]
assert not {"Back up saves", "Restore saves…", "Open backups folder"} & set(quick), name
st.quick = False
entries = menu_sections(g, st, _key)
full = [e[0] for e in _items(entries)]
live = g is QUEST and st.saves and not st.job
assert ("Back up saves" in full) == live, name
assert ("Restore saves…" in full) == live, name
assert ("Open backups folder" in full) == (g is QUEST), name
if g is QUEST:
assert _section_of(entries, "Open backups folder") == "Saves"
-40
View File
@@ -1,40 +0,0 @@
from copy import deepcopy
from test_patches import _analysis
from frameport.core import library
from frameport.patches.applicability import uses_scene
def test_scene_migration_only_changes_automatic_recipes_once():
data = {"settings": {"migrations": []}, "games": {
"com.test.scene": {"analysis": _analysis(meta_permissions=["com.oculus.permission.USE_SCENE"]).to_dict(),
"recipe": {"source": "heuristic", "patches": {}}},
"com.test.anchor": {"analysis": _analysis(meta_permissions=["com.oculus.permission.USE_ANCHOR_API"]).to_dict(),
"recipe": {"source": "catalog", "patches": {"adapter.scene_emul": {"value": 0}}}},
"com.test.mine": {"analysis": _analysis(meta_permissions=["com.oculus.permission.USE_SCENE"]).to_dict(),
"recipe": {"source": "user", "patches": {}}},
"com.test.other": {"analysis": _analysis().to_dict(), "recipe": {"source": "heuristic", "patches": {}}}}}
library._migrate(data)
for pkg in ("com.test.scene", "com.test.anchor"):
assert data["games"][pkg]["recipe"]["patches"]["adapter.scene_emul"] == {"value": 1}
for pkg in ("com.test.mine", "com.test.other"):
assert "adapter.scene_emul" not in data["games"][pkg]["recipe"]["patches"]
data["games"]["com.test.scene"]["recipe"]["patches"].pop("adapter.scene_emul")
before = deepcopy(data)
library._migrate(data)
assert data == before
def test_scene_emulation_brings_the_xrshim():
# OVRPort's dispatcher refuses the scene functions; only the shim gets them to FrameBridge (Pinball FX VR)
from frameport.patches.frame.adapter import needs_xrshim
assert needs_xrshim({"adapter.scene_emul": {"value": 1}})
assert not needs_xrshim({"adapter.scene_emul": {"value": 0}})
def test_both_scene_permissions_apply_without_mr_only():
for permission in ("USE_SCENE", "USE_ANCHOR_API"):
assert uses_scene(_analysis(meta_permissions=["com.oculus.permission." + permission]))
assert uses_scene(_analysis(extra={"mr_only": False, "meta_permissions_used": [permission]}))
-92
View File
@@ -1,92 +0,0 @@
import struct
import xml.etree.ElementTree as ET
import pytest
from frameport.mr.room import (
Mesh,
Plane,
Volume,
box_room,
dumps,
from_boundary,
parse,
preview_svg,
read_mesh,
summary,
write_mesh,
)
def test_roundtrip():
r = box_room(4, 3, 2.6)
r.universe = 2**64 - 1
r.volumes.append(Volume("a"*32, "TABLE", 1, 0, .8, 25, 1, .6, .8))
r.planes.append(Plane("b"*32, "WINDOW_FRAME", 0, 1, -1.5, 0, 0, 0, 1, 1, 1))
r.mesh = Mesh("c"*32, vertex_count=3, index_count=3)
assert dumps(parse(dumps(r))) == dumps(r)
assert summary(r) == dict(walls=4, volumes={"TABLE": 1}, planes=1, floor_area=12, ceiling_height=2.6)
root = ET.fromstring(preview_svg(r))
assert root.tag.endswith("svg")
assert any(e.text == "TABLE" for e in root.iter())
def test_tolerant_and_defaults():
r = parse("# comment\n\nframeport-room 1 # format\nfloor " + "1"*32 + " 1\n"
"outline 4 0 0 0 3 4 3 4 0\nfuture whatever\n")
assert r.ceiling == 3.5
assert summary(r)["floor_area"] == 12
assert len(r.walls) == 4
# Clockwise input is repaired, as are explicitly supplied wall directions.
text = dumps(r).replace("outline 4 4 0 4 3 0 3 0 0", "outline 4 0 0 0 3 4 3 4 0")
assert dumps(parse(text)) == dumps(r)
assert parse("frameport-room 1\n" + "\n".join(line for line in dumps(r).splitlines()
if line.startswith("wall "))).outline
@pytest.mark.parametrize("text", ["", "frameport-room 2", "frameport-room 1\noutline 3 0 0 1 1",
"frameport-room 1\noutline 3 0 0 1 1 nan 2", "frameport-room 1\noutline 3 0 0 1 1 2 2"])
def test_invalid(text):
with pytest.raises(ValueError):
parse(text)
@pytest.mark.parametrize("record", ["universe -1", "universe 18446744073709551616", "source unknown",
"mesh " + "a"*32 + " ../oops 3 3", "volume " + "a"*32 + " TABLE 0 0 1 0 -1 1 1",
"wall " + "a"*32 + " 0 0 1 0 1 0", "room bad", "floor " + "a"*32 + " inf"])
def test_invalid_record(record):
with pytest.raises(ValueError):
parse(dumps(box_room(3, 4)) + record + "\n")
def test_mesh_roundtrip_and_validation():
m = Mesh(vertices=[(0, 0, 0), (1, 0, 0), (0, 0, 1)], indices=[0, 1, 2])
data = write_mesh(m)
got = read_mesh(data)
assert got.vertices == m.vertices and got.indices == m.indices
for bad in [data[:10], data[:-1], b"oops" + data[4:], data + b"x",
data[:-4] + struct.pack("<I", 3)]:
with pytest.raises(ValueError):
read_mesh(bad)
def test_boundary_and_preview():
r = from_boundary([(0, 0), (0, 3), (4, 3), (4, 0)])
assert r.source == "boundary"
assert [(w.x0, w.z0) for w in r.walls] == r.outline
r.play_area = [(1, 1), (2, 1), (2, 2)]
assert "stroke-dasharray" in preview_svg(r)
for dims in [(0, 1, 1), (1, -1, 1), (1, 1, float("nan"))]:
with pytest.raises(ValueError):
box_room(*dims)
def test_clockwise_outline_and_wall_directions_are_repaired():
r = box_room(4, 3)
r.outline.reverse()
for w in r.walls:
w.x0, w.x1 = w.x1, w.x0
w.z0, w.z1 = w.z1, w.z0
got = parse(dumps(r))
edges = list(zip(got.outline, got.outline[1:] + got.outline[:1], strict=True))
assert all(((w.x0, w.z0), (w.x1, w.z1)) in edges for w in got.walls)
-28
View File
@@ -1,28 +0,0 @@
import pytest
from frameport.validate.triage import triage
@pytest.mark.parametrize("line", ["scene: capture requested -> guardian box",
"scene: capture requested -> using guardian room", "scene: room from guardian",
"scene: room 1.50 x 1.50 x 2.50 m (from guardian)",
"scene: room 1.50 x 1.50 x 2.50 m (configured/default size)"])
def test_fallback_room_is_an_informational_hint(line):
result = triage("FrameBridge: " + line, "RUNNING", "com.example.mr")
finding = next(f for f in result.findings if f.id == "mr-no-room")
assert finding.severity == "info" and "Scan your room" in finding.diagnosis
assert finding.suggest == []
assert result.verdict == "pass"
@pytest.mark.parametrize("line", ["overportOXR: Unknown proc addr: xrDiscoverSpacesMETA",
"OVRPlugin: Unknown proc addr: xrDiscoverSpacesMETA", "MRUK: NoRoomsFound"])
def test_discovery_suggests_scene_emulation(line):
result = triage(line, "RUNNING", "com.example.mr")
finding = next(f for f in result.findings if f.id == "mr-discovery-unsupported")
assert finding.suggest == ["adapter.scene_emul"]
def test_loaded_scan_is_not_a_missing_room():
result = triage("FrameBridge: scene: loaded room.txt (source scan)", "RUNNING")
assert not any(f.id.startswith("mr-") for f in result.findings)
-201
View File
@@ -1,201 +0,0 @@
"""RoomView capture (agent) and the PC's scan flow, without SteamVR: pose math, the final raw->STAGE rewrite of a
dump, prompt choice, the worker's status file, and rooms.scan's polling."""
import json
import math
import struct
from pathlib import Path
from types import SimpleNamespace
import pytest
from test_agent import load_agent
from frameport.install import rooms
def rot_y(deg, t=(0.0, 0.0, 0.0)):
c, s = math.cos(math.radians(deg)), math.sin(math.radians(deg))
return [c, 0, s, t[0], 0, 1, 0, t[1], -s, 0, c, t[2]]
def test_m34_mul_and_quat(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
m = a._m34_mul(rot_y(90, (0, 1.6, 0)), rot_y(0, (1, 0, 0)))
assert m[3] == pytest.approx(0, abs=1e-6) and m[7] == pytest.approx(1.6) and m[11] == pytest.approx(-1)
x, y, z, w = a._m34_quat(rot_y(90))
assert (x, z) == (pytest.approx(0), pytest.approx(0))
assert y == pytest.approx(math.sin(math.radians(45))) and w == pytest.approx(math.cos(math.radians(45)))
for deg in (0, 179.9, -120): # every branch of the matrix -> quaternion conversion stays a unit rotation
q = a._m34_quat(rot_y(deg))
assert sum(v * v for v in q) == pytest.approx(1)
def test_prompt_choice(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
assert a._prompt_for(None, set(), {"up": 0, "down": 0}) == "start"
assert a._prompt_for(1.0, {0, 1, 2}, {"up": 0, "down": 0}) == "look_03"
assert a._prompt_for(1.0, set(range(12)), {"up": 0, "down": 0}) == "ceiling"
assert a._prompt_for(1.0, set(range(12)), {"up": 9, "down": 0}) == "floor"
assert a._prompt_for(1.0, set(range(11)), {"up": 9, "down": 9}) == "look_11"
def test_scan_completes_early_only_when_everything_was_seen(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
full, seen = set(range(12)), {"up": 9, "down": 9}
assert a._scan_complete(0.0, 61.0, full, seen)
assert not a._scan_complete(0.0, 50.0, full, seen) # too short
assert not a._scan_complete(0.0, 61.0, set(range(11)), seen)
assert not a._scan_complete(0.0, 61.0, full, {"up": 9, "down": 1})
assert not a._scan_complete(None, 61.0, full, seen)
def test_view_coverage_bins(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
bins, pitch = set(), {"up": 0, "down": 0}
for deg in range(15, 360, 30): # mid-bin: exact multiples of 30 sit on bin edges
a._view_coverage(rot_y(deg), bins, pitch)
assert len(bins) == 12 and pitch == {"up": 0, "down": 0}
up = [1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0] # mesh +Z (third column) pointing up
a._view_coverage(up, bins, pitch)
assert pitch["up"] == 1
class FakeReader:
"""A stream of six fresh mesh blocks (alternately facing 0 / 90 deg), then nothing; raw->standing moves the
floor by +0.5 m at the end. The first two only make the stream count as "passthrough on"."""
def __init__(self, app_type=None, blocks=6):
import ctypes
self.ct = ctypes
self.blocks = [(i, rot_y(90 * (i % 2 == 0), (0, 1.0, 0))) for i in range(1, blocks + 1)]
self.verts = ctypes.create_string_buffer(struct.pack("<9f", *range(9)))
self.idx = ctypes.create_string_buffer(struct.pack("<3I", 0, 1, 2))
self.data, self.layout = "data", "layout"
self.current = None
def up_to_date(self):
return True
def acquire(self, queue, read_type, timeout_ms):
if queue == "layout":
return SimpleNamespace(value="layout"), self.ct.addressof(self.idx)
if not self.blocks:
return None, None
self.current = self.blocks.pop(0)
return SimpleNamespace(value="data"), self.ct.addressof(self.verts)
def prop(self, root, path, fmt):
return {"/meshData/meshId": self.current[0] if self.current else None, "/meshData/layoutId": 5,
"/meshData/elementCount": 9, "/meshData/rawFromMeshPose": tuple(self.current[1]),
"/meshLayout/indexCount": 3, "/meshLayout/layoutId": 5}.get(path)
def release(self, queue, blk):
pass
def raw_to_standing(self):
return rot_y(0, (0, 0.5, 0))
def close(self):
pass
def test_capture_rewrites_poses_with_final_transform(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
monkeypatch.setattr(a, "_RoomViewReader", FakeReader)
out = tmp_path / "scan.fprs"
clock = iter(i * 0.05 for i in range(10000))
monkeypatch.setattr(a.time, "time", lambda: next(clock))
res = a.capture_roomview(3, str(out), max_hz=100)
assert res["frames"] == 3 # blocks 4-6: three meshes had to stream before the first recorded one
data = out.read_bytes()
assert data[:8] == b"FPRS\x01\0\0\0"
hdr = struct.calcsize(a.FPRS_FRAME)
t, px, py, pz, qx, qy, qz, qw, vc, ic = struct.unpack_from(a.FPRS_FRAME, data, 8)
assert (vc, ic) == (3, 3) and py == pytest.approx(1.5) # raw y 1.0 + the final floor shift 0.5
assert qy == pytest.approx(math.sin(math.radians(45))) # block 4 faces 90 deg
second = 8 + hdr + 3 * 12 + 3 * 4
_, _, py2, _, qx2, qy2, qz2, qw2, _, _ = struct.unpack_from(a.FPRS_FRAME, data, second)
assert py2 == pytest.approx(1.5) and qy2 == pytest.approx(0) # block 5 faces 0 deg
assert "ceiling not seen: look up" in res["warnings"]
def test_capture_ignores_a_leftover_block(monkeypatch, tmp_path):
# passthrough off: the queue still hands out its last block once; that is no scan (owner's run, 2026-10-16)
a = load_agent(monkeypatch, tmp_path)
monkeypatch.setattr(a, "_RoomViewReader", lambda app_type=None: FakeReader(app_type, blocks=1))
clock = iter(i * 0.05 for i in range(10000))
monkeypatch.setattr(a.time, "time", lambda: next(clock))
res = a.capture_roomview(3, str(tmp_path / "scan.fprs"), max_hz=100)
assert res["frames"] == 0 and res["warnings"][0].startswith("no passthrough")
def test_prompt_asks_for_passthrough_whenever_the_stream_stops(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
assert a._prompt_for(5.0, {0, 1}, {"up": 0, "down": 0}, streaming=False) == "start"
def test_capture_worker_status(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
monkeypatch.setattr(a, "capture_roomview", lambda *args, **kw: {"frames": 0, "warnings": []})
a.room_capture_worker(json.dumps({"seconds": 10, "wait": 0}))
st = a.cmd_room_capture_status({})
assert st["state"] == "failed" and "passthrough" in st["error"]
monkeypatch.setattr(a, "capture_roomview", lambda *args, **kw: {"frames": 12, "warnings": []})
a.room_capture_worker(json.dumps({"seconds": 10, "wait": 0})) # a scrap of a scan never replaces a room
assert a.cmd_room_capture_status({})["state"] == "failed"
built = {"report": {"walls": 4}, "room": {"universe": 7}}
monkeypatch.setattr(a, "capture_roomview", lambda *args, **kw: {"frames": 40, "warnings": []})
monkeypatch.setattr(a, "cmd_room_build", lambda args: built)
a.room_capture_worker(json.dumps({"seconds": 10, "wait": 0}))
st = a.cmd_room_capture_status({})
assert st["state"] == "done" and st["report"] == {"walls": 4}
class ScanFrame:
home = "/fake-frame"
def __init__(self, states):
self.states = list(states)
self.calls = []
self.puts = []
self.sums = ""
def agent(self, cmd, **kw):
self.calls.append((cmd, kw))
if cmd == "room_capture_status":
return self.states.pop(0)
return {"started": True}
def run(self, cmd):
return 0, self.sums, ""
def put(self, local, remote, resume=True):
self.puts.append((Path(local).name, remote))
def test_scan_polls_until_done(monkeypatch):
monkeypatch.setattr(rooms, "ensure_scanner", lambda frame: "x")
monkeypatch.setattr(rooms, "ensure_prompts", lambda frame: "x")
frame = ScanFrame([{"state": "waiting"}, {"state": "recording", "directions": 4}, {"state": "done", "room": {}}])
seen = []
st = rooms.scan(frame, seconds=30, wait=60, progress=seen.append, sleep=lambda s: None)
assert st["state"] == "done" and [s["state"] for s in seen] == ["waiting", "recording", "done"]
assert frame.calls[0] == ("room_capture", {"seconds": 30, "wait": 60})
frame = ScanFrame([{"state": "failed", "error": "No passthrough view arrived"}])
with pytest.raises(RuntimeError, match="No passthrough"):
rooms.scan(frame, sleep=lambda s: None)
def test_ensure_prompts_uploads_only_changed(monkeypatch, tmp_path):
import hashlib
(tmp_path / "roomscan").mkdir()
for name in ("a.png", "b.png"):
(tmp_path / "roomscan" / name).write_bytes(name.encode())
monkeypatch.setattr(rooms, "artifacts_dir", lambda: tmp_path)
frame = ScanFrame([])
frame.sums = hashlib.sha256(b"a.png").hexdigest() + " a.png\n" + "0" * 64 + " b.png\n"
rooms.ensure_prompts(frame)
assert [p[0] for p in frame.puts] == ["b.png"]
-109
View File
@@ -1,109 +0,0 @@
import hashlib
import io
from pathlib import Path
from types import SimpleNamespace
import pytest
from typer.testing import CliRunner
from frameport.install import rooms
from frameport.mr.room import Mesh, box_room, dumps, write_mesh
class Frame:
home = "/fake-frame"
def __init__(self):
self.files = {}
self.calls = []
self.commands = []
self.answer = {}
self.sftp = SimpleNamespace(open=lambda path, mode: io.BytesIO(self.files[path]))
def run(self, cmd):
self.commands.append(cmd)
return 0, "", ""
def put(self, local, remote, **kwargs):
self.files[remote] = Path(local).read_bytes()
def agent(self, command, **args):
self.calls.append((command, args))
return self.answer
def test_import_export_and_preview(tmp_path):
f = Frame()
r = box_room(4, 3)
m = Mesh(vertices=[(0, 0, 0), (1, 0, 0), (0, 0, 1)], indices=[0, 1, 2])
r.mesh = Mesh(vertex_count=3, index_count=3)
path = tmp_path / "room.txt"
path.write_text(dumps(r))
(tmp_path / "room-mesh.bin").write_bytes(write_mesh(m))
rooms.put(f, path)
assert f.calls[0][0] == "room_put"
assert f.commands[-1].startswith("rm -rf -- ")
assert len(f.files) == 2
f.answer = {"room_text": dumps(r)}
f.files[f"{f.home}/.local/share/frameport/rooms/0/room-mesh.bin"] = write_mesh(m)
out = rooms.export(f, tmp_path / "export")
assert out.read_text() == dumps(r)
assert (out.parent / "room-mesh.bin").read_bytes() == write_mesh(m)
assert rooms.show(f, tmp_path / "preview.svg")["floor_area"] == 12
assert "<svg" in (tmp_path / "preview.svg").read_text()
(tmp_path / "room-mesh.bin").write_bytes(b"wrong")
with pytest.raises(ValueError):
rooms.put(f, path)
f.answer = {}
with pytest.raises(ValueError, match="No room"):
rooms.export(f, tmp_path / "empty")
def test_helper_sync_shared_with_live_view(tmp_path, monkeypatch):
from frameport.install.livestream import sync_helper
f = Frame()
local = tmp_path / "fp_roomscan"
local.write_bytes(b"scanner")
remote = sync_helper(f, local, "fp_roomscan")
assert remote.endswith("/bin/fp_roomscan")
assert list(f.files.values()) == [b"scanner"]
f.files.clear()
monkeypatch.setattr(f, "run", lambda cmd: (0, hashlib.sha256(b"scanner").hexdigest()+" path", ""))
assert sync_helper(f, local, "fp_roomscan") == remote
assert not f.files
def test_build_and_capture(tmp_path, monkeypatch):
f = Frame()
monkeypatch.setattr(rooms, "ensure_scanner", lambda frame: "scanner")
dump = tmp_path / "scan.fprs"
dump.write_bytes(b"FPRS\x01\0\0\0")
rooms.build(f, dump, 7)
assert f.calls[-1][0] == "room_build" and f.calls[-1][1]["universe"] == 7
dump.write_bytes(b"wrong")
with pytest.raises(ValueError, match="FPRS"):
rooms.build(f, dump)
f.answer = {"error": "not implemented yet"}
with pytest.raises(RuntimeError, match="not implemented yet"):
rooms.capture(f)
def test_cli_room_commands(tmp_path, monkeypatch):
from frameport import cli
f = Frame()
monkeypatch.setattr(cli, "_target", lambda frame: SimpleNamespace(frame=f))
runner = CliRunner()
for command, expected in [("status", "room_status"), ("from-boundary", "room_from_boundary"),
("delete", "room_delete")]:
result = runner.invoke(cli.app, ["frame", "room", command])
assert result.exit_code == 0, result.output
assert f.calls[-1][0] == expected
r = box_room(3, 4)
f.answer = {"room_text": dumps(r)}
result = runner.invoke(cli.app, ["frame", "room", "show", "--output", str(tmp_path / "preview.svg")])
assert result.exit_code == 0, result.exception
assert (tmp_path / "preview.svg").exists()
result = runner.invoke(cli.app, ["frame", "room", "export", str(tmp_path / "out")])
assert result.exit_code == 0, result.exception
-390
View File
@@ -1,390 +0,0 @@
"""The hosted build runs exactly the reconstruction sources shipped in fp_roomscan."""
from __future__ import annotations
import importlib.util
import json
import math
import re
import shutil
import struct
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("synth_scan", ROOT / "scripts/mr/synth_scan.py")
synth = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(synth)
@pytest.fixture(scope="module")
def scanner(tmp_path_factory):
compiler = shutil.which("cc") or shutil.which("clang")
if not compiler:
pytest.skip("roomscan tests need a host C compiler")
binary = tmp_path_factory.mktemp("roomscan-build") / "fp_roomscan"
subprocess.run([compiler, "-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off",
"-fno-math-errno", "-DFP_HOSTED",
*map(str, sorted((ROOT / "native/roomscan").glob("*.c"))), "-o", str(binary)],
check=True, capture_output=True, text=True)
return binary
@pytest.fixture(scope="module")
def dumps(tmp_path_factory):
directory = tmp_path_factory.mktemp("depth-meshes")
cases = {"box": {}, "l": {"shape": "l"}, "down": {"no_ceiling": True}, "one": {"frames": 1},
"rotated": {"yaw": math.radians(27), "offset": (.3, .1, -.2)},
"missing": {"missing_wall": 1, "objects": False}}
result = {}
for name, options in cases.items():
result[name] = directory / f"{name}.fprs"
synth.render(result[name], profile="legacy", **options)
return result
def run(scanner, dump, directory, *args, success=True):
proc = subprocess.run([str(scanner), "--offline", str(dump), "--out", str(directory), "--json", *args],
text=True, capture_output=True, timeout=15)
assert proc.returncode == (0 if success else 2), proc.stdout + proc.stderr
assert len(proc.stdout.splitlines()) == 1
report = json.loads(proc.stdout)
assert report["ok"] is success
return report
def room(directory):
records = {}
lines = (directory / "room.txt").read_text().splitlines()
assert lines[0] == "frameport-room 1"
for line in lines:
if not line or line.startswith("#"):
continue
kind, *values = line.split()
records.setdefault(kind, []).append(values)
return records
def outline(records):
n, *values = records["outline"][0]
assert len(values) == 2 * int(n)
values = list(map(float, values))
return list(zip(values[::2], values[1::2], strict=False))
def area(polygon):
return sum(ax * bz - bx * az for (ax, az), (bx, bz) in zip(polygon, polygon[1:] + polygon[:1], strict=False)) / 2
def assert_walls(polygon, expected):
assert len(polygon) == len(expected)
assert area(polygon) > 0
# Corner positions and segment orientation are checked independently.
for x, z in expected:
assert min(math.hypot(x - a, z - b) for a, b in polygon) < .05
expected_angles = [math.atan2(bz - az, bx - ax)
for (ax, az), (bx, bz) in zip(expected, expected[1:] + expected[:1], strict=False)]
for a, b in zip(polygon, polygon[1:] + polygon[:1], strict=False):
heading = math.atan2(b[1] - a[1], b[0] - a[0])
assert min(abs(math.atan2(math.sin(heading - e), math.cos(heading - e)))
for e in expected_angles) < math.radians(2)
dx, dz = b[0] - a[0], b[1] - a[1]
length = math.hypot(dx, dz)
assert synth.inside((a[0] + b[0]) / 2 - .04 * dz / length,
(a[1] + b[1]) / 2 + .04 * dx / length, polygon)
def mesh(directory):
data = (directory / "room-mesh.bin").read_bytes()
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
assert (magic, version) == (b"FPRM", 1)
assert 0 < ni // 3 <= 200_000 and ni % 3 == 0
assert len(data) == 16 + nv * 12 + ni * 4
vertices = list(struct.iter_unpack("<fff", data[16:16 + nv * 12]))
indices = struct.unpack_from(f"<{ni}I", data, 16 + nv * 12)
assert all(math.isfinite(c) for vertex in vertices for c in vertex)
assert max(indices) < nv
return vertices, indices
def test_box_room_geometry_furniture_and_wire_format(scanner, dumps, tmp_path):
report = run(scanner, dumps["box"], tmp_path, "--universe", "18446744073709551615")
assert report["walls"] == report["detected_walls"] == 4
assert all(w == "out-of-range depth vertices skipped" for w in report["warnings"])
assert report["coverage_percent"] == 100
assert abs(report["floor_y"]) < .05
assert abs(report["ceiling_y"] - 2.6) < .05
records = room(tmp_path)
assert records["universe"] == [["18446744073709551615"]]
assert records["source"] == [["scan"]]
assert_walls(outline(records), synth.BOX_OUTLINE)
tables = [v for v in records["volume"] if v[1] == "TABLE"]
assert len(tables) == 1
_, _, x, z, top, yaw, width, depth, height = tables[0]
assert abs(float(x) + .6) < .07 and abs(float(z) + .25) < .07
assert abs(float(top) - .75) < .05
assert abs(float(width) - 1.2) < .08 and abs(float(depth) - .7) < .08
assert abs(float(height) - .75) < .08
assert abs(float(yaw)) < 2
assert any(v[1] == "COUCH" for v in records["volume"])
ids = [row[0] for kind in ("room", "floor", "ceiling", "wall", "volume", "mesh") for row in records[kind]]
assert len(ids) == len(set(ids))
assert all(re.fullmatch("[0-9a-f]{32}", value) for value in ids)
vertices, indices = mesh(tmp_path)
assert records["mesh"][0][1:] == ["room-mesh.bin", str(len(vertices)), str(len(indices))]
# Normals on the structural surfaces must face the room interior.
correct = checked = 0
for i in range(0, len(indices), 3):
a, b, c = [vertices[j] for j in indices[i:i + 3]]
centre = [sum(coords) / 3 for coords in zip(a, b, c, strict=False)]
ab, ac = [v - u for u, v in zip(a, b, strict=False)], [v - u for u, v in zip(a, c, strict=False)]
normal = (ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0])
x, y, z = centre
component = None
if abs(y) < .03:
component = normal[1]
elif abs(y - 2.6) < .03:
component = -normal[1]
elif abs(abs(x) - 2) < .02:
component = -x * normal[0]
elif abs(abs(z) - 1.5) < .02:
component = -z * normal[2]
if component is not None:
checked += 1
correct += component > 0
assert checked > 5000
assert correct / checked > .99
assert all(value >= 0 for value in report["timings_ms"].values())
def test_l_shape_preserves_reentrant_corner(scanner, dumps, tmp_path):
report = run(scanner, dumps["l"], tmp_path)
assert report["detected_walls"] == 6
polygon = outline(room(tmp_path))
assert_walls(polygon, synth.L_OUTLINE)
assert abs(area(polygon) - 12) < .15
assert not synth.inside(1, 1, polygon)
def test_pose_transform_and_non_axis_aligned_walls(scanner, dumps, tmp_path):
report = run(scanner, dumps["rotated"], tmp_path)
yaw = math.radians(27)
expected = [(math.cos(yaw) * x + math.sin(yaw) * z + .3,
-math.sin(yaw) * x + math.cos(yaw) * z - .2) for x, z in synth.BOX_OUTLINE]
assert_walls(outline(room(tmp_path)), expected)
assert abs(report["floor_y"] - .1) < .05
assert abs(report["ceiling_y"] - 2.7) < .05
def test_unseen_ceiling_uses_relative_default(scanner, dumps, tmp_path):
report = run(scanner, dumps["down"], tmp_path, "--ceiling-default", "2.7")
assert "ceiling not seen" in report["warnings"]
assert abs(report["ceiling_y"] - report["floor_y"] - 2.7) < .001
def test_one_frame_warns_and_still_emits_finite_room(scanner, dumps, tmp_path):
report = run(scanner, dumps["one"], tmp_path)
assert report["frames"] == 1
assert any("few frames" in warning for warning in report["warnings"])
assert 0 <= report["coverage_percent"] <= 100
assert len(outline(room(tmp_path))) >= 3
mesh(tmp_path)
def test_missing_wall_is_closed_and_circle_hint_is_ignored(scanner, dumps, tmp_path):
hint = tmp_path / "circle.txt"
hint.write_text("".join(f"{math.cos(i * math.tau / 32)} {math.sin(i * math.tau / 32)}\n" for i in range(32)))
out = tmp_path / "out"
report = run(scanner, dumps["missing"], out, "--hint", str(hint))
assert not report["hint_used"]
assert "default circle hint ignored" in report["warnings"]
assert report["coverage_percent"] < 100
assert "INVISIBLE_WALL_FACE" in (out / "room.txt").read_text()
labels = [row[7] if len(row) == 8 else "WALL_FACE" for row in room(out)["wall"]]
assert "INVISIBLE_WALL_FACE" in labels and "WALL_FACE" in labels
assert area(outline(room(out))) > 8
def test_matching_hint_fills_missing_wall(scanner, dumps, tmp_path):
hint = tmp_path / "outline.txt"
hint.write_text("# clockwise hint is also accepted\n" + "".join(f"{x} {z}\n" for x, z in synth.BOX_OUTLINE[::-1]))
report = run(scanner, dumps["missing"], tmp_path / "out", "--hint", str(hint))
assert report["hint_used"]
assert report["coverage_percent"] < 100
assert_walls(outline(room(tmp_path / "out")), synth.BOX_OUTLINE)
@pytest.mark.parametrize("cut", [0, 3, 7, 8, 9, 51, 52, 60, -1])
def test_truncated_dump_has_no_outputs(scanner, dumps, tmp_path, cut):
data = dumps["one"].read_bytes()
dump = tmp_path / "broken.fprs"
dump.write_bytes(data[:cut])
out = tmp_path / "out"
report = run(scanner, dump, out, success=False)
assert report["error"]
assert not out.exists()
@pytest.mark.parametrize(("offset", "replacement"), [
(0, b"NOPE"), (4, struct.pack("<I", 2)), (12, struct.pack("<I", 0x7ff00000)),
(16, struct.pack("<f", float("nan"))), (40, struct.pack("<f", 50)),
(44, struct.pack("<I", 0xffffffff)), (48, struct.pack("<I", 4)),
(52, struct.pack("<f", float("inf"))),
])
def test_corrupt_dump_is_rejected_before_publishing(scanner, dumps, tmp_path, offset, replacement):
data = bytearray(dumps["one"].read_bytes())
data[offset:offset + len(replacement)] = replacement
dump = tmp_path / "bad.fprs"
dump.write_bytes(data)
out = tmp_path / "out"
out.mkdir()
# An invalid replacement must preserve a previously installed room, byte for byte.
for name in ("room.txt", "room-mesh.bin"):
(out / name).write_bytes(b"previous room")
run(scanner, dump, out, success=False)
assert {p.name: p.read_bytes() for p in out.iterdir()} == {
"room.txt": b"previous room", "room-mesh.bin": b"previous room"}
def test_bad_index_and_trailing_partial_frame(scanner, dumps, tmp_path):
data = bytearray(dumps["one"].read_bytes())
nv = struct.unpack_from("<I", data, 44)[0]
struct.pack_into("<I", data, 52 + nv * 12, nv)
bad = tmp_path / "bad-index.fprs"
bad.write_bytes(data)
assert "index" in run(scanner, bad, tmp_path / "out", success=False)["error"]
bad.write_bytes(dumps["one"].read_bytes() + b"\0")
assert "truncated" in run(scanner, bad, tmp_path / "out", success=False)["error"]
@pytest.mark.parametrize("args", [("--voxel", "0"), ("--voxel", ".01"), ("--grazing", "nan"),
("--universe", "18446744073709551616"), ("--surprise", "1")])
def test_invalid_options_are_json_errors(scanner, dumps, tmp_path, args):
run(scanner, dumps["one"], tmp_path / "out", *args, success=False)
def test_coarser_voxels_and_repeatable_geometry(scanner, dumps, tmp_path):
first, second = tmp_path / "first", tmp_path / "second"
a = run(scanner, dumps["box"], first, "--voxel", ".08")
b = run(scanner, dumps["box"], second, "--voxel", ".08")
assert a["walls"] == b["walls"] == 4
assert (first / "room-mesh.bin").read_bytes() == (second / "room-mesh.bin").read_bytes()
assert room(first)["room"] != room(second)["room"] # fresh OS-random anchors on every scan
mesh(first)
@pytest.mark.parametrize("offset", [12, 16, 28, 52])
def test_nonfinite_frame_is_skipped_before_and_after_valid_frame(scanner, dumps, tmp_path, offset):
data = dumps["one"].read_bytes()
bad = bytearray(data[8:])
# High word of timestamp, pose position/quaternion, or a vertex; body offsets exclude FPRS header.
struct.pack_into("<I", bad, offset - 8, 0x7fffffff)
dump = tmp_path / "tracking-start.fprs"
dump.write_bytes(data[:8] + bad + data[8:] + bad)
report = run(scanner, dump, tmp_path / "out")
assert report["frames"] == 3 and report["skipped_frames"] == 2
assert "non-finite or invalid frames skipped" in report["warnings"]
assert len(outline(room(tmp_path / "out"))) >= 3
def test_far_vertices_are_dropped_per_vertex(scanner, dumps, tmp_path):
data = bytearray(dumps["one"].read_bytes())
# A single inpainted vertex must remove every incident triangle, without losing its whole frame.
struct.pack_into("<3f", data, 52, 0, 0, 9)
dump = tmp_path / "far.fprs"
dump.write_bytes(data)
report = run(scanner, dump, tmp_path / "out")
assert report["skipped_frames"] == 0 and report["far_vertices"] >= 1
assert report["rejected_triangles"] >= 1
@pytest.fixture(scope="module")
def roomview_dump(tmp_path_factory):
path = tmp_path_factory.mktemp("roomview-fan") / "synthetic.fprs"
synth.render(path)
return path
def frames(path):
with path.open("rb") as stream:
assert stream.read(8) == b"FPRS\x01\0\0\0"
while header := stream.read(44):
timestamp, *values = struct.unpack("<d7fII", header)
pose, nv, ni = values[:7], values[7], values[8]
vertices = list(struct.iter_unpack("<3f", stream.read(nv * 12)))
indices = struct.unpack(f"<{ni}I", stream.read(ni * 4))
yield timestamp, pose, vertices, indices
def test_roomview_renderer_grid_camera_axes_drift_and_inpainting(roomview_dump):
heights, far = [], 0
for i, (timestamp, pose, vertices, indices) in enumerate(frames(roomview_dump)):
assert abs(timestamp - i / 3) < 1e-8
assert len(vertices) == 132 * 128 and len(indices) == 99822
assert indices[:6] == (0, 132, 1, 1, 132, 133)
assert vertices[0][0] < 0 and vertices[0][1] < 0 and vertices[0][2] > 0
assert vertices[-1][0] > 0 and vertices[-1][1] > 0 and vertices[-1][2] > 0
if i < 2:
assert not all(map(math.isfinite, pose))
else:
assert all(map(math.isfinite, pose))
heights.append(pose[1])
far += sum(sum(c * c for c in vertex) > 16 for vertex in vertices)
assert .89 <= min(heights) <= .92 and 1.68 <= max(heights) <= 1.71
assert far > 1000
def test_depth_smoothing_reduces_local_noise_without_changing_grid():
depth = [1 + ((x * 31 + y * 17) % 7 - 3) * .04 for y in range(128) for x in range(132)]
filtered = synth.smooth_depths(depth, 132, 128)
assert len(filtered) == len(depth)
assert sum((v - 1) ** 2 for v in filtered) < sum((v - 1) ** 2 for v in depth) * .1
assert synth.smooth_depths([2.0] * (132 * 128), 132, 128) == [2.0] * (132 * 128)
def test_roomview_style_scan_retains_room_and_furniture(scanner, roomview_dump, tmp_path):
report = run(scanner, roomview_dump, tmp_path)
assert report["frames"] == 30 and report["skipped_frames"] == 2
assert report["far_vertices"] > 1000
assert report["detected_walls"] == 4
assert abs(report["floor_y"]) < .05 and abs(report["ceiling_y"] - 2.6) < .05
records = room(tmp_path)
assert_walls(outline(records), synth.BOX_OUTLINE)
assert any(row[1] == "TABLE" and abs(float(row[4]) - .75) < .05 for row in records["volume"])
assert any(row[1] == "COUCH" for row in records["volume"])
mesh(tmp_path)
def test_depth_discontinuity_is_rejected_below_absolute_edge_limit(scanner, dumps, tmp_path):
data = dumps["one"].read_bytes()
vertices = (0, 0, 1, .1, 0, 1, 0, .1, 1.5)
frame = struct.pack("<d7fII", 0, 0, 1, 0, 0, 0, 0, 1, 3, 3)
frame += struct.pack("<9f3I", *vertices, 0, 1, 2)
path = tmp_path / "discontinuity.fprs"
path.write_bytes(data[:8] + frame + data[8:])
report = run(scanner, path, tmp_path / "out")
assert report["discontinuity_triangles"] >= 1
def test_small_ceiling_patch_does_not_override_default(scanner, dumps, tmp_path):
# A lone down-facing patch at shelf height (1.8 m, below a 2 m ceiling) is no ceiling. A patch at ceiling height
# is accepted on purpose: the owner's real ceiling (2026-10-16) was only ~0.1 m^2 of fused cells after looking
# up, less evidence than a 0.7 m patch seen ten times, so area or a share of the floor can't tell them apart.
data = dumps["down"].read_bytes()
width = 15
vertices = [(x * .05 - .35, .3, z * .05 - .35) for z in range(width) for x in range(width)]
indices = []
for z in range(width - 1):
for x in range(width - 1):
a = z * width + x
indices.extend((a, a + width, a + 1, a + 1, a + width, a + width + 1))
frame = struct.pack("<d7fII", 0, 0, 1.5, 0, 0, 0, 0, 1, len(vertices), len(indices))
frame += b"".join(struct.pack("<3f", *v) for v in vertices)
frame += struct.pack(f"<{len(indices)}I", *indices)
path = tmp_path / "weak-ceiling.fprs"
path.write_bytes(data + frame * 10)
report = run(scanner, path, tmp_path / "out", "--ceiling-default", "2.7")
assert "ceiling not seen" in report["warnings"]
assert abs(report["ceiling_y"] - report["floor_y"] - 2.7) < .001
+204
View File
@@ -0,0 +1,204 @@
"""Save backups on the PC (GitHub discussion #352): storage, retention, manifests, and the stream to/from the agent,
which runs here for real (a stand-in `podman` whose `unshare` just runs the command)."""
import json
import os
import subprocess
import sys
import tarfile
import time
from pathlib import Path
import pytest
from frameport.core import library
from frameport.install import saves
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
PKG = "com.example.game"
class _Chan:
def __init__(self, proc):
self.proc = proc
def shutdown_write(self):
if not self.proc.stdin.closed:
self.proc.stdin.close()
def recv_exit_status(self):
return self.proc.wait(30)
def close(self):
self.proc.kill()
class _Stream:
def __init__(self, f, chan):
self.f, self.channel = f, chan
def read(self, n=-1):
return self.f.read(n)
def write(self, data):
self.f.write(data)
self.f.flush()
class FakeFrame:
"""The agent run as a local process over pipes, like an SSH exec channel."""
def __init__(self, home: Path, env: dict):
self.home, self.env = home, env
self.client = self
def ensure_agent(self):
return str(AGENT)
def exec_command(self, cmd, timeout=None):
proc = subprocess.Popen(["sh", "-c", cmd], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=self.env)
chan = _Chan(proc)
return _Stream(proc.stdin, chan), _Stream(proc.stdout, chan), _Stream(proc.stderr, chan)
def agent(self, command, **args):
p = subprocess.run([sys.executable, str(AGENT), command], input=json.dumps(args), capture_output=True,
text=True, env=self.env)
reply = json.loads(p.stdout.strip().splitlines()[-1])
if not reply["ok"]:
raise RuntimeError(reply["error"])
return reply["result"]
@pytest.fixture
def frame(tmp_path, monkeypatch):
home = tmp_path / "frame-home"
anchor = home / "Applications/quest-frame" / PKG
(anchor / "lepton-app").mkdir(parents=True)
(anchor / "deployment.json").write_text(json.dumps({"package": PKG, "appid": 7, "version": "2.0"}))
internal = anchor / "lepton-data/internal" / PKG
external = anchor / "lepton-data/external/Android/data" / PKG
for path, data in ((internal / "shared_prefs/p.xml", "<map/>"), (internal / "files/slot.sav", "level 3"),
(external / "files/save.dat", "coins 10"), (external / "cache/x", "cache")):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(data)
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
podman = bin_dir / "podman"
podman.write_text('#!/bin/sh\nif [ "$1" = unshare ]; then shift; exec "$@"; fi\n'
'if [ "$1" = ps ]; then cat "$FAKE_PS" 2>/dev/null; fi\nexit 0\n')
podman.chmod(0o755)
env = dict(os.environ, HOME=str(home), PATH=f"{bin_dir}:{os.environ['PATH']}", FAKE_PS=str(tmp_path / "ps"))
env.pop("FRAMEPORT_SAVES_NS", None)
return FakeFrame(home, env), anchor, internal, external, tmp_path
def test_backup_restore_round_trip_through_the_agent(frame):
fr, anchor, internal, external, tmp = frame
b = saves.backup(fr, PKG)
assert b.path.parent == saves.backups_dir(PKG) and b.path.exists() and b.sidecar.exists()
assert b.files == 3 and b.version == "2.0" and b.bytes == len("<map/>level 3coins 10")
m = saves.read_manifest(b.path)
assert {f["path"] for f in m["files"]} == {"internal/shared_prefs/p.xml", "internal/files/slot.sav",
"external/files/save.dat"}
assert saves.list_backups(PKG)[0].path == b.path
(external / "files/save.dat").write_text("coins 0")
r = saves.restore(fr, PKG, b.path)
assert (external / "files/save.dat").read_text() == "coins 10"
assert Path(r["rollback"]) == anchor / "saves-before-restore.tar.gz"
assert saves.info(fr, PKG)["rollback"]["size"] > 0
saves.undo_restore(fr, PKG)
assert (external / "files/save.dat").read_text() == "coins 0"
def test_backup_refused_while_the_game_runs(frame):
fr, anchor, internal, external, tmp = frame
(tmp / "ps").write_text("lepton-steamlaunch-7\n")
with pytest.raises(saves.SavesError, match="running"):
saves.backup(fr, PKG)
assert not list(saves.backups_dir(PKG).glob("*.part")) and not saves.list_backups(PKG)
with pytest.raises(saves.SavesError, match="running"):
saves.restore(fr, PKG, _fake_backup(PKG))
def _fake_backup(pkg: str, created: float | None = None, name: str | None = None) -> Path:
import io
d = saves.backups_dir(pkg)
d.mkdir(parents=True, exist_ok=True)
path = d / (name or time.strftime("%Y%m%d-%H%M%S.tar.gz"))
raw = json.dumps({"format": 1, "package": pkg, "created": created or time.time(), "files": []}).encode()
with tarfile.open(path, "w:gz") as tar:
ti = tarfile.TarInfo(saves.MANIFEST)
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
return path
def test_retention_keeps_the_newest(tmp_path):
for i in range(6):
_fake_backup(PKG, created=1000 + i, name=f"2026010{i}-120000.tar.gz")
assert len(saves.list_backups(PKG)) == 6 # sidecars rebuilt from the archives
assert all(p.with_suffix("").with_suffix(".json").exists() for p in saves.backups_dir(PKG).glob("*.tar.gz"))
gone = saves.prune(PKG, keep=4)
assert sorted(p.name for p in gone) == ["20260100-120000.tar.gz", "20260101-120000.tar.gz"]
left = saves.list_backups(PKG)
assert [b.created for b in left] == [1005, 1004, 1003, 1002]
assert not (saves.backups_dir(PKG) / "20260100-120000.json").exists()
def test_keep_setting(tmp_path):
assert saves.keep_count() == saves.KEEP_DEFAULT
library.set_setting("saves.keep", 3)
assert saves.keep_count() == 3
library.set_setting("saves.keep", "junk")
assert saves.keep_count() == saves.KEEP_DEFAULT
def test_restore_refuses_a_foreign_or_broken_file_before_touching_the_frame(tmp_path):
other = _fake_backup("com.other.game")
class NoFrame:
def ensure_agent(self):
raise AssertionError("the Frame must not be contacted")
with pytest.raises(saves.SavesError, match="com.other.game"):
saves.restore(NoFrame(), PKG, other)
junk = tmp_path / "junk.tar.gz"
junk.write_bytes(b"nope")
with pytest.raises(ValueError):
saves.restore(NoFrame(), PKG, junk)
def test_cli_backup_list_restore_undo(frame, monkeypatch):
from types import SimpleNamespace
from typer.testing import CliRunner
from frameport import cli
fr, anchor, internal, external, tmp = frame
target = SimpleNamespace(frame=fr, installed=lambda: [{"package": PKG}, {"package": "rift.x", "kind": "pcvr"}])
monkeypatch.setattr(cli, "_target", lambda frame=None, password=None, to="frame": target)
run = CliRunner().invoke
r = run(cli.app, ["saves", "backup", "--all"])
assert r.exit_code == 0, r.output
assert f"{PKG}: 3 file(s)" in r.output and "rift.x" not in r.output
r = run(cli.app, ["saves", "list", PKG])
assert r.exit_code == 0 and r.output.lstrip().startswith("1 ")
(external / "files/save.dat").write_text("lost")
r = run(cli.app, ["saves", "restore", PKG, "1", "--yes"])
assert r.exit_code == 0, r.output
assert (external / "files/save.dat").read_text() == "coins 10"
r = run(cli.app, ["saves", "undo", PKG])
assert r.exit_code == 0, r.output
assert (external / "files/save.dat").read_text() == "lost"
r = run(cli.app, ["saves", "restore", PKG, "7", "--yes"])
assert r.exit_code == 2 # no 7th backup
(tmp / "ps").write_text("lepton-steamlaunch-7\n")
r = run(cli.app, ["saves", "backup", PKG])
assert r.exit_code == 1 and "running" in r.output
def test_bad_package_names_never_make_paths():
with pytest.raises(ValueError):
saves.backups_dir("../../etc")
assert saves.has_backups("../x") is False
+323
View File
@@ -0,0 +1,323 @@
"""Save backups on the Frame (GitHub discussion #352): the agent's archive builder, the untrusted-archive checks on
restore, refusing while the game runs, and the rollback copy. podman unshare is switched off (files are ours here)."""
import importlib.util
import io
import json
import os
import tarfile
from pathlib import Path
import pytest
AGENT = Path(__file__).resolve().parents[1] / "agent" / "frameport_agent.py"
PKG = "com.example.game"
def load_agent(monkeypatch, tmp_path):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("FRAMEPORT_SAVES_NS", raising=False)
spec = importlib.util.spec_from_file_location("frameport_agent_saves", AGENT)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
monkeypatch.setattr(mod, "as_owner", lambda: [])
monkeypatch.setattr(mod, "container_running", lambda appid: False)
return mod
def write(path: Path, data, mode=0o660):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data if isinstance(data, bytes) else data.encode())
os.chmod(path, mode)
return path
@pytest.fixture
def game(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
anchor = Path(a.ANCHORS) / PKG
anchor.mkdir(parents=True)
(anchor / "lepton-app").mkdir()
(anchor / "deployment.json").write_text(json.dumps({"package": PKG, "appid": 123, "version": "1.2.3",
"title": "Example"}))
internal = anchor / "lepton-data/internal" / PKG
external = anchor / "lepton-data/external/Android/data" / PKG
write(internal / "shared_prefs/prefs.xml", "<map><int name='level' value='7'/></map>")
write(internal / "files/UnrealGame/G/G/Saved/SaveGames/slot1.sav", b"\x01" * 100)
write(internal / "files/UnrealGame/G/G/Saved/Logs/G.log", "log")
write(internal / "cache/tmp.bin", b"x" * 10)
write(internal / "code_cache/x", b"x")
write(internal / "databases/game.db", b"db")
write(external / "files/SaveData.es3", "save")
write(external / "files/fb_snap_0.ppm", b"P6")
write(external / "files/framebridge.conf", "scale=1")
write(external / "files/il2cpp/Metadata/global-metadata.dat", b"m" * 50)
write(external / "files/VulkanProgramBinaryCache/x.bin", b"v")
write(external / "files/Video/intro.mp4", b"vid")
write(external / "files/big.bundle", b"b" * 2000)
write(external / "cache/ga.sqlite3", b"g")
os.symlink("/etc", external / "files/escape")
return a, anchor, internal, external
def backup_bytes(a, max_file=1000):
out, err = io.BytesIO(), io.StringIO()
assert a.saves_backup_session({"package": PKG, "max_file": max_file}, out, err) == 0, err.getvalue()
reply = json.loads(err.getvalue().strip().splitlines()[-1])
assert reply["ok"]
return out.getvalue(), reply["result"]
def names(data: bytes) -> list[str]:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
return tar.getnames()
def manifest_of(data: bytes) -> dict:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
return json.loads(tar.extractfile("frameport-saves.json").read())
def test_backup_holds_saves_and_leaves_caches_out(game):
a, anchor, internal, external = game
data, summary = backup_bytes(a)
got = names(data)
assert got[-1] == "frameport-saves.json"
files = {n for n in got if not n.endswith(".json")}
assert "internal/shared_prefs/prefs.xml" in files
assert "internal/files/UnrealGame/G/G/Saved/SaveGames/slot1.sav" in files
assert "internal/databases/game.db" in files
assert "external/files/SaveData.es3" in files
for left_out in ("internal/cache/tmp.bin", "internal/code_cache/x",
"internal/files/UnrealGame/G/G/Saved/Logs/G.log",
"external/files/fb_snap_0.ppm", "external/files/framebridge.conf",
"external/files/il2cpp/Metadata/global-metadata.dat",
"external/files/VulkanProgramBinaryCache/x.bin", "external/files/Video/intro.mp4",
"external/files/big.bundle", "external/cache/ga.sqlite3", "external/files/escape"):
assert left_out not in files, left_out
m = manifest_of(data)
assert m["package"] == PKG and m["version"] == "1.2.3" and m["format"] == 1
by_path = {f["path"]: f for f in m["files"]}
assert by_path["internal/files/UnrealGame/G/G/Saved/SaveGames/slot1.sav"]["size"] == 100
assert len(by_path["external/files/SaveData.es3"]["sha256"]) == 64
assert by_path["internal/shared_prefs/prefs.xml"]["mode"] == 0o660
reasons = {s["path"]: s["reason"] for s in m["skipped"]}
assert reasons["external/files/big.bundle"] == "size" and reasons["external/files/escape"] == "link"
assert summary["files"] == len(m["files"])
assert [s["path"] for s in summary["skipped"]] == ["external/files/big.bundle"]
def test_backup_refused_while_the_game_runs(game, monkeypatch):
a, *_ = game
monkeypatch.setattr(a, "container_running", lambda appid: True)
out, err = io.BytesIO(), io.StringIO()
assert a.saves_backup_session({"package": PKG}, out, err) == 1
assert out.getvalue() == b"" and "running" in json.loads(err.getvalue())["error"]
def test_backup_and_restore_refused_while_the_launcher_holds_its_lock(game):
import fcntl
a, anchor, *_ = game
fd = os.open(anchor / ".launch.lock", os.O_RDWR | os.O_CREAT)
fcntl.flock(fd, fcntl.LOCK_EX)
try:
err = io.StringIO()
assert a.saves_backup_session({"package": PKG}, io.BytesIO(), err) == 1
assert "running" in err.getvalue()
out = io.StringIO()
assert a.saves_restore_session({"package": PKG}, io.BytesIO(b""), out) == 1
assert "running" in out.getvalue()
finally:
os.close(fd)
def test_backup_refuses_pc_vr_and_unknown_games(game):
a, anchor, *_ = game
err = io.StringIO()
assert a.saves_backup_session({"package": "com.not.installed"}, io.BytesIO(), err) == 1
assert "not installed" in err.getvalue()
err = io.StringIO()
assert a.saves_backup_session({"package": "../etc"}, io.BytesIO(), err) == 1
def restore(a, data: bytes, **extra) -> dict:
out = io.StringIO()
a.saves_restore_session({"package": PKG, **extra}, io.BytesIO(data), out)
return json.loads(out.getvalue().strip().splitlines()[-1])
def test_restore_round_trip_keeps_modes_and_makes_a_rollback_copy(game):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
save = external / "files/SaveData.es3"
prefs = internal / "shared_prefs/prefs.xml"
os.chmod(prefs, 0o600) # the Frame's current file keeps its own mode
save.write_text("newer progress")
write(internal / "files/new_slot.sav", "made after the backup")
big = external / "files/big.bundle"
reply = restore(a, data)
assert reply["ok"], reply
r = reply["result"]
assert save.read_text() == "save"
assert not (internal / "files/new_slot.sav").exists() # replaced, not merged
assert big.exists() and (internal / "cache/tmp.bin").exists() # left-out files stay
assert (prefs.stat().st_mode & 0o777) == 0o600
assert r["restored"] >= 4 and r["removed"] == 1
rollback = anchor / "saves-before-restore.tar.gz"
assert Path(r["rollback"]) == rollback and rollback.exists()
assert "internal/files/new_slot.sav" in names(rollback.read_bytes())
assert not list(anchor.glob(".saves-restore-*")) # staging cleaned up
# undo: the rollback copy goes back, and the restored state becomes the new rollback copy
reply = restore(a, b"", rollback=True)
assert reply["ok"], reply
assert save.read_text() == "newer progress" and (internal / "files/new_slot.sav").exists()
assert "internal/files/new_slot.sav" not in names(rollback.read_bytes())
def test_restore_onto_a_fresh_install_creates_the_folders(game):
import shutil
a, anchor, internal, external = game
data, _ = backup_bytes(a)
shutil.rmtree(anchor / "lepton-data")
reply = restore(a, data)
assert reply["ok"], reply
assert (internal / "files/UnrealGame/G/G/Saved/SaveGames/slot1.sav").read_bytes() == b"\x01" * 100
assert (external / "files/SaveData.es3").read_text() == "save"
assert internal.is_dir() and not (external / "cache").exists()
def evil_archive(members: list[tuple[tarfile.TarInfo, bytes | None]], manifest: dict | None = None) -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for ti, data in members:
tar.addfile(ti, io.BytesIO(data) if data is not None else None)
if manifest is not None:
raw = json.dumps(manifest).encode()
ti = tarfile.TarInfo("frameport-saves.json")
ti.size = len(raw)
tar.addfile(ti, io.BytesIO(raw))
return buf.getvalue()
def reg(name: str, data: bytes) -> tuple[tarfile.TarInfo, bytes]:
ti = tarfile.TarInfo(name)
ti.size = len(data)
return ti, data
def good_manifest(*files: tuple[str, bytes]) -> dict:
import hashlib
return {"format": 1, "package": PKG, "files": [{"path": n, "size": len(d), "sha256": hashlib.sha256(d).hexdigest()}
for n, d in files]}
@pytest.mark.parametrize("name", ["../../../etc/passwd", "/etc/passwd", "internal/../../x", "other/file", "internal",
"internal\\..\\x"])
def test_restore_refuses_paths_outside_the_save_folders(game, name):
a, anchor, *_ = game
data = evil_archive([reg(name, b"x")], good_manifest((name, b"x")))
reply = restore(a, data)
assert not reply["ok"]
assert not (anchor / "saves-before-restore.tar.gz").exists() # nothing touched
@pytest.mark.parametrize("kind", [tarfile.SYMTYPE, tarfile.LNKTYPE, tarfile.CHRTYPE, tarfile.FIFOTYPE])
def test_restore_refuses_links_and_devices(game, kind):
a, *_ = game
ti = tarfile.TarInfo("internal/files/x")
ti.type, ti.linkname = kind, "/etc/passwd"
reply = restore(a, evil_archive([(ti, None)], good_manifest()))
assert not reply["ok"] and "link or special" in reply["error"]
def test_restore_refuses_writing_through_a_link_on_the_frame(game):
a, anchor, internal, external = game
os.symlink(str(anchor.parent), internal / "lib")
data = evil_archive([reg("internal/lib/evil.txt", b"x")], good_manifest(("internal/lib/evil.txt", b"x")))
reply = restore(a, data)
assert not reply["ok"] and "link" in reply["error"]
assert not (anchor.parent / "evil.txt").exists()
def test_restore_refuses_damaged_foreign_or_manifestless_archives(game):
a, *_ = game
good = ("internal/files/s.sav", b"save")
assert "no manifest" in restore(a, evil_archive([reg(*good)]))["error"]
bad_hash = good_manifest(good)
bad_hash["files"][0]["sha256"] = "0" * 64
assert "doesn't match" in restore(a, evil_archive([reg(*good)], bad_hash))["error"]
other = good_manifest(good)
other["package"] = "com.other.game"
assert "com.other.game" in restore(a, evil_archive([reg(*good)], other))["error"]
assert not restore(a, b"not a tar at all")["ok"]
whole, _ = backup_bytes(a)
assert not restore(a, whole[: len(whole) // 2])["ok"] # cut off mid-stream
def test_restore_refuses_oversize_members(game, monkeypatch):
a, *_ = game
monkeypatch.setattr(a, "SAVES_RESTORE_MAX_FILE", 3)
good = ("internal/files/s.sav", b"save")
reply = restore(a, evil_archive([reg(*good)], good_manifest(good)))
assert not reply["ok"] and "too big" in reply["error"]
def test_restore_refused_while_running_leaves_everything(game, monkeypatch):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
monkeypatch.setattr(a, "container_running", lambda appid: True)
reply = restore(a, data)
assert not reply["ok"] and "running" in reply["error"]
assert not (anchor / "saves-before-restore.tar.gz").exists()
def test_failed_swap_puts_the_old_saves_back(game, monkeypatch):
a, anchor, internal, external = game
data, _ = backup_bytes(a)
save = external / "files/SaveData.es3"
save.write_text("current")
real = a.apply_staged_saves
calls = []
def flaky(roots, entries, manifest):
calls.append(1)
if len(calls) == 1:
real(roots, {k: e for k, e in entries.items() if e["kind"] == "dir" or "SaveData" in k[-1]}, manifest)
raise OSError("disk full")
return real(roots, entries, manifest)
monkeypatch.setattr(a, "apply_staged_saves", flaky)
reply = restore(a, data)
assert not reply["ok"] and "put back" in reply["error"]
assert save.read_text() == "current"
def test_saves_info_and_rollback_delete(game):
a, anchor, *_ = game
info = a.cmd_saves_info({"package": PKG, "max_file": 1000})
assert info["files"] >= 4 and info["rollback"] is None and info["running"] is False
assert [s["path"] for s in info["skipped"]] == ["external/files/big.bundle"]
data, _ = backup_bytes(a)
assert restore(a, data)["ok"]
assert a.cmd_saves_info({"package": PKG})["rollback"]["size"] > 0
assert a.cmd_saves_rollback_delete({"package": PKG}) == {"removed": True}
assert a.cmd_saves_info({"package": PKG})["rollback"] is None
def test_namespace_handoff_runs_the_agent_in_podman_unshare(game, monkeypatch):
a, *_ = game
seen = {}
class Done:
returncode = 0
def fake_run(cmd, env=None, **kw):
seen["cmd"], seen["ns"] = cmd, env.get("FRAMEPORT_SAVES_NS")
return Done()
monkeypatch.setattr(a, "as_owner", lambda: ["podman", "unshare"])
monkeypatch.setattr(a.subprocess, "run", fake_run)
assert a.saves_backup_session({"package": PKG}, io.BytesIO(), io.StringIO()) == 0
assert seen["cmd"][:2] == ["podman", "unshare"] and seen["cmd"][3:5] == [str(AGENT), "_saves_ns"]
assert seen["cmd"][5] == "backup" and json.loads(seen["cmd"][6]) == {"package": PKG}
assert seen["ns"] == "1"
-226
View File
@@ -1,226 +0,0 @@
"""Offline host tests of the real FrameBridge scene implementation and on-disk room contract."""
import os
import shutil
import struct
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
def uuid(n):
return f"{n:032x}"
@pytest.fixture(scope="module")
def harness(tmp_path_factory):
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
if not cc or os.name == "nt" or not Path("/proc/self/cmdline").exists():
pytest.skip("needs a POSIX host C compiler and /proc")
inc = next((p for p in sorted((ROOT / "native/.cache").glob("openxr-*"))
if "#define XR_META_spatial_entity_discovery 1" in (p / "openxr/openxr.h").read_text()), None)
if inc is None:
pytest.skip("needs the cached OpenXR headers with META discovery (native/build.py)")
d = tmp_path_factory.mktemp("scene-host")
binary = d / "scene-test"
external = d / "external"
external.mkdir()
subprocess.run([cc, "-std=gnu11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function",
f'-DSCENE_EXTERNAL_ROOT="{external}"', "-I", str(inc), "-I", str(ROOT / "native/adapter"),
str(ROOT / "tests/native/scene_emu_test.c"), "-pthread", "-lm", "-o", str(binary)], check=True)
return binary, external
def run(harness, folder, mode, *args, process="org.frameport.test"):
binary, _ = harness
env = {**os.environ, "FRAMEBRIDGE_CONFIG": str(folder / "settings.conf")}
result = subprocess.run([process, mode, *map(str, args)], executable=binary, capture_output=True, text=True, env=env)
assert result.returncode == 0, result.stdout + result.stderr
return result.stderr
def room(folder, *, generated_walls=False):
points = [(0, 0), (4, 0), (4, 3), (2, 4), (0, 3)]
lines = ["frameport-room 1 # format", "source scan", "universe 1234", f"room {uuid(1)}",
f"floor {uuid(2)} 0", f"ceiling {uuid(3)} 2.5",
"outline 5 " + " ".join(f"{x} {z}" for x, z in points),
# Interleave furniture and walls to test layout ordering and handle assignment.
f"volume {uuid(40)} TABLE,OTHER 2 1 0.75 90 2 1 0.75",
f"plane {uuid(41)} WINDOW_FRAME 1 1.5 0 0 0 0 1 0.8 1.2"]
if not generated_walls:
for i, ((x, z), (xx, zz)) in enumerate(zip(points, points[1:] + points[:1])):
lines.append(f"wall {uuid(10 + i)} {x} {z} {xx} {zz} 0 2.5")
lines += [f"mesh {uuid(42)} room-mesh.bin 4 6", "future-data ignored"]
(folder / "room.txt").write_text("\n".join(lines) + "\n")
verts = [(0, 0, 0), (4, 0, 0), (4, 0, 3), (0, 0, 3)]
mesh = b"FPRM" + struct.pack("<III", 1, 4, 6)
mesh += b"".join(struct.pack("<fff", *v) for v in verts) + struct.pack("<6I", 0, 1, 2, 0, 2, 3)
(folder / "room-mesh.bin").write_bytes(mesh)
def test_room_geometry_components_queries_and_mesh(harness, tmp_path):
room(tmp_path)
log = run(harness, tmp_path, "room", 5, 1)
assert "scene: room from scan file (5 walls, 1 volumes, 1 planes, 2 mesh triangles, universe 1234)" in log
assert "scene: capture requested -> scanned room" in log
def test_no_global_mesh_without_the_mesh_extension(harness, tmp_path, monkeypatch):
# an app that didn't enable XR_META_spatial_entity_mesh can't place a GLOBAL_MESH anchor (older SDKs then failed
# the whole scene load): the room comes without it
room(tmp_path)
monkeypatch.setenv("SCENE_NO_MESH_EXT", "1")
log = run(harness, tmp_path, "room", 5, 0)
assert "scene: room from scan file (5 walls, 1 volumes, 1 planes, 0 mesh triangles, universe 1234)" in log
def test_wall_label_field(harness, tmp_path):
# optional 8th field: INVISIBLE_WALL_FACE for edges the scanner closed itself; other labels are refused
room(tmp_path)
text = (tmp_path / "room.txt").read_text()
last = f"wall {uuid(14)} 0 3 0 0 0 2.5"
assert last in text
(tmp_path / "room.txt").write_text(text.replace(last, last + " INVISIBLE_WALL_FACE")
+ f"wall {uuid(70)} 0 0 1 0 0 2 TABLE\n")
log = run(harness, tmp_path, "room", 5, 1)
assert "scene: room from scan file (5 walls" in log and log.count("bad room line") == 1
def test_walls_derived_from_outline(harness, tmp_path):
room(tmp_path, generated_walls=True)
run(harness, tmp_path, "room", 5, 1)
def test_bad_lines_are_skipped_without_losing_valid_geometry(harness, tmp_path):
room(tmp_path)
with (tmp_path / "room.txt").open("a") as f:
f.write(f"volume {uuid(60)} TABLE nan 0 1 0 1 1 1\n")
f.write(f"wall {uuid(61)} 0 0 0 0 0 3\n")
f.write(f"plane {uuid(62)} WALL_ART 0 0 0 0 0 0 0 1 1\n")
f.write("outline 5 99 99 invalid 0 0 0 0 0 0 0\n")
f.write("universe -1\n" + "outline " + "x" * 70000 + "\n")
f.write(f"volume {uuid(40)} DUPLICATE 0 0 1 0 1 1 1\n")
log = run(harness, tmp_path, "room", 5, 1)
assert log.count("bad room line") == 7
@pytest.mark.parametrize("damage", ["magic", "version", "truncated", "counts", "index", "nonfinite", "trailing"])
def test_rejects_corrupt_mesh_without_losing_room(harness, tmp_path, damage):
room(tmp_path)
p = tmp_path / "room-mesh.bin"
data = bytearray(p.read_bytes())
if damage == "magic":
data[:4] = b"NOPE"
elif damage == "version":
data[4:8] = struct.pack("<I", 2)
elif damage == "truncated":
data = data[:-1]
elif damage == "counts":
data[8:12] = struct.pack("<I", 0xFFFFFFFF)
elif damage == "index":
data[-4:] = struct.pack("<I", 4)
elif damage == "nonfinite":
data[16:20] = struct.pack("<f", float("nan"))
else:
data += b"junk"
p.write_bytes(data)
log = run(harness, tmp_path, "room", 5, 0)
assert "bad room line" in log and "0 mesh triangles" in log
@pytest.mark.parametrize("record", [f"mesh {uuid(80)} ../escape.bin 4 6", f"mesh {uuid(80)} room-mesh.bin 600001 600000",
f"mesh {uuid(80)} room-mesh.bin 4 7"])
def test_mesh_caps_and_path_confinement(harness, tmp_path, record):
room(tmp_path)
p = tmp_path / "room.txt"
p.write_text("\n".join(record if x.startswith("mesh ") else x for x in p.read_text().splitlines()) + "\n")
run(harness, tmp_path, "room", 5, 0)
@pytest.mark.parametrize("text", [None, "", "frameport-room 2\n", "bad 1\n", "# comment before magic\nframeport-room 1\n",
"frameport-room 1\noutline 3 0 0 1 1 2 2\n"])
def test_box_fallback_unchanged(harness, tmp_path, text):
if text is not None:
(tmp_path / "room.txt").write_text(text)
log = run(harness, tmp_path, "box")
assert "scene: room 1.50 x 1.50 x 2.50 m (from guardian)" in log
assert "scene: capture requested -> guardian box" in log
def test_box_size_settings(harness, tmp_path):
assert "scene: room 3.00 x 4.00 x 2.70 m" in run(harness, tmp_path, "override")
def test_fb_and_meta_anchors_persist_across_sessions_and_processes(harness, tmp_path):
_, external = harness
process = "org.frameport.persist"
folder = external / process / "files"
folder.mkdir(parents=True)
run(harness, tmp_path, "save", process=process + ":game")
p = folder / "framebridge-anchors.txt"
data = p.read_text()
lines = data.splitlines()
assert len(lines) == 2 and len({x.split()[1] for x in lines}) == 2
assert all(len(x.split()) == 9 and x.startswith("anchor ") for x in lines)
assert not list(folder.glob("*.tmp-*"))
assert "loaded 2 saved anchors" in run(harness, tmp_path, "reload", process=process)
assert p.read_text() == data
run(harness, tmp_path, "erase-failure", process=process)
assert p.read_text() == data and not list(folder.glob("*.tmp-*"))
run(harness, tmp_path, "erase", process=process)
assert p.read_text() == ""
run(harness, tmp_path, "empty", process=process)
def test_failed_anchor_write_is_reported_and_rolled_back(harness, tmp_path):
# No external files directory: the accepted async call reports IO failure in its completion event.
run(harness, tmp_path, "save-failure", process="org.frameport.missing")
def test_missing_outline_and_ceiling_are_derived(harness, tmp_path):
points = [(0, 0), (4, 0), (4, 3), (0, 3)]
lines = ["frameport-room 1", f"room {uuid(1)}", f"floor {uuid(2)} 1"]
for i, ((x, z), (xx, zz)) in enumerate(zip(points, points[1:] + points[:1])):
lines.append(f"wall {uuid(10 + i)} {x} {z} {xx} {zz} 1 4")
(tmp_path / "room.txt").write_text("\n".join(lines) + "\n")
run(harness, tmp_path, "derived")
def test_hundreds_of_walls_have_distinct_default_uuids(harness, tmp_path):
# Missing walls are generated from all outline edges, including more than the old 64-entity cap.
import math
points = [(5 * math.cos(i * math.tau / 300), 5 * math.sin(i * math.tau / 300)) for i in range(300)]
text = "frameport-room 1\noutline 300 " + " ".join(f"{x} {z}" for x, z in points) + "\n"
(tmp_path / "room.txt").write_text(text)
run(harness, tmp_path, "many")
def test_real_shim_bypasses_a_dispatcher_that_refuses_scene_calls(harness, tmp_path):
import re
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
inc = next(p for p in (ROOT / "native/.cache").glob("openxr-*")
if "#define XR_META_spatial_entity_discovery 1" in (p / "openxr/openxr.h").read_text())
native = ROOT / "tests/native"
common = [cc, "-std=gnu11", "-O2", "-I", str(inc)]
subprocess.run([*common, "-shared", "-fPIC", "-fvisibility=hidden", "-DSCENE_SHIM_TEST",
"-I", str(ROOT / "native/adapter"), str(native / "scene_emu_test.c"), "-pthread", "-lm",
"-o", str(tmp_path / "libopenxr_loader_generic.so")], check=True)
subprocess.run([*common, "-shared", "-fPIC", "-DFAKE_DISPATCH", str(native / "scene_shim_test.c"),
"-o", str(tmp_path / "libopenxr_loader.so")], check=True)
shim = tmp_path / "libframe_xrshim.so"
subprocess.run([*common, "-shared", "-fPIC", "-fvisibility=hidden", "-I", str(ROOT / "tests/fixtures/src/host_include"),
str(ROOT / "native/xrshim/xrshim.c"), "-ldl", "-pthread", "-lm", "-o", str(shim)], check=True)
binary = tmp_path / "test-shim"
subprocess.run([*common, str(native / "scene_shim_test.c"), "-ldl", "-o", str(binary)], check=True)
# Exercise every name the emulation serves, including future additions to its dispatch table.
names = re.findall(r'\{"(xr\w+)",', (ROOT / "native/adapter/scene_emu.c").read_text())
result = subprocess.run([str(binary), str(shim), *names], capture_output=True, text=True,
env={**os.environ, "LD_LIBRARY_PATH": str(tmp_path),
"FRAMEBRIDGE_CONFIG": str(tmp_path / "settings.conf")})
assert result.returncode == 0, result.stdout + result.stderr
for name in names:
assert f"extension shim: {name} -> FrameBridge" in result.stderr
-70
View File
@@ -1,70 +0,0 @@
from types import SimpleNamespace
import flet as ft
from frameport.mr.room import box_room, dumps, summary
from frameport.ui.views.frame import FrameView, room_state
def walk(control):
yield control
for child in control._get_children() if hasattr(control, "_get_children") else []:
yield from walk(child)
def fake_app(answer):
pending, refreshes, toasts = [], [], []
target = SimpleNamespace(frame=SimpleNamespace(agent=lambda command: answer))
return SimpleNamespace(target=target, frame_state="connected", run_bg=lambda fn: pending.append(fn),
refresh_view=lambda: refreshes.append(1), pending=pending, refreshes=refreshes,
toast=lambda message, **kw: toasts.append(message), toasts=toasts)
def test_room_status_loads_off_render_path_and_caches(tmp_path):
from frameport.core.paths import user_data_dir
room = box_room(4, 3)
app = fake_app(dict(room_text=dumps(room), room=dict(source="scan", summary=summary(room)),
aligned=True, scanner_installed=True, default_circle=False))
state = room_state(app)
assert state["loading"] and len(app.pending) == 1
# Building the card while loading does not write a preview or query SSH.
assert FrameView(app).room_card()
assert not (user_data_dir() / "room-previews").exists()
app.pending.pop()()
assert state["preview"].endswith(".svg")
assert (user_data_dir() / state["preview"]).exists()
assert len(app.refreshes) == 1
assert FrameView(app).room_card()
room_state(app)
assert not app.pending
room_state(app, force=True)
assert len(app.pending) == 1
def test_room_status_error_and_reconnection():
app = fake_app({})
def fail(command):
raise RuntimeError("query failed")
app.target.frame.agent = fail
state = room_state(app)
app.pending.pop()()
assert "query failed" in state["error"]
assert FrameView(app).room_card()
app.frame_state = "disconnected"
assert room_state(app)["target"] is None
app.frame_state = "connected"
assert room_state(app) is not state
def test_card_shows_preview_and_default_circle_hint():
app = fake_app({})
app._room_state = dict(target=app.target, status=dict(default_circle=True, room=None),
preview=None, error=None, loading=False, time=float("inf"))
control = FrameView(app).room_card()
# Building with each documented state catches invalid Flet properties without a desktop or socket.
assert isinstance(control, ft.Control)
app._room_state["status"].update(default_circle=False, aligned=True, scanner_installed=True,
room=dict(source="boundary", summary=summary(box_room(3, 4))))
app._room_state["preview"] = "room-previews/example.svg"
assert FrameView(app).room_card()