Compare commits

...
Author SHA1 Message Date
spoopyghosty0 02c72f6707 MR: room entities offer SHARABLE like Meta's runtime; log refused scene components (Demeter rejected the room) 2026-10-16 14:58:19 -04:00
spoopyghosty0 e2ef06375e MR: rooms older Meta SDKs accept (labels as Meta's runtime maps them, GLOBAL_MESH only with the mesh extension), rectangular rooms from perpendicular walls, ceilings found after a short look up, 60 s minimum scan 2026-10-16 14:50:43 -04:00
spoopyghosty0 e9bda15a87 MR scan: record only while passthrough streams meshes (a leftover block started a scan), pause + prompt when it stops, refuse scans under 30 frames 2026-10-16 14:35:41 -04:00
spoopyghosty0 fcd8432184 MR scan prompts: starts by itself (no passthrough wait), ceiling then 'look back down', floor step, finish early once everything was seen 2026-10-16 14:33:52 -04:00
spoopyghosty0 73ad29c7c5 MR: agent and PC room parsers accept the optional wall label (scanner output was refused) 2026-10-16 11:54:15 -04:00
spoopyghosty0 dc06b1cb40 MR: scene emulation always brings the xrshim (OVRPort's dispatcher refuses scene calls; Pinball FX VR) 2026-10-16 11:34:01 -04:00
spoopyghosty0 9c41acee58 Ship robust room scanner and document capture performance 2026-10-16 11:24:47 -04:00
spoopyghosty0 ab53f3447f Fit stable room surfaces from smoothed depth fans 2026-10-16 11:24:46 -04:00
spoopyghosty0 229ceb9d8e Skip unusable tracking frames and reject inpainted depth 2026-10-16 11:24:45 -04:00
spoopyghosty0 4638a8bc61 Ship deterministic room scanner artifact and device validation notes 2026-10-16 11:24:44 -04:00
spoopyghosty0 487e0781a7 Validate room scanning geometry and harden bounded reconstruction 2026-10-16 11:24:42 -04:00
spoopyghosty0 ebb9255e59 Add offline room reconstruction and synthetic depth renderer 2026-10-16 11:24:40 -04:00
spoopyghosty0 578da5642e CLAUDE.md: mixed-reality room scanning notes 2026-10-16 11:02:05 -04:00
spoopyghosty0 ebc9017980 MR: scan the room in the headset - RoomView 3D capture with headset prompts, wall labels, agent 92
The agent records Valve's passthrough depth mesh (IVRBlockQueue + IVRPaths, Background/Overlay OpenVR app), converts
poses with the final raw->standing transform, shows pre-rendered prompts as a SteamVR overlay, then builds the room
with fp_roomscan (room_capture / room_capture_status). PC: rooms.scan, Frame page progress, frameport frame room scan.
FrameBridge reads the optional wall label (INVISIBLE_WALL_FACE). ROOM_FORMAT: outline orientation stated exactly.
2026-10-16 11:01:02 -04:00
spoopyghosty0 21534589ec Finish room launcher integration and document device validation 2026-10-16 10:48:31 -04:00
spoopyghosty0 ef37ea5b48 Diagnose missing MR rooms and unsupported scene discovery 2026-10-16 10:48:30 -04:00
spoopyghosty0 d9ae479977 Enable scene emulation for scene and spatial anchor games 2026-10-16 10:48:28 -04:00
spoopyghosty0 e90df7b278 Show room readiness and floor plans in the Frame UI 2026-10-16 10:48:26 -04:00
spoopyghosty0 25d138909d Add room CLI and reuse host helper synchronization 2026-10-16 10:48:25 -04:00
spoopyghosty0 34dfc6f508 Store rooms on the Frame and synchronize Quest launchers 2026-10-16 10:48:23 -04:00
spoopyghosty0 f3b8ece9a6 Add portable room format validation and floor plan previews 2026-10-16 10:48:22 -04:00
spoopyghosty0 631e4bca5a Document scene implementation validation and exact device handoff 2026-10-16 10:47:50 -04:00
spoopyghosty0 2c50578f2f Rebuild FrameBridge scene emulation artifacts deterministically 2026-10-16 10:47:49 -04:00
spoopyghosty0 ce8b7b1e02 Verify shim routing and protect erased or stale scene handles 2026-10-16 10:47:48 -04:00
spoopyghosty0 dac01dfccf Test room geometry discovery persistence and malformed data on the host 2026-10-16 10:47:46 -04:00
spoopyghosty0 aca93db6c9 Report scanned room or guardian box on scene capture requests 2026-10-16 10:47:45 -04:00
spoopyghosty0 f5a268853d Route all emulated scene APIs and fake space consumers through the shim 2026-10-16 10:47:44 -04:00
spoopyghosty0 f895a2f62a Persist random UUID application anchors across sessions 2026-10-16 10:47:43 -04:00
spoopyghosty0 53b79a4eda Add META discovery and persistence entry points and events 2026-10-16 10:47:42 -04:00
spoopyghosty0 0ec26ffd0b Expose room polygons volume bounds and triangle meshes 2026-10-16 10:47:40 -04:00
spoopyghosty0 cf98f95ed2 Load scanned STAGE room files and bounded mesh sidecars 2026-10-16 10:47:39 -04:00
spoopyghosty0 8cb27832be MR: optional wall label in room.txt; in-headset scan prompts (render_prompts.py) 2026-10-16 10:44:22 -04:00
spoopyghosty0 e4e1a42cde MR: Phase 0 findings - Valve's RoomView 3D mesh is readable through IVRBlockQueue/IVRPaths 2026-10-16 10:40:30 -04:00
spoopyghosty0 4bf48d3f51 MR: room file contract (room.txt, room-mesh.bin, scan dumps) 2026-10-16 10:20:51 -04:00
spoopyghosty0 98ddfa7157 authorized_keys: never glue FramePort's key onto a last line without a newline (#242)
bootstrap.sh and Frame.install_key appended with echo >> after an exact-line grep; a file whose last key had no
newline got FramePort's key glued onto it, which sshd never matches, so OpenSSH uploads were refused while Paramiko
logged in with the user's own key. Both now add the missing newline first (one shared install_key_command).
2026-10-16 10:12:17 -04:00
spoopyghosty0 91bc6af2b7 Catalog: SmartTube (issues; tested 32.56 on the Frame)
Closes #350
2026-10-16 10:02:44 -04:00
spoopyghosty0 04faa0be65 Catalog: The Climb (works)
Closes #341
2026-10-16 09:39:49 -04:00
spoopyghosty0 1399d7c5a3 Catalog: Attack on Titan VR: Unbreakable works with its asset files (was Freedom, issues)
Closes #337
2026-10-16 09:36:24 -04:00
spoopyghosty0 fa5c5b32ce Catalog: Sclerosis (works)
Closes #339
2026-10-16 09:33:54 -04:00
spoopyghosty0 0228516a2a Catalog: 7 community recipes (Breathedge VR, Cities: VR, Lawn Mowing Simulator VR, Exploding Kittens VR, Lost Recipes, Neko Atsume Purrfect, Real VR Fishing)
Closes #347
Closes #345
Closes #344
Closes #343
Closes #340
Closes #338
Closes #333
2026-10-16 09:31:12 -04:00
spoopyghosty0 960da4469b Announcements: a one-time pop-up in the app
Source of truth: announcements/current.md on main (front matter: id, title,
date, optional image/button/expires; Markdown body), read from raw GitHub every
6 h like the catalog, so a push reaches users without a release. Ships
switched off (active: false).

Shown only to people who used FramePort before the announcement's date, never
on a first start; any close (X, click outside, Escape) dismisses that id for
good, and a new announcement = a new id. Centred over the content area, right
of the sidebar. announcements/README.md explains the file;
ui_smoke.py --announcement renders it.
2026-10-16 09:23:46 -04:00
spoopyghosty0 e9720f83d2 Catalog: Crisis Zone VR (works)
Closes #331
2026-10-16 00:14:17 -04:00
spoopyghosty0 729f77a9e3 Docs: update screenshots and videos 2026-10-16 00:08:46 -04:00
spoopyghosty0 4824b30e94 Catalog: Vermillion works (confirmed in the headset, #200) 2026-10-15 23:54:32 -04:00
spoopyghosty0 39e1762e53 Game menu: Update with a newer version… for Linux apps and PC VR games (#161)
Follow-up to "Update with a newer APK…": Linux apps take their new AppImage/archive or folder (a file or a folder
picker, like the app was added), PC VR games their new game folder. pipeline.replace_linux_app / replace_rift_game
re-run the add path (add_linux_app(replace=) / add_rift_game(replace=)): the entry keeps its package (so the Steam
shortcut), name, artwork, tags, switches and the user's recipe; a folder keeps the program chosen before if it's still
there, else FramePort picks again (and asks, for ambiguous PC VR games). A name that looks like another app's is
allowed with a heads-up.

The entry gets a new source_id, recorded in the deployment's recipe at install (no agent change):
components.source_replaced shows the Frame copy (and a PC install, pc_outdated) as outdated until then. Such an
install also re-sends same-size files changed after the last install (installer.changed_since), since uploads
otherwise skip files the Frame has with the same size. The old PC VR folder is left out of later scans
(replaced_dirs). CLI: frameport new-version <game> <path> (APKs too). Docs: INSTALL.md.
2026-10-15 20:10:50 -04:00
spoopyghosty0 3d260dccb1 OpenSSH uploads log in like the Frame connection; SSH key files in Settings (#242)
A Frame that only authorizes the user's own key refused OpenSSH, which
offered FramePort's keys alone (IdentitiesOnly), so every upload fell back
to Paramiko. The connection now records which login worked (Frame.auth):
after a login with this PC's own keys/agent OpenSSH also offers private
copies of ~/.ssh/id_* (OpenSSH skips its defaults once -i is given) and the
agent; a refused strict login is retried that way once. FramePort doesn't
add its key to the Frame for this.

New: Settings > Installing > SSH keys (library ssh.extra_keys, CLI
frameport frame ssh-keys): extra private key files for the connection and
OpenSSH, checked with Paramiko (passphrase keys: use the agent). OpenSSH
runs with -v and a refused login's error lists the keys offered and its
warnings. whoami's SID is read from bytes (non-ASCII user names).
Checked with Windows ssh.exe/sftp.exe against the Frame.
2026-10-15 19:58:58 -04:00
spoopyghosty0 26a34ddadb OpenSSH uploads: don't escape glob characters inside sftp's quotes (#324)
sftp escapes * ? [ inside double quotes itself, so FramePort's extra
backslash became part of the path: a folder named "... [ENG] ..." failed
every OpenSSH upload (on Linux too) and fell back to Paramiko. On Windows
the local path now goes with forward slashes: Win32-OpenSSH turned every
backslash, escapes included, into '/' ("C://Users", "/[ENG]").
Checked with Windows sftp.exe and Linux sftp against the Frame (put and
reput, sha256 match).
2026-10-15 19:58:57 -04:00
117 changed files with 9408 additions and 306 deletions

No files matched your search

+41
View File
@@ -223,6 +223,13 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
store matches and links then only update `auto_title`; a locked name gets no twin suffix); `sync_title` → Frame
agent v77 `rename` (deployment.json title, same shortcut appid: upsert matches by Exe) + art + one Steam restart, PC
`PcReviveTarget.rename`; an unreachable Frame → `steam_name_stale` (game page "Update name on Frame", next install).
- Newer versions (GitHub #161): game menu "Update with a newer APK…" (`pipeline.replace_apk`, outdated via
`build.source_version`) and, for Linux apps / PC VR games, "Update with a newer version…" (`replace_linux_app` /
`replace_rift_game` via `add_linux_app(replace=)` / `add_rift_game(replace=)`, CLI `frameport new-version`): the
entry keeps package, name, art, tags, switches, user recipe (a confirmed exe if the new folder has it); a new
`source_id` goes into the deployment's `recipe` dict at install (no agent change) → `C.source_replaced` = outdated
(Frame + `pc_outdated`); such an install re-sends same-size files changed after the last install (mtime/ctime,
`installer.changed_since`); the old Rift folder is in `replaced_dirs` (rescans skip it). Untested on the device.
- `Recipe.as_is` = install unchanged (pre-patched libraries): `pipeline.prepare_as_is`; auto for APKs that already
contain FrameBridge (`frame_patched`). For Rift it changes nothing (the dump is never modified; the Frame copy
still gets launch fixes like the crash-reporter rename — `-nocrashreports` alone doesn't stop UE 4.23) — it does
@@ -267,6 +274,15 @@ Rick and Morty runs on the Frame via its catalog recipe (OpenVR, no Revive),
`working-config.yml` issue → maintainer label `catalog-accepted` (a plain issue needs `working-config` added by
hand) → `catalog-from-issue.yml` workflow (`scripts/catalog_from_issue.py` validates) opens a catalog PR. App log:
`core/applog.py` (`<data>/logs/app.log`, finished GUI jobs in `<data>/logs/jobs/`).
- Announcements (2026-10-15, announcements/README.md; app only, the owner dropped the site pop-up): source of
truth `announcements/current.md` on `main` (front matter id/title/date + optional image/image_alt/button+url/
expires/active, Markdown body; ships `active: false`). `announcements.py` (no Flet; raw GitHub via
`cache.cached_text`, 6 h) + `ui/announcer.py` (pop-up 20 s after start / every 6 h when no dialog is open and not
on welcome; `C.dialog` with the portal edge, image downloaded + Pillow-checked into `<data>/announcements/`, shown
by asset URL). Shown only if the user was around before `date` (setting `announce.since`, set once at the GUI's
first start: 0 when `USED_BEFORE` settings exist, else now), once: any close (X, outside click, Escape, button)
stores the id (`announce.dismissed`, last 50). New announcement = new id; same id = typo fix.
`FRAMEPORT_NO_ANNOUNCEMENTS`; `ui_smoke.py --announcement` renders it.
- Self-update (docs/ARCHITECTURE.md "Self-update", docs/INSTALL.md "Updating"): `_version.py` = the only version
(`frameport.__version__`; pyproject reads it via hatch `dynamic`; app log, diagnostics, User-Agent, Settings use
it — never `importlib.metadata`, bundles have no dist-info). `updates.py` (no Flet): `check()` = GitHub
@@ -804,6 +820,24 @@ 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).
@@ -904,6 +938,13 @@ hide_space_warp / vk_hide_fdm / no Valve layers; FNAF HW (#135) "freeze" = first
on Zink (threads busy in zink/disk cache), not a hang; VR4 (#199) reporter's patch.203 OBB truncated. Upload benchmark
(`scripts/upload_bench.py`, #242): home Wi-Fi caps every method at ~11 MB/s; paramiko uses ~3x OpenSSH's CPU; Windows
OpenSSH refuses FramePort's key file (ACL "bad permissions") until its permissions are tightened.
**OpenSSH uploads (2026-10-15, #324/#242):** sftp escapes `* ? [` inside double quotes itself (and undoes it for
put's destination), so `native_upload.quote` escapes only `\\`/`"`; Win32 sftp turns every local backslash into `/`
(escapes too) → local paths go as `C:/…`. Logins: `Frame.auth` = the attempt that worked (app_key / user_key = a key
file from Settings → Installing → SSH keys, library `ssh.extra_keys`, CLI `frame ssh-keys` / agent / password).
OpenSSH strict (FramePort + Settings keys, IdentitiesOnly) unless auth was "agent"; a refused strict login retries
relaxed (+ private copies of ~/.ssh/id_{rsa,dsa,ecdsa,ed25519}: OpenSSH skips its defaults once any -i is given, + the
agent); `-v` key lines go into the error. FramePort doesn't add its key to the Frame for this. Windows-tested.
**Meta user id (GitHub #182, 2026-10-11):** OVRPort's platform loader (runtimes 3.4.3-23204ea and -aa54c3f) answers
`ovr_GetLoggedInUserID` (and two other callers) without a Meta account with a made-up id from a helper (`stp; mov x29,
sp; bl random; …; mov/movk x9 = 0x53444835ec580000 (6e18); add x0, x8, x9; ldp; ret`), kept only in memory → a new id
+885 -3
View File
@@ -26,6 +26,7 @@ import base64
import glob
import hashlib
import json
import math
import os
import re
import shlex
@@ -44,7 +45,7 @@ try:
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
fcntl = None
AGENT_VERSION = 91
AGENT_VERSION = 92
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -3189,6 +3190,8 @@ def upgrade_launchers():
new = set_gamepad_line(new, wants_gamepad(os.path.dirname(path)))
new = set_config_sync_line(new, os.path.basename(os.path.dirname(path)),
wants_config_sync(os.path.dirname(path)))
new = set_room_sync_line(new, os.path.basename(os.path.dirname(path)),
wants_scene_room(os.path.dirname(path)))
pkg = os.path.basename(os.path.dirname(path))
base = launcher_base(new, pkg)
if base: # games on a drive whose path has a space reach Lepton through a link (agent v83)
@@ -3600,7 +3603,8 @@ def upgrade_linux_launcher(text):
return text
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False, config_sync=False):
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False,
config_sync=False, scene_room=False):
extra = "".join(f"export {k}={shlex.quote(str(v))}\n" for k, v in (env or {}).items()
if re.fullmatch(r"[A-Z_][A-Z0-9_]*", k))
text = LAUNCH_SH.format(title=title.replace("\n", " "), pkg=pkg, app_dir=app_dir_lines(base, pkg), appid=appid,
@@ -3611,6 +3615,7 @@ def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False,
plays_start=plays_lines(anchor)[0],
plays_end=plays_lines(anchor)[1], restart=restart_block(pkg, lepton))
text = set_config_sync_line(text, pkg, config_sync)
text = set_room_sync_line(text, pkg, scene_room)
path = os.path.join(anchor, "launch.sh")
with open(path + ".tmp", "w") as f:
f.write(text)
@@ -3921,6 +3926,869 @@ def set_flatscreen(app_dir, on):
os.remove(marker)
# ---------------------------------------------------------------- mixed-reality room storage (format v1)
# Standalone stdlib code: the PC validates the complete geometry; the agent also checks records and mesh sizes
# before making an uploaded room current. Never import the desktop package on the Frame.
ROOMS_DIR = os.path.join(HOME, ".local/share/frameport/rooms")
ROOM_SCANNER = os.path.join(HOME, ".local/share/frameport/bin/fp_roomscan")
ROOM_SYNC_LINE = ('python3 {agent_q} _room_sync {pkg_q} >"$app_dir/room-sync.log" 2>&1 9>&- || true '
'# frameport-room-sync')
def room_universe(value):
text = str(value)
if not re.fullmatch(r"[0-9]{1,20}", text) or int(text) >= 2**64:
raise AgentError("invalid chaperone universe")
return str(int(text))
def room_universe_from_log(text):
"""Last universe switch in vrserver.txt; 424242 is Valve's dummy universe (not a room)."""
found = None
for line in text.splitlines():
match = re.search(r"chaperone universe\s+(\d+)|Switch to\s+(?:.*?\s+)?universe\s*[:=]?\s*(\d+)", line, re.I)
if match:
found = int(match.group(1) or match.group(2))
return None if found is None else (0 if found == 424242 else int(room_universe(found)))
def room_chaperone():
try:
with open(os.path.join(HOME, ".config/openvr/config/chaperone_info.vrchap")) as f:
return json.load(f).get("universes") or []
except (OSError, ValueError, AttributeError):
return []
def room_current_universe():
try:
with open(os.path.join(STEAM, "logs/vrserver.txt"), "rb") as f:
f.seek(0, 2)
f.seek(max(0, f.tell() - 2 * 1024 * 1024))
found = room_universe_from_log(f.read().decode("utf-8", "replace"))
if found is not None:
return found
except (OSError, ValueError, AgentError):
pass
universes = [u for u in room_chaperone() if str(u.get("universeID", "0")) not in ("0", "424242")]
if not universes:
return 0
newest = max(enumerate(universes), key=lambda item: (str(item[1].get("time", "")), item[0]))[1]
try:
return int(room_universe(newest["universeID"]))
except (KeyError, AgentError):
return 0
def room_boundary(universes, current):
"""Chaperone collision quads' bottom edges, in standing/STAGE space, chained into an outline."""
import math
candidates = [u for u in universes if str(u.get("universeID", "0")) not in ("0", "424242")]
if not candidates:
raise AgentError("No play area found. Set up a play area in SteamVR first.")
u = next((u for u in candidates if str(u.get("universeID")) == str(current)), None)
if u is None:
u = max(enumerate(candidates), key=lambda item: (str(item[1].get("time", "")), item[0]))[1]
edges = []
for quad in u.get("collision_bounds") or []:
if len(quad) != 4 or any(len(p) != 3 for p in quad):
raise AgentError("Invalid chaperone wall")
quad = [[float(v) for v in p] for p in quad]
if not all(math.isfinite(v) for p in quad for v in p):
raise AgentError("Invalid chaperone coordinate")
bottom = min(p[1] for p in quad)
pts = list(dict.fromkeys((p[0], p[2]) for p in quad if abs(p[1] - bottom) < .001))
if len(pts) != 2:
raise AgentError("Invalid chaperone wall edge")
edges.append(tuple(pts))
if len(edges) < 3:
raise AgentError("No play area outline found")
outline = list(edges.pop(0))
while edges:
for i, (a, b) in enumerate(edges):
if a == outline[-1] or b == outline[-1]:
outline.append(b if a == outline[-1] else a)
edges.pop(i)
break
else:
raise AgentError("Play area outline is not closed")
if outline[-1] != outline[0]:
raise AgentError("Play area outline is not closed")
return int(room_universe(u["universeID"])), outline[:-1]
def room_default_circle(points):
import math
if len(points) != 20:
return False
cx = sum(p[0] for p in points) / 20
cz = sum(p[1] for p in points) / 20
radii = [math.hypot(x-cx, z-cz) for x, z in points]
return all(.85 <= r <= 1.15 for r in radii) and max(radii) - min(radii) < .05
def room_records(text):
"""Validate the wire format without desktop dependencies; return its status counts and mesh metadata."""
import math
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 AgentError("expected frameport-room 1")
result = {"universe": 0, "source": "manual", "time": 0,
"summary": {"walls": 0, "volumes": {}, "planes": 0}}
outline, walls, floor, ceiling = [], [], 0, None
ids = set()
for row in lines[1:]:
k, v = row[0], row[1:]
try:
if k in ("room", "floor", "ceiling", "wall", "volume", "plane", "mesh"):
if not v or not re.fullmatch(r"[a-f0-9]{32}", v[0]) or v[0] in ids:
raise ValueError("invalid or duplicate entity UUID")
ids.add(v[0])
if k == "universe":
if len(v) != 1:
raise ValueError("expected universe")
result[k] = int(room_universe(v[0]))
elif k == "time":
if len(v) != 1 or not v[0].isdigit():
raise ValueError("invalid time")
result[k] = int(v[0])
elif k == "source":
if len(v) != 1 or v[0] not in ("scan", "boundary", "box", "manual"):
raise ValueError("invalid source")
result[k] = v[0]
elif k == "room":
if len(v) != 1:
raise ValueError("invalid room")
elif k in ("floor", "ceiling", "wall", "volume", "plane", "outline"):
expected = {"floor": 2, "ceiling": 2, "wall": 7, "volume": 9, "plane": 11}
if k == "wall" and len(v) == 8: # optional label: an edge the scanner closed itself
if v[7] not in ("WALL_FACE", "INVISIBLE_WALL_FACE"):
raise ValueError("invalid wall label")
v = v[:7]
if k != "outline" and len(v) != expected[k]:
raise ValueError("invalid field count")
nums = [float(x) for x in v[2 if k in ("volume", "plane") else 1:]]
if not all(math.isfinite(x) for x in nums):
raise ValueError("nonfinite coordinate")
if k == "floor":
floor = nums[0]
elif k == "ceiling":
ceiling = nums[0]
elif k == "outline":
if int(v[0]) < 3 or len(nums) != int(v[0]) * 2:
raise ValueError("invalid outline")
outline = list(zip(nums[::2], nums[1::2], strict=True))
elif k == "wall":
if nums[5] <= nums[4] or nums[:2] == nums[2:4]:
raise ValueError("invalid wall dimensions")
walls.append(nums)
elif k in ("volume", "plane"):
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", v[1]) or min(nums[-3 if k == "volume" else -2:]) <= 0:
raise ValueError("invalid label or dimensions")
if k == "plane":
if sum(x*x for x in nums[3:7]) < 1e-12:
raise ValueError("zero quaternion")
result["summary"]["planes"] += 1
else:
counts = result["summary"]["volumes"]
counts[v[1]] = counts.get(v[1], 0) + 1
elif k == "mesh":
ident, name, nv, ni = v
nv, ni = int(nv), int(ni)
if (not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]*", name) or name in ("room.txt", "scan.log")
or nv < 0 or ni < 0 or ni % 3 or ni > 600000):
raise ValueError("invalid mesh record")
result["mesh"] = {"filename": name, "vertices": nv, "indices": ni}
except (ValueError, IndexError) as exc:
raise AgentError(f"Invalid room {k}: {exc}") from exc
if not outline and walls:
xs = [x for w in walls for x in (w[0], w[2])]
zs = [z for w in walls for z in (w[1], w[3])]
outline = [(min(xs), min(zs)), (max(xs), min(zs)), (max(xs), max(zs)), (min(xs), max(zs))]
area = abs(sum(x*b-a*z for (x, z), (a, b) in zip(outline, outline[1:]+outline[:1], strict=True))) / 2
height = (ceiling if ceiling is not None else floor + 2.5) - floor
if len(outline) < 3 or area < 1e-8 or height <= 0:
raise AgentError("Invalid room outline or ceiling")
result["summary"].update(walls=len(walls) or len(outline), floor_area=area, ceiling_height=height)
return result
def room_validate_mesh(data, record):
import math
if len(data) < 16:
raise AgentError("truncated room mesh")
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
if (magic != b"FPRM" or version != 1 or nv != record["vertices"] or ni != record["indices"]
or len(data) != 16 + 12*nv + 4*ni):
raise AgentError("invalid room mesh header or counts")
if any(not math.isfinite(v) for (v,) in struct.iter_unpack("<f", data[16:16+12*nv])):
raise AgentError("invalid room mesh coordinate")
if any(i >= nv for (i,) in struct.iter_unpack("<I", data[16+12*nv:])):
raise AgentError("invalid room mesh index")
def room_selected(universe=None):
"""The matching universe, otherwise current; never follow a link outside the room store."""
for name in (room_universe(room_current_universe() if universe is None else universe), "current"):
path = os.path.realpath(os.path.join(ROOMS_DIR, name))
if (os.path.dirname(path) == os.path.realpath(ROOMS_DIR) and os.path.basename(path).isdigit()
and os.path.isfile(os.path.join(path, "room.txt"))):
return path
return None
def cmd_room_status(args):
current = room_current_universe()
selected = room_selected(current)
rooms = []
for path in glob.glob(os.path.join(ROOMS_DIR, "*")):
if not os.path.basename(path).isdigit() or os.path.islink(path):
continue
try:
with open(os.path.join(path, "room.txt")) as f:
record = room_records(f.read())
rooms.append(record)
except (OSError, AgentError):
continue
active = None
text = None
if selected:
with open(os.path.join(selected, "room.txt")) as f:
text = f.read()
active = room_records(text)
try:
_, points = room_boundary(room_chaperone(), current)
circle = room_default_circle(points)
except (AgentError, ValueError, TypeError):
circle = None
return {"rooms": sorted(rooms, key=lambda r: r["time"], reverse=True), "universe": current,
"room": active, "room_text": text,
"aligned": bool(active and current and active["universe"] == current),
"default_circle": circle, "scanner_installed": os.access(ROOM_SCANNER, os.X_OK)}
def room_store(text, mesh=None, scan_log=None):
record = room_records(text)
if record.get("mesh"):
if mesh is None:
raise AgentError("room mesh sidecar is missing")
room_validate_mesh(mesh, record["mesh"])
elif mesh is not None:
raise AgentError("room has no mesh record")
universe = room_universe(record["universe"])
os.makedirs(ROOMS_DIR, exist_ok=True)
with open(os.path.join(ROOMS_DIR, ".lock"), "a") as lock:
if fcntl:
fcntl.flock(lock, fcntl.LOCK_EX)
dest = os.path.join(ROOMS_DIR, universe)
if os.path.islink(dest):
raise AgentError("refusing a linked room folder")
stage = tempfile.mkdtemp(prefix=".room-", dir=ROOMS_DIR)
previous = dest + ".previous"
try:
with open(os.path.join(stage, "room.txt"), "w") as f:
f.write(text)
if record.get("mesh"):
with open(os.path.join(stage, record["mesh"]["filename"]), "wb") as f:
f.write(mesh)
if scan_log:
with open(os.path.join(stage, "scan.log"), "w") as f:
f.write(scan_log)
shutil.rmtree(previous, ignore_errors=True)
if os.path.isdir(dest):
os.rename(dest, previous)
try:
os.rename(stage, dest)
except OSError:
if os.path.isdir(previous):
os.rename(previous, dest)
raise
link = os.path.join(ROOMS_DIR, ".current-new")
if os.path.lexists(link):
os.unlink(link)
os.symlink(universe, link)
os.replace(link, os.path.join(ROOMS_DIR, "current"))
shutil.rmtree(previous, ignore_errors=True)
finally:
shutil.rmtree(stage, ignore_errors=True)
return record
def cmd_room_put(args):
# PC uploads to a unique directory below the room store, not to a game's files or an arbitrary host path.
path = os.path.realpath(args["path"])
if not _inside(path, os.path.realpath(ROOMS_DIR)) or os.path.basename(path) != "room.txt":
raise AgentError("upload room.txt below the room store")
with open(path) as f:
text = f.read(8*1024*1024)
record = room_records(text)
mesh = None
if record.get("mesh"):
mesh_path = os.path.realpath(os.path.join(os.path.dirname(path), record["mesh"]["filename"]))
if os.path.dirname(mesh_path) != os.path.dirname(path):
raise AgentError("linked mesh outside upload folder")
with open(mesh_path, "rb") as f:
mesh = f.read(64*1024*1024)
return {"room": room_store(text, mesh)}
def cmd_room_delete(args):
selected = room_selected()
name = room_universe(args["universe"]) if "universe" in args else (os.path.basename(selected) if selected else None)
if name:
path = os.path.join(ROOMS_DIR, name)
if os.path.islink(path):
raise AgentError("refusing a linked room folder")
shutil.rmtree(path, ignore_errors=True)
current = os.path.join(ROOMS_DIR, "current")
if os.path.islink(current) and not os.path.exists(current):
os.unlink(current)
remaining = cmd_room_status({})["rooms"]
if remaining:
os.symlink(room_universe(remaining[0]["universe"]), current)
return cmd_room_status({})
def cmd_room_from_boundary(args):
import math
import uuid
universe, outline = room_boundary(room_chaperone(), room_current_universe())
if room_default_circle(outline):
raise AgentError("Valve's default circle is not a room. Set up your play area in SteamVR first.")
height = float(args.get("height", 2.5))
if not math.isfinite(height) or height <= 0:
raise AgentError("room height must be positive")
area = sum(x*b-a*z for (x, z), (a, b) in zip(outline, outline[1:]+outline[:1], strict=True))
if area < 0:
outline.reverse()
rows = ["frameport-room 1", f"universe {universe}", "source boundary", f"time {int(time.time())}",
f"room {uuid.uuid4().hex}", f"floor {uuid.uuid4().hex} 0", f"ceiling {uuid.uuid4().hex} {height}",
"outline " + str(len(outline)) + " " + " ".join(str(v) for p in outline for v in p)]
rows += [f"wall {uuid.uuid4().hex} {x} {z} {a} {b} 0 {height}"
for (x, z), (a, b) in zip(outline, outline[1:]+outline[:1], strict=True)]
room_store("\n".join(rows) + "\n")
return cmd_room_status({})
def cmd_room_build(args):
dump = os.path.realpath(args["dump"])
if not _inside(dump, os.path.realpath(ROOMS_DIR)):
raise AgentError("upload scan dump below the room store")
if not os.access(ROOM_SCANNER, os.X_OK):
raise AgentError("Room scanner is not installed")
universe = room_universe(args.get("universe", room_current_universe()))
with tempfile.TemporaryDirectory(prefix=".build-", dir=ROOMS_DIR) as out:
result = subprocess.run([ROOM_SCANNER, "--offline", dump, "--out", out, "--universe", universe, "--json"],
capture_output=True, text=True, timeout=300)
if result.returncode:
raise AgentError("Room scan failed: " + result.stderr[-4000:])
try:
report = json.loads(result.stdout)
except ValueError as exc:
raise AgentError("Room scanner returned invalid JSON") from exc
with open(os.path.join(out, "room.txt")) as f:
text = f.read()
record = room_records(text)
if str(record["universe"]) != universe:
raise AgentError("Room scanner returned the wrong universe")
mesh = None
if record.get("mesh"):
with open(os.path.join(out, record["mesh"]["filename"]), "rb") as f:
mesh = f.read()
room_store(text, mesh, result.stderr)
return {"report": report, **cmd_room_status({})}
# --- RoomView 3D capture (docs/mr/PHASE0.md) -------------------------------------------------------------------
# Valve's passthrough computes a head-centred depth mesh (~30/s) and publishes it in two OpenVR block queues;
# FramePort records it into a .fprs scan dump (docs/mr/ROOM_FORMAT.md) for fp_roomscan. Background app only: reading
# never starts SteamVR or changes it. Poses are kept in SteamVR's raw space while recording and converted to STAGE
# with the raw->standing transform at the END: Valve's floor estimate moves that transform during a session.
ROOMVIEW_DATA = b"/xrservice/roomview3d/meshData"
ROOMVIEW_LAYOUT = b"/xrservice/roomview3d/meshLayout"
FPRS_FRAME = "<d7fII" # time, pose (px py pz qx qy qz qw), vertex count, index count
FPRS_POSE_OFFSET = 8 # the pose's byte offset inside a frame header (rewritten at the end)
ROOMVIEW_MAX_DEPTH = 4.0 # metres; beyond this Valve's depth is inpainted (only reported here)
VR_APP_OVERLAY = 2 # EVRApplicationType VRApplication_Overlay
def _m34_mul(a, b):
"""Row-major 3x4 affine a * b."""
out = []
for r in range(3):
for c in range(4):
v = sum(a[r * 4 + k] * b[k * 4 + c] for k in range(3))
out.append(v + (a[r * 4 + 3] if c == 3 else 0.0))
return out
def _m34_quat(m):
"""Rotation of a row-major 3x4 as (x, y, z, w)."""
r00, r01, r02, r10, r11, r12, r20, r21, r22 = m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]
tr = r00 + r11 + r22
if tr > 0:
s = math.sqrt(tr + 1.0) * 2
return ((r21 - r12) / s, (r02 - r20) / s, (r10 - r01) / s, 0.25 * s)
if r00 > r11 and r00 > r22:
s = math.sqrt(1.0 + r00 - r11 - r22) * 2
return (0.25 * s, (r01 + r10) / s, (r02 + r20) / s, (r21 - r12) / s)
if r11 > r22:
s = math.sqrt(1.0 + r11 - r00 - r22) * 2
return ((r01 + r10) / s, 0.25 * s, (r12 + r21) / s, (r02 - r20) / s)
s = math.sqrt(1.0 + r22 - r00 - r11) * 2
return ((r02 + r20) / s, (r12 + r21) / s, 0.25 * s, (r10 - r01) / s)
def _view_coverage(m, yaw_bins, pitch):
"""Note where the camera looked (+Z of the mesh = forward): yaw in 30 deg bins, how many frames looked up."""
fx, fy, fz = m[2], m[6], m[10]
yaw_bins.add(int((math.degrees(math.atan2(fx, fz)) % 360) // 30))
if fy > 0.6:
pitch["up"] += 1
elif fy < -0.6:
pitch["down"] += 1
class _RoomViewReader:
"""The two RoomView queues + their path properties through libopenvr_api.so (ctypes)."""
def __init__(self, app_type=None):
import ctypes
self.ct = ctypes
lib = next((p for p in OPENVR_LIBS if os.path.isfile(p)), None)
if lib is None:
raise RuntimeError("SteamVR isn't installed")
vr = self.vr = ctypes.CDLL(lib)
vr.VR_InitInternal2.restype = ctypes.c_uint32
vr.VR_InitInternal2.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_int, ctypes.c_char_p]
vr.VR_GetGenericInterface.restype = ctypes.c_void_p
vr.VR_GetGenericInterface.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int)]
vr.VR_ShutdownInternal.restype = None
err = ctypes.c_int(0)
# an Overlay app may create the prompt overlay; Background (no prompts) can't start SteamVR by accident
vr.VR_InitInternal2(ctypes.byref(err), VR_APP_BACKGROUND if app_type is None else app_type, None)
if err.value:
raise RuntimeError("SteamVR isn't running")
H, E, VP = ctypes.c_uint64, ctypes.c_int, ctypes.c_void_p
self.H, self.VP = H, VP
class BQ(ctypes.Structure): # openvr_capi.h VR_IVRBlockQueue_FnTable (IVRBlockQueue_005)
_fields_ = [("Create", VP), ("Connect", ctypes.CFUNCTYPE(E, ctypes.POINTER(H), ctypes.c_char_p)),
("Destroy", VP), ("AcquireWriteOnlyBlock", VP), ("ReleaseWriteOnlyBlock", VP),
("WaitAndAcquireReadOnlyBlock", ctypes.CFUNCTYPE(E, H, ctypes.POINTER(H), ctypes.POINTER(VP),
ctypes.c_int, ctypes.c_uint32)),
("AcquireReadOnlyBlock", VP), ("ReleaseReadOnlyBlock", ctypes.CFUNCTYPE(E, H, H)),
("QueueHasReader", VP)]
class PathRead(ctypes.Structure): # openvr_driver.h PathRead_t
_fields_ = [("ulPath", ctypes.c_uint64), ("pvBuffer", VP), ("unBufferSize", ctypes.c_uint32),
("unTag", ctypes.c_uint32), ("unRequiredBufferSize", ctypes.c_uint32), ("eError", ctypes.c_int),
("pszPath", ctypes.c_char_p)]
class M34(ctypes.Structure):
_fields_ = [("m", ctypes.c_float * 12)]
self.PathRead, self.M34 = PathRead, M34
e = ctypes.c_int(0)
ptr = vr.VR_GetGenericInterface(b"FnTable:IVRBlockQueue_005", ctypes.byref(e))
paths = vr.VR_GetGenericInterface(b"IVRPaths_002", ctypes.byref(e))
system = next((s for s in (vr.VR_GetGenericInterface(n, ctypes.byref(e))
for n in (b"IVRSystem_023", b"IVRSystem_022")) if s), None)
if not ptr or not paths or not system:
self.close()
raise RuntimeError("this SteamVR has no RoomView mesh interfaces")
self.bq = ctypes.cast(ptr, ctypes.POINTER(BQ)).contents
self.paths = paths
pvt = ctypes.cast(ctypes.cast(paths, ctypes.POINTER(VP))[0], ctypes.POINTER(VP))
self._read = ctypes.CFUNCTYPE(E, VP, ctypes.c_uint64, ctypes.POINTER(PathRead), ctypes.c_uint32)(pvt[0])
self._handle = ctypes.CFUNCTYPE(E, VP, ctypes.POINTER(ctypes.c_uint64), ctypes.c_char_p)(pvt[2])
self.system = system
svt = ctypes.cast(ctypes.cast(system, ctypes.POINTER(VP))[0], ctypes.POINTER(VP))
self._raw_to_standing = ctypes.CFUNCTYPE(M34, VP)(svt[13]) # GetRawZeroPoseToStandingAbsoluteTrackingPose
self.handles = {}
self.data, self.layout = self._connect(ROOMVIEW_DATA), self._connect(ROOMVIEW_LAYOUT)
if self.data is None or self.layout is None:
self.close()
raise RuntimeError("SteamVR has no RoomView mesh (passthrough never started since SteamVR started?)")
def _connect(self, path):
h = self.H(0)
return h if self.bq.Connect(self.ct.byref(h), path) == 0 else None
def prop(self, root, path, fmt):
ct = self.ct
if path not in self.handles:
h = ct.c_uint64(0)
self._handle(self.paths, ct.byref(h), path.encode())
self.handles[path] = h.value
buf = (ct.c_ubyte * 64)()
pr = self.PathRead(self.handles[path], ct.cast(buf, self.VP), 64, 0, 0, 0, None)
self._read(self.paths, root, ct.byref(pr), 1)
if pr.eError or pr.unRequiredBufferSize < struct.calcsize(fmt):
return None
vals = struct.unpack_from(fmt, bytes(buf))
return vals if len(vals) > 1 else vals[0]
def up_to_date(self):
return bool(self.prop(self.data.value, "/meshData/meshIsUpToDate", "<?"))
def acquire(self, queue, read_type, timeout_ms):
blk, buf = self.H(0), self.VP(0)
if self.bq.WaitAndAcquireReadOnlyBlock(queue, self.ct.byref(blk), self.ct.byref(buf), read_type, timeout_ms):
return None, None
return blk, buf.value
def release(self, queue, blk):
self.bq.ReleaseReadOnlyBlock(queue, blk)
def raw_to_standing(self):
return list(self._raw_to_standing(self.system).m)
def close(self):
try:
self.vr.VR_ShutdownInternal()
except Exception: # noqa: BLE001 - shutting down a half-initialised client
pass
class _ScanPrompt:
"""A head-locked SteamVR overlay showing FramePort's pre-rendered scan prompts (artifacts/roomscan/*.png,
uploaded next to fp_roomscan): IVROverlay_028 through its C function table. Failing to show it never stops a
scan (the PC app shows the same state)."""
SLOTS = {"Create": 1, "Destroy": 3, "SortOrder": 20, "Width": 22, "DeviceRelative": 35, "Show": 43, "Hide": 44,
"FromFile": 63}
def __init__(self, rv, image_dir):
ct = self.ct = rv.ct
self.dir, self.shown, self.handle = image_dir, None, None
e = ct.c_int(0)
table = rv.vr.VR_GetGenericInterface(b"FnTable:IVROverlay_028", ct.byref(e))
if not table or not os.path.isdir(image_dir):
return
fn = ct.cast(table, ct.POINTER(ct.c_void_p))
U64, E = ct.c_uint64, ct.c_int
self.create = ct.CFUNCTYPE(E, ct.c_char_p, ct.c_char_p, ct.POINTER(U64))(fn[self.SLOTS["Create"]])
self.destroy = ct.CFUNCTYPE(E, U64)(fn[self.SLOTS["Destroy"]])
self.sort = ct.CFUNCTYPE(E, U64, ct.c_uint32)(fn[self.SLOTS["SortOrder"]])
self.width = ct.CFUNCTYPE(E, U64, ct.c_float)(fn[self.SLOTS["Width"]])
self.relative = ct.CFUNCTYPE(E, U64, ct.c_uint32, ct.POINTER(rv.M34))(fn[self.SLOTS["DeviceRelative"]])
self.show_fn = ct.CFUNCTYPE(E, U64)(fn[self.SLOTS["Show"]])
self.from_file = ct.CFUNCTYPE(E, U64, ct.c_char_p)(fn[self.SLOTS["FromFile"]])
h = U64(0)
if self.create(b"frameport.roomscan", b"FramePort room scan", ct.byref(h)):
return
self.handle = h
m = rv.M34()
for i, v in enumerate((1, 0, 0, 0, 0, 1, 0, -0.25, 0, 0, 1, -1.2)): # 1.2 m ahead, a bit below eye level
m.m[i] = v
self.width(h, 0.8)
self.sort(h, 10)
self.relative(h, 0, ct.byref(m)) # k_unTrackedDeviceIndex_Hmd
def show(self, name):
if self.handle is None or name == self.shown:
return
path = os.path.join(self.dir, name + ".png")
if os.path.isfile(path) and self.from_file(self.handle, path.encode()) == 0:
self.show_fn(self.handle)
self.shown = name
def close(self, final=None, linger=4.0):
if self.handle is None:
return
if final:
self.show(final)
time.sleep(linger)
self.destroy(self.handle)
self.handle = None
ROOM_SCAN_LOOK_FRAMES = 5 # recorded frames looking up / down (~1.7 s at 3/s) that count as "ceiling/floor seen"
ROOM_SCAN_MIN_SECONDS = 60 # a complete scan still records this long: a 32 s one found 2 of 7 furniture boxes
ROOMVIEW_STREAM_MESHES = 3 # new meshes within ROOMVIEW_STREAM_WINDOW s = passthrough is on (Valve: ~30/s)
ROOMVIEW_STREAM_WINDOW = 2.0
ROOM_SCAN_MIN_FRAMES = 30 # fewer recorded frames is no room: refused instead of replacing the saved one
def _prompt_for(started, yaw_bins, pitch, streaming=True):
"""Which prompt image the headset shows: "start" (turn on passthrough) whenever no meshes stream, also mid-scan."""
if started is None or not streaming:
return "start"
if len(yaw_bins) >= 10 and pitch["up"] < ROOM_SCAN_LOOK_FRAMES:
return "ceiling"
if len(yaw_bins) >= 10 and pitch["down"] < ROOM_SCAN_LOOK_FRAMES:
return "floor"
return f"look_{min(12, len(yaw_bins)):02d}"
def _scan_complete(started, now, yaw_bins, pitch):
return (started is not None and now - started >= ROOM_SCAN_MIN_SECONDS and len(yaw_bins) >= 12
and pitch["up"] >= ROOM_SCAN_LOOK_FRAMES and pitch["down"] >= ROOM_SCAN_LOOK_FRAMES)
def capture_roomview(seconds, out_path, wait=0.0, max_hz=3.0, progress=None, prompts=None):
"""Record RoomView 3D meshes into a .fprs dump: `seconds` of fresh meshes (after waiting up to `wait` s for the
first one), at most `max_hz` frames per second. progress(dict) is called about once a second. Returns a summary:
frames, duration, yaw coverage (30 deg bins seen), frames looking up/down, warnings."""
rv = _RoomViewReader(VR_APP_OVERLAY if prompts else None)
ct = rv.ct
prompt = _ScanPrompt(rv, prompts) if prompts else None
tmp = out_path + ".part"
offsets, raw_poses = [], []
yaw_bins, pitch = set(), {"up": 0, "down": 0}
indices, layout_id, last_mesh, last_write = None, None, None, 0.0
# Valve only computes new meshes while passthrough is shown; a queue's last block (and meshIsUpToDate) stays
# after it stops. So "streaming" = ROOMVIEW_STREAM_MESHES new mesh ids within ROOMVIEW_STREAM_WINDOW seconds, and
# frames are recorded only then (2026-10-16: a scan started on one leftover block and recorded nothing else).
arrivals = []
seen_mesh = None
started, paused_since, paused_total = None, None, 0.0
t_end = time.time() + (wait or seconds)
next_progress = time.time()
try:
with open(tmp, "wb") as f:
f.write(b"FPRS" + struct.pack("<I", 1))
while time.time() < t_end:
now = time.time()
arrivals = [t for t in arrivals if now - t < ROOMVIEW_STREAM_WINDOW]
streaming = len(arrivals) >= ROOMVIEW_STREAM_MESHES
if started is not None: # passthrough off mid-scan: the countdown waits (at most `wait` in all)
if not streaming and paused_since is None:
paused_since = now
elif streaming and paused_since is not None:
pause = min(now - paused_since, max(0.0, (wait or seconds) - paused_total))
t_end += pause
paused_total += pause
paused_since = None
if _scan_complete(started, now, yaw_bins, pitch):
break # every direction, the ceiling and the floor seen: "Room scanned" at once
if now >= next_progress:
if prompt:
prompt.show(_prompt_for(started, yaw_bins, pitch, streaming))
if progress:
progress({"recording": started is not None, "frames": len(offsets),
"seconds": round(now - started - paused_total, 1) if started else 0,
"directions": len(yaw_bins), "looked_up": pitch["up"],
"looked_down": pitch["down"], "live": streaming})
next_progress = now + 1
blk, buf = rv.acquire(rv.data, 1, 200) # EBlockQueueReadType_New
if blk is None:
continue
try:
mesh_id = rv.prop(blk.value, "/meshData/meshId", "<Q")
if mesh_id != seen_mesh:
seen_mesh = mesh_id
arrivals.append(now)
if not streaming or not rv.up_to_date() or now - last_write < 1.0 / max_hz:
continue
lid = rv.prop(blk.value, "/meshData/layoutId", "<Q")
count = rv.prop(blk.value, "/meshData/elementCount", "<i") or 0
pose = rv.prop(blk.value, "/meshData/rawFromMeshPose", "<12f")
if mesh_id == last_mesh or pose is None or count < 9 or not all(map(math.isfinite, pose)):
continue
if lid != layout_id: # the index layout only changes with the mesh resolution
lb, lbuf = rv.acquire(rv.layout, 0, 500)
if lb is not None:
n = rv.prop(lb.value, "/meshLayout/indexCount", "<i") or 0
indices = ct.string_at(lbuf, n * 4)
layout_id = rv.prop(lb.value, "/meshLayout/layoutId", "<Q")
rv.release(rv.layout, lb)
if indices is None or layout_id != lid:
continue
verts = ct.string_at(buf, count * 4)
finally:
rv.release(rv.data, blk)
if started is None:
started = now
t_end = now + seconds
offsets.append(f.tell())
raw_poses.append(list(pose))
_view_coverage(pose, yaw_bins, pitch)
f.write(struct.pack(FPRS_FRAME, now, 0, 0, 0, 0, 0, 0, 1, count // 3, len(indices) // 4))
f.write(verts)
f.write(indices)
last_mesh, last_write = mesh_id, now
r2s = rv.raw_to_standing()
finally:
if prompt:
prompt.close("done" if offsets else "failed")
rv.close()
with open(tmp, "r+b") as f: # raw -> STAGE with the final floor/universe transform
for off, raw in zip(offsets, raw_poses, strict=True):
m = _m34_mul(r2s, raw)
f.seek(off + FPRS_POSE_OFFSET)
f.write(struct.pack("<7f", m[3], m[7], m[11], *_m34_quat(m)))
warnings = []
if not offsets:
warnings.append("no passthrough mesh: turn on passthrough in the headset")
if len(yaw_bins) < 9:
warnings.append("not all directions seen: look around the whole room")
if pitch["up"] < 5:
warnings.append("ceiling not seen: look up")
os.replace(tmp, out_path)
return {"frames": len(offsets), "seconds": round(last_write - started, 1) if started else 0,
"directions": len(yaw_bins), "looked_up": pitch["up"], "looked_down": pitch["down"],
"bytes": os.path.getsize(out_path), "warnings": warnings}
ROOM_SCAN_STATUS = os.path.join(ROOMS_DIR, "scan-status.json")
ROOM_SCAN_DUMPS = os.path.join(ROOMS_DIR, "scans")
ROOM_SCAN_PROMPTS = os.path.join(HOME, ".local/share/frameport/bin/roomscan-prompts")
ROOM_SCAN_UNIT = "frameport-room-scan"
ROOM_SCAN_KEEP = 2 # newest dumps kept for diagnostics (each ~100-600 MB)
def cmd_room_capture(args):
"""Start a room scan in the headset: a detached job records the passthrough mesh (with prompts in the headset),
then builds and stores the room. Progress: room_capture_status."""
seconds = float(args.get("seconds", 90))
wait = float(args.get("wait", 180))
if not (10 <= seconds <= 900 and 0 <= wait <= 1800):
raise AgentError("scan length out of range")
if not os.access(ROOM_SCANNER, os.X_OK):
raise AgentError("Room scanner is not installed")
state = cmd_room_capture_status({})
if state.get("state") in ("waiting", "recording", "building") and _unit_active(ROOM_SCAN_UNIT):
raise AgentError("A room scan is already running")
os.makedirs(ROOMS_DIR, exist_ok=True)
_write_json(ROOM_SCAN_STATUS, {"state": "waiting", "started": time.time(), "seconds": seconds})
p = run(["systemd-run", "--user", "--collect", "--quiet", f"--unit={ROOM_SCAN_UNIT}-{int(time.time())}",
"--setenv=HOME=" + HOME, sys.executable, os.path.abspath(__file__), "_room_capture",
json.dumps({"seconds": seconds, "wait": wait})])
if p.returncode != 0:
_write_json(ROOM_SCAN_STATUS, {"state": "failed", "error": p.stderr.strip()[-300:]})
raise AgentError(f"couldn't start the room scan: {p.stderr.strip()[-300:]}")
return {"started": True, "status": ROOM_SCAN_STATUS}
def _unit_active(prefix):
p = run(["systemctl", "--user", "list-units", "--no-legend", "--plain", f"{prefix}-*"])
return any(line.split()[2:3] == ["active"] for line in (p.stdout or "").splitlines() if line.strip())
def cmd_room_capture_status(args):
try:
with open(ROOM_SCAN_STATUS) as f:
return json.load(f)
except (OSError, ValueError):
return {"state": "none"}
def room_capture_worker(payload):
job = json.loads(payload)
status = {"state": "waiting", "started": time.time(), "seconds": job["seconds"]}
os.makedirs(ROOM_SCAN_DUMPS, exist_ok=True)
dump = os.path.join(ROOM_SCAN_DUMPS, time.strftime("scan-%Y%m%d-%H%M%S.fprs"))
def progress(p):
status.update(p, state="recording" if p.get("recording") else "waiting")
_write_json(ROOM_SCAN_STATUS, status)
try:
summary = capture_roomview(job["seconds"], dump, wait=job["wait"], progress=progress,
prompts=ROOM_SCAN_PROMPTS if os.path.isdir(ROOM_SCAN_PROMPTS) else None)
status.update(capture=summary, state="building")
_write_json(ROOM_SCAN_STATUS, status)
if summary["frames"] < ROOM_SCAN_MIN_FRAMES: # never replace a saved room with a scrap of one
raise AgentError("The scan saw too little of the room: keep passthrough on, look around until the "
"headset says \"Room scanned\", and scan again")
built = cmd_room_build({"dump": dump})
status.update(state="done", report=built.get("report"), room=built.get("room"), finished=time.time())
except Exception as exc: # noqa: BLE001 - every failure ends in the status file the PC polls
status.update(state="failed", error=str(exc)[-500:], finished=time.time())
finally:
_write_json(ROOM_SCAN_STATUS, status)
for old in sorted(glob.glob(os.path.join(ROOM_SCAN_DUMPS, "scan-*.fprs")))[:-ROOM_SCAN_KEEP]:
try:
os.remove(old)
except OSError:
pass
def wants_scene_room(anchor):
try:
with open(os.path.join(anchor, "deployment.json")) as f:
dep = json.load(f)
# Live Game settings changes are in settings.conf; older recipes list patch ids only.
conf = os.path.join(dep.get("base", anchor), "settings.conf")
if os.path.isfile(conf):
for line in open(conf):
key, _, value = line.strip().partition("=")
if key == "scene_emul":
return float(value) != 0
recipe = dep.get("recipe") or {}
return dep.get("scene_room", "adapter.scene_emul" in (recipe.get("patches") or []))
except (OSError, ValueError, AttributeError):
return False
def set_room_sync_line(text, pkg, want):
text = "".join(line for line in text.splitlines(True) if "# frameport-room-sync" not in line)
if not want or "\nsetsid " not in text:
return text
line = ROOM_SYNC_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg))
return text.replace("\nsetsid ", "\n" + line + "\nsetsid ", 1)
def room_sync_worker(pkg):
dep = deployment(check_pkg(pkg))
if not dep or dep.get("kind", "quest") != "quest":
return
base = dep["base"]
# Explicit scene_room follows the recipe at finalize; older deployments derive it on launcher upgrade.
selected = room_selected() if wants_scene_room(os.path.join(ANCHORS, pkg)) else None
record = None
if selected:
with open(os.path.join(selected, "room.txt")) as f:
text = f.read()
record = room_records(text)
if record.get("mesh"):
with open(os.path.join(selected, record["mesh"]["filename"]), "rb") as f:
mesh = f.read()
room_validate_mesh(mesh, record["mesh"])
# The public format allows any sidecar basename; per-game copies use the contract's fixed name.
text = re.sub(r"(?m)^(mesh\s+[a-f0-9]{32}\s+)\S+", r"\g<1>room-mesh.bin", text)
with open(os.path.join(base, "room-mesh.bin.tmp"), "wb") as f:
f.write(mesh)
os.replace(os.path.join(base, "room-mesh.bin.tmp"), os.path.join(base, "room-mesh.bin"))
with open(os.path.join(base, "room.txt.tmp"), "w") as f:
f.write(text)
os.replace(os.path.join(base, "room.txt.tmp"), os.path.join(base, "room.txt"))
for name in ("room.txt", "room-mesh.bin"):
if not selected or (name == "room-mesh.bin" and not record.get("mesh")):
try:
os.unlink(os.path.join(base, name))
except FileNotFoundError:
pass
return {"room": record, "aligned": bool(record and record["universe"] == room_current_universe())}
def room_setting_changed(pkg, dep, settings):
"""Live settings must update the launcher too, including switching emulation on after install."""
if "scene_emul" not in settings:
return
dep["scene_room"] = float(settings["scene_emul"]) != 0
write_deployment(pkg, dep)
path = os.path.join(ANCHORS, pkg, "launch.sh")
if os.path.isfile(path):
with open(path) as f:
old = f.read()
text = set_room_sync_line(old, pkg, dep["scene_room"])
if text != old:
with open(path + ".tmp", "w") as f:
f.write(text)
os.chmod(path + ".tmp", 0o755)
os.replace(path + ".tmp", path)
if not dep["scene_room"]:
room_sync_worker(pkg)
# ---------------------------------------------------------- end mixed-reality room storage
def cmd_finalize(args):
"""Move uploaded files into place, write launcher/settings/config files/deployment.json. Keeps saves."""
pkg = check_pkg(args["package"])
@@ -4076,7 +4944,11 @@ def configure_install(args, prep, moved):
if gamepad:
ensure_podman_wrapper()
config_sync = args.get("config_sync") if isinstance(args.get("config_sync"), dict) else {}
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad, bool(config_sync))
scene_value = settings.get("scene_emul", (recipe.get("adapter") or {}).get(
"scene_emul", int("adapter.scene_emul" in (recipe.get("patches") or []))))
scene_room = float(scene_value) != 0
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"), hw_video, gamepad,
bool(config_sync), scene_room)
art_in = os.path.join(base, "incoming-artwork")
art_moved = False
if args.get("consume_artwork") and os.path.isdir(art_in):
@@ -4089,6 +4961,7 @@ def configure_install(args, prep, moved):
"sha256": args.get("apk_sha256") or previous.get("sha256"),
"recipe": args.get("recipe"), "installed_by": "frameport",
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "config_sync": config_sync,
"scene_room": scene_room,
"agent_version": AGENT_VERSION, "time": time.time()}
if previous.get("eye_base"): # the runtime's eye size learned from an earlier start (device.config_sync)
dep["eye_base"] = previous["eye_base"]
@@ -4110,6 +4983,8 @@ def configure_install(args, prep, moved):
dep["apk_stat"] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns}
synced = sync_game_config(pkg, dep)
write_deployment(pkg, dep)
if not scene_room:
room_sync_worker(pkg)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models,
"config_sync": synced, "deployment": deployment_summary(dep)}
@@ -5380,6 +6255,7 @@ def cmd_set_settings(args):
os.makedirs(files_dir, exist_ok=True)
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
room_setting_changed(pkg, dep, args.get("settings") or {})
models = install_controller_models(files_dir, current.get("controller_models", "0") not in ("0", "0.0"))
synced = sync_game_config(pkg, dep)
if isinstance(dep.get("configuration"), dict):
@@ -7926,6 +8802,9 @@ def main():
if len(sys.argv) >= 3 and sys.argv[1] == "_purge_worker":
purge_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_room_capture":
room_capture_worker(sys.argv[2])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_move_worker":
move_worker(sys.argv[2])
return 0
@@ -7943,6 +8822,9 @@ def main():
if len(sys.argv) >= 4 and sys.argv[1] == "_dashboard_worker":
dashboard_worker(sys.argv[2], sys.argv[3])
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_room_sync":
print(json.dumps(room_sync_worker(sys.argv[2])))
return 0
if len(sys.argv) >= 3 and sys.argv[1] == "_config_sync":
config_sync_worker(sys.argv[2])
return 0
+40
View File
@@ -0,0 +1,40 @@
# Announcements
`current.md` is the one announcement FramePort shows as a pop-up in the app. Edit it on `main`: the app reads it
from GitHub `main` (raw.githubusercontent.com) at most every 6 hours, so a push reaches everyone without a release.
## Who sees it
- **Only people who already used FramePort before the announcement's `date`.** A new install never shows one, and
nor does an announcement published before FramePort was installed.
- **Once.** Closing it in any way (the X, a click outside it, Escape, or its button) dismisses it for good. The app
remembers dismissals in its settings (`announce.dismissed`).
- **A new announcement = a new `id`.** Fixing a typo with the same `id` doesn't show it again to anyone who closed it.
A new `id` shows to everyone who used FramePort before its `date`, whether they closed the earlier one or not.
## The file
```markdown
---
active: true # false = nothing is shown (the fields are still checked)
id: meetup-2026-11 # letters, digits, . _ - — change it for every new announcement
title: Steam Frame meetup on Discord # required
date: 2026-11-02T18:00:00Z # required: when it was published (UTC; a bare date = midnight UTC)
image: announcements/meetup.png # optional: a file in this folder, or an https URL (PNG, JPEG, WebP, GIF; ≤ 5 MiB)
image_alt: The meetup poster # optional: what the picture shows (screen readers)
button: Join the meetup # optional: an action button, with url
url: https://example.com/meetup
expires: 2026-11-30 # optional: not shown after this day (UTC, inclusive)
---
The body, in Markdown: **bold**, *italic*, `code`, [links](https://frameport.app/), lists, headings.
```
The body is Markdown (no HTML). A `date` in the future works as a schedule: nobody sees it before then.
Images look best at about 2:1 (e.g. 1200×600). The pop-up is 600 px wide and shows between 140 and 320 px of the
picture's height.
`pytest tests/test_announcements.py` checks this file, so a broken announcement fails CI instead of silently showing
nothing.
To turn announcements off for testing: `FRAMEPORT_NO_ANNOUNCEMENTS=1`.
+16
View File
@@ -0,0 +1,16 @@
---
# The announcement FramePort shows once (announcements/README.md). Switched off: set active: true.
active: false
id: example-2026-10
title: Example announcement
date: 2026-10-15T12:00:00Z
# image: announcements/example.png
# image_alt: What the picture shows
# button: Read more
# url: https://frameport.app/changelog/
# expires: 2026-11-30
---
This is where the text goes, in **Markdown**: *emphasis*, `code`, [links](https://frameport.app/) and lists.
- Keep it short: one or two paragraphs.
- Change `id` for every new announcement.
+21 -2
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
0f058921e9c1f5b9ef5847cc9e6b80a23e75028c76c1d3bb02d1610b535f81c9 ./arm64-v8a/libopenxr_loader_generic.so
095cabaffdfc1d4437551fe748b35b108f26b1513fea7174114e64152ba3a012 ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
9e8a7ce599b62a7c607b0d3637e4ec1b1ea1974c3373a7af3f5920ad2adc5e00 ./armeabi-v7a/libopenxr_loader_generic.so
512b345dd30e7a32608e3e32ed79b0e99c61e9996b86dff3dbf822fe786e9f66 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
@@ -27,6 +27,25 @@ b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
63af0050054ad3705b17a0b00ce0c076ba1af0b73abd673ba6c588f64b4591cd ./linux-arm64-bin/fp_roomscan
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
00c2dead0fd7497929833467f0e55699d57d26be1b85dce35402993852f7b111 ./roomscan/ceiling.png
c1962de31ed2810d31c760cf9ee63316ac2b8c9528ccbcd9299ebf5ba39b2995 ./roomscan/done.png
0dfaea4c68979b0a45a9fef06fdf1a92468ffc8f46f80a3a014fce1d805972d5 ./roomscan/failed.png
1147391822be3f1aa5351374b8fd9ec85f8b5ce40e3cb9f3146d778e6fbc652a ./roomscan/floor.png
eb3c8b55a9dbafa030797ca9f06a22c3ee3bbef96cb7adea26d433ebad43d43f ./roomscan/look_00.png
6ba965ff6d22a469c558f2a68229bd2ff48fc28badb95889ca245310d1dd3e96 ./roomscan/look_01.png
0a11cec25ebc76fe11d505d1f2ec162c9b4a6754393065800e73b1f81e2d94c8 ./roomscan/look_02.png
fa5a4c94d20af56797b9fcc111148767847a6633ade9217cab373de232efad90 ./roomscan/look_03.png
e80fa493e380fba39084be2ee62859adcff4e39aa66566abee10a4d6339c52e9 ./roomscan/look_04.png
133617f0d8d1e69c37408aac80033f71856b592b62e3b780cc12b446bd974680 ./roomscan/look_05.png
d5dd296ff23641c6973c57ac139147e99c7b783316ab60537adb2155dd196880 ./roomscan/look_06.png
c08570aa78f966ae937b2e21d0bad887c5352fbca56aa579a2a7ef8541fb96ad ./roomscan/look_07.png
3767e7db5fb6babc872fa62c42604f749c65936717d332101745b201cd450cd5 ./roomscan/look_08.png
2924dc65e5c497545340209edc16060e12e1834d11af6871d1c1baa50b7b2562 ./roomscan/look_09.png
cd0150dfc10b2aea39403c74eff72fd05fb0111178ef39b4c98657a6d16d105e ./roomscan/look_10.png
011355e412f73ade3896e9037490e607c4365d0cf8ceb807d236bf68b414d3ed ./roomscan/look_11.png
1b8b47ffb47568d540921f065b5958eeb44a6489498d58e0f33f5d1f535154e2 ./roomscan/look_12.png
926c7bc6a103adee5b9bf938f5d4e39095ab77fd6270b5301be3b0428edd9ae6 ./roomscan/start.png
c2c70f4af1a5c3e6089dff1130161dd417f8044f99ec639eeecf1083930aab16 ./win-x64/fp_oculushmd.exe
60ebc5fea05b8945082e2ca8b9f2eb74616012a26eb50133af273dff0fcddc42 ./win-x64/fp_vrsettings.exe
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+5 -1
View File
@@ -34,7 +34,11 @@ say "Letting FramePort log in"
key=$(curl -fsS "$PC_URL/key?code=$PAIR_CODE")
[[ "$key" == ssh-ed25519\ * ]] || { echo "Couldn't reach FramePort at $PC_URL. Is it still open?"; exit 1; }
mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
grep -qxF "$key" ~/.ssh/authorized_keys || echo "$key" >> ~/.ssh/authorized_keys
# a file without a final newline would glue our key onto its last line, where sshd never matches it (GitHub #242)
grep -qxF "$key" ~/.ssh/authorized_keys || {
[ -n "$(tail -c1 ~/.ssh/authorized_keys)" ] && echo >> ~/.ssh/authorized_keys
echo "$key" >> ~/.ssh/authorized_keys
}
say "Setting up Lepton's containers"
# rootless podman leaks one kernel keyring per container start; ~200 game launches would exhaust the quota
+20
View File
@@ -0,0 +1,20 @@
package: com.CreaTeam.Sclerosis
title: Sclerosis
status: works
notes: Copy the "Amnesia The Dark Descent" folder from a Windows/Proton install to "FramePort/com.CreaTeam.Sclerosis/lepton-data/external".
Default hand positions are rather askew and should be amended via VR Controller Mappings in Steam itself. FOV
warping effects are EXTREMELY disorienting in VR, beware. Otherwise seems to work without issue.
tested_version: 1.10.7
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
verified:
date: '2026-10-16'
known_good_sha256: e17421ad9894f5e57fb9b1b8110544715166756ef924b5a86831f6f20e70a6d5
app: 1.2.0
overport_cli: 1.2.5
issue: 339
source_hint: Sclerosis_v1.10.7
@@ -0,0 +1,24 @@
package: com.Curve.LawnMowingSimulatorVR
title: Lawn Mowing Simulator VR
status: issues
notes: Left eye textures flicker only when mowing in specific areas.
tested_version: 1.0.50
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
pose_consistency: 1
hide_space_warp: 1
verified:
date: '2026-10-16'
known_good_sha256: 2653e9b1dfbd11d607b5dd17b9c07ab19d9e98e1004ef9b14cb52867b5f959dc
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 344
source_hint: com.Curve.LawnMowingSimulatorVR
@@ -0,0 +1,22 @@
package: com.HypetrainDigitalLtd.Breathedge
title: Breathedge VR
status: works
notes: Loading fade is broken and only shows a small square but the game works!
tested_version: 1.0.7
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 4975a32fbf36b4d417142a944bf7d771c18a184dc0320b5937ac2c67bd013575
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 347
source_hint: Breathadge
@@ -0,0 +1,28 @@
package: com.Miragesoft.RealVrFishingQ
title: Real VR Fishing
status: issues
notes: Seems to be almost 100% functional with meta permissions enabled. In the new Mixed Reality mode, the room
scan doesn't seem to do anything and I can't seem to manually place anything either.
tested_version: '3.06096'
engine: Unity
xr: OpenXR
frame:
- frame.vivox_audio_route
- frame.unity_text_input
- frame.oculusos
- frame.meta_permissions
device:
- device.text_input_window
adapter:
haptic_fix: 1
scene_emul: 1
controller_models: 1
verified:
date: '2026-10-16'
known_good_sha256: 8b17dd26ef0486618ec006f9d948a60dabea4d7763bbe6061db07ee8aad27c76
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 91
issue: 333
source_hint: Real VR Fishing
@@ -0,0 +1,22 @@
package: com.SchellGames.LostRecipes
title: Lost Recipes
status: works
tested_version: 1.0.1+273175
engine: Unreal
xr: VrApi
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.unreal_skip_obb_check
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: f071a1deea3e3b6dce0c3a923127f540a48b88532106536c1838f496e9d3e6c9
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 340
source_hint: Lost Recipes
+12 -10
View File
@@ -1,22 +1,24 @@
package: com.UNIVRS.Freedom
title: Freedom
status: issues
notes: Volume can't be changed in the game.
title: 'Attack on Titan VR: Unbreakable'
status: works
notes: Keep the extra files that come with the APK and OBB next to the OBB (they hold the audio). Cutscenes are
detached from the head but still viewable.
tested_version: 1.0.2950
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.asset_files
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-10'
known_good_sha256: 24662272c4cbe5dc7c4f3c803bdb9dead9661ac3d59bf80cca2e260256acc4bc
app: 0.12.0
date: '2026-10-16'
known_good_sha256: 1b3ced1a24b3b708f66aba6aa879863b667c0e502ef153d8a8cfa03e529d21fd
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 59
issue: 132
source_hint: AOT VR
frame_build: '20261015.6141207'
agent: 91
issue: 337
source_hint: Aot
+21
View File
@@ -0,0 +1,21 @@
package: com.crytek.climb
title: The Climb
status: works
tested_version: '666.0'
engine: CryEngine
xr: VrApi
frame:
- frame.vrapi_bridge
device_files:
user.cfg: 'r_vr_foveated = 0
'
verified:
date: '2026-10-16'
known_good_sha256: 3899967dd6d3ee0ce3409e94d543405c779e9c282ead29536abe93b3531ca285
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 341
source_hint: TheClimb
@@ -0,0 +1,22 @@
package: com.fasttravelgames.mascot
title: 'Cities: VR'
status: works
notes: Loading screen Cities VR Logo doesn't load right.
tested_version: 1.00.502
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
device:
- device.text_input_window
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 5537e4e9333a04eda8c1b2f96cdc9572c1bbed52a9d3af42269ba442ce9ba801
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 345
source_hint: CitiesSkylines
+4 -2
View File
@@ -5,8 +5,10 @@ source_hint: NOPE CHALLENGE
engine: Unreal
xr: OpenXR
status: works
notes: Meta XR Audio telemetry patch + nodebug. MR "Reality Breach" mode unavailable.
details: The MR 'Reality Breach' mode needs passthrough; standard VR mode used.
notes: "Meta XR Audio telemetry patch + nodebug. MR mode: needs a scanned room."
details: The MR 'Reality Breach' mode needs passthrough and a room; standard VR mode was verified.
adapter:
scene_emul: 1
alt_overport:
- patch_remove_unreal_force_quit
frame:
@@ -0,0 +1,22 @@
package: com.s3d.ExplodingKittensVR.TestRelease
title: Exploding Kittens VR
status: issues
notes: Requires online for most modes. Practice lobby does work though.
tested_version: 1.4.25779
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
frame:
- frame.hw_video_decode
adapter:
haptic_fix: 1
verified:
date: '2026-10-16'
known_good_sha256: 4b0a2348a5553851dcec820835e02c93a1a64927a30533d554943a9b81b3baa0
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 343
source_hint: com.s3d.ExplodingKittensVR.TestRelease
+2 -1
View File
@@ -1,13 +1,14 @@
package: com.zenstudios.PFXVRQuest
title: Pinball FX VR
status: issues
notes: Plays; mixed reality mode not working yet. Space warp off (fewer stutters).
notes: "Plays; space warp off (fewer stutters). MR mode: needs a scanned room."
tested_version: '1.9'
engine: Unreal
xr: OpenXR
alt_overport:
- patch_remove_unreal_force_quit
adapter:
scene_emul: 1
equirect_emul: 1
hide_space_warp: 1
verified:
+24
View File
@@ -0,0 +1,24 @@
package: jp.co.hit_point.vneko
title: Neko Atsume Purrfect
status: works
tested_version: 2.3.0
engine: Unity
xr: OpenXR
frame:
- frame.unity_text_input
- frame.oculusos
device:
- device.text_input_window
adapter:
controller_models: 1
haptic_fix: 1
scene_emul: 1
verified:
date: '2026-10-16'
known_good_sha256: da0196efa495d6ea7b34be2cea5b279acf23489134bb1dd1acef7cf54aa57da6
app: 1.2.0
overport_cli: 1.2.5
frame_build: '20261015.6141207'
agent: 91
issue: 338
source_hint: jp.co.hit_point.vneko
+18
View File
@@ -0,0 +1,18 @@
package: org.crisiszone.vr
title: Crisis Zone VR
status: works
notes: Played the beginning of the first mission without issues.
tested_version: 0.1.4
engine: Other
xr: OpenXR
frame:
- frame.sdl_clipboard
verified:
date: '2026-10-15'
known_good_sha256: 747d1e6613ae443ef03e509b509e4ec89039eec1aecf3689643db4057e53266a
app: 1.2.1.dev433
overport_cli: 1.2.5
frame_build: '20261007.6125817'
agent: 91
issue: 331
source_hint: CrisisZoneVR-v0.1.4-quest3
+18
View File
@@ -0,0 +1,18 @@
package: org.smarttube.stable
title: SmartTube
status: issues
notes: Android's home screen shows for a few seconds while the app starts (normal for flat Android apps; longest on the
first start after an install). Froze once in the reporter's session.
tested_version: '32.56'
engine: Other
xr: '?'
as_is: true
frame:
- frame.hw_video_decode
verified:
date: '2026-10-16'
known_good_sha256: cc53f1aedac52ea0d4e77803acd580e32e7e5179adc96b55db7267b63a16c864
app: 1.2.0
overport_cli: 1.2.5
issue: 350
source_hint: SmartTube_stable_arm64-v8a
+6 -6
View File
@@ -1,9 +1,9 @@
package: xyz.TheAviary.Vermillion
title: Vermillion
status: issues
notes: Runs. The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in
front of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub
#200). Not yet confirmed in the headset with this patch.
status: works
notes: "The canvas used to hide the hand and brush while painting (the Frame draws the game's canvas layer in front
of everything); the recipe makes the game draw canvases in the scene instead (fix found by Klownicle, GitHub #200).
Confirmed in the headset by Klownicle."
tested_version: 1.7.2
engine: Unity
xr: OpenXR
@@ -16,10 +16,10 @@ adapter:
haptic_fix: 1
scene_emul: 1
verified:
date: '2026-10-11'
date: '2026-10-15'
app: 1.0.1
frame_build: '20261007.6125817'
agent: 79
issue: 200
updated: '2026-10-12'
updated: '2026-10-15'
source_hint: Vermillion - VR Painting
+10
View File
@@ -6,6 +6,16 @@
# with diagnostics. Launch tests and real play sessions (agent session_log, `frameport session`) are both matched.
# Sources: every failure we hit porting 34 games to the Steam Frame (see docs/PLAYBOOK.md).
signatures:
- id: mr-no-room
pattern: 'FrameBridge[^\r\n]*scene: (?:capture requested -> (?:guardian box|using guardian room)|room from guardian|room [^\r\n]*(?:from guardian|configured/default size|box))'
severity: info
diagnosis: 'The game is using a simple fallback room. Scan your room for mixed reality, or use Frame > Room > Use my play area for walls without furniture. A recorded scan can be imported until headset capture is available.'
suggest: []
- id: mr-discovery-unsupported
pattern: '(?:Unknown proc addr:\s*xrDiscoverSpacesMETA|(?:MRUK[^\r\n]*)?NoRoomsFound)'
severity: warning
diagnosis: 'The game cannot discover a Meta room. Enable room layout (adapter.scene_emul), rebuild and reinstall for the updated extension shim, then save a room under Frame > Room.'
suggest: [adapter.scene_emul]
- id: meta-asset-status-error
pattern: 'Asset status error\s+\(code 100\)'
severity: error
+24
View File
@@ -60,6 +60,30 @@
`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.
## 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),
KHR_vulkan_enable(2), KHR_opengl_es_enable, KHR_composition_layer_depth, FB_display_refresh_rate, EXT_hand_tracking
+13 -2
View File
@@ -13,6 +13,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Arkio | Quest | ✅ Works | |
| Asgard's Wrath 2 | Quest | ✅ Works | |
| Assassin's Creed Nexus | Quest | ✅ Works | |
| Attack on Titan VR: Unbreakable | Quest | ✅ Works | |
| Audica | Quest | ✅ Works | |
| AugmentedEmpire | Quest | ✅ Works | |
| BAM | Quest | ✅ Works | |
@@ -29,12 +30,15 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| BlazeRush | Quest | ✅ Works | |
| BodyCombat | Quest | ✅ Works | |
| BONELAB | Quest | ✅ Works | |
| Breathedge VR | Quest | ✅ Works | |
| Carve Snowboarding | Quest | ✅ Works | |
| Caves | Quest | ✅ Works | |
| Cities: VR | Quest | ✅ Works | |
| COMPOUND | Quest | ✅ Works | |
| Cook-Out | Quest | ✅ Works | |
| Cosmodread | Quest | ✅ Works | |
| Creed | Quest | ✅ Works | |
| Crisis Zone VR | Quest | ✅ Works | |
| cubism | Quest | ✅ Works | |
| Dance Central | Quest | ✅ Works | |
| Deep Cuts | Quest | ✅ Works | |
@@ -72,6 +76,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Ladder | Quest | ✅ Works | |
| Lambda1VR | Quest | ✅ Works | |
| LEGO® Bricktales | Quest | ✅ Works | |
| Lost Recipes | Quest | ✅ Works | |
| Lucky's Tale | Quest | ✅ Works | |
| Maestro | Quest | ✅ Works | |
| Marvel's Deadpool VR | Quest | ✅ Works | |
@@ -85,6 +90,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Moss: Book II | Quest | ✅ Works | |
| Munkr Funkr (The Legacy Days) | Quest | ✅ Works | |
| Nano | Quest | ✅ Works | |
| Neko Atsume Purrfect | Quest | ✅ Works | |
| NEX Player | Quest | ✅ Works | |
| NFL PRO ERA II | Quest | ✅ Works | |
| NOPE CHALLENGE | Quest | ✅ Works | |
@@ -107,6 +113,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Riven | Quest | ✅ Works | |
| Robo Recall | Quest | ✅ Works | |
| RUINSMAGUS | Quest | ✅ Works | |
| Sclerosis | Quest | ✅ Works | |
| Shadow Point | Quest | ✅ Works | |
| Shoot The Zombirds VR | Quest | ✅ Works | |
| Shores of Loci | Quest | ✅ Works | |
@@ -127,6 +134,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Tea For God | Quest | ✅ Works | |
| TetrisEffect | Quest | ✅ Works | |
| The Boys VR | Quest | ✅ Works | |
| The Climb | Quest | ✅ Works | |
| The Climb 2 | Quest | ✅ Works | |
| The Last Clockwinder | Quest | ✅ Works | |
| The Room VR | Quest | ✅ Works | |
@@ -143,6 +151,7 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Vader Immortal: Episode III | Quest | ✅ Works | |
| Vampire Survivors | Quest | ✅ Works | |
| Vendetta Forever | Quest | ✅ Works | |
| Vermillion | Quest | ✅ Works | |
| VR HOT Quest | Quest | ✅ Works | |
| VR4 | Quest | ✅ Works | |
| Walkabout Mini Golf | Quest | ✅ Works | |
@@ -156,10 +165,11 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Does it Stack? | Quest | ⚠️ Works with issues | Mixed reality mode does not work (works on Demeo), everything else seems to be OK |
| Dungeons of Eternity | Quest | ⚠️ Works with issues | It can not connect to servers |
| End Space | Quest | ⚠️ Works with issues | Controls stop responding after the first mission loads. |
| Exploding Kittens VR | Quest | ⚠️ Works with issues | Requires online for most modes. |
| ExploreVR | Quest | ⚠️ Works with issues | The 3D world warps and shifts with head movement (wrong perspective). |
| Fisherman | Quest | ⚠️ Works with issues | Loading screens have corruption, but everything works otherwise. |
| Freedom | Quest | ⚠️ Works with issues | Volume can't be changed in the game. |
| Homeworld: Vast Reaches | Quest | ⚠️ Works with issues | The game starts but after selecting to start a new game it seems to crash. |
| Lawn Mowing Simulator VR | Quest | ⚠️ Works with issues | Left eye textures flicker only when mowing in specific areas. |
| MacacoVR | Quest | ⚠️ Works with issues | 2d loading screens are glitchy, otherwise main gameplay is fine |
| Majora's Mask VR | Quest | ⚠️ Works with issues | Audio on the file select screen sounds crunchy. |
| Myst | Quest | ⚠️ Works with issues | Minor graphical glitches on some objects. |
@@ -168,13 +178,14 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
| 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. |
| Silhouette | Quest | ⚠️ Works with issues | Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky. |
| SmartTube | Quest | ⚠️ Works with issues | Android's home screen shows for a few seconds while the app starts (normal for flat Android apps; longest on the first start after an install). |
| Spunky Dungeon | Quest | ⚠️ Works with issues | Gear VR / Oculus Go game (32-bit only), runs through FramePort's 32-bit bridge (experimental). |
| Swarm 2 | Quest | ⚠️ Works with issues | MSAA creates visible seams around edge geometry and also the skybox edge |
| The Light Brigade | Quest | ⚠️ Works with issues | Judder in weapons |
| The Thrill Of The Fight | Quest | ⚠️ Works with issues | Right eye doesn't draw the opponent or the referee. |
| Time Stall | Quest | ⚠️ Works with issues | Both eyes distort during movement (unresolved). |
| Vermillion | Quest | ⚠️ Works with issues | Runs. |
| WiiCompiled VR | Quest | ⚠️ Works with issues | To add a game: in FramePort's Files tab, upload your .wcgame file to this game's storage, folder Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR… |
| Deadly Delivery | Quest | ❌ Doesn't run | Quits after Meta's entitlement check fails: it needs Meta's online services. |
| Espire 1: VR Operative (Quest Edition) | Quest | ❌ Doesn't run | Mesa GL driver crash during texture upload. |
+5
View File
@@ -167,6 +167,10 @@ FramePort downloads it, checks it, restarts and keeps your games and settings. *
- Settings → **Updates** turns the check off or turns on **Install updates automatically**.
- **Recipes** (the tested patches and settings for each game) update by themselves. A game whose recipe changed shows
**Update on Frame**.
- **A newer version of a game:** right-click it (or use **…** on its page) → **Update with a newer APK…** (Quest and
Android games) or **Update with a newer version…** (Linux apps: the new AppImage, archive or folder; PC VR games:
the new game folder). Its name, artwork, tags and your settings stay. Then click **Update on Frame** (or **on this
PC**).
- **Dev builds:** if you're asked to test a change before its release, use Settings → Updates → **Install the latest
dev build…**. The next release then arrives as a normal update.
@@ -255,6 +259,7 @@ option. The main ones:
|---|---|
| `scan <folder>` / `list` / `show <game>` | add games, list the library, show a game's patches |
| `add-linux <path> [--exe <program>]` | add a Linux app (AppImage, folder or archive) |
| `new-version <game> <path>` | point a game at its newer version (APK, Linux app file/folder, PC VR game folder) |
| `recipe <game> --enable/--disable <patch>` | change a game's patches (`patches` lists them all) |
| `build <game>` / `install <game>` / `test <game>` | build, install on the Frame (`--to pc` for this PC), launch test |
| `frame discover` / `frame connect` / `frame info` | find, connect to and describe the Frame |
+40 -40
View File
@@ -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 | 38 |
| [Remove Unreal's ForceQuit](#patch_remove_unreal_force_quit) | `patch_remove_unreal_force_quit` | Patches from OVRPort | 41 |
| [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 |
@@ -47,7 +47,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [VrApi → OpenXR adapter (OVRPort)](#patch_vrapi_openxr) | `patch_vrapi_openxr` | Patches from OVRPort | |
| [32-bit ARM bridge (Gear VR / Go games)](#frame-a32_bridge) | `frame.a32_bridge` | Steam Frame patches | 6 |
| [FrameBridge OpenXR adapter](#frame-adapter) | `frame.adapter` | Steam Frame patches | default |
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 1 |
| [Content files from the game's data](#frame-asset_files) | `frame.asset_files` | Steam Frame patches | 2 |
| [Asset-file service bypass](#frame-asset_files_stub) | `frame.asset_files_stub` | Steam Frame patches | |
| [Skip Meta avatars](#frame-avatar_stub) | `frame.avatar_stub` | Steam Frame patches | 1 |
| [bHaptics: skip the Bluetooth permission request](#frame-bhaptics_permission) | `frame.bhaptics_permission` | Steam Frame patches | 5 |
@@ -56,22 +56,22 @@ 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 | 19 |
| [Hardware video decoding (H.264, HEVC, VP9)](#frame-hw_video_decode) | `frame.hw_video_decode` | Steam Frame patches | 23 |
| [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 |
| [Fix Lua startup crashes on Frame](#frame-luajit_pointers) | `frame.luajit_pointers` | Steam Frame patches | |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 2 |
| [Declare Meta-only permissions](#frame-meta_permissions) | `frame.meta_permissions` | Steam Frame patches | 3 |
| [Meta XR Audio: skip Quest telemetry class](#frame-metaxr_telemetry) | `frame.metaxr_telemetry` | Steam Frame patches | default |
| [Clear android:debuggable](#frame-nodebug) | `frame.nodebug` | Steam Frame patches | 3 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 15 |
| [Oculus OS telemetry class stubs](#frame-oculusos) | `frame.oculusos` | Steam Frame patches | 17 |
| [Fix crash when the game prepares the microphone](#frame-ovr_microphone) | `frame.ovr_microphone` | Steam Frame patches | |
| [Keep the same Meta user between starts](#frame-ovr_stable_user) | `frame.ovr_stable_user` | Steam Frame patches | default |
| [Trace Meta platform requests (diagnostics)](#frame-ovr_trace) | `frame.ovr_trace` | Steam Frame patches | |
| [Platform compat (ovrMessageType_ToString)](#frame-ovrplatformcompat) | `frame.ovrplatformcompat` | Steam Frame patches | default |
| [Stub missing Meta platform functions](#frame-ovrstubs) | `frame.ovrstubs` | Steam Frame patches | default |
| [Unpaired pointer-authentication checks](#frame-pac_hints) | `frame.pac_hints` | Steam Frame patches | 1 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 4 |
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 5 |
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
@@ -82,7 +82,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [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 | 102 |
| [Make Unity text fields work](#frame-unity_text_input) | `frame.unity_text_input` | Steam Frame patches | 107 |
| [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 |
@@ -90,21 +90,21 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Unreal: stand-ins for OVRPlugin functions OVRPort lacks](#frame-unreal_ovrp_entrypoints) | `frame.unreal_ovrp_entrypoints` | Steam Frame patches | 2 |
| [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 | 5 |
| [Unreal: start without the OBB check](#frame-unreal_skip_obb_check) | `frame.unreal_skip_obb_check` | Steam Frame patches | 6 |
| [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 |
| [Vermillion: paint canvases in the scene](#frame-vermillion_scene_canvas) | `frame.vermillion_scene_canvas` | Steam Frame patches | 1 |
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 1 |
| [Vivox voice chat: skip Android 12 audio routing](#frame-vivox_audio_route) | `frame.vivox_audio_route` | Steam Frame patches | 2 |
| [Vulkan: drop invalid render-pass pointers](#frame-vk_sanitize) | `frame.vk_sanitize` | Steam Frame patches | default · 1 |
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 3 |
| [VrApi → OpenXR bridge](#frame-vrapi_bridge) | `frame.vrapi_bridge` | Steam Frame patches | 4 |
| [Quiet Meta's VrApi loader (OpenXR games)](#frame-vrapi_stub) | `frame.vrapi_stub` | Steam Frame patches | 2 |
| [OpenGL ES: shader fixes (Vulkan layer under Zink)](#frame-zink_shader_fix) | `frame.zink_shader_fix` | Steam Frame patches | 1 |
| [Pointer start](#adapter-aim_forward) | `adapter.aim_forward` | Game settings | |
| [Pointer angle](#adapter-aim_pitch) | `adapter.aim_pitch` | Game settings | |
| [Pointer turn](#adapter-aim_yaw) | `adapter.aim_yaw` | Game settings | |
| [Use controllers](#adapter-controller_fix) | `adapter.controller_fix` | Game settings | 1 |
| [Show Steam Frame controllers](#adapter-controller_models) | `adapter.controller_models` | Game settings | 4 |
| [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 |
@@ -127,9 +127,9 @@ 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 | 81 |
| [Fix freezes when controllers vibrate](#adapter-haptic_fix) | `adapter.haptic_fix` | Game settings | 88 |
| [Vibration strength](#adapter-haptic_scale) | `adapter.haptic_scale` | Game settings | |
| [Turn off space warp](#adapter-hide_space_warp) | `adapter.hide_space_warp` | Game settings | 4 |
| [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 | |
| [Extra diagnostics (log)](#adapter-layer_debug) | `adapter.layer_debug` | Game settings | |
| [Drop broken layers](#adapter-layer_fix) | `adapter.layer_fix` | Game settings | |
@@ -137,7 +137,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Unity frame loop: wait for skipped frames](#adapter-ovrp_begin_gate) | `adapter.ovrp_begin_gate` | Game settings | 1 |
| [Steadier hands (older Unity)](#adapter-ovrp_hold_physics) | `adapter.ovrp_hold_physics` | Game settings | 1 |
| [See your room](#adapter-passthrough_emul) | `adapter.passthrough_emul` | Game settings | |
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 1 |
| [Steadier head tracking](#adapter-pose_consistency) | `adapter.pose_consistency` | Game settings | 2 |
| [Pose timing diagnostics (log)](#adapter-pose_debug) | `adapter.pose_debug` | Game settings | |
| [Fix hands lagging behind controllers](#adapter-pose_time_fix) | `adapter.pose_time_fix` | Game settings | 14 |
| [Treat newer Quest controllers as Touch](#adapter-profile_remap) | `adapter.profile_remap` | Game settings | |
@@ -148,7 +148,7 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [Re-create play space](#adapter-respace_kick) | `adapter.respace_kick` | Game settings | |
| [Sharpness](#adapter-scale) | `adapter.scale` | Game settings | 4 |
| [Room depth](#adapter-scene_depth) | `adapter.scene_depth` | Game settings | |
| [Pretend room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 9 |
| [Use room layout](#adapter-scene_emul) | `adapter.scene_emul` | Game settings | 13 |
| [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 | |
@@ -170,12 +170,12 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
| [OpenGL ES shader fixes](#adapter-zink_shader_fix) | `adapter.zink_shader_fix` | Game settings | 1 |
| [Keep the game's saved resolution in step](#device-config_sync) | `device.config_sync` | Files and settings on the Frame | 1 |
| [Show as VR or as a flat window](#device-display_mode) | `device.display_mode` | Files and settings on the Frame | |
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 2 |
| [Settings files for the game](#device-files) | `device.files` | Files and settings on the Frame | 3 |
| [Foveated rendering (Valve)](#device-foveation) | `device.foveation` | Files and settings on the Frame | 3 |
| [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 | 105 |
| [Show the app's Android window (for typing)](#device-text_input_window) | `device.text_input_window` | Files and settings on the Frame | 110 |
| [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) | |
@@ -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 (38):** Asgard's Wrath 2, BARTENDER VR SIMULATOR, Carve Snowboarding, Creed, Dance Central, Espire 1: VR Operative (Quest Edition), Espire 2, ExploreVR and 30 more.
- **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.
- **Suggested by launch-test signatures:**
- `force-quit` (error): The game quit itself right after starting.
- **Source:** [patches/overport.py](../src/frameport/patches/overport.py)
@@ -526,7 +526,7 @@ Lets the game find extra content it keeps next to its data (for example Star War
- `frame.asset_files`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** The game asks Meta's platform for asset files and its data has … content file(s) (*.pak) that OVRPort's loader doesn't report.
- **Catalog games (1):** Star Wars: Tales from the Galaxy's Edge.
- **Catalog games (2):** Attack on Titan VR: Unbreakable, Star Wars: Tales from the Galaxy's Edge.
- **Source:** [patches/frame/langpack.py](../src/frameport/patches/frame/langpack.py)
> Some games keep content in separate files next to their OBB (for example Star Wars: Tales from the Galaxy's Edge: its seasons and sound banks) and ask Meta's platform for them as asset files. OVRPort's platform loader doesn't know them, so the game waits for content that never arrives (black screen after loading). Lists the content files shipped with the game's data (*.pak) as installed and answers their download request at once with the file's path; nothing is copied or downloaded. Shares the language-pack library.
@@ -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 (19):** 4XVR Video Player, AugmentedEmpire, Batman: Arkham Shadow, Battlemarked, Dance Central, Eye of the Temple, Fruit Ninja, Ghost Town and 11 more.
- **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.
- **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.
@@ -706,7 +706,7 @@ Declares Meta's permissions some mixed-reality games ask for, so they don't stop
- `frame.meta_permissions`
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Mixed-reality game that needs the room model: its 'use spatial data' permission must be granted (for example Demeter).
- **Catalog games (2):** Arkio, Demeter.
- **Catalog games (3):** Arkio, Demeter, Real VR Fishing.
- **Source:** [patches/frame/manifest_fixes.py](../src/frameport/patches/frame/manifest_fixes.py)
> Declares com.oculus.permission.* / horizonos.permission.* that the app uses, so Android grants them at install (for example the 'use spatial data' permission of mixed-reality games like Demeter).
@@ -749,7 +749,7 @@ Provides stand-ins for Quest system calls some games make at start.
- **Suggested (on):** The Unreal build of Meta XR Audio looks up com.oculus.os.AnalyticsEvent and aborts without it (for example NOPE Challenge).
- **Suggested (on):** Several Meta SDK libraries look up com.oculus.os.AnalyticsEvent (…); games like this abort without the stub (for example Nano).
- **Offered (off):** … references com.oculus.os.AnalyticsEvent; usually harmless (for example Batman: Arkham Shadow and LEGO Bricktales run without it). Enable if the game aborts with ClassNotFoundException.
- **Catalog games (15):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, NOPE CHALLENGE, RC Pilot Trainer and 7 more.
- **Catalog games (17):** AgeOfJoy, AllInOneSports, Caves, Deep Cuts, Does it Stack?, Nano, Neko Atsume Purrfect, NOPE CHALLENGE and 9 more.
- **Suggested by launch-test signatures:**
- `oculus-os-class` (fatal): The game looks up a Quest-only system class and aborts.
- **Source:** [patches/frame/telemetry.py](../src/frameport/patches/frame/telemetry.py)
@@ -838,7 +838,7 @@ Lets SDL and LÖVE games start on the Frame (they'd crash looking for a clipboar
- `frame.sdl_clipboard` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** SDL app: it crashes at start in Lepton, which has no clipboard service.
- **Catalog games (4):** Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
- **Catalog games (5):** Crisis Zone VR, Majora's Mask VR, Time Crisis II VR (Test), Time Crisis VR (Experimental), WiiCompiled VR.
- **Suggested by launch-test signatures:**
- `sdl-no-clipboard` (fatal): The app crashed at start because the Frame's Android has no clipboard.
- **Source:** [patches/frame/sdl_clipboard.py](../src/frameport/patches/frame/sdl_clipboard.py)
@@ -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 (102):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica, AugmentedEmpire and 94 more.
- **Catalog games (107):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable, Audica and 99 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.
@@ -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 (5):** Dance Central, Kill It With Fire VR, MacacoVR, Maestro, Ragnarock.
- **Catalog games (6):** Dance Central, Kill It With Fire VR, Lost Recipes, MacacoVR, Maestro, 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)
@@ -1158,7 +1158,7 @@ Lets games with Vivox voice chat start on the Frame (they'd crash on an Android
- `frame.vivox_audio_route` · also for 2D apps
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** Vivox voice chat: it calls Android 12 audio methods, which Lepton (Android 11) lacks, and crashes.
- **Catalog games (1):** Dungeons of Eternity.
- **Catalog games (2):** Dungeons of Eternity, Real VR Fishing.
- **Suggested by launch-test signatures:**
- `vivox-api31` (fatal): The game's Vivox voice-chat SDK calls Android 12 audio-routing methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the app crashes (for example Green Hell VR).
- **Source:** [patches/frame/vivox_audio_route.py](../src/frameport/patches/frame/vivox_audio_route.py)
@@ -1189,7 +1189,7 @@ Lets games built on Meta's oldest VR interface run on the Frame.
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
- **Suggested (on):** The engine calls libvrapi.so directly; OVRPort can't translate it.
- **Conflicts with:** [`frame.vrapi_stub`](#frame-vrapi_stub)
- **Catalog games (3):** BlazeRush, Path of the Warrior, The Climb 2.
- **Catalog games (4):** BlazeRush, Path of the Warrior, The Climb, The Climb 2.
- **Suggested by launch-test signatures:**
- `direct-vrapi` (warning): The game uses Meta's VrApi directly and needs the VrApi bridge.
- `vrapi-frame-rejected` (fatal): The VrApi bridge rejected the game's frame options, so no picture reaches the headset even though frame pacing can appear in the log.
@@ -1295,7 +1295,7 @@ For games that show the headset's own controllers. Takes effect after a reinstal
- **Options:**
- Value: int, default `0`.
- Game settings → Controllers → "Show Steam Frame controllers".
- **Catalog games (4):** Ocean Rift, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
- **Catalog games (6):** Neko Atsume Purrfect, Ocean Rift, Real VR Fishing, SynthRiders, VR HOT Quest, Walkabout Mini Golf.
- **Suggested by launch-test signatures:**
- `render-model-requested` (info): The game shows Quest controllers (or none) because the Frame doesn't provide controller models.
- **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 (81):** AgeOfJoy, Ancient_Dungeon, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber, Caves and 73 more.
- **Catalog games (88):** AgeOfJoy, Ancient_Dungeon, Attack on Titan VR: Unbreakable, Audica, AugmentedEmpire, Battlemarked, BattleSisters, Beat Saber and 80 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.
@@ -1662,7 +1662,7 @@ For games whose picture flickers or smears (for example Into The Radius 2).
- **Options:**
- Value: int, default `0`.
- Game settings → Troubleshooting → "Turn off space warp" (advanced).
- **Catalog games (4):** Into The Radius 2, Metro Awakening, Pinball FX VR, The Boys VR.
- **Catalog games (5):** Into The Radius 2, Lawn Mowing Simulator VR, Metro Awakening, Pinball FX VR, The Boys VR.
- **Suggested by launch-test signatures:**
- `space-warp-used` (info): The game uses Meta's space warp (it renders half of its frames plus motion vectors, and the runtime makes up the rest).
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
@@ -1781,7 +1781,7 @@ For games that judder or shimmer while you hold your head still (for example I A
- **Options:**
- Value: int, default `0`.
- Game settings → Picture → "Steadier head tracking" (advanced).
- **Catalog games (1):** I Am Cat.
- **Catalog games (2):** I Am Cat, Lawn Mowing Simulator VR.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> 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).
@@ -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>Pretend room layout
### <a id="adapter-scene_emul"></a>Use room layout
Mixed-reality games that build their level from your room get a simple room with walls.
Scene games get your saved room. Without one, they get a simple room from the play area.
- `adapter.scene_emul`
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
- **Suggested (on), value `1`:** Mixed-reality game that builds its level from the room model: emulate a guardian-sized room (for example Demeter).
- **Suggested (on), value `1`:** The game asks Meta's system for your room layout (scene), which the Frame doesn't have: emulate a room from the play area (for example VR HOT's room setup).
- **Suggested (on), value `1`:** The game uses Meta's scene or spatial anchor APIs: serve the saved room on the Frame, or a simple play-area room until one is scanned.
- **Options:**
- Value: int, default `0`.
- Game settings → Mixed reality → "Pretend room layout".
- **Catalog games (9):** Arkio, AugmentedEmpire, Battlemarked, Demeo, Demeter, Ghostbusters: Rise of the Ghost Lord, Ocean Rift, Smash Drums and 1 more.
- 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.
- **Suggested by launch-test signatures:**
- `mr-discovery-unsupported` (warning): The game cannot discover a Meta room.
- `scene-missing` (warning): The game wants Meta's room data (scene API), which the Frame lacks.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (for example Demeter).
> Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved room, use a simple play-area box (for example Demeter).
### <a id="adapter-scene_height"></a>Room height
@@ -2272,7 +2272,7 @@ Puts settings files next to the game on the Frame, for example to turn off an un
- **Changes:** Install only: written on the Frame when the game is installed; the APK isn't changed.
- **Suggested (on):** CryEngine: variable-rate shading isn't supported on the Frame, so user.cfg turns it off (… = 0; The Climb / The Climb 2).
- **Options:** `files`: text, default none — path relative to files/ -&gt; content
- **Catalog games (2):** SUPERHOT VR, The Climb 2.
- **Catalog games (3):** SUPERHOT VR, The Climb, The Climb 2.
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
> Writes config files into the game's Android/data/&lt;package&gt;/files/ on the Frame, to turn off engine features the Frame doesn't support (for example a CryEngine user.cfg with r_variable_rate_shading = 0, as The Climb 2 needs).
@@ -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 (105):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Audica and 97 more.
- **Catalog games (110):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arcsmith, Arkio, Assassin's Creed Nexus, Attack on Titan VR: Unbreakable and 102 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".
+5 -1
View File
@@ -63,6 +63,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Un-cracked Oculus PC VR game: "Initializing OVR session" then exits / signature check on the runtime | Revive's hooks don't install on Proton-arm64; the Oculus shim rejects the unsigned Revive runtime | Not runnable on the Frame without the repack's crack extracted (not done by FramePort); use the PC version |
| PC VR game: `Failed to initialize Oculus API (-3001)` / `Unable to load LibOVRRT DLL` | Revive's LoadLibrary hook doesn't work under Proton arm64 (ARM64EC kernelbase); the game's LibOVR shim finds no runtime DLL | Symlink `LibOVRRT64_1.dll` → Revive's DLL in the prefix's system32 (manual so far) → then -3021 (runtime signature check; open) |
| Uploads to the Frame are slow | The link, storage or transfer backend can limit throughput | Check the job's "Transfer link"; try the Frame's hotspot or USB cable. Large install files prefer system OpenSSH SFTP, retaining paired-host checks and `.part` resume; unavailable or failed native uploads fall back to Paramiko. The log names the backend. Settings → Installing → Parallel uploads accepts 1–8 (default 3; saved preferences are preserved). More is not always faster. Measure the production path with `scripts/upload_bench.py --methods app-files-3 --check-resume` (three repetitions, checksum verification) |
| Log: `OpenSSH SFTP failed (255): … Permission denied (publickey,password)`, then Paramiko | OpenSSH offered keys the Frame doesn't authorize (GitHub #242) | The log lists the keys OpenSSH offered (`Offering public key`) and its warnings. A connection that logged in with the PC's own keys/agent makes OpenSSH use them too (copies of `~/.ssh/id_*` + the agent); a refused FramePort key is retried that way once. A Frame that accepts only another key file: Settings → Installing → SSH keys (CLI `frameport frame ssh-keys --add <file>`); passphrase keys go through the SSH agent |
| Log: `OpenSSH SFTP failed (1): stat C://…/[…: No such file or directory` | sftp got glob escapes inside its quotes (GitHub #324, fixed): `[` in a local folder name | Update FramePort; the native_upload `quote()` passes only `\\` and `\"` escapes, Windows paths with `/` |
| PC VR game exits right away: `Unhandled Exception: 0xc06d007e` (after `FOnlineSubsystemOculus::InitWithWindowsPlatform`) | Delay-loaded `LibOVRPlatform64_1.dll` (Oculus Platform SDK, installed with the Oculus app) is missing | None on the Frame (entitlement check; FramePort doesn't replace it). PC mode with the Oculus app |
| Unity PC VR game (Oculus + SteamVR build: `OVRPlugin.dll` and `openvr_api.dll` in `<Name>_Data/Plugins`) opens a window and quits after ~20 s; no VR Vulkan instance after the game's DXVK device in launch.log; Unity log (`unity log … Player.log`, agent v67): `OpenVR failed initialization` / `Initialization of device … failed` | Unity's built-in VR tries its SDKs in list order (Oculus first) unless `-vrmode` picks one; a catalog recipe verified with another build of the game (for example SUPERHOT VR's OpenXR build, GitHub #105) used to drop the build's own arguments | `pcvr.launch_args` = `-vrmode OpenVR` (detected for such builds; kept when the catalog's `xr` differs from the build's). A game's Electron launcher next to it (SHVR.exe) is ranked below the game |
| Unreal PC VR game opens the crash reporter instead of closing | UE starts CrashReportClient.exe on a crash | `pcvr.no_crash_reporter` (default for Unreal Rift games): `-nocrashreports` + CrashReportClient.exe renamed `.disabled` in the Frame copy |
@@ -174,7 +176,9 @@ 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 (Demeter) | no Meta scene API | adapter `scene_emul=1` (+ `frame.meta_permissions`) |
| MR game stuck waiting for room data (e.g. Demeter) | No Meta scene API | `adapter.scene_emul=1` (+ `frame.meta_permissions`); Frame → Room → Use my play area, or import a scanned room |
| MR uses an empty/simple room; FrameBridge `scene: capture requested -> guardian box` / `using guardian room`, `room from guardian`, or fallback box dimensions (`mr-no-room`) | No saved room, so FrameBridge uses its guardian box | Save a room in Frame → Room. A real SteamVR boundary supplies walls; Valve's default circle is refused. Import a scanner-built room for furniture/mesh; headset capture is not implemented yet |
| `Unknown proc addr: xrDiscoverSpacesMETA` or MRUK `NoRoomsFound` (`mr-discovery-unsupported`) | Scene discovery is unavailable or no room was discovered | Enable `adapter.scene_emul`, rebuild/reinstall to get the updated extension shim, then save a room. All games declaring USE_SCENE or USE_ANCHOR_API now get this suggestion |
| Hand-tracking game janky (Silhouette) | Frame synthesizes hands from controllers | `controller_fix=0` passes hands through; not really fixable |
| Eye distortion while moving (Arcsmith, Time Stall) | unknown (not eye swap, tracking, Valve layers, depth or pacing) | unresolved |
| The Climb 666.0: hand outlines ripple in both eyes while menus and gameplay work (GitHub #238) | Unknown. Sessions paced around 72 fps, controller tracking reported valid, and SteamVR motion smoothing was already off; these checks do not establish the cause | Unresolved after wearer comparisons on 2026-10-12: disabling Valve's Vulkan layers, setting `r_AntialiasingMode = 1`, and setting `r_AntialiasingMode = 0` with `r_MotionBlur = 0` each gave the same or worse result. These were config comparisons; runtime cvar values were not verified. Restored the playable configuration (`r_vr_foveated = 0` with the default Vulkan layers), retaining the startup/rendering crash fixes above. Do not keep the unsuccessful experimental settings as a fix |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 KiB

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 99 KiB

+49
View File
@@ -0,0 +1,49 @@
# Phase 0: reading Valve's RoomView 3D mesh (dev Frame, SteamOS 0.4.5 build 20261007, 2026-10-16)
Result: **a third-party OpenVR client can read the passthrough depth mesh, and it carries enough of the room for
a scan** (floor, table-height surfaces, orthogonal walls; the ceiling only weakly).
## Access
- Background OpenVR app (`VR_InitInternal2(..., VRApplication_Background)`, never starts SteamVR) through
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`.
- `FnTable:IVRBlockQueue_005`: `Connect("/xrservice/roomview3d/meshData")` and `.../meshLayout` work (created by
Valve's cv driver; no exclusivity seen next to the compositor's own reader). Read with
`WaitAndAcquireReadOnlyBlock(queue, &block, &buffer, EBlockQueueReadType_New, timeout_ms)` + `ReleaseReadOnlyBlock`.
- Metadata through the C++ interface `IVRPaths_002` (vtable slot 0 `ReadPathBatch(root, PathRead_t*, n)`, slot 2
`StringToHandle(&handle, path)`; `FnTable:IVRPaths_001` doesn't exist), root = the **block** handle, except
`/meshData/meshIsUpToDate` which is a property of the **queue** handle:
| path | tag | meaning |
|---|---|---|
| `/meshLayout/layoutId` | 3 (u64) | id of the index layout (static while the mesh resolution stays) |
| `/meshLayout/vertexCount` | 2 (i32) | 16896 = 132 x 128 |
| `/meshLayout/indexCount` | 2 | 99822 (u32 triangle indices into the grid, row width 132) |
| `/meshData/meshId` | 3 | frame counter (~30 per second while passthrough runs) |
| `/meshData/layoutId` | 3 | matches the layout block |
| `/meshData/elementCount` | 2 | floats in the data block (vertexCount x 3) |
| `/meshData/rawFromMeshPose` | 20 (HmdMatrix34) | mesh -> SteamVR raw tracking space |
| `/meshData/posePredictionBaseTime` | 7 (f64) | seconds (system uptime clock) |
| `/meshData/meshIsUpToDate` (queue) | 4 (bool) | false in standby / headset off |
- STAGE (= standing universe) = `IVRSystem_023` vtable slot 13 `GetRawZeroPoseToStandingAbsoluteTrackingPose()` x
rawFromMeshPose. Check: the mesh origin is 6.7 cm from `GetDeviceToAbsoluteTrackingPose(Standing)`'s head
position in every frame (camera vs head centre). **The raw->standing transform changes during a session** (its
height went 1.60 -> 1.07 m while recording: Valve's floor estimate) -> capture raw poses and convert all frames
with the transform at the end of the capture.
## The mesh
- A fixed fan of view directions, +Z forward, rows top->bottom (row 0 = up in the image, -Y), ~126 deg across the
corners (corner directions ~(+-0.67, +-0.66, 0.34)); one depth per grid point. Not head-locked in world terms:
its pose is the passthrough camera pose at capture.
- Range: reliable ~0.3-4 m; far values are inpainted (up to ~10 m) -> drop > 4 m. Strongly smoothed (soft edges,
small objects lost); locally planar: plane-fit residual of 12x12 patches median 5-17 mm.
- First frames after tracking starts can have NaN poses -> skip non-finite frames.
- Fused normals (60 s of looking around, ~3 frames/s used): floor clearly at y ~= 0, a table-height surface at
0.7 m, a lower one at 0.5 m; vertical surfaces in two orientation clusters ~85 deg apart (orthogonal walls), wall
offsets readable; ceiling: few down-facing points (people rarely look straight up; prompt for it).
- Data rate: 16896 x 12 B vertices + 99822 x 4 B indices per frame (the dump repeats the indices per frame).
## Consequences for the design
- Capture = stdlib Python on the Frame (ctypes), as above; the C scanner works offline on the dump.
- Scanner input quality: smooth, noisy at depth discontinuities; walls/floor via normals + RANSAC are robust;
furniture only as coarse boxes; ceiling needs a guided "look up" step, else the default height.
+82
View File
@@ -0,0 +1,82 @@
# FramePort room files (mixed reality)
Quest MR games read a room model (floor, ceiling, walls, furniture, a global mesh) through Meta's scene APIs. The
Steam Frame has no such data for apps (see docs/FRAME_RUNTIME.md "Room data"), so FramePort scans the room itself
(`fp_roomscan`) and FrameBridge (`native/adapter/scene_emu.c`) serves it to games. This file is the contract between
the scanner, the Frame agent, the PC app and FrameBridge.
## Coordinates
- Everything is in **STAGE space** = SteamVR's standing universe = OpenXR `XR_REFERENCE_SPACE_TYPE_STAGE`:
metres, right-handed, +Y up, floor at y = 0, -Z = the universe's forward.
- A room belongs to one chaperone universe (`universe` line). The Frame sometimes starts a new universe without
relocalizing; then the stored room may be misaligned (the agent reports `aligned: false`, nothing breaks).
## room.txt (UTF-8 text, one record per line, `#` starts a comment, fields separated by spaces)
```
frameport-room 1 # magic + format version (required, first line)
universe <decimal u64> # chaperone universe id the room was scanned in (0 = unknown)
source scan|boundary|box|manual # how it was made
time <unix seconds>
room <uuid> # the room anchor
floor <uuid> <y> # floor height (normally 0)
ceiling <uuid> <y> # ceiling height
outline <n> x0 z0 x1 z1 ... x(n-1) z(n-1)
# floor polygon, n >= 3, not closed, POSITIVE signed area:
# sum(x[i]*z[i+1] - x[i+1]*z[i]) > 0 (turning from +X towards +Z; seen from
# above with -Z forward that is clockwise). Readers reverse a negative outline.
wall <uuid> x0 z0 x1 z1 <y0> <y1> [LABEL]
# one wall face from (x0,z0) to (x1,z1), bottom y0, top y1.
# Its normal points INTO the room: walls follow the outline's order, and the
# inward normal is (-dz, dx)/|d| for d = (x1-x0, z1-z0).
# LABEL (optional, default WALL_FACE): INVISIBLE_WALL_FACE for an edge the
# scanner closed without seeing a wall (open side of a room, doorway gap).
volume <uuid> <LABEL> cx cz top_y yaw_deg w d h
# furniture box: top-face centre (cx, top_y, cz), yaw about +Y in degrees
# (0 = local X along world X), width w along local X, depth d along the
# horizontal local axis perpendicular to it, height h downwards from the top
plane <uuid> <LABEL> px py pz qx qy qz qw w h
# extra 2D plane (DOOR_FRAME, WINDOW_FRAME, WALL_ART, ...): pose of its centre,
# Meta convention (bounds in local XY, local +Z = the plane's normal)
mesh <uuid> <file name> <vertex count> <index count>
# global mesh sidecar in the same folder (GLOBAL_MESH entity)
```
- `<uuid>` = 32 lowercase hex digits (no dashes). New ones per scan; FrameBridge uses them as the XrUuidEXT bytes.
- Labels are Meta's semantic label strings: FLOOR, CEILING, WALL_FACE, INVISIBLE_WALL_FACE, TABLE, COUCH, BED,
STORAGE, SCREEN, LAMP, PLANT, DOOR_FRAME, WINDOW_FRAME, WALL_ART, OTHER, GLOBAL_MESH.
- Readers ignore unknown record types (forward compatibility) and reject a file whose first line isn't
`frameport-room <version>` with a version they know. Missing ceiling -> 2.5 m above the floor. Missing walls ->
walls from the outline edges. Missing outline -> the bounding rectangle of the walls.
### How FrameBridge maps records to Meta entities (scene_emu.c)
- ROOM: ROOM_LAYOUT (floor, ceiling, walls in file order) + SPACE_CONTAINER (every other entity) + LOCATABLE/STORABLE.
- floor/ceiling: pose at the outline centroid at height y; local +Z = +Y (floor) / -Y (ceiling);
BOUNDED_2D = the outline's bounding rect in local XY; BOUNDARY_2D = the outline polygon in local XY.
- wall: pose at the segment midpoint, height (y0+y1)/2; local +X along the wall (x0,z0)->(x1,z1), local +Y up,
local +Z = inward normal; BOUNDED_2D = length x (y1-y0) centred.
- volume: pose at the top-face centre, rotation = yaw about +Y then local +Z pointing up (+Y); BOUNDED_3D offset
(-w/2, -d/2, -h), extent (w, d, h) (Meta: volumes hang below their anchor); BOUNDED_2D = the top face (w x d).
- plane: pose as given; BOUNDED_2D = w x h centred.
- mesh: entity at the identity pose labelled GLOBAL_MESH with TRIANGLE_MESH (XR_META_spatial_entity_mesh).
## room-mesh.bin (little-endian)
```
char[4] "FPRM", u32 version = 1, u32 vertex_count, u32 index_count,
float32 xyz[vertex_count], u32 indices[index_count] (triangles; counter-clockwise seen from the room's inside)
```
Coordinates in STAGE space. Kept below 200k triangles (decimated).
## Where the files live
- Frame: `~/.local/share/frameport/rooms/<universe>/room.txt` + `room-mesh.bin` (+ `scan.log`).
`~/.local/share/frameport/rooms/current` = symlink to the newest room's folder.
- Per game: launch.sh copies the current universe's room (else `current`) to `<app_dir>/room.txt` (+ mesh) before
Lepton starts, next to `settings.conf`; FrameBridge reads `dirname($FRAMEBRIDGE_CONFIG)/room.txt`. No room ->
the old guardian box.
- A game's own saved anchors: `/sdcard/Android/data/<pkg>/files/framebridge-anchors.txt`
(`anchor <uuid> px py pz qx qy qz qw` per line, STAGE space), written by FrameBridge on save/erase.
## Scan dumps (*.fprs) — recorded RoomView 3D meshes, input of `fp_roomscan --offline`
```
char[4] "FPRS", u32 version = 1, then frames until EOF:
f64 time_s, float32 pose[7] (px py pz qx qy qz qw: mesh -> STAGE), u32 vertex_count, u32 index_count,
float32 xyz[vertex_count] (mesh space), u32 indices[index_count]
```
+216
View File
@@ -0,0 +1,216 @@
# Scene emulation stream
Implemented against `ROOM_FORMAT.md` without changing the contract, patch revisions, agent version or analysis version.
No Frame access or external downloads were used. Headset behavior still needs maintainer verification.
## Implementation
- `scene_emu.c` owns entities, component APIs, pose transforms, bounded query snapshots and the event queue.
`scene_room.c` parses room files and meshes; `scene_meta.c` implements META discovery and batch persistence;
`scene_anchors.c` implements per-game saved anchors. All are included by the adapter, with no new dependencies.
- At session creation, read `dirname(FRAMEBRIDGE_CONFIG)/room.txt`. Invalid magic/version, unusable geometry or
absent files retain the original guardian box, settings and seven deterministic UUIDs. Unknown records are
ignored; malformed records are logged and skipped. Missing ceiling, outline and walls follow the contract.
- Floor/ceiling use the polygon area centroid and real local-XY polygons. Walls retain file order, with local +X
along their segment, +Y up and +Z inward. Furniture poses use yaw then +Z up; volume bounds extend downwards.
The room container includes every other room-file entity, excluding independently created application anchors.
- Geometry exposes BOUNDED_2D, BOUNDED_3D, semantic labels and TRIANGLE_MESH as appropriate. OpenXR has no separate
`BOUNDARY_2D` component enum: `xrGetSpaceBoundary2DFB` belongs to BOUNDED_2D. Labels preserve comma-separated strings.
All variable-sized outputs support count-only and insufficient-capacity calls, including independent mesh arrays.
- Limits: 2,048 entities including application anchors, 1,024 outline points, 255 label bytes, 64 KiB record lines,
one global mesh, 600,000 vertices/indices (200,000 triangles) and 16 MiB mesh payload. Sidecar filenames cannot
escape the config directory. Magic, version, counts, exact byte size, finite coordinates and indices are checked.
- FB queries and META discovery snapshot results under the mutation lock, before queuing available/complete events.
META component/UUID filters intersect; unrecognized filters impose no restriction. FB max-result limits are honored.
There are 64 retained query snapshots and 63 queued events; calls that cannot fit their events return
`XR_ERROR_LIMIT_REACHED`. Geometry readers share the mutation lock with persistence.
- Application anchors use kernel randomness (`getrandom`, then `/dev/urandom`), never index-derived UUIDs.
Saved poses are in STAGE space. FB/META saves write a private temporary file, flush/fsync, then rename to
`/sdcard/Android/data/<package>/files/framebridge-anchors.txt`. A process suffix such as `:game` is stripped.
Erase removes application anchors from disk and the discoverable in-memory set. Failed writes report failure
through completion events and restore memory. Room-file entities remain owned by the scan: save/erase are no-ops
for those entities. Storage is local; there is no cloud synchronization.
- A new session reloads the room and saved anchors. Fake handles fit both adapter ABIs and include a session
generation; erased/stale fake spaces are never passed to SteamVR. Session generations wrap after 256 sessions.
- Capture completes immediately and logs exactly `scene: capture requested -> scanned room` for a usable room file,
or `scene: capture requested -> guardian box` for the fallback. The former covers all file sources, including manual
and boundary files. The load summary preserves the actual `source` and `universe`.
The cached `openxr-f2448a8797` headers already contain discovery, persistence and mesh declarations and enum values.
No local structure/enum substitutes were necessary. Both META extensions are added to the existing conditional
extension advertisement/instance filtering. Their system capability structures report support when emulation is active.
Passthrough and unrelated adapter settings are unchanged.
## Routing and integration
All of the following use `scene_emulation` through **both** the adapter's normal `xrGetInstanceProcAddr` and
`framebridge_extension_proc`, reached through xrshim's existing name-agnostic lookup:
```
xrQuerySpacesFB xrRetrieveSpaceQueryResultsFB
xrGetSpaceUuidFB xrEnumerateSpaceSupportedComponentsFB
xrSetSpaceComponentStatusFB xrGetSpaceComponentStatusFB
xrGetSpaceContainerFB xrGetSpaceRoomLayoutFB
xrGetSpaceBoundingBox2DFB xrGetSpaceBoundingBox3DFB
xrGetSpaceBoundary2DFB xrGetSpaceSemanticLabelsFB
xrRequestSceneCaptureFB xrCreateSpatialAnchorFB
xrSaveSpaceFB xrEraseSpaceFB
xrGetSpaceTriangleMeshMETA xrDiscoverSpacesMETA
xrRetrieveSpaceDiscoveryResultsMETA xrSaveSpacesMETA
xrEraseSpacesMETA
```
The shim additionally routes `xrPollEvent`, `xrGetSystemProperties`, `xrLocateSpace`, `xrLocateSpaces`,
`xrLocateSpacesKHR` and `xrDestroySpace` to the adapter while scene emulation is active. Other shim names continue to
OVRPort, preserving existing haptics/render-model handling. The real shim is host-tested against a dispatcher that
refuses every scene function; every emulated scene lookup succeeds and an actual discovery/retrieve round trip runs.
**Plumb-stream requirement:** ensure `scene_emul=1` also installs xrshim and rewrites OVRPlugin's loader string to it.
The pre-existing adapter patch only did this for other shim settings. These files are outside this stream's ownership.
Delivery of room files before Lepton starts and detection of universe misalignment also belong to plumb. FrameBridge
serves the supplied coordinates; it does not silently transform a room belonging to another universe.
No contract changes are needed. Whether each MRUK version accepts a room without further permissions, how it chooses
volume versus mesh geometry, and whether its visible placement matches the scan need headset tests. The existing
single-active-session architecture is retained; simultaneous independent sessions are not implemented.
## Offline verification
- 25 host tests passed, compiling the actual emulation and shim. Coverage includes malformed records, unknown
records, mesh corruption/path/cap checks, derived geometry, 300 walls, poses/normals, all buffer sizing idioms,
discovery event order/filters/backpressure, UUID query/discovery of saved anchors, new sessions/processes,
FB/META save/erase and atomic-write failure rollback.
- ASan/UBSan: scanned room, generated walls and box cases passed. LeakSanitizer was disabled because this sandbox
prohibits ptrace; its default exit failed with that environmental limitation.
- `python native/build.py --only adapter,xrshim` succeeded from cached tools. Two final builds were byte-identical,
including `SHA256SUMS`. The shim was rebuilt and is byte-identical to its previous artifact (the routing lives in
the adapter). Artifact content changes are the two adapter binaries and two checksum lines.
- Full pytest was attempted: **1,771 passed, 40 skipped, 11 failed, 16 errors**. All 27 failing/erroring tests create
local HTTP sockets, which the sandbox prohibits (`PermissionError: [Errno 1] Operation not permitted`). They are
in `test_deeplink`, `test_devkit`, `test_install_links_extra`, `test_livestream`, `test_setup_url` and `test_ui_state`.
With exactly those 27 tests deselected, and the additional real-shim test included: **1,772 passed, 40 skipped,
27 deselected**. An unrestricted maintainer run of the whole suite is still required.
- The worktree had no usable venv. Verification reused the existing local dependencies through an ignored venv
`.pth`, with `PYTHONPATH="$PWD/src"`, `UV_CACHE_DIR=/tmp/frameport-uv-cache`, `UV_OFFLINE=true` and
`uv run --no-sync pytest`. No dependencies were downloaded. The build cache was made a local directory with
symlinks to cached tools/headers so generated shader files stayed inside this writable worktree.
Adapter SHA-256:
```
arm64-v8a: a90c8d58904857bc198a48fd7a411b7807f68f14fa58fd268c57e2c2ca87e389
armeabi-v7a: 92daecaf5b86e1857ee8a61b76598e5645f8b427e5f373067c1f87bf8ff99d07
```
## Maintainer device tests
These commands are a handoff, not commands run by this agent. Use an isolated populated `FRAMEPORT_HOME`, cached
build tools, a paired Frame and legally obtained game sources. Stop games before changing their files. Back up the
existing recipe, APK, room files and saved anchors first. Integrate plumb before testing normal room delivery.
On the PC, from the integrated checkout (replace `frame.local` if necessary):
```sh
export FRAMEPORT_HOME="$PWD/.home"
export FRAMEPORT_NO_UPDATE_CHECK=1 FRAMEPORT_NO_CATALOG_UPDATE=1
FRAME=steamos@frame.local
PKG=com.Novelab.Demeter
uv run frameport recipe "$PKG" --set scene_emul=1
uv run frameport install "$PKG" --frame "$FRAME" --force
uv run frameport test "$PKG" --frame "$FRAME" --seconds 45
ssh "$FRAME"
```
In that Frame shell, find the deployment's actual base (also supports a game moved to another drive):
```sh
PKG=com.Novelab.Demeter
BASE=$(python3 -c 'import json,os,sys; p=os.path.expanduser("~/Applications/quest-frame/"+sys.argv[1]+"/deployment.json"); print(json.load(open(p)).get("base",os.path.dirname(p)))' "$PKG")
grep -E 'scene:|extension shim:.*(Spaces|TriangleMesh|BoundingBox3D|PollEvent)' "$BASE/launch.log"
```
With a current scan delivered beside `settings.conf`, expect:
```
scene: room from scan file (N walls, M volumes, P planes, K mesh triangles, universe U)
scene: capture requested -> scanned room
```
The capture line appears only if the game requests capture. A source made from a boundary/manual file names that
source in the summary. The shim logs each requested function, for example
`extension shim: xrDiscoverSpacesMETA -> FrameBridge`. There must be no `Unknown proc addr` for a scene function
that the game obtains through the shim. Repeat the rebuild/install/headset check for `com.gritGames.Nope` and
`com.zenstudios.PFXVRQuest` with `scene_emul=1`; these exercise newer MRUK discovery/persistence paths.
Wear the headset and launch from Steam. Enter each game's MR mode and request room setup if offered. Verify the
floor polygon, every wall, ceiling and furniture match the scan; tables sit below their top-face anchors; doors and
windows have the correct orientation. Headless startup alone cannot prove tracking or visual placement.
Fallback/undo test, while stopped: preserve `$BASE/room.txt` and `$BASE/room-mesh.bin` under a separate backup
folder, then disable plumb's room delivery for this run or move the source room out of `rooms/current` as well.
Without that precaution launch.sh will restore the file. Launch again; expect the original box summary and
`scene: capture requested -> guardian box`. Test `scene_width=3 scene_depth=4 scene_height=2.7` with the existing
`frameport settings` command and expect `scene: room 3.00 x 4.00 x 2.70 m`. Restore the saved files, recipe/settings
and normal room delivery afterward. Do not delete a real game's saved anchors to test the fallback.
### Android persistence probe (independent of a game's UI)
This probe uses the **real scene code with the fake runtime from the host harness**. It checks Android storage,
process-name handling and both persistence APIs; the headset tests above check the real SteamVR integration.
The following cross-compilation command was verified offline:
```sh
native/.cache/ndk-27.2.12479018/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android29-clang \
-std=gnu11 -O2 -Wall -Wextra -Werror -Wno-unused-function \
-I native/.cache/openxr-f2448a8797 -I native/adapter tests/native/scene_emu_test.c \
-lm -o /tmp/frameport-scene-test-android
scp /tmp/frameport-scene-test-android "$FRAME":/tmp/
```
Keep a freshly installed game running so its Lepton container exists, then run in the Frame shell:
```sh
APPID=$(python3 -c 'import json,os,sys; print(json.load(open(os.path.expanduser("~/Applications/quest-frame/"+sys.argv[1]+"/deployment.json")))["appid"])' "$PKG")
CONTAINER=lepton-steamlaunch-$APPID
podman cp /tmp/frameport-scene-test-android "$CONTAINER":/data/local/tmp/frameport-scene-test
podman exec "$CONTAINER" test ! -e /sdcard/Android/data/org.frameport.scenetest
# Continue only if that test succeeds: use a fresh test-only package directory.
podman exec "$CONTAINER" mkdir -p /sdcard/Android/data/org.frameport.scenetest/files /data/local/tmp/frameport-scene-probe
for mode in save reload erase-failure reload erase empty; do
podman exec "$CONTAINER" sh -c "FRAMEBRIDGE_CONFIG=/data/local/tmp/frameport-scene-probe/settings.conf exec -a org.frameport.scenetest /data/local/tmp/frameport-scene-test $mode" || break
done
podman exec "$CONTAINER" wc -l /sdcard/Android/data/org.frameport.scenetest/files/framebridge-anchors.txt
```
Every invocation must exit 0 without `FAIL`; logs show `anchors saved`, `loaded 2 saved anchors`, a deliberate
`anchor write failed` during `erase-failure` (then the following reload still finds two), and `anchors erased`.
The final file has zero lines. `save` also recreates a session internally, while separate invocations validate
new-process reload. Undo, before stopping this container:
```sh
podman exec "$CONTAINER" rm -rf /sdcard/Android/data/org.frameport.scenetest /data/local/tmp/frameport-scene-probe
podman exec "$CONTAINER" rm -f /data/local/tmp/frameport-scene-test
rm -f /tmp/frameport-scene-test-android
```
Remove the PC's temporary probe binary too. Restore backed-up game files/settings and install the previous APK if
rolling the feature back. No probe command changes real application anchors.
## Commit handoff under the sandbox
This worktree's real Git metadata is on a read-only mount. Commits therefore live on `mr/emul` in
`/tmp/frameport-mr-emul.git`, with a portable bundle at `/tmp/frameport-mr-emul.bundle`. The actual worktree contains
all final source/artifact edits but its original branch metadata cannot be advanced here. No local instruction,
app-data or credential files were staged. The base is `4bf48d3`.
After reviewing the worktree and verifying it contains exactly these changes, the maintainer can import the commits
and align the existing worktree index without discarding its files:
```sh
git bundle verify /tmp/frameport-mr-emul.bundle
git fetch /tmp/frameport-mr-emul.bundle mr/emul
git log --oneline 4bf48d3..FETCH_HEAD
git reset --mixed FETCH_HEAD
```
Run the unrestricted whole suite before merging. The bundle preserves the ordered implementation/test/build commits.
+171
View File
@@ -0,0 +1,171 @@
# Room plumbing stream
Implemented on `mr/plumb`; no Frame access or device verification. The shared `ROOM_FORMAT.md`, agent version,
analysis version and patch revisions were left unchanged. No native binaries belong to this stream.
## What changed
- `frameport.mr.room`: room/entity dataclasses, v1 text and binary-mesh validation, canonical serialization,
missing ceiling/wall/outline defaults, clockwise outline/wall repair, summaries and top-down SVG previews.
Mesh counts, indices, finite coordinates, UUIDs and sidecar basenames are checked before import.
- Standalone stdlib agent room commands live in one marked block. Rooms are staged under
`~/.local/share/frameport/rooms/<universe>/`; a `current` symlink selects the latest saved room. Uploads validate
before replacing a room. Deleting the current room selects the newest remaining room.
- `room_status` returns `rooms`, current `universe`, selected `room`, `room_text`, `aligned`, `default_circle`
(null when no usable boundary exists) and `scanner_installed`. Summaries include wall/plane counts, furniture
counts by label, floor area and ceiling height. Status text lets the UI preview a room without another transfer.
- Universe detection takes the last matching vrserver log line, then the newest chaperone record if no log match
exists. Dummy universe 424242 means unknown. Boundary conversion chains collision quads' bottom edges and refuses
the default 20-point, roughly 1 m circle. Boundary ceilings default to 2.5 m; SteamVR's 2.43 m wall extrusion
does not measure the real ceiling.
- Finalize records `scene_room` from adapter settings. A marked `_room_sync` launcher line follows the existing
config-sync pattern, before Lepton starts. Launcher upgrades also handle old recipes listing
`adapter.scene_emul`. Matching universe wins over `current`; absent rooms remove stale sidecars. Live setting
changes update the launcher and clear copies when disabled. Non-scene launchers keep their existing layout.
- PC room helpers implement status/import/export/delete/boundary/build/show/capture. Scanner upload reuses the
SHA-256 helper extracted from live view; `fp_venc` retains its soft-failure behavior. Import uploads canonical
text. Export checks mesh counts; build uploads FPRS dumps, runs the scanner and cleans its staging directory.
- CLI: `frameport frame room status|from-boundary|import|export|delete|build|show`, with `--frame` on each command.
`show --output <path.svg>` writes a preview (default below `FRAMEPORT_HOME/rooms`). Build accepts `--universe`.
- Frame page Room card: checklist, asset-URL preview, boundary/delete/capture actions, refresh and recoverable errors.
SSH and preview writes run in a background worker; state is cached per connection. Scene-game pages link to Room
setup when the connected Frame has no room. `ui_smoke.py --fake-frame` supplies local room-command answers.
- Every USE_SCENE/USE_ANCHOR_API game gets the scene-emulation suggestion. Migration `scene_emul_all` enables it once
in automatic recipes, preserving user recipes. NOPE and Pinball FX keep their other recipe fields and now request
scene emulation. Triage explains guardian fallback and missing discovery; patch docs and translation template
were regenerated.
## Integration questions and limits
1. Headset capture is intentionally unfinished: `room_capture` returns `{"error":"not implemented yet"}`. The
`capture_roomview(seconds, out_path)` hook documents the pending RoomView queue work. The GUI explains this and
offers the boundary path. Do not present the capture button as a completed scan workflow yet.
2. This branch has no `fp_roomscan` artifact; merge the scan stream before using `frame room build`. The exact scanner
invocation is `fp_roomscan --offline <dump> --out <temporary directory> --universe U --json`. Stdout must contain
one JSON value, stderr is saved as `scan.log`, and output must include a valid `room.txt` with the requested U.
3. Merge the emulation/shim stream before expecting games to use saved rooms, meshes or new Meta entry points.
Runtime log spelling is owned there; triage accepts both existing `using guardian room`/dimension logs and the
brief's `guardian box`/`room from guardian` messages. Reconcile new success logs at integration.
4. Confirm the chaperone JSON path, exact universe-switch logs and whether collision quads are already in STAGE on
the device. This implementation follows the brief's path and coordinate contract; it does not apply a second
standing-origin transform. A universe match alone cannot detect Valve changing an origin without changing its ID.
5. Agent and desktop perform independent validation because the deployed agent cannot import the desktop package.
The desktop canonicalizes outlines and fills defaults; the agent preserves scanner output and validates it.
The format contract needed no changes.
6. Rooms are shared across games; per-game saved anchors are not deleted or rewritten by these commands. Replacing
a room creates new entity UUIDs, so a game may need its own room-setup reset. No automatic game reset was added.
## Offline validation
All commands used `FRAMEPORT_HOME` in this worktree (tests use temporary isolated homes), an existing dependency
venv and offline/no-sync uv settings. No dependencies or native toolchains were downloaded.
- Room, PC/CLI, UI, heuristics, migration, MR triage and translation checks passed: 63 tests. The related
agent/launcher/screenshots/codec/gamepad regression group passed 170 tests. The final deployment/configuration
regression check passed 48 tests, including all four recipe/settings precedence cases.
- Ruff over changed Python files and `scripts/patch_docs.py --check` passed. The UI detector reported no findings.
`scripts/i18n_extract.py` regenerated `src/frameport/locales/template.json`.
- `pytest -m games`: 3 skipped (no game dumps). Dev/holdout evaluation used only the checked-in demo analysis
snapshot, 21 catalog matches: dev dated 9/17 exact, F1 81.1%; holdout dated 1/4 exact, F1 73.3%. These are limited
fixture results, not a new score for the owner's complete collection; the evaluator exits 1 for recipe differences.
- Final full suite: **1790 passed, 40 skipped, 11 failed, 16 errors** in 87.35 s. All 27 failures/errors are
`PermissionError: [Errno 1] Operation not permitted` when existing tests create sockets. The whole-suite green
requirement cannot be met in this sandbox; rerun `uv run pytest -q` on a host permitting loopback sockets.
Browser UI smoke also requires a loopback server, so it
was not run. New UI tests construct the real Flet controls and exercise deferred preview/error/cache states.
## Exact device checks (maintainer only)
Run these after integrating emul + scan. Substitute the paired Frame address below; keep the current worktree's
`FRAMEPORT_HOME=.home` and use explicit `--frame` so the test does not open another app data directory. The source
connection syncs the agent by digest even without a version bump, unless the Frame has a newer agent version.
The maintainer must perform integration version bumps before testing against a newer installed agent.
```sh
export FRAME='steamos@<frame>'
export PKG='com.gritGames.Nope' # repeat with com.zenstudios.PFXVRQuest and a known scene game
uv run frameport frame info --frame "$FRAME"
uv run frameport frame room status --frame "$FRAME" > .home/mr-status.json
# Back up ALL rooms on the Frame before destructive checks; use an unused backup name.
ssh "$FRAME" 'test ! -e ~/.local/share/frameport/rooms.before-mr-test && { test ! -d ~/.local/share/frameport/rooms || cp -a ~/.local/share/frameport/rooms ~/.local/share/frameport/rooms.before-mr-test; }'
```
1. Inspect status: no room gives `room: null`, `aligned: false`. With Valve's default circle, `default_circle: true`;
`uv run frameport frame room from-boundary --frame "$FRAME"` must fail with the default-circle explanation.
Set a real play area in SteamVR, repeat `from-boundary`: expect source `boundary`, one wall per boundary edge,
correct floor area and `aligned: true`. Open Frame → Room: visible preview, warning for the unscanned room,
aligned row ready; the capture button should show the friendly unavailable message without crashing.
2. Exercise import/export and preview with a manual fixture in the current universe:
```sh
uv run frameport frame room status --frame "$FRAME" > .home/mr-status.json
uv run python - <<'PY'
import json
from pathlib import Path
from frameport.mr.room import Mesh, Volume, box_room, dumps, write_mesh
p = Path('.home/mr-check'); p.mkdir(parents=True, exist_ok=True)
r = box_room(4, 3, 2.5)
r.source = 'manual'
r.universe = json.loads(Path('.home/mr-status.json').read_text())['universe']
r.volumes.append(Volume('a'*32, 'TABLE', .5, 0, .8, 20, 1.2, .7, .8))
r.mesh = Mesh(vertices=[(-2, 0, -1.5), (2, 0, -1.5), (2, 0, 1.5)], indices=[0, 1, 2])
(p/'room.txt').write_text(dumps(r))
(p/'room-mesh.bin').write_bytes(write_mesh(r.mesh))
PY
uv run frameport frame room import .home/mr-check/room.txt --frame "$FRAME"
uv run frameport frame room export .home/mr-export --frame "$FRAME"
uv run frameport frame room show --output .home/mr-check/preview.svg --frame "$FRAME"
cmp .home/mr-check/room-mesh.bin .home/mr-export/room-mesh.bin
```
Expect four walls, one TABLE, area 12 m² and height 2.5 m. The preview must label TABLE and the game must show it
hanging below its top-face anchor. Check the corresponding universe folder and `rooms/current` on the Frame.
3. Use a recorded dump from the same standing universe (do not invent one). Copy it to `.home/mr-check/scan.fprs`,
then run the command below. Expect the scanner JSON under `report`, source `scan`, aligned room data, `scan.log`
on the Frame and an installed executable. Run twice: an unchanged scanner must not upload again.
```sh
uv run frameport frame room build .home/mr-check/scan.fprs --frame "$FRAME"
uv run frameport frame room status --frame "$FRAME"
```
If the recording is from another universe, pass its original `--universe <id>`; do not relabel its coordinates.
4. Prepare the game's source and its existing backed-up signing key in the isolated test library, then force a
rebuild/reinstall after integration: `uv run frameport install "$PKG" --frame "$FRAME" --force --apk-only`.
Repeat for both catalog games. In each deployment, expect `scene_room: true`. Run `frame info` twice to upgrade
old launchers and check one marker only:
```sh
uv run frameport frame info --frame "$FRAME"
uv run frameport frame info --frame "$FRAME"
ssh "$FRAME" "grep -c '# frameport-room-sync' ~/Applications/quest-frame/$PKG/launch.sh"
uv run frameport settings "$PKG" scene_emul=1 --frame "$FRAME"
```
Expected marker count: 1. Start the game with Steam's Play button in the headset, enter its MR mode, then inspect
`<deployment.base>/room-sync.log`: JSON has the selected universe and alignment. The two room files must be
beside `<deployment.base>/settings.conf`. FrameBridge must load that room; `xrDiscoverSpacesMETA` must resolve
through the extension shim, with no `Unknown proc addr`/MRUK `NoRoomsFound`. Exact new native success log strings
should be taken from the emul stream. Verify walls/table placement physically; never infer alignment from FPS.
5. Exit the game. Switch `scene_emul=0` using Game settings (or the settings command above with 0): marker and game
sidecars disappear. Switch it back to 1: one marker returns. Delete the room and launch again: stale copies are
removed and existing FrameBridge guardian fallback logs trigger informational `mr-no-room` triage.
6. To check a universe mismatch, back up/export the room, change its `universe` record to another valid ID and import
it, then delete the original universe's room with `frame room delete --universe <original-id>`. Status must select
`current` but report `aligned: false`; the Room card warns. Restore the original file before playing physically.
The dummy universe 424242 should report current universe 0 rather than claim alignment.
## Undo device checks
Close all games. Restore the backed-up store (or remove only the fixture universes if there was no previous store):
```sh
ssh "$FRAME" 'if test -d ~/.local/share/frameport/rooms.before-mr-test; then rm -rf ~/.local/share/frameport/rooms; mv ~/.local/share/frameport/rooms.before-mr-test ~/.local/share/frameport/rooms; fi'
```
Set each tested game's `scene_emul` back to its recorded original value. To remove a test room without a backup:
`uv run frameport frame room delete --universe <fixture-universe> --frame "$FRAME"`. Start a scene game once to
refresh/remove its copies. The scanner can be removed with
`ssh "$FRAME" 'rm -f ~/.local/share/frameport/bin/fp_roomscan'`; the next build reinstalls it. Game saves and
`framebridge-anchors.txt` are untouched. Restore the previous app/agent/native artifacts through the maintainer's
normal integration rollback if testing the combined native implementation fails.
+338
View File
@@ -0,0 +1,338 @@
# Offline room scanner (scan stream)
`fp_roomscan` converts recorded FPRS v1 meshes to the [room contract](ROOM_FORMAT.md), including the optional
wall label adopted from upstream commit `8cb2783`:
`room.txt` and `room-mesh.bin`. It does not capture RoomView queues, contact a headset, install rooms, or modify
the current-room symlink. Scanner work changes `native/roomscan/`, its build target and artifact/checksum,
`scripts/mr/synth_scan.py`, `tests/test_roomscan.py`, and this note. The upstream contract commit also brings its
existing prompt assets and renderer unchanged. No runtime, agent, patch, or analysis versions were bumped.
## Build and run
Use the cached NDK; no downloads are needed for this target:
```sh
python native/build.py --only roomscan
```
The result is `artifacts/linux-arm64-bin/fp_roomscan`, a static aarch64 Linux ELF with its own `_start`, raw Linux
syscalls, and no interpreter, dynamic section, libc, libm, UAPI headers, generated tables, or new dependencies.
`platform.h` is the reconstruction code's entire I/O/allocation boundary. The same five C sources compile on the
host:
```sh
cc -std=c11 -O2 -Wall -Wextra -Werror -ffp-contract=off -fno-math-errno -DFP_HOSTED native/roomscan/*.c -o /tmp/fp_roomscan
python scripts/mr/synth_scan.py /tmp/room.fprs
/tmp/fp_roomscan --offline /tmp/room.fprs --out /tmp/scanned-room --universe 0 --json
```
The output directory's parent must exist; the scanner creates the final directory if needed. It always prints
exactly one JSON summary, whether `--json` is supplied or omitted. Success exits 0, errors exit 2 with
`{"ok":false,"error":"..."}`. `--help` prints usage and exits 0. Successful summaries contain:
```json
{"ok":true,"frames":30,"skipped_frames":0,"far_vertices":0,"discontinuity_triangles":0,
"walls":4,"detected_walls":4,"volumes":2,"floor_y":-0.0023,
"ceiling_y":2.5992,"coverage_percent":100.0,"hint_used":false,"triangles":70997,
"accepted_triangles":118804,"rejected_triangles":704,"warnings":[],
"timings_ms":{"read":0,"fusion":0,"model":0,"mesh":0,"total":0}}
```
The example above illustrates the schema; actual counts and timings depend on the input. Heights are absolute
STAGE coordinates. `frames` includes skipped frames; `skipped_frames`, `far_vertices`, and
`discontinuity_triangles` count input exclusions once, not once per pass. `walls` counts output polygon
edges including synthesized edges; `detected_walls` counts fitted physical planes. Coverage means the percentage
of the output perimeter supported by observed wall planes, **not camera coverage of all room surfaces**. Timings
include parsing, fusion, structural/furniture modelling, mesh generation, and total time including output.
All frame headers, timestamps/vertices/poses, approximately unit quaternions, counts, indices and EOF boundaries
are checked before publication. A non-finite timestamp/pose/vertex or invalid tracking quaternion skips the entire
frame after consuming and structurally validating its record, with a count and warning. A bad frame never erases
usable frames elsewhere in the dump. Vertices outside the camera-relative range (default 4 m) and every triangle
touching them are discarded. Triangles with a camera-depth jump over `--depth-jump + --depth-ratio * nearest_depth`
are rejected before fusion; this complements long-edge and grazing rejection. Frame counts are capped at
1 million vertices / 3 million indices;
oversized records fail rather than allocating from untrusted counts. A header-only dump or one without usable
triangles fails. Empty frames may accompany useful frames. Structural corruption (truncation, unsupported header,
bad counts or indices) remains fatal, preserves any existing output byte for byte and creates no output directory.
UUIDs come from `getrandom`, fresh per scan; mesh geometry is repeatable.
Both complete files are written to exclusive, randomly named temporary files, then individually renamed, with
the mesh first and `room.txt` last. A write failure removes the temporary files. These are atomic **file**
replacements, not a two-file filesystem transaction: the installer should scan into a fresh staging directory
and publish that directory/symlink after success. Do not run concurrent writers into the same output directory.
There is no fsync-based power-loss durability promise. Neither scan nor build writes application data outside
the worktree's `FRAMEPORT_HOME`; fixtures and test output use temporary directories.
## Reconstruction
1. The first streaming pass validates every frame, rotates its mesh by the supplied quaternion, and translates
it into STAGE. It rejects degenerate triangles, excessively long edges, out-of-range points, and grazing
surfaces. Winding is corrected toward the observing camera. A deterministic bounded reservoir of 32,768
accepted triangle centroids determines median position and padded bounds. No frames are retained across reads.
2. The second pass rasterizes accepted triangles at 0.75 voxel spacing. Per-cell surfels average positions and
inward normals, weighted by area and squared incidence. A capped accumulated weight lets later views correct
earlier ones. The grid is sized from the sample bounds and capped at 8 x 8 x 4 metres about the data median.
Unobserved cells have zero weight. This implementation uses a bounded dense backing array for the sparse
observations, rather than a hash table; the 4.2-million-cell hard cap bounds all later work. Complete regular
fans are recognized from their index topology, without hardcoding a camera resolution. Their normals come from
12 x 12 neighbourhood covariance plane fits, rejecting patches above `--patch-residual` RMS; irregular meshes
retain triangle normals. Each cell counts contributing frames once and needs `--min-views` evidence, capped at
ten percent of usable frames (rounded down, at least one) for brief captures. This suppresses transient stereo
geometry without requiring a short scan to see each surface five times.
3. Horizontal normal/height histograms select the dominant floor within the STAGE floor band and a ceiling above
the minimum ceiling height. Missing floor uses STAGE y=0 with a warning; missing ceiling uses floor plus
`--ceiling-default`, warning exactly `ceiling not seen`. A ceiling must also have at least 15% as many supported
cells as all upward-facing horizontal surfaces, so a few isolated down-facing patches do not invent a ceiling.
4. RANSAC fits near-vertical planes using distance and normal agreement, then refines them by position covariance.
Minimum height, span, and area reject furniture faces. Wall directions within the snap tolerance of the
dominant wall's orthogonal directions are snapped, with offsets refitted. Directed wall segments are connected
by nearest endpoints and adjacent line intersections. This preserves the L-shaped fixture's re-entrant corner.
Parallel same-facing planes within 25 cm / approximately 14° merge, suppressing duplicate softened faces.
Planes below 20% of the strongest original plane's area are discarded. When at least 65% of candidate wall area
supports an orthogonal family, directions outside 12° of that family are discarded (`--snap-degrees 0` disables
this orthogonal consensus filter). Large gaps close along observed endpoints. Ambiguous endpoints with three
or four planes can use inward half-plane intersections; concave rooms retain segment ordering. Otherwise a
warned observed-bounds rectangle is used. Polygon checks reject crossing edges. The output follows the contract's positive
shoelace ordering in (x,z), with left-facing inward normals.
5. Furniture tops are clustered separately from vertical faces, within supported horizontal height bands. A
softened edge connecting a tabletop to a wall can no longer absorb the whole room into one box. Top boxes use
a minimum-area horizontal orientation search and the median surface height; vertical screen candidates use
face-direction harmonics / PCA. Bounds trim the outer 2% to suppress stereo halos. Top boxes extend down to the
floor (the footprint and lower extent remain geometric estimates). Weak/sparse top patches are discarded.
Labels follow height, area, long-side (with two voxels of measurement tolerance),
thinness and near-wall rules, in order BED, COUCH, TABLE, SCREEN, STORAGE, OTHER. Only volumes are emitted;
optional table plane records are not required. Floor/ceiling/wall surfaces are excluded before clustering.
6. Surfel splats form an incidence/area-weighted signed distance field within three voxels of each surface. The
sign is positive in observed free room space. Marching cubes traces zero crossings on cube faces, resolving
ambiguous faces from their centre sign. It fans closed contours and orients triangles toward the positive
gradient. Unknown corners suppress extraction rather than bridging unobserved space. If necessary, extraction
is repeated on a coarser lattice (strides 2–4) until at most 200,000 triangles remain, with a decimation warning;
excess beyond that fails. Vertices are unshared triangle vertices, so at most 600,000 vertices. Structural
planes are semantic anchors, not substituted for the observed global mesh.
Peak allocation stays below 260 MiB, including maximum frame buffers, the grid, model worklists and mesh buffers
(the latter phases do not retain frame buffers). Default synthetic scenes use a much smaller grid. Thresholds
are CLI options below or named constants next to their algorithms. No room-specific coordinates or dimensions are
used; on-device quality and speed still need validation.
## Tuning
Distances are metres, areas m², volumes m³. `--universe` accepts the full unsigned 64-bit range; 0 means unknown.
| Option | Default | Accepted range / meaning |
| --- | ---: | --- |
| `--voxel` | 0.05 | 0.04–0.20, spatial resolution; lower values cost more memory/work |
| `--ceiling-default` | 2.5 | 1.8–3.8, height above floor when ceiling is unseen |
| `--truncation` | 3 | 1–5, TSDF band in voxel units |
| `--max-edge` | 0.6 | 0.05–1.5, reject longer triangle edges |
| `--grazing` | 0.12 | 0.01–0.9, minimum absolute camera/normal cosine |
| `--range` | 4 | 1–10, maximum camera-relative vertex distance; >4 m opts back into inpainted depth |
| `--depth-jump` | 0.25 | 0.02–1, absolute part of a triangle's maximum depth discontinuity |
| `--depth-ratio` | 0.12 | 0–0.5, additional fraction of nearest vertex depth |
| `--min-views` | 5 | 1–16, temporal support target; brief scans cap it as described above |
| `--patch-residual` | 0.03 | 0.005–0.1, maximum RMS residual of a regular-fan normal patch |
| `--plane-distance` | 0.045 | 0.01–0.15, point-to-plane inlier tolerance |
| `--min-plane-area` | 0.45 | 0.1–4, minimum occupied plane area estimate |
| `--wall-height` | 1.5 | 0.5–3, minimum observed vertical wall span |
| `--snap-degrees` | 5 | 0–15, orthogonal snap tolerance |
| `--floor-band` | 0.35 | 0.05–1, floor search distance from STAGE y=0 |
| `--ceiling-min` | 1.8 | 1.5–3, minimum absolute ceiling y |
| `--min-volume` | 0.05 | 0.005–1, reject smaller furniture boxes |
| `--cluster-gap` | 0.10 | 0.04–0.20, voxel neighbourhood gap (rounded up to cells) |
| `--gap` | 0.65 | 0.1–1.5, maximum adjacent-wall endpoint extrapolation |
| `--horizontal` | 0.85 | 0.6–0.99, minimum floor/ceiling/top normal cosine |
Additional constants in `model.c` include RANSAC trials, normal agreement, wall span, furniture margins,
label height/area thresholds, box trimming and circle recognition. Fusion sampling/weight limits are in
`fusion.c`; mesh splat radii/decimation limits in `mesh.c`; hard resource limits in `scan.h`.
`--hint outline.txt` accepts one `x z` point per line, decimal/scientific notation, optional `#` comments, either
winding, at most 96 points / 8 KiB. At least eight points with radial coefficient of variation below 5% count as
the default circle and are ignored. A non-circular hint is used only when walls are incomplete **and every
observed wall has a compatible hint edge** within `--gap`. Complete scans are never constrained to chaperone.
Incompatible inset play boundaries are ignored with a warning. Hint-completed edges retain synthesized status.
## Limits for integration
- Synthesized wall records now append `INVISIBLE_WALL_FACE` as their optional eighth field. Observed walls omit
the label and default to `WALL_FACE`; comments are no longer used to carry semantics. This matches upstream's
authorized contract change exactly; adapters need that same contract revision.
- A private RoomView recording was used for offline robustness work. It, its derived geometry, and its results
stay outside committed files. Synthetic fixtures independently exercise the input characteristics. They are
regressions, not proof
of headset accuracy. Furniture labels are geometric heuristics; adjacent/touching objects can merge, occluded
objects may split, top-only clusters have inferred depth, and thin fragments can be screen candidates. Nearby
parallel walls and small wall jogs can merge under coarse depth uncertainty. Large disconnected coplanar wall segments are
currently fitted together; complicated rooms can fall back to a warned rectangle.
- Cropped or sparse captures can produce an incomplete mesh and synthesized outline. Surfaces are not filled
through unseen regions. Furniture is not an accessibility/safety boundary. Rooms beyond the grid cap are
cropped with a warning. Default-circle recognition may also ignore an intentionally circular boundary.
- Output paths are fixed by the contract. The plumbing stream should stage a new directory and switch `current`
after scanner success, preserving the previous room for rollback. Assign the recording's actual universe id;
this standalone scanner cannot query SteamVR or validate universe alignment.
## Offline validation
`tests/test_roomscan.py` builds with the host compiler (skips only if none exists). Its 41 tests cover the noisy
4 x 3 x 2.6 m room with table/couch, six-wall L, moving and rotated/translated mesh poses, ceiling fallback, a single
frame, missing walls, circular/matching hints, correct UUIDs/wire layout, interior mesh winding, triangle budget,
repeatable mesh bytes with fresh UUIDs, truncated/corrupt dumps, invalid counts/indices/poses and invalid CLI values.
The test module takes about fourteen seconds including synthetic rendering and compilation (under the 60 s budget).
New cases cover non-finite frames before/after usable frames, per-vertex far rejection, depth jumps below the edge
limit, weak ceiling evidence, wall labels and a complete camera-fan scan. The deterministic stdlib renderer now
defaults to a 132 x 128 full index fan (99,822 indices), +Z forward / -Y image up, with poses mapping to STAGE.
It adds separable depth smoothing, spatially correlated noise, far inpainting, two initial NaN poses, 3 Hz timestamps
and head-height drift from 0.9 to 1.7 m. `--profile legacy` retains the smaller 110° x 76° test cone. New CLI controls
are `--profile`, `--invalid-start`, `--smoothing` and optional resolution overrides. Use `--invalid-start 0` for a
usable one-frame fixture; default RoomView warm-up frames otherwise have no valid tracking.
```sh
UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run pytest -q tests/test_roomscan.py
UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run ruff check scripts/mr/synth_scan.py tests/test_roomscan.py
```
The synthetic camera fan passes AddressSanitizer + UndefinedBehaviorSanitizer. Two consecutive final native builds
produce identical SHA-256 `0d88e5adfa1a21da7e0cfec2698a835d7767961e15c0b41c84235fa24ae1e751` (54,248 bytes),
matching `artifacts/SHA256SUMS`. ELF inspection confirms aarch64, little-endian, no PT_INTERP / PT_DYNAMIC, and no
embedded local build paths.
The required full `uv run pytest -q` run completed with **1,788 passed, 40 skipped, 11 failed, 16 errors** in 79 s.
All 27 failures/errors are the sandbox's `PermissionError: [Errno 1] Operation not permitted` on socket creation
in existing HTTP/pairing/live-stream tests (`test_deeplink`, `test_devkit`, `test_install_links_extra`,
`test_livestream`, `test_setup_url`, `test_ui_state`). This environment forbids sockets, including loopback.
The unchanged baseline had the identical 27 failures/errors before the scanner tests were added (1,747 passed).
Consequently the whole-suite-pass requirement cannot be satisfied here; rerun it outside that restriction at
integration. No tests were changed/skipped to hide the restriction and no network/device access was attempted.
No patch/settings/catalog/triage files were touched, so the conditional patch documentation regeneration is
unnecessary.
## Ten-minute capture budget (synthetic benchmark)
The scanner is streaming and single-threaded. On an Intel Core i7-14700K host, a **synthetic-only** 1,800-frame
132 x 128 dump (1,083,751,208 bytes, equivalent to ten minutes at 3 Hz) took **7.41 s elapsed / 7.08 s CPU**, with
**27,208 KiB maximum RSS** at the default voxel size. The file repeats a 30-view synthetic sweep, including its
warm-up frames; it tests parsing/fusion throughput rather than 1,800 independent viewpoints. Geometry-dependent
model/mesh work and storage caches affect timing. No private recording or derived room data is in that benchmark.
Reproduce with bounded copies rather than loading a gigabyte into Python:
```sh
python scripts/mr/synth_scan.py /tmp/roomview.fprs
python - <<'PY'
import shutil
with open('/tmp/roomview.fprs', 'rb') as source, open('/tmp/roomscan-1800.fprs', 'wb') as out:
out.write(source.read(8))
for repeat in range(60):
source.seek(8)
shutil.copyfileobj(source, out, 1024 * 1024)
PY
/usr/bin/time -v /tmp/fp_roomscan --offline /tmp/roomscan-1800.fprs --out /tmp/roomscan-benchmark --json
```
For the Frame's Cortex-A720/X4-class cores, budgeting **3–5 times the host CPU time plus storage** gives roughly
**23–38 s** for this workload, leaving headroom under the requested ~60 s. This is an estimate, not an ARM
measurement or an 8-core speedup claim. Worst-case allocations are bounded below 260 MiB independently of frame
count (48-byte cells, 4.2-million-cell cap, bounded frame buffers/worklists), below both the original 300 MB and
follow-up 600 MB limits. A maximum-sized complex room can add model/mesh work beyond this small synthetic sweep.
The maintainer must time a ten-minute real capture on the Frame and check CPU time, wall time and RSS before
claiming the target is achieved on-device. Hardware square root instructions replace iterative division; neither
build links libm. The build and host command both require `-fno-math-errno` for that intrinsic.
## Exact device checks (maintainer only; none executed by this stream)
1. On the development PC, build, then render the canonical fixtures:
```sh
python native/build.py --only roomscan
python scripts/mr/synth_scan.py /tmp/box.fprs
python scripts/mr/synth_scan.py /tmp/l.fprs --shape l
python scripts/mr/synth_scan.py /tmp/down.fprs --no-ceiling
python scripts/mr/synth_scan.py /tmp/one.fprs --frames 1 --invalid-start 0
scp artifacts/linux-arm64-bin/fp_roomscan /tmp/box.fprs /tmp/l.fprs /tmp/down.fprs /tmp/one.fprs steamos@<FRAME>:~/Downloads/
```
2. In a terminal **on the Frame**, use an isolated directory; no game or current room is changed:
```sh
mkdir -p "$HOME/.local/share/frameport/roomscan-test"
cd "$HOME/.local/share/frameport/roomscan-test"
cp "$HOME/Downloads/fp_roomscan" ./fp_roomscan
chmod 700 ./fp_roomscan
./fp_roomscan --help
./fp_roomscan --offline "$HOME/Downloads/box.fprs" --out box --universe 0 --json > box.json
cat box.json
./fp_roomscan --offline "$HOME/Downloads/l.fprs" --out l --json > l.json
./fp_roomscan --offline "$HOME/Downloads/down.fprs" --out down --ceiling-default 2.7 --json > down.json
./fp_roomscan --offline "$HOME/Downloads/one.fprs" --out one --json > one.json
```
Expected JSON: box has `ok:true`, `walls:4`, `detected_walls:4`, `volumes:2`, floor within 5 cm of 0,
ceiling within 5 cm of 2.6, coverage 100; the only expected box warnings concern skipped warm-up frames and
out-of-range vertices. `box/room.txt` includes TABLE and COUCH volume records.
L has six walls and the concave corner near (0,0), rather than a rectangle. Down includes exactly the warning
string `ceiling not seen` (other warnings are expected), with ceiling minus floor 2.7. One includes
`few frames; scan more viewpoints`. Every mesh should have at most 200,000 triangles. JSON is the scanner log;
there are no expected stderr status lines.
3. Verify sidecars, wall labels and the corrupt-input exit status on the Frame:
```sh
python3 - <<'PY'
import json, pathlib, struct
for name in ('box', 'l', 'down', 'one'):
report = json.loads(pathlib.Path(name + '.json').read_text())
data = pathlib.Path(name, 'room-mesh.bin').read_bytes()
magic, version, nv, ni = struct.unpack_from('<4sIII', data)
assert report['ok'] and magic == b'FPRM' and version == 1
assert ni % 3 == 0 and 0 < ni // 3 <= 200000
assert len(data) == 16 + nv * 12 + ni * 4
for line in pathlib.Path(name, 'room.txt').read_text().splitlines():
if line.startswith('wall '):
fields = line.split()
assert len(fields) in (8, 9)
if len(fields) == 9:
assert fields[-1] == 'INVISIBLE_WALL_FACE'
print(name, nv, ni // 3, report['warnings'])
pathlib.Path('broken.fprs').write_bytes(b'FPRS\x01\0\0\0\0')
PY
./fp_roomscan --offline broken.fprs --out broken --json
echo "$?"
test ! -e broken/room.txt
test ! -e broken/room-mesh.bin
```
Expected: `{"ok":false,"error":"truncated frame header"}`, exit status 2, no `broken` outputs.
4. Record actual RoomView dumps with the maintainer's recorder (FPRS v1, mesh-to-STAGE poses, stable universe).
Copy the recording to `~/Downloads/capture.fprs`. Look around slowly for about a minute, including floor,
ceiling, every wall, and furniture from several directions. With the **recorded universe id** assigned below:
```sh
scan_universe=0 # replace 0 with the recorder's actual decimal universe id
/usr/bin/time -v ./fp_roomscan --offline "$HOME/Downloads/capture.fprs" --out real --universe "$scan_universe" --json > real.json 2> real-timing.log
cat real.json real-timing.log
cat real/room.txt
```
Measure wall offsets/yaws, ceiling and table top physically and compare to the records (target 5 cm / 2°).
Check resident memory below 300 MB, sensible coverage/warnings, tabletop labels and no false wall/furniture
blobs from smeared edges. Repeat with `--max-edge 0.35 --grazing 0.2` into `real-stricter` if edges smear;
try `--voxel 0.08 --plane-distance 0.07` into `real-coarser` if depth is noisy. Keep both JSON logs and raw
dumps **outside git** for threshold calibration. These are proposed tuning probes, not established device fixes.
5. Repeat the synthetic 1,800-frame benchmark from the preceding section on the Frame, using the same command
and `/usr/bin/time -v`. Then test a ten-minute independent real capture. Target: about 60 s elapsed or less,
less than 600 MB RSS, successful JSON and bounded mesh size. Record CPU time as well as wall time so storage
overhead is visible. With difficult stereo edges compare `--min-views 8 --patch-residual 0.02` in a fresh output
directory; short captures cap view support automatically. Include the JSON's exclusion counters in triage;
skipped frames may occur anywhere in the capture. Keep all personal results outside git.
6. After merging plumbing/emulation, publish this staged room through that stream's room-install path and try
Demeter, then an MRUK discovery/mesh game. Check room loading, furniture alignment, and warning/fallback
behaviour in-headset; the scanner alone cannot verify the Meta API path. Do not replace `rooms/current` by hand
during these standalone checks.
Undo standalone checks: remove only the isolated `~/.local/share/frameport/roomscan-test` directory and the five
test files copied to `~/Downloads` (plus `capture.fprs` if no longer needed). No Steam settings, games, agent,
room universe folder, or current-room symlink were changed by these steps.
+32 -5
View File
@@ -348,8 +348,9 @@ 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 the guardian bounds");
if (!emulate_scene) LOG("emulating Meta scene/spatial-entity extensions from room file or guardian bounds");
emulate_scene = 1;
if (!strcmp(ext, "XR_META_spatial_entity_mesh")) scene_app_mesh = 1;
} else if (controller_models && render_models_available && !strcmp(ext, XR_FB_RENDER_MODEL_EXTENSION_NAME)) {
emulate_render_model = 1;
LOG("emulating XR_FB_render_model with Steam Frame controller models");
@@ -875,6 +876,11 @@ 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)
@@ -921,7 +927,10 @@ XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpace(XrSpace space, XrSpace baseSpace, X
pose_time_note("xrLocateSpace", (uint64_t)(uintptr_t)space, (uint64_t)(uintptr_t)baseSpace, time);
if (emulate_scene && location) {
XrPosef pose; XrSpaceLocationFlags flags;
if (locate_fake(space, baseSpace, time, &pose, &flags)) {
pthread_mutex_lock(&scene_lock);
int fake = locate_fake(space, baseSpace, time, &pose, &flags);
pthread_mutex_unlock(&scene_lock);
if (fake) {
location->pose = pose;
location->locationFlags = flags;
return XR_SUCCESS;
@@ -943,8 +952,8 @@ static XrResult locate_spaces_common(const char *name, XrSession session, const
if (info) pose_time_note(name, info->spaceCount ? (uint64_t)(uintptr_t)info->spaces[0] : 0,
(uint64_t)(uintptr_t)info->baseSpace, info->time);
if (emulate_scene && info && locations) {
any_fake = fake_space_index(info->baseSpace) >= 0;
for (uint32_t i = 0; i < info->spaceCount && !any_fake; ++i) any_fake = fake_space_index(info->spaces[i]) >= 0;
any_fake = is_fake_space(info->baseSpace);
for (uint32_t i = 0; i < info->spaceCount && !any_fake; ++i) any_fake = is_fake_space(info->spaces[i]);
}
if (!any_fake) {
PFN_xrLocateSpaces fn = (PFN_xrLocateSpaces)lookup(active_instance, name);
@@ -967,7 +976,12 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_locate_spaces_khr(XrSession session, c
}
XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace space) {
if (fake_space_index(space) >= 0) return XR_SUCCESS; // entity spaces live for the whole session
if (is_fake_space(space)) {
pthread_mutex_lock(&scene_lock);
int valid = fake_space_index(space) >= 0;
pthread_mutex_unlock(&scene_lock);
return valid ? XR_SUCCESS : XR_ERROR_HANDLE_INVALID; // entity spaces live for the whole session
}
PFN_xrDestroySpace fn = (PFN_xrDestroySpace)lookup(active_instance, "xrDestroySpace");
return fn ? fn(space) : XR_ERROR_FUNCTION_UNSUPPORTED;
}
@@ -2109,6 +2123,19 @@ __attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_
NATIVE_PROC(xrCreateVulkanInstanceKHR)
#undef NATIVE_PROC
}
// The shim bypasses OVRPort's fixed dispatch table for all scene calls, including consumers of fake spaces.
if (name && emulate_scene) {
PFN_xrVoidFunction scene = scene_emulation(name);
if (scene) return scene;
#define SCENE_PROC(fn) if (!strcmp(name, #fn)) return (PFN_xrVoidFunction)fn;
SCENE_PROC(xrPollEvent)
SCENE_PROC(xrGetSystemProperties)
SCENE_PROC(xrLocateSpace)
SCENE_PROC(xrLocateSpaces)
SCENE_PROC(xrDestroySpace)
#undef SCENE_PROC
if (!strcmp(name, "xrLocateSpacesKHR")) return (PFN_xrVoidFunction)emu_locate_spaces_khr;
}
return name ? render_model_emulation(name) : NULL;
}
+115
View File
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by scene_emu.c. Persist only application anchors; scanned entities remain owned by the room file.
#include <sys/random.h>
#include <sys/stat.h>
#include <unistd.h>
#ifndef SCENE_EXTERNAL_ROOT
#define SCENE_EXTERNAL_ROOT "/sdcard/Android/data"
#endif
static int anchor_path(char *path, size_t size) {
char process[256] = {0};
FILE *f = fopen("/proc/self/cmdline", "r");
if (!f) return 0;
size_t n = fread(process, 1, sizeof(process)-1, f);
fclose(f);
if (!n) return 0;
char *colon = strchr(process, ':');
if (colon) *colon = 0;
// Only package-like process names: never let a command line become a path traversal.
if (!strchr(process, '.') || !strcmp(process, ".") || !strcmp(process, "..")) return 0;
for (const char *p = process; *p; ++p)
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') || *p == '.' || *p == '_')) return 0;
int len = snprintf(path, size, "%s/%s/files/framebridge-anchors.txt", SCENE_EXTERNAL_ROOT, process);
return len > 0 && (size_t)len < size;
}
static int anchor_uuid(XrUuidEXT *u) {
size_t have = 0;
while (have < sizeof(u->data)) {
ssize_t n = getrandom(u->data + have, sizeof(u->data) - have, 0);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) break;
have += (size_t)n;
}
if (have < sizeof(u->data)) {
FILE *f = fopen("/dev/urandom", "rb");
if (!f) return 0;
have = fread(u->data, 1, sizeof(u->data), f);
fclose(f);
if (have != sizeof(u->data)) return 0;
}
u->data[6] = (uint8_t)((u->data[6] & 15) | 0x40);
u->data[8] = (uint8_t)((u->data[8] & 63) | 0x80);
for (int i = 0; i < entity_count; ++i) if (uuid_equal(u, &entities[i].uuid)) return 0;
return 1;
}
static int write_anchors(void) {
char path[4096], tmp[4128];
if (!anchor_path(path, sizeof(path))) return 0;
snprintf(tmp, sizeof(tmp), "%s.tmp-XXXXXX", path);
int fd = mkstemp(tmp);
if (fd < 0) return 0;
FILE *f = fdopen(fd, "w");
if (!f) { close(fd); unlink(tmp); return 0; }
int ok = 1;
for (int i = 0; i < entity_count; ++i) {
const scene_entity *e = &entities[i];
if (!e->is_anchor || !e->saved || e->erased) continue;
char uuid[33];
for (int k = 0; k < 16; ++k) snprintf(uuid + k*2, 3, "%02x", e->uuid.data[k]);
const XrPosef p = e->pose_in_stage;
if (fprintf(f, "anchor %s %.9g %.9g %.9g %.9g %.9g %.9g %.9g\n", uuid,
p.position.x, p.position.y, p.position.z, p.orientation.x, p.orientation.y, p.orientation.z, p.orientation.w) < 0) ok = 0;
}
if (fflush(f) || fsync(fd)) ok = 0;
if (fclose(f)) ok = 0;
if (ok && rename(tmp, path)) ok = 0;
if (!ok) { LOG("scene: anchor write failed (%d)", errno); unlink(tmp); }
return ok;
}
static void load_anchors(void) {
char path[4096];
if (!anchor_path(path, sizeof(path))) return;
FILE *f = fopen(path, "r");
if (!f) return;
char line[1024], *t[10];
unsigned loaded = 0;
while (fgets(line, sizeof(line), f)) {
if (!strchr(line, '\n') && !feof(f)) { int c; while ((c=fgetc(f)) != EOF && c != '\n') {} continue; }
XrUuidEXT uuid; float v[7];
int n = room_tokens(line, t, 10);
if (n != 9 || strcmp(t[0], "anchor") || !room_uuid(t[1], &uuid) || !room_values(t, 2, 7, v)) continue;
float norm = sqrtf(v[3]*v[3]+v[4]*v[4]+v[5]*v[5]+v[6]*v[6]);
if (norm < 0.0001f) continue;
int duplicate = 0;
for (int i = 0; i < entity_count; ++i) duplicate |= uuid_equal(&uuid, &entities[i].uuid);
if (duplicate) continue;
int i = add_entity((XrPosef){{v[3]/norm,v[4]/norm,v[5]/norm,v[6]/norm},{v[0],v[1],v[2]}}, NULL, 0, 0, 0, 1);
if (i < 0) break;
entities[i].uuid = uuid; entities[i].saved = 1; ++loaded;
}
fclose(f);
LOG("scene: loaded %u saved anchors", loaded);
}
// Batch writes are atomic both on disk and in memory. Failure never silently destroys the old anchor set.
static XrResult persist_spaces(uint32_t count, const XrSpace *spaces, uint32_t uuid_count, const XrUuidEXT *uuids, int erase) {
unsigned char selected[MAX_ENTITIES] = {0}, old_saved[MAX_ENTITIES], old_erased[MAX_ENTITIES];
for (uint32_t k = 0; k < count; ++k) {
int i = fake_space_index(spaces[k]);
if (i < 0) return XR_ERROR_HANDLE_INVALID;
if (entities[i].is_anchor) selected[i] = 1;
}
if (erase) for (int i = 0; i < entity_count; ++i)
if (entities[i].is_anchor && !entities[i].erased && uuid_matches(uuid_count, uuids, &entities[i])) selected[i] = 1;
int changed = 0;
for (int i = 0; i < entity_count; ++i) {
old_saved[i] = (unsigned char)entities[i].saved; old_erased[i] = (unsigned char)entities[i].erased;
if (selected[i]) { entities[i].saved = !erase; entities[i].erased = erase; changed = 1; }
}
if (changed && !write_anchors()) {
for (int i = 0; i < entity_count; ++i) { entities[i].saved = old_saved[i]; entities[i].erased = old_erased[i]; }
return XR_ERROR_RUNTIME_FAILURE;
}
if (changed) LOG("scene: anchors %s", erase ? "erased" : "saved");
return XR_SUCCESS;
}
+347 -120
View File
@@ -1,16 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by frame_adapter.c. Emulates Meta's scene / spatial-entity extensions (XR_FB_spatial_entity,
// _query, _storage, _container, XR_FB_scene, XR_FB_scene_capture) for mixed-reality apps on runtimes that
// lack them (Steam Frame). The "room" is a box built from the runtime's STAGE bounds (the guardian /
// chaperone rectangle): floor, ceiling and four walls, reported as one room anchor with a layout.
// lack them (Steam Frame). Room files use STAGE coordinates; absent/invalid files retain the guardian box.
// Entity spaces are fake handles; every function that consumes a space is intercepted for them.
static float scene_min_size = 1.5f; // smallest room edge if the guardian is tiny or unavailable
static int emulate_scene; // app requested the scene extensions and the runtime lacks them
static int scene_app_mesh; // app enabled XR_META_spatial_entity_mesh: only then is the global mesh an entity
// (an older SDK's scene loader fails on an anchor it can't place)
static const char *const scene_extensions[] = {
"XR_FB_spatial_entity", "XR_FB_spatial_entity_query", "XR_FB_spatial_entity_storage",
"XR_FB_spatial_entity_container", "XR_FB_scene", "XR_FB_scene_capture",
"XR_META_spatial_entity_mesh", "XR_META_spatial_entity_discovery", "XR_META_spatial_entity_persistence",
};
#define SCENE_EXTENSION_COUNT (sizeof(scene_extensions) / sizeof(scene_extensions[0]))
@@ -42,38 +44,58 @@ static XrPosef pose_inv(XrPosef a) {
// ---------------------------------------------------------------- entities
enum { ENT_ROOM, ENT_FLOOR, ENT_CEILING, ENT_WALL0, ENT_WALL1, ENT_WALL2, ENT_WALL3, ENT_FIXED_COUNT };
#define MAX_ENTITIES 64
#define MAX_ENTITIES 2048
#define MAX_OUTLINE 1024
typedef struct {
XrSpace handle; // fake handle given to the app
XrUuidEXT uuid;
XrPosef pose_in_stage;
const char *label; // semantic label, NULL if none
char label[256]; // comma-separated Meta semantic labels
XrRect2Df bounds; // 2D bounds in the entity's local XY plane
int has_bounds, is_room, is_anchor;
XrRect3DfFB bounds3;
XrVector2f *boundary;
uint32_t boundary_count;
XrVector3f *vertices;
uint32_t *indices, vertex_count, index_count;
int has_bounds, has_bounds3, is_room, is_anchor, is_wall, erased, saved;
} scene_entity;
static pthread_mutex_t scene_lock = PTHREAD_MUTEX_INITIALIZER;
static scene_entity entities[MAX_ENTITIES];
static int entity_count;
static int entity_count, room_entity_count, wall_count, room_from_file;
static char room_source[32];
static uint64_t room_universe;
static XrSpace scene_stage = XR_NULL_HANDLE; // real STAGE space used to anchor everything
static XrSession scene_session = XR_NULL_HANDLE;
static uint64_t next_request = 0x5ce0000001ull;
#define FAKE_SPACE_TAG 0x5ce0000000000000ull
// Fit the tag in uintptr_t as the adapter is also built for AArch32. Session generations prevent stale
// handles from aliasing new room entities when an engine recreates its session in the same process.
#if UINTPTR_MAX > 0xffffffffu
#define FAKE_SPACE_TAG ((uintptr_t)0x5ce0000000000000ull)
#define FAKE_SPACE_MASK ((uintptr_t)0xfff0000000000000ull)
#else
#define FAKE_SPACE_TAG ((uintptr_t)0x5ce00000u)
#define FAKE_SPACE_MASK ((uintptr_t)0xfff00000u)
#endif
static unsigned scene_generation;
static int is_fake_space(XrSpace space) { return ((uintptr_t)space & FAKE_SPACE_MASK) == FAKE_SPACE_TAG; }
static int fake_space_index(XrSpace space) {
uint64_t v = (uint64_t)(uintptr_t)space;
if ((v & 0xfff0000000000000ull) != FAKE_SPACE_TAG) return -1;
int i = (int)(v & 0xffff);
return i < entity_count ? i : -1;
uintptr_t v = (uintptr_t)space;
if ((v & FAKE_SPACE_MASK) != FAKE_SPACE_TAG || ((v >> 12) & 255) != scene_generation) return -1;
int i = (int)(v & 4095);
return i < entity_count && !entities[i].erased ? i : -1;
}
static XrSpace fake_space(int index) { return (XrSpace)(uintptr_t)(FAKE_SPACE_TAG | (uint64_t)index); }
static XrSpace fake_space(int index) { return (XrSpace)(FAKE_SPACE_TAG | ((uintptr_t)scene_generation << 12) | (uintptr_t)index); }
static XrUuidEXT make_uuid(int index) {
XrUuidEXT u;
for (int i = 0; i < XR_UUID_SIZE_EXT; ++i) u.data[i] = (uint8_t)(0x5c ^ (i * 37) ^ (index * 101));
u.data[6] = (uint8_t)((u.data[6] & 0x0f) | 0x40); // version 4 style
u.data[15] = (uint8_t)index;
u.data[14] ^= (uint8_t)((unsigned)index >> 8);
return u;
}
static int uuid_equal(const XrUuidEXT *a, const XrUuidEXT *b) { return !memcmp(a->data, b->data, XR_UUID_SIZE_EXT); }
@@ -86,7 +108,7 @@ static int add_entity(XrPosef pose, const char *label, float w, float h, int is_
e->handle = fake_space(i);
e->uuid = make_uuid(i);
e->pose_in_stage = pose;
e->label = label;
if (label) snprintf(e->label, sizeof(e->label), "%s", label);
e->has_bounds = w > 0 && h > 0;
e->bounds = (XrRect2Df){{-w / 2, -h / 2}, {w, h}};
e->is_room = is_room;
@@ -94,9 +116,23 @@ static int add_entity(XrPosef pose, const char *label, float w, float h, int is_
return i;
}
static void reset_room(void) {
for (int i = 0; i < entity_count; ++i) {
free(entities[i].boundary);
free(entities[i].vertices);
free(entities[i].indices);
}
memset(entities, 0, sizeof(entities));
entity_count = room_entity_count = wall_count = room_from_file = 0;
room_universe = 0;
}
#include "scene_room.c"
// Build the room once the session exists: guardian rectangle -> floor/ceiling/walls.
static void build_room(void) {
if (entity_count) return;
if (load_room_file()) return;
float width = 0, depth = 0;
PFN_xrGetReferenceSpaceBoundsRect bounds_fn =
(PFN_xrGetReferenceSpaceBoundsRect)lookup(active_instance, "xrGetReferenceSpaceBoundsRect");
@@ -121,6 +157,9 @@ static void build_room(void) {
add_entity((XrPosef){{0, 1, 0, 0}, {0, h / 2, depth / 2}}, "WALL_FACE", width, h, 0, 0); // faces -Z
add_entity((XrPosef){{0, s, 0, s}, {-width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces +X
add_entity((XrPosef){{0, -s, 0, s}, {width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces -X
wall_count = 4;
room_entity_count = entity_count;
for (int i = ENT_WALL0; i < entity_count; ++i) entities[i].is_wall = 1;
}
// Supported components per entity.
@@ -128,12 +167,17 @@ static int entity_components(const scene_entity *e, XrSpaceComponentTypeFB *out)
int n = 0;
out[n++] = XR_SPACE_COMPONENT_TYPE_LOCATABLE_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_STORABLE_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SHARABLE_FB; // Meta's runtime lists it; nothing is shared from here
if (e->is_room) {
out[n++] = XR_SPACE_COMPONENT_TYPE_ROOM_LAYOUT_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SPACE_CONTAINER_FB;
} else if (!e->is_anchor) {
out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB;
out[n++] = XR_SPACE_COMPONENT_TYPE_SEMANTIC_LABELS_FB;
if (e->has_bounds) {
out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB;
}
if (e->has_bounds3) out[n++] = XR_SPACE_COMPONENT_TYPE_BOUNDED_3D_FB;
if (e->vertex_count) out[n++] = XR_SPACE_COMPONENT_TYPE_TRIANGLE_MESH_META;
if (e->label[0]) out[n++] = XR_SPACE_COMPONENT_TYPE_SEMANTIC_LABELS_FB;
}
return n;
}
@@ -148,6 +192,7 @@ static int entity_has_component(const scene_entity *e, XrSpaceComponentTypeFB ty
#define MAX_EVENTS 64
static XrEventDataBuffer scene_events[MAX_EVENTS];
static int event_head, event_tail;
static int event_free(void) { return (event_head - event_tail - 1 + MAX_EVENTS) % MAX_EVENTS; }
static void push_event(const void *event, size_t size) {
if ((event_tail + 1) % MAX_EVENTS == event_head) return;
memset(&scene_events[event_tail], 0, sizeof(XrEventDataBuffer));
@@ -171,6 +216,7 @@ static int stage_pose(XrSpace space, XrTime time, XrPosef *pose, XrSpaceLocation
XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT | XR_SPACE_LOCATION_POSITION_TRACKED_BIT;
return 1;
}
if (is_fake_space(space)) return 0; // erased or from a prior session: never pass a fake handle to SteamVR
PFN_xrLocateSpace locate = (PFN_xrLocateSpace)lookup(active_instance, "xrLocateSpace");
if (!locate || !scene_stage) return 0;
XrSpaceLocation loc = {XR_TYPE_SPACE_LOCATION, NULL, 0, {{0, 0, 0, 1}, {0, 0, 0}}};
@@ -181,7 +227,7 @@ static int stage_pose(XrSpace space, XrTime time, XrPosef *pose, XrSpaceLocation
}
// Locate `space` in `base` when either is fake. Returns 0 if neither is fake (caller forwards).
static int locate_fake(XrSpace space, XrSpace base, XrTime time, XrPosef *pose, XrSpaceLocationFlags *flags) {
if (fake_space_index(space) < 0 && fake_space_index(base) < 0) return 0;
if (!is_fake_space(space) && !is_fake_space(base)) return 0;
XrPosef s, b;
XrSpaceLocationFlags fs = 0, fb = 0;
if (!stage_pose(space, time, &s, &fs) || !stage_pose(base, time, &b, &fb)) {
@@ -195,99 +241,96 @@ static int locate_fake(XrSpace space, XrSpace base, XrTime time, XrPosef *pose,
}
// ---------------------------------------------------------------- emulated entry points
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
scene_session = session;
build_room();
*request = next_request++;
XrEventDataSpaceQueryResultsAvailableFB avail = {XR_TYPE_EVENT_DATA_SPACE_QUERY_RESULTS_AVAILABLE_FB, NULL, *request};
XrEventDataSpaceQueryCompleteFB done = {XR_TYPE_EVENT_DATA_SPACE_QUERY_COMPLETE_FB, NULL, *request, XR_SUCCESS};
push_event(&avail, sizeof(avail));
push_event(&done, sizeof(done));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
// Query filters are kept per request so retrieve can apply them.
// Results are snapshotted before their available event is queued, so callers on another thread cannot
// observe a half-stored filter. A bounded request history supports the count/data calls independently.
#define MAX_QUERIES 64
typedef struct {
XrAsyncRequestIdFB id;
int has_component;
XrSpaceComponentTypeFB component;
uint32_t uuid_count;
XrUuidEXT uuids[64];
} query_filter;
static query_filter filters[16];
static int filter_next;
static void store_filter(XrAsyncRequestIdFB id, const XrSpaceQueryInfoFB *q) {
query_filter *f = &filters[filter_next++ % 16];
memset(f, 0, sizeof(*f));
f->id = id;
const XrSpaceFilterInfoBaseHeaderFB *flt = q->filter;
if (flt && flt->type == XR_TYPE_SPACE_COMPONENT_FILTER_INFO_FB) {
f->has_component = 1;
f->component = ((const XrSpaceComponentFilterInfoFB *)flt)->componentType;
} else if (flt && flt->type == XR_TYPE_SPACE_UUID_FILTER_INFO_FB) {
const XrSpaceUuidFilterInfoFB *u = (const XrSpaceUuidFilterInfoFB *)flt;
f->uuid_count = u->uuidCount < 64 ? u->uuidCount : 64;
for (uint32_t i = 0; i < f->uuid_count; ++i) f->uuids[i] = u->uuids[i];
}
uint32_t count;
XrSpaceQueryResultFB *results;
} scene_query;
static scene_query queries[MAX_QUERIES];
static unsigned query_next;
static scene_query *new_query(void) {
scene_query *q = &queries[query_next % MAX_QUERIES];
XrSpaceQueryResultFB *results = malloc((size_t)entity_count * sizeof(*results));
if (!results) return NULL;
++query_next;
free(q->results);
*q = (scene_query){next_request++, 0, results};
return q;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces_filtered(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
XrResult r = emu_query_spaces(session, info, request);
if (XR_SUCCEEDED(r) && info->type == XR_TYPE_SPACE_QUERY_INFO_FB) {
pthread_mutex_lock(&scene_lock);
store_filter(*request, (const XrSpaceQueryInfoFB *)info);
const XrSpaceQueryInfoFB *q = (const XrSpaceQueryInfoFB *)info;
LOG("scene: query request=%llu filter=%d", (unsigned long long)*request, q->filter ? q->filter->type : 0);
pthread_mutex_unlock(&scene_lock);
}
return r;
static scene_query *find_query(XrAsyncRequestIdFB id) {
for (int i = 0; i < MAX_QUERIES; ++i) if (queries[i].id == id) return &queries[i];
return NULL;
}
static int uuid_matches(uint32_t n, const XrUuidEXT *uuids, const scene_entity *e) {
for (uint32_t i = 0; i < n; ++i) if (uuid_equal(&uuids[i], &e->uuid)) return 1;
return 0;
}
#include "scene_anchors.c"
static int filter_matches(const query_filter *f, const scene_entity *e) {
static int fb_filter_matches(const XrSpaceFilterInfoBaseHeaderFB *f, const scene_entity *e) {
if (!f) return 1;
if (f->has_component) return entity_has_component(e, f->component);
if (f->uuid_count) {
for (uint32_t i = 0; i < f->uuid_count; ++i) if (uuid_equal(&f->uuids[i], &e->uuid)) return 1;
return 0;
if (f->type == XR_TYPE_SPACE_COMPONENT_FILTER_INFO_FB)
return entity_has_component(e, ((const XrSpaceComponentFilterInfoFB *)f)->componentType);
if (f->type == XR_TYPE_SPACE_UUID_FILTER_INFO_FB) {
const XrSpaceUuidFilterInfoFB *u = (const XrSpaceUuidFilterInfoFB *)f;
return u->uuids && uuid_matches(u->uuidCount, u->uuids, e);
}
return 1;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_query_spaces_filtered(XrSession session, const XrSpaceQueryInfoBaseHeaderFB *info,
XrAsyncRequestIdFB *request) {
if (!info || !request || info->type != XR_TYPE_SPACE_QUERY_INFO_FB) return XR_ERROR_VALIDATION_FAILURE;
const XrSpaceQueryInfoFB *info_fb = (const XrSpaceQueryInfoFB *)info;
pthread_mutex_lock(&scene_lock);
scene_session = session;
build_room();
if (event_free() < 2) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
scene_query *q = new_query();
if (!q) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_OUT_OF_MEMORY; }
for (int i = 0; i < entity_count && q->count < info_fb->maxResultCount; ++i) {
scene_entity *e = &entities[i];
if (!e->erased && fb_filter_matches(info_fb->filter, e))
q->results[q->count++] = (XrSpaceQueryResultFB){e->handle, e->uuid};
}
*request = q->id;
XrEventDataSpaceQueryResultsAvailableFB avail = {XR_TYPE_EVENT_DATA_SPACE_QUERY_RESULTS_AVAILABLE_FB, NULL, *request};
XrEventDataSpaceQueryCompleteFB done = {XR_TYPE_EVENT_DATA_SPACE_QUERY_COMPLETE_FB, NULL, *request, XR_SUCCESS};
push_event(&avail, sizeof(avail)); push_event(&done, sizeof(done));
LOG("scene: query request=%llu results=%u", (unsigned long long)*request, q->count);
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_retrieve_results(XrSession session, XrAsyncRequestIdFB request,
XrSpaceQueryResultsFB *results) {
(void)session;
if (!results) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
const query_filter *f = NULL;
for (int i = 0; i < 16; ++i) if (filters[i].id == request) f = &filters[i];
uint32_t n = 0;
for (int i = 0; i < entity_count; ++i) {
if (!filter_matches(f, &entities[i])) continue;
if (results->resultCapacityInput && n < results->resultCapacityInput && results->results) {
results->results[n].space = entities[i].handle;
results->results[n].uuid = entities[i].uuid;
scene_query *q = find_query(request);
XrResult r = XR_SUCCESS;
if (!q) r = XR_ERROR_VALIDATION_FAILURE;
else {
results->resultCountOutput = q->count;
if (results->resultCapacityInput && results->resultCapacityInput < q->count) r = XR_ERROR_SIZE_INSUFFICIENT;
else if (results->resultCapacityInput) {
if (!results->results) r = XR_ERROR_VALIDATION_FAILURE;
else memcpy(results->results, q->results, q->count * sizeof(*q->results));
}
++n;
}
results->resultCountOutput = n;
pthread_mutex_unlock(&scene_lock);
return (results->resultCapacityInput && results->resultCapacityInput < n) ? XR_ERROR_SIZE_INSUFFICIENT : XR_SUCCESS;
return r;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_uuid(XrSpace space, XrUuidEXT *uuid) {
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_uuid(XrSpace space, XrUuidEXT *uuid) {
int i = fake_space_index(space);
if (i < 0 || !uuid) return XR_ERROR_HANDLE_INVALID;
*uuid = entities[i].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count,
static XRAPI_ATTR XrResult XRAPI_CALL scene_enumerate_components(XrSpace space, uint32_t capacity, uint32_t *count,
XrSpaceComponentTypeFB *types) {
int i = fake_space_index(space);
if (i < 0 || !count) return XR_ERROR_HANDLE_INVALID;
@@ -296,16 +339,33 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_components(XrSpace space, ui
*count = (uint32_t)n;
if (!capacity) return XR_SUCCESS;
if (capacity < (uint32_t)n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!types) return XR_ERROR_VALIDATION_FAILURE;
for (int k = 0; k < n; ++k) types[k] = list[k];
return XR_SUCCESS;
}
// Which entity refused which component (the first ones only): an app's scene loader gives up on the whole room over
// one of these, and OVRPlugin's own error line doesn't name the component (Demeter, 2026-10-16).
static void log_refused_component(const char *op, int i, XrSpaceComponentTypeFB type) {
static int logged;
if (logged >= 20) return;
++logged;
const scene_entity *e = &entities[i];
LOG("scene: %s component %d refused on %s (entity %d)", op, (int)type,
e->is_room ? "room" : e->is_anchor ? "app anchor" : e->label[0] ? e->label : "unlabelled", i);
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_set_component_status(XrSpace space, const XrSpaceComponentStatusSetInfoFB *info,
XrAsyncRequestIdFB *request) {
int i = fake_space_index(space);
if (i < 0 || !info || !request) return XR_ERROR_HANDLE_INVALID;
if (!entity_has_component(&entities[i], info->componentType)) return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(space);
if (i < 0 || !info || !request) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!entity_has_component(&entities[i], info->componentType)) {
log_refused_component("set", i, info->componentType);
pthread_mutex_unlock(&scene_lock);
return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
}
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
*request = next_request++;
XrEventDataSpaceSetStatusCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SET_STATUS_COMPLETE_FB, NULL, *request, XR_SUCCESS,
space, entities[i].uuid, info->componentType, info->enabled};
@@ -314,44 +374,49 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_set_component_status(XrSpace space, co
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_component_status(XrSpace space, XrSpaceComponentTypeFB type,
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_component_status(XrSpace space, XrSpaceComponentTypeFB type,
XrSpaceComponentStatusFB *status) {
int i = fake_space_index(space);
if (i < 0 || !status) return XR_ERROR_HANDLE_INVALID;
if (!entity_has_component(&entities[i], type)) return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
if (!entity_has_component(&entities[i], type)) {
log_refused_component("get", i, type);
return XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB;
}
status->enabled = XR_TRUE;
status->changePending = XR_FALSE;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_container(XrSession session, XrSpace space, XrSpaceContainerFB *container) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !container) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].is_room) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
uint32_t n = ENT_FIXED_COUNT - 1; // everything except the room itself
uint32_t n = room_entity_count - 1; // everything except the room itself
container->uuidCountOutput = n;
if (!container->uuidCapacityInput) return XR_SUCCESS;
if (container->uuidCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!container->uuids) return XR_ERROR_VALIDATION_FAILURE;
for (uint32_t k = 0; k < n; ++k) container->uuids[k] = entities[ENT_FLOOR + k].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_room_layout(XrSession session, XrSpace space, XrRoomLayoutFB *layout) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !layout) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].is_room) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
layout->floorUuid = entities[ENT_FLOOR].uuid;
layout->ceilingUuid = entities[ENT_CEILING].uuid;
layout->wallUuidCountOutput = 4;
layout->wallUuidCountOutput = (uint32_t)wall_count;
if (!layout->wallUuidCapacityInput) return XR_SUCCESS;
if (layout->wallUuidCapacityInput < 4) return XR_ERROR_SIZE_INSUFFICIENT;
for (int k = 0; k < 4; ++k) layout->wallUuids[k] = entities[ENT_WALL0 + k].uuid;
if (layout->wallUuidCapacityInput < (uint32_t)wall_count) return XR_ERROR_SIZE_INSUFFICIENT;
if (!layout->wallUuids) return XR_ERROR_VALIDATION_FAILURE;
for (int k = 0; k < wall_count; ++k) layout->wallUuids[k] = entities[ENT_WALL0 + k].uuid;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_bounding_box_2d(XrSession session, XrSpace space, XrRect2Df *rect) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !rect) return XR_ERROR_HANDLE_INVALID;
@@ -360,19 +425,29 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_2d(XrSession session,
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_bounding_box_3d(XrSession session, XrSpace space, XrRect3DfFB *box) {
(void)session; (void)space; (void)box;
return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_bounding_box_3d(XrSession session, XrSpace space, XrRect3DfFB *box) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !box) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].has_bounds3) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
*box = entities[i].bounds3;
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_boundary_2d(XrSession session, XrSpace space, XrBoundary2DFB *boundary) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !boundary) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].has_bounds) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
boundary->vertexCountOutput = 4;
uint32_t n = entities[i].boundary_count ? entities[i].boundary_count : 4;
boundary->vertexCountOutput = n;
if (!boundary->vertexCapacityInput) return XR_SUCCESS;
if (boundary->vertexCapacityInput < 4) return XR_ERROR_SIZE_INSUFFICIENT;
if (boundary->vertexCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
if (!boundary->vertices) return XR_ERROR_VALIDATION_FAILURE;
if (entities[i].boundary_count) {
memcpy(boundary->vertices, entities[i].boundary, n * sizeof(*boundary->vertices));
return XR_SUCCESS;
}
const XrRect2Df b = entities[i].bounds;
boundary->vertices[0] = (XrVector2f){b.offset.x, b.offset.y};
boundary->vertices[1] = (XrVector2f){b.offset.x + b.extent.width, b.offset.y};
@@ -381,16 +456,76 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_get_boundary_2d(XrSession session, XrS
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_triangle_mesh(XrSpace space, const XrSpaceTriangleMeshGetInfoMETA *info,
XrSpaceTriangleMeshMETA *mesh) {
(void)info;
int i = fake_space_index(space);
if (i < 0 || !mesh) return XR_ERROR_HANDLE_INVALID;
const scene_entity *e = &entities[i];
if (!e->vertex_count) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
mesh->vertexCountOutput = e->vertex_count;
mesh->indexCountOutput = e->index_count;
if ((mesh->vertexCapacityInput && mesh->vertexCapacityInput < e->vertex_count) ||
(mesh->indexCapacityInput && mesh->indexCapacityInput < e->index_count)) return XR_ERROR_SIZE_INSUFFICIENT;
if ((mesh->vertexCapacityInput && !mesh->vertices) || (mesh->indexCapacityInput && !mesh->indices))
return XR_ERROR_VALIDATION_FAILURE;
if (mesh->vertexCapacityInput) memcpy(mesh->vertices, e->vertices, e->vertex_count * sizeof(*e->vertices));
if (mesh->indexCapacityInput) memcpy(mesh->indices, e->indices, e->index_count * sizeof(*e->indices));
return XR_SUCCESS;
}
// Labels as Meta's runtime hands them to an app (XR_FB_scene): an app that sends no XrSemanticLabelsSupportInfoFB
// knows only the first label set; one that does gets "OTHER" for labels missing from its recognizedLabels, WALL_FACE
// for invisible walls unless it accepts them, and DESK for TABLE unless it accepts that migration. Older Meta SDKs
// (Demeter, 2026-10-16) rejected a whole scanned room over one INVISIBLE_WALL_FACE and kept asking for Space Setup.
static const char *const scene_legacy_labels = "DESK,COUCH,FLOOR,CEILING,WALL_FACE,DOOR_FRAME,WINDOW_FRAME,OTHER";
static int label_listed(const char *list, const char *label, size_t len) {
for (const char *p = list; p && *p;) {
const char *end = strchr(p, ',');
size_t n = end ? (size_t)(end - p) : strlen(p);
if (n == len && !strncmp(p, label, len)) return 1;
p = end ? end + 1 : NULL;
}
return 0;
}
static void scene_app_labels(const char *label, const XrSemanticLabelsFB *labels, char *out, size_t size) {
const XrSemanticLabelsSupportInfoFB *support = NULL;
for (const XrBaseInStructure *n = labels->next; n; n = n->next)
if (n->type == XR_TYPE_SEMANTIC_LABELS_SUPPORT_INFO_FB) support = (const XrSemanticLabelsSupportInfoFB *)n;
const char *known = support ? support->recognizedLabels : scene_legacy_labels;
XrSemanticLabelsSupportFlagsFB flags = support ? support->flags : 0;
out[0] = 0;
for (const char *p = label; *p;) {
const char *end = strchr(p, ',');
size_t len = end ? (size_t)(end - p) : strlen(p);
char one[64];
snprintf(one, sizeof(one), "%.*s", (int)len, p);
if (!strcmp(one, "INVISIBLE_WALL_FACE") && !(flags & XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_INVISIBLE_WALL_FACE_BIT_FB))
snprintf(one, sizeof(one), "WALL_FACE");
if (!strcmp(one, "TABLE") && !(flags & XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_DESK_TO_TABLE_MIGRATION_BIT_FB))
snprintf(one, sizeof(one), "DESK");
if (known && *known && !label_listed(known, one, strlen(one))) snprintf(one, sizeof(one), "OTHER");
if (!label_listed(out, one, strlen(one)))
snprintf(out + strlen(out), size - strlen(out), "%s%s", out[0] ? "," : "", one);
p = end ? end + 1 : p + len;
}
}
static XRAPI_ATTR XrResult XRAPI_CALL scene_get_semantic_labels(XrSession session, XrSpace space, XrSemanticLabelsFB *labels) {
(void)session;
int i = fake_space_index(space);
if (i < 0 || !labels) return XR_ERROR_HANDLE_INVALID;
if (!entities[i].label) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
uint32_t n = (uint32_t)strlen(entities[i].label) + 1;
if (!entities[i].label[0]) return XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB;
char text[sizeof(entities[0].label)];
scene_app_labels(entities[i].label, labels, text, sizeof(text));
uint32_t n = (uint32_t)strlen(text) + 1;
labels->bufferCountOutput = n;
if (!labels->bufferCapacityInput) return XR_SUCCESS;
if (labels->bufferCapacityInput < n) return XR_ERROR_SIZE_INSUFFICIENT;
memcpy(labels->buffer, entities[i].label, n);
if (!labels->buffer) return XR_ERROR_VALIDATION_FAILURE;
memcpy(labels->buffer, text, n);
return XR_SUCCESS;
}
@@ -401,11 +536,12 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_request_scene_capture(XrSession sessio
pthread_mutex_lock(&scene_lock);
scene_session = session;
build_room();
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
*request = next_request++;
XrEventDataSceneCaptureCompleteFB e = {XR_TYPE_EVENT_DATA_SCENE_CAPTURE_COMPLETE_FB, NULL, *request, XR_SUCCESS};
push_event(&e, sizeof(e));
LOG("scene: capture requested -> %s", room_from_file ? "scanned room" : "guardian box");
pthread_mutex_unlock(&scene_lock);
LOG("scene: capture requested -> using guardian room");
return XR_SUCCESS;
}
@@ -417,9 +553,16 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_create_anchor(XrSession session, const
build_room();
XrPosef base = {{0, 0, 0, 1}, {0, 0, 0}};
XrSpaceLocationFlags flags = 0;
stage_pose(info->space, info->time, &base, &flags);
if (event_free() < 1) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
if (!stage_pose(info->space, info->time, &base, &flags) ||
!(flags & XR_SPACE_LOCATION_POSITION_VALID_BIT) || !(flags & XR_SPACE_LOCATION_ORIENTATION_VALID_BIT)) {
pthread_mutex_unlock(&scene_lock); return XR_ERROR_POSE_INVALID;
}
XrUuidEXT uuid;
if (!anchor_uuid(&uuid)) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_RUNTIME_FAILURE; }
int i = add_entity(pose_mul(base, info->poseInSpace), NULL, 0, 0, 0, 1);
*request = next_request++;
if (i >= 0) entities[i].uuid = uuid;
XrEventDataSpatialAnchorCreateCompleteFB e = {XR_TYPE_EVENT_DATA_SPATIAL_ANCHOR_CREATE_COMPLETE_FB, NULL, *request,
i >= 0 ? XR_SUCCESS : XR_ERROR_LIMIT_REACHED, i >= 0 ? entities[i].handle : XR_NULL_HANDLE,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}};
@@ -431,28 +574,97 @@ static XRAPI_ATTR XrResult XRAPI_CALL emu_create_anchor(XrSession session, const
static XRAPI_ATTR XrResult XRAPI_CALL emu_save_space(XrSession session, const XrSpaceSaveInfoFB *info, XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
int i = fake_space_index(info->space);
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(info->space);
if (i < 0) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(1, &info->space, 0, NULL, 0);
*request = next_request++;
XrEventDataSpaceSaveCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB, NULL, *request, XR_SUCCESS, info->space,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}, info->location};
XrEventDataSpaceSaveCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB, NULL, *request, r, info->space,
entities[i].uuid, info->location};
push_event(&e, sizeof(e));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_erase_space(XrSession session, const XrSpaceEraseInfoFB *info, XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
pthread_mutex_lock(&scene_lock);
int i = fake_space_index(info->space);
if (i < 0) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_HANDLE_INVALID; }
if (!event_free()) { pthread_mutex_unlock(&scene_lock); return XR_ERROR_LIMIT_REACHED; }
XrResult r = persist_spaces(1, &info->space, 0, NULL, 1);
*request = next_request++;
XrEventDataSpaceEraseCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB, NULL, *request, r, info->space,
entities[i].uuid, info->location};
push_event(&e, sizeof(e));
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_erase_space(XrSession session, const XrSpaceEraseInfoFB *info, XrAsyncRequestIdFB *request) {
(void)session;
if (!info || !request) return XR_ERROR_VALIDATION_FAILURE;
int i = fake_space_index(info->space);
// 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);
*request = next_request++;
XrEventDataSpaceEraseCompleteFB e = {XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB, NULL, *request, XR_SUCCESS, info->space,
i >= 0 ? entities[i].uuid : (XrUuidEXT){{0}}, info->location};
push_event(&e, sizeof(e));
XrResult r = scene_get_uuid(space, uuid);
pthread_mutex_unlock(&scene_lock);
return XR_SUCCESS;
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;
}
#include "scene_meta.c"
static PFN_xrVoidFunction scene_emulation(const char *name) {
if (!emulate_scene) return NULL;
@@ -468,11 +680,16 @@ 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},
};
for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); ++i)
if (!strcmp(name, table[i].name)) return table[i].fn;
@@ -485,6 +702,16 @@ static void scene_on_create_session(XrSession session) {
PFN_xrCreateReferenceSpace create = (PFN_xrCreateReferenceSpace)lookup(active_instance, "xrCreateReferenceSpace");
XrReferenceSpaceCreateInfo ci = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO, NULL, XR_REFERENCE_SPACE_TYPE_STAGE,
{{0, 0, 0, 1}, {0, 0, 0}}};
pthread_mutex_lock(&scene_lock);
reset_room();
scene_generation = (scene_generation + 1) & 255;
event_head = event_tail = 0;
for (int i = 0; i < MAX_QUERIES; ++i) { free(queries[i].results); memset(&queries[i], 0, sizeof(queries[i])); }
query_next = 0;
scene_session = session;
scene_stage = XR_NULL_HANDLE;
if (create && XR_SUCCEEDED(create(session, &ci, &scene_stage))) LOG("scene: emulation active (anchored to STAGE)");
build_room();
load_anchors();
pthread_mutex_unlock(&scene_lock);
}
+84
View File
@@ -0,0 +1,84 @@
// 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
@@ -0,0 +1,268 @@
// SPDX-License-Identifier: GPL-3.0-only
// Included by scene_emu.c. Bounded parsing keeps corrupt scan files from exhausting an Android game.
#include <errno.h>
#include <stdint.h>
static int room_uuid(const char *s, XrUuidEXT *u) {
if (strlen(s) != 32) return 0;
for (int i = 0; i < 16; ++i) {
unsigned n = 0;
for (int k = 0; k < 2; ++k) {
char c = s[i * 2 + k];
if (c >= '0' && c <= '9') n = n * 16 + (unsigned)(c - '0');
else if (c >= 'a' && c <= 'f') n = n * 16 + (unsigned)(c - 'a' + 10);
else return 0;
}
u->data[i] = (uint8_t)n;
}
return 1;
}
static int room_float(const char *s, float *v) {
char *end;
errno = 0;
*v = strtof(s, &end);
return *s && !*end && !errno && isfinite(*v) && fabsf(*v) <= 100000;
}
static int room_uint(const char *s, uint64_t *v) {
char *end;
if (*s < '0' || *s > '9') return 0;
errno = 0;
*v = strtoull(s, &end, 10);
return !*end && !errno;
}
static int room_tokens(char *s, char **t, int cap) {
char *comment = strchr(s, '#');
if (comment) *comment = 0;
int n = 0;
char *save;
for (char *p = strtok_r(s, " \t\r\n", &save); p; p = strtok_r(NULL, " \t\r\n", &save)) {
if (n == cap) return -1;
t[n++] = p;
}
return n;
}
static int room_values(char **t, int start, int count, float *v) {
for (int i = 0; i < count; ++i) if (!room_float(t[start + i], &v[i])) return 0;
return 1;
}
static int room_path(char *out, size_t size, const char *name) {
const char *config = getenv("FRAMEBRIDGE_CONFIG");
if (!config || !*config || strchr(name, '/') || strchr(name, '\\') || !strcmp(name, "..")) return 0;
const char *slash = strrchr(config, '/');
int n = slash ? snprintf(out, size, "%.*s/%s", (int)(slash - config), config, name)
: snprintf(out, size, "%s", name);
return n >= 0 && (size_t)n < size;
}
static uint32_t room_u32(const unsigned char *p) {
return (uint32_t)p[0] | (uint32_t)p[1] << 8 | (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}
static int room_mesh(scene_entity *e, const char *name, uint64_t nv, uint64_t ni) {
// At most 200k triangles and 16 MiB of payload; validate every index before publishing the mesh.
if (!nv || nv > 600000 || !ni || ni > 600000 || ni % 3 || nv * 12 + ni * 4 > 16 * 1024 * 1024) return 0;
char path[4096];
if (!room_path(path, sizeof(path), name)) return 0;
FILE *f = fopen(path, "rb");
if (!f) return 0;
unsigned char header[16], word[4];
int ok = fread(header, 1, 16, f) == 16 && !memcmp(header, "FPRM", 4) && room_u32(header + 4) == 1 &&
room_u32(header + 8) == nv && room_u32(header + 12) == ni;
XrVector3f *verts = ok ? malloc((size_t)nv * sizeof(*verts)) : NULL;
uint32_t *indices = ok ? malloc((size_t)ni * sizeof(*indices)) : NULL;
ok = ok && verts && indices;
for (uint64_t i = 0; ok && i < nv; ++i) {
float xyz[3];
for (int k = 0; ok && k < 3; ++k) {
ok = fread(word, 1, 4, f) == 4;
uint32_t bits = ok ? room_u32(word) : 0;
memcpy(&xyz[k], &bits, 4);
ok = ok && isfinite(xyz[k]) && fabsf(xyz[k]) <= 100000;
}
if (ok) verts[i] = (XrVector3f){xyz[0], xyz[1], xyz[2]};
}
for (uint64_t i = 0; ok && i < ni; ++i) {
ok = fread(word, 1, 4, f) == 4;
indices[i] = ok ? room_u32(word) : 0;
ok = ok && indices[i] < nv;
}
ok = ok && fgetc(f) == EOF && !ferror(f);
fclose(f);
if (!ok) { free(verts); free(indices); return 0; }
e->vertices = verts; e->indices = indices;
e->vertex_count = (uint32_t)nv; e->index_count = (uint32_t)ni;
return 1;
}
static int room_set_uuid(int i, XrUuidEXT uuid) {
for (int k = 0; k < entity_count; ++k)
if (k != i && uuid_equal(&entities[k].uuid, &uuid)) return 0;
entities[i].uuid = uuid;
return 1;
}
static int room_wall(XrUuidEXT uuid, const float *v, const char *label) {
float dx = v[2] - v[0], dz = v[3] - v[1], length = hypotf(dx, dz);
if (length < 0.001f || v[5] <= v[4]) return 0;
float angle = atan2f(-dz, dx) / 2;
int i = add_entity((XrPosef){{0, sinf(angle), 0, cosf(angle)},
{(v[0] + v[2]) / 2, (v[4] + v[5]) / 2, (v[1] + v[3]) / 2}},
label, length, v[5] - v[4], 0, 0);
if (i < 0) return 0;
if (!room_set_uuid(i, uuid)) { --entity_count; return 0; }
entities[i].is_wall = 1;
return 1;
}
static int room_outline(scene_entity *e, const XrVector2f *points, int n, float cx, float cz, int ceiling) {
e->boundary = malloc((size_t)n * sizeof(*e->boundary));
if (!e->boundary) return 0;
e->boundary_count = (uint32_t)n;
float xmin = INFINITY, ymin = INFINITY, xmax = -INFINITY, ymax = -INFINITY;
for (int i = 0; i < n; ++i) {
XrVector2f p = {points[i].x - cx, (points[i].y - cz) * (ceiling ? 1 : -1)};
e->boundary[i] = p;
xmin = fminf(xmin, p.x); xmax = fmaxf(xmax, p.x);
ymin = fminf(ymin, p.y); ymax = fmaxf(ymax, p.y);
}
e->has_bounds = 1;
e->bounds = (XrRect2Df){{xmin, ymin}, {xmax - xmin, ymax - ymin}};
return 1;
}
static int load_room_file(void) {
char path[4096];
if (!room_path(path, sizeof(path), "room.txt")) return 0;
FILE *f = fopen(path, "r");
if (!f) return 0;
// Long enough for a 1024-point outline. Reject whole overlong lines, never interpret their tails.
char *line = malloc(65536);
char **t = malloc((2 * MAX_OUTLINE + 4) * sizeof(*t));
if (!line || !t) { free(line); free(t); fclose(f); return 0; }
int ok = fgets(line, 65536, f) != NULL;
int n = ok ? room_tokens(line, t, 2 * MAX_OUTLINE + 4) : 0;
if (n != 2 || strcmp(t[0], "frameport-room") || strcmp(t[1], "1")) {
LOG("scene: bad room magic/version -> guardian box");
free(line); free(t); fclose(f); return 0;
}
const float s = 0.70710678f;
add_entity((XrPosef){{0, 0, 0, 1}, {0, 0, 0}}, NULL, 0, 0, 1, 0);
add_entity((XrPosef){{-s, 0, 0, s}, {0, 0, 0}}, "FLOOR", 0, 0, 0, 0);
add_entity((XrPosef){{s, 0, 0, s}, {0, 2.5f, 0}}, "CEILING", 0, 0, 0, 0);
XrVector2f outline[MAX_OUTLINE];
int outline_n = 0, ceiling_set = 0, volumes = 0, planes = 0, triangles = 0;
unsigned lineno = 1;
snprintf(room_source, sizeof(room_source), "manual");
while (fgets(line, 65536, f)) {
++lineno;
if (!strchr(line, '\n') && !feof(f)) {
int c; while ((c = fgetc(f)) != EOF && c != '\n') {}
LOG("scene: bad room line %u (too long)", lineno); continue;
}
n = room_tokens(line, t, 2 * MAX_OUTLINE + 4);
if (!n) continue;
ok = n > 0;
float v[10]; XrUuidEXT uuid; uint64_t a, b;
if (n < 0) ok = 0;
else if (!strcmp(t[0], "source")) {
ok = n == 2 && (!strcmp(t[1], "scan") || !strcmp(t[1], "boundary") || !strcmp(t[1], "box") || !strcmp(t[1], "manual"));
if (ok) snprintf(room_source, sizeof(room_source), "%s", t[1]);
} else if (!strcmp(t[0], "universe")) {
ok = n == 2 && room_uint(t[1], &a);
if (ok) room_universe = a;
} else if (!strcmp(t[0], "room") || !strcmp(t[0], "floor") || !strcmp(t[0], "ceiling")) {
int i = !strcmp(t[0], "room") ? ENT_ROOM : !strcmp(t[0], "floor") ? ENT_FLOOR : ENT_CEILING;
ok = n == (i == ENT_ROOM ? 2 : 3) && room_uuid(t[1], &uuid) &&
(i == ENT_ROOM || room_float(t[2], v)) && room_set_uuid(i, uuid);
if (ok && i != ENT_ROOM) entities[i].pose_in_stage.position.y = v[0];
if (ok && i == ENT_CEILING) ceiling_set = 1;
} else if (!strcmp(t[0], "outline")) {
ok = n >= 2 && room_uint(t[1], &a) && a >= 3 && a <= MAX_OUTLINE && n == 2 + 2 * (int)a;
XrVector2f parsed[MAX_OUTLINE];
for (uint64_t k = 0; ok && k < a; ++k) ok = room_float(t[2 + k * 2], &parsed[k].x) && room_float(t[3 + k * 2], &parsed[k].y);
if (ok) { memcpy(outline, parsed, (size_t)a * sizeof(*outline)); outline_n = (int)a; }
} else if (!strcmp(t[0], "wall")) {
// optional 8th field: WALL_FACE (default) or INVISIBLE_WALL_FACE for an edge the scanner closed itself
const char *label = n == 9 ? t[8] : "WALL_FACE";
ok = (n == 8 || (n == 9 && (!strcmp(label, "WALL_FACE") || !strcmp(label, "INVISIBLE_WALL_FACE")))) &&
room_uuid(t[1], &uuid) && room_values(t, 2, 6, v) && room_wall(uuid, v, label);
} else if (!strcmp(t[0], "volume") || !strcmp(t[0], "plane")) {
int volume = !strcmp(t[0], "volume");
ok = n == (volume ? 10 : 12) && room_uuid(t[1], &uuid) && strlen(t[2]) < sizeof(entities[0].label) && room_values(t, 3, volume ? 7 : 9, v);
XrPosef pose = {{0, 0, 0, 1}, {0, 0, 0}};
float w = 0, h = 0;
if (ok && volume) {
float yaw = v[3] * 0.00872664626f;
pose = (XrPosef){quat_mul((XrQuaternionf){0, sinf(yaw), 0, cosf(yaw)}, (XrQuaternionf){-s, 0, 0, s}), {v[0], v[2], v[1]}};
w = v[4]; h = v[5]; ok = v[6] > 0;
} else if (ok) {
float norm = sqrtf(v[3]*v[3]+v[4]*v[4]+v[5]*v[5]+v[6]*v[6]);
ok = norm > 0.0001f;
if (ok) pose = (XrPosef){{v[3]/norm,v[4]/norm,v[5]/norm,v[6]/norm}, {v[0],v[1],v[2]}};
w = v[7]; h = v[8];
}
int i = ok && w > 0 && h > 0 ? add_entity(pose, t[2], w, h, 0, 0) : -1;
ok = i >= 0;
if (ok && !room_set_uuid(i, uuid)) { --entity_count; ok = 0; }
if (ok && volume) {
entities[i].has_bounds3 = 1;
entities[i].bounds3 = (XrRect3DfFB){{-w/2, -h/2, -v[6]}, {w, h, v[6]}};
++volumes;
} else if (ok) ++planes;
} else if (!strcmp(t[0], "mesh")) {
if (!scene_app_mesh) continue; // the app didn't enable XR_META_spatial_entity_mesh
ok = n == 5 && room_uuid(t[1], &uuid) && room_uint(t[3], &a) && room_uint(t[4], &b) && !triangles;
int i = ok ? add_entity((XrPosef){{0,0,0,1},{0,0,0}}, "GLOBAL_MESH", 0, 0, 0, 0) : -1;
ok = i >= 0 && room_set_uuid(i, uuid) && room_mesh(&entities[i], t[2], a, b);
if (ok) triangles = (int)b / 3;
else if (i >= 0) --entity_count;
}
if (!ok) LOG("scene: bad room line %u (skipped)", lineno);
}
ok = !ferror(f);
free(line); free(t); fclose(f);
// Layout walls must be contiguous and in file order, regardless of where other records occurred.
for (int i = ENT_WALL0; i < entity_count; ++i) if (entities[i].is_wall) {
scene_entity wall = entities[i];
memmove(&entities[ENT_WALL0 + wall_count + 1], &entities[ENT_WALL0 + wall_count],
(size_t)(i - ENT_WALL0 - wall_count) * sizeof(wall));
entities[ENT_WALL0 + wall_count++] = wall;
}
if (!outline_n && wall_count) {
float xmin = INFINITY, zmin = INFINITY, xmax = -INFINITY, zmax = -INFINITY;
for (int i = ENT_WALL0; i < ENT_WALL0 + wall_count; ++i) {
scene_entity *e = &entities[i];
XrVector3f d = quat_rotate(e->pose_in_stage.orientation, (XrVector3f){e->bounds.extent.width/2,0,0});
xmin = fminf(xmin, e->pose_in_stage.position.x - fabsf(d.x)); xmax = fmaxf(xmax, e->pose_in_stage.position.x + fabsf(d.x));
zmin = fminf(zmin, e->pose_in_stage.position.z - fabsf(d.z)); zmax = fmaxf(zmax, e->pose_in_stage.position.z + fabsf(d.z));
}
outline[0]=(XrVector2f){xmin,zmin}; outline[1]=(XrVector2f){xmax,zmin};
outline[2]=(XrVector2f){xmax,zmax}; outline[3]=(XrVector2f){xmin,zmax}; outline_n=4;
}
double area = 0, cx = 0, cz = 0;
for (int i = 0; i < outline_n; ++i) {
XrVector2f a = outline[i], b = outline[(i+1)%outline_n];
double cross = (double)a.x*b.y-(double)b.x*a.y;
area += cross; cx += (a.x+b.x)*cross; cz += (a.y+b.y)*cross;
}
if (!ok || outline_n < 3 || fabs(area) < 0.00001) { reset_room(); return 0; }
cx /= 3*area; cz /= 3*area;
if (!ceiling_set) entities[ENT_CEILING].pose_in_stage.position.y = entities[ENT_FLOOR].pose_in_stage.position.y + 2.5f;
for (int i = ENT_FLOOR; i <= ENT_CEILING; ++i) {
entities[i].pose_in_stage.position.x = (float)cx; entities[i].pose_in_stage.position.z = (float)cz;
if (!room_outline(&entities[i], outline, outline_n, (float)cx, (float)cz, i == ENT_CEILING)) { reset_room(); return 0; }
}
if (!wall_count) for (int i = 0; i < outline_n; ++i) {
XrVector2f a = outline[i], b = outline[(i+1)%outline_n];
float v[] = {a.x,a.y,b.x,b.y,entities[ENT_FLOOR].pose_in_stage.position.y,entities[ENT_CEILING].pose_in_stage.position.y};
if (!room_wall(make_uuid(entity_count), v, "WALL_FACE")) { reset_room(); return 0; }
++wall_count;
}
// Move generated walls ahead of furniture as well.
if (wall_count && !entities[ENT_WALL0].is_wall) {
scene_entity *walls = malloc((size_t)wall_count * sizeof(*walls));
if (!walls) { reset_room(); return 0; }
memcpy(walls, &entities[entity_count-wall_count], (size_t)wall_count*sizeof(*walls));
memmove(&entities[ENT_WALL0+wall_count], &entities[ENT_WALL0], (size_t)(entity_count-wall_count-ENT_WALL0)*sizeof(*walls));
memcpy(&entities[ENT_WALL0], walls, (size_t)wall_count*sizeof(*walls)); free(walls);
}
for (int i = 0; i < entity_count; ++i) entities[i].handle = fake_space(i);
room_entity_count = entity_count; room_from_file = 1;
LOG("scene: room from %s file (%d walls, %d volumes, %d planes, %d mesh triangles, universe %llu)",
room_source, wall_count, volumes, planes, triangles, (unsigned long long)room_universe);
return 1;
}
+15 -2
View File
@@ -199,6 +199,18 @@ def build_venc(tc: Path):
f"-ffile-prefix-map={HERE}=native", "fp_venc.c", "convert.c", "sys.c", "-o", out / "fp_venc"], cwd=src)
def build_roomscan(tc: Path):
"""Offline room reconstruction (Linux aarch64). No libc, headers, generated tables or fetched dependencies."""
src = HERE / "roomscan"
out = ART / "linux-arm64-bin"
out.mkdir(parents=True, exist_ok=True)
run([tc / "bin/clang", "--target=aarch64-linux-gnu", "-ffreestanding", "-nostdlib", "-nostdlibinc", "-static",
"-fno-stack-protector", "-fno-builtin", "-ffp-contract=off", "-fno-math-errno", "-O2", "-Wall", "-Wextra", "-Werror",
"-fuse-ld=lld", "-Wl,--build-id=none", "-Wl,-z,max-page-size=65536", "-e", "_start",
f"-ffile-prefix-map={HERE}=native", "main.c", "fusion.c", "model.c", "mesh.c", "platform.c",
"-o", out / "fp_roomscan"], cwd=src)
OCULUSHMD_IMPORTS = ("CreateEventW", "CreateJobObjectW", "AssignProcessToJobObject", "QueryInformationJobObject",
"CreateProcessW", "ResumeThread", "WaitForSingleObject", "GetExitCodeProcess", "CloseHandle",
"Sleep", "GetCommandLineW", "GetLastError", "GetStdHandle", "WriteFile", "ExitProcess")
@@ -438,7 +450,7 @@ def write_sums():
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,a32")
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,roomscan,a32")
ap.add_argument("--ndk")
args = ap.parse_args()
parts = set(args.only.split(","))
@@ -454,9 +466,10 @@ def main():
"ovrpshim": lambda: build_ovrpshim(tc), "tlskeys": lambda: build_tlskeys(tc),
"luajit": lambda: build_luajit(tc),
"vrsettings": lambda: build_vrsettings(tc), "venc": lambda: build_venc(tc),
"roomscan": lambda: build_roomscan(tc),
"vroverlay_probe": lambda: build_vroverlay_probe(tc), "a32": lambda: build_a32(tc)}
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "luajit", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
"vroverlay_probe", "a32"):
"roomscan", "vroverlay_probe", "a32"):
if name in parts:
log(f"build {name}")
steps[name]()
+325
View File
@@ -0,0 +1,325 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define MIN_TRIANGLE_TWICE_AREA 1e-8f
#define MIN_RANGE .1f
#define MAX_COORDINATE 10000.f
#define QUATERNION_NORM_TOLERANCE .1f
#define SAMPLE_SPACING_VOXELS .75f
#define MAX_SURFEL_WEIGHT 10.f
#define FEW_FRAMES 8
#define MAX_GRID_XZ 8.f
#define MAX_GRID_Y 4.f
#define GRID_PADDING_VOXELS 5.f
#define NORMAL_PATCH_SIZE 12
#define MIN_PATCH_POINTS 24
float root(float x)
{
if (x <= 0) return 0;
/* -fno-math-errno makes this one hardware sqrt, with no libc/libm symbol on either build. */
return __builtin_sqrtf(x);
}
Vec unit(Vec p) { float n = root(dot(p,p)); return n > 1e-12f ? mul(p,1/n) : vec(0,0,0); }
int flo(float x) { int i = (int)x; return x < (float)i ? i-1 : i; }
float sine(float x)
{
while (x > PI) x -= 2*PI;
while (x < -PI) x += 2*PI;
if (x > PI/2) x = PI-x;
if (x < -PI/2) x = -PI-x;
float q = x*x;
return x*(1-q/6+q*q/120-q*q*q/5040+q*q*q*q/362880);
}
float cosine(float x) { return sine(x+PI/2); }
float angle(float y, float x)
{
/* atan approximation, maximum error < .012 degrees. */
float a = mn(ab(x),ab(y)) / mx(mx(ab(x),ab(y)),1e-20f), q = a*a;
float r = a*(.999866f+q*(-.3302995f+q*(.180141f+q*(-.085133f+q*.0208351f))));
if (ab(y) > ab(x)) r = PI/2-r;
if (x < 0) r = PI-r;
return y < 0 ? -r : r;
}
int same(const char *a, const char *b)
{
while (*a && *a == *b) a++,b++;
return *a == *b;
}
u32 random_next(Scan *s) { s->rng ^= s->rng<<13; s->rng ^= s->rng>>17; s->rng ^= s->rng<<5; return s->rng; }
void warn(Scan *s, const char *m)
{
for (int i = 0; i < s->nwarnings; i++) if (same(s->warnings[i],m)) return;
if (s->nwarnings < 24) s->warnings[s->nwarnings++] = m;
}
static u32 u32le(const u8 *p) { return (u32)p[0]|(u32)p[1]<<8|(u32)p[2]<<16|(u32)p[3]<<24; }
static float f32le(const u8 *p) { union { u32 u; float f; } v = {u32le(p)}; return v.f; }
/* 1 = complete, 0 = clean EOF, -1 = I/O error or partial record. */
static int exact(int fd, void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = io_read(fd,(u8 *)p+done,n-done);
if (r < 0 || (!r && done)) return -1;
if (!r) return 0;
done += (usize)r;
}
return 1;
}
Vec grid_point(Scan *s, int x, int y, int z)
{
return add(s->origin,vec(x*s->opt.voxel,y*s->opt.voxel,z*s->opt.voxel));
}
int cell_index(Scan *s, Vec p)
{
Vec q = mul(sub(p,s->origin),1/s->opt.voxel);
int x = flo(q.x+.5f), y = flo(q.y+.5f), z = flo(q.z+.5f);
if (x < 0 || y < 0 || z < 0 || x >= s->nx || y >= s->ny || z >= s->nz) return -1;
return (y*s->nz+z)*s->nx+x;
}
static void sample(Scan *s, Vec p, Vec n, float w)
{
int i = cell_index(s,p);
if (i < 0) { s->clipped++; return; }
Cell *c = &s->cells[i];
/* Cap repeated views' influence without throwing away later measurements. */
float scale = c->weight > MAX_SURFEL_WEIGHT ? MAX_SURFEL_WEIGHT/c->weight : 1;
c->p = add(mul(c->p,scale),mul(p,w));
c->n = add(mul(c->n,scale),mul(n,w));
c->weight = c->weight*scale+w;
if(c->last_frame!=s->fuse_frame) { c->last_frame=s->fuse_frame;c->views++; }
}
static void triangle(Scan *s, Vec a, Vec b, Vec c, Vec eye, Vec patch_normal, int fuse)
{
Vec abv = sub(b,a), ac = sub(c,a), bc = sub(c,b);
float longest = root(mx(dot(abv,abv),mx(dot(ac,ac),dot(bc,bc))));
Vec p = mul(add(a,add(b,c)),1.f/3), n = cross(abv,ac);
float twice_area = root(dot(n,n));
Vec view = sub(eye,p);
float distance = root(dot(view,view));
if (twice_area < MIN_TRIANGLE_TWICE_AREA || longest > s->opt.max_edge || distance > s->opt.range || distance < MIN_RANGE) {
if (!fuse) s->rejected++;
return;
}
n = mul(n,1/twice_area);
if(dot(patch_normal,patch_normal)>.5f) n=patch_normal;
float facing = dot(n,view)/distance;
if (ab(facing) < s->opt.grazing) { if (!fuse) s->rejected++; return; }
if (facing < 0) n = mul(n,-1);
if (!fuse) {
s->accepted++;
s->seen++;
int slot = s->seen <= MAX_SAMPLES ? (int)s->seen-1 : (int)(random_next(s)%s->seen);
if (slot < MAX_SAMPLES) s->samples[slot] = p;
s->nsamples = (int)mn((float)s->seen,MAX_SAMPLES);
} else {
int steps = (int)(longest/(s->opt.voxel*SAMPLE_SPACING_VOXELS))+1;
float weight = twice_area*facing*facing / ((steps+1)*(steps+2)*.5f);
for (int j = 0; j <= steps; j++) for (int k = 0; k <= steps-j; k++) {
Vec q = add(a,add(mul(abv,(float)j/steps),mul(ac,(float)k/steps)));
sample(s,q,n,weight);
}
}
}
static Vec patch_normal(Vec *points, float *depths, int width, int x0, int y0, int x1, int y1,
float tolerance)
{
Vec mean=vec(0,0,0);int count=0;
/* Shift before accumulating the covariance: room positions can be large relative to patch residuals. */
Vec origin=points[y0*width+x0];
for(int y=y0;y<y1;y++) for(int x=x0;x<x1;x++) if(depths[y*width+x]>0) {
mean=add(mean,sub(points[y*width+x],origin));count++;
}
if(count<MIN_PATCH_POINTS) return vec(0,0,0);
mean=mul(mean,1.f/count);
float cov[3][3]={{0}},basis[3][3]={{1,0,0},{0,1,0},{0,0,1}};
for(int y=y0;y<y1;y++) for(int x=x0;x<x1;x++) if(depths[y*width+x]>0) {
Vec p=sub(sub(points[y*width+x],origin),mean);float a[3]={p.x,p.y,p.z};
for(int i=0;i<3;i++) for(int j=0;j<3;j++) cov[i][j]+=a[i]*a[j];
}
for(int sweep=0;sweep<8;sweep++) {
int p=0,q=1;
if(ab(cov[0][2])>ab(cov[p][q])) { p=0;q=2; }
if(ab(cov[1][2])>ab(cov[p][q])) { p=1;q=2; }
if(ab(cov[p][q])<1e-10f) break;
float phi=.5f*angle(2*cov[p][q],cov[q][q]-cov[p][p]),co=cosine(phi),si=sine(phi);
float aa=cov[p][p],bb=cov[q][q],abv=cov[p][q];
for(int k=0;k<3;k++) if(k!=p && k!=q) {
float a=cov[k][p],b=cov[k][q];
cov[k][p]=cov[p][k]=co*a-si*b;cov[k][q]=cov[q][k]=si*a+co*b;
}
cov[p][p]=co*co*aa-2*si*co*abv+si*si*bb;
cov[q][q]=si*si*aa+2*si*co*abv+co*co*bb;cov[p][q]=cov[q][p]=0;
for(int k=0;k<3;k++) {
float a=basis[k][p],b=basis[k][q];basis[k][p]=co*a-si*b;basis[k][q]=si*a+co*b;
}
}
int least=0;
for(int i=1;i<3;i++) if(cov[i][i]<cov[least][least]) least=i;
if(cov[least][least]/count>tolerance*tolerance) return vec(0,0,0);
return unit(vec(basis[0][least],basis[1][least],basis[2][least]));
}
static int grid_normals(Scan *s, Vec *points, float *depths, Vec *normals, u32 nv, u32 ni, const u8 *indices)
{
if(ni<6 || u32le(indices)!=0 || u32le(indices+8)!=1) return 0;
u32 width=u32le(indices+4);
if(width<3 || width>nv || nv%width || ni!=6*(width-1)*(nv/width-1)) return 0;
/* Only a complete regular fan uses patch normals. Other meshes retain ordinary triangle normals. */
u32 at=0;
for(u32 row=0;row+1<nv/width;row++) for(u32 col=0;col+1<width;col++) {
u32 a=row*width+col,expected[6]={a,a+width,a+1,a+1,a+width,a+width+1};
for(int k=0;k<6;k++) if(u32le(indices+4*at++)!=expected[k]) return 0;
}
int height=(int)(nv/width);
for(int y=0;y<height;y+=NORMAL_PATCH_SIZE) for(int x=0;x<(int)width;x+=NORMAL_PATCH_SIZE) {
int x1=(int)mn(x+NORMAL_PATCH_SIZE,(float)width),y1=(int)mn(y+NORMAL_PATCH_SIZE,height);
Vec n=patch_normal(points,depths,(int)width,x,y,x1,y1,s->opt.patch_residual);
for(int yy=y;yy<y1;yy++) for(int xx=x;xx<x1;xx++) normals[yy*width+xx]=n;
}
return 1;
}
const char *read_dump(Scan *s, int fuse)
{
int fd = io_open(s->opt.input,0);
if (fd < 0) return "cannot open scan dump";
u8 header[44];
const char *error = 0;
Vec *vertices = 0,*normals=0;
float *depths = 0;
u8 *indices = 0;
usize vs = 0, is = 0, ds = 0;
if (exact(fd,header,8) != 1 || header[0]!='F' || header[1]!='P' || header[2]!='R' || header[3]!='S' ||
u32le(header+4) != 1) { error = "invalid FPRS header"; goto done; }
unsigned frames = 0;
for (;;) {
int r = exact(fd,header,44);
if (r == 0) break;
if (r < 0) { error = "truncated frame header"; break; }
/* Tracking can start before poses are available. Consume the entire bounded record before skipping,
* so one NaN cannot hide a truncated payload or desynchronize the next frame. */
int invalid=(u32le(header+4)&0x7ff00000u) == 0x7ff00000u;
float pose[7];
for (int i = 0; i < 7; i++) {
pose[i] = f32le(header+8+i*4);
if (!finite(pose[i]) || ab(pose[i]) > MAX_COORDINATE) invalid=1;
}
float norm = 0;
for (int i = 3; i < 7; i++) norm += pose[i]*pose[i];
if (!finite(norm) || ab(norm-1) > QUATERNION_NORM_TOLERANCE) invalid=1;
if(!invalid) for (int i = 3; i < 7; i++) pose[i] /= root(norm);
u32 nv = u32le(header+36), ni = u32le(header+40);
if (nv > MAX_FRAME_VERTICES || ni > MAX_FRAME_INDICES || ni%3 || (!nv && ni)) {
error = "invalid frame counts"; break;
}
usize needv = (usize)nv*12, needi = (usize)ni*4;
if (needv > vs) {
io_free(vertices,vs);io_free(normals,vs);vs=needv;vertices=io_alloc(vs);normals=io_alloc(vs);
}
if (needi > is) { io_free(indices,is); is=needi; indices=io_alloc(is); }
if((usize)nv*4>ds) { io_free(depths,ds);ds=(usize)nv*4;depths=io_alloc(ds); }
if ((nv && (!vertices || !depths || !normals)) || (ni && !indices)) { error = "frame allocation failed"; break; }
if ((needv && exact(fd,vertices,needv)!=1) || (needi && exact(fd,indices,needi)!=1)) {
error = "truncated frame data"; break;
}
Vec eye = vec(pose[0],pose[1],pose[2]), q = vec(pose[3],pose[4],pose[5]);
u64 far=0;
for (u32 i = 0; i < nv; i++) {
u8 *p = (u8 *)&vertices[i];
Vec v = vec(f32le(p),f32le(p+4),f32le(p+8));
if (!finite(v.x) || !finite(v.y) || !finite(v.z) ||
ab(v.x)>MAX_COORDINATE || ab(v.y)>MAX_COORDINATE || ab(v.z)>MAX_COORDINATE) {
invalid=1;break;
}
depths[i]=root(dot(v,v));
if(depths[i]>s->opt.range || depths[i]<MIN_RANGE) { depths[i]=-1;far++;continue; }
Vec t = mul(cross(q,v),2);
vertices[i] = add(eye,add(v,add(mul(t,pose[6]),cross(q,t))));
}
int regular=!invalid && grid_normals(s,vertices,depths,normals,nv,ni,indices);
/* Structural errors are fatal even in a frame whose tracking/depth is unusable. */
s->fuse_frame=frames+1;
for (u32 i = 0; i < ni; i+=3) {
u32 a=u32le(indices+4*i), b=u32le(indices+4*(i+1)), c=u32le(indices+4*(i+2));
if (a>=nv || b>=nv || c>=nv) { error = "index outside vertex array"; break; }
if(invalid) continue;
if(depths[a]<0 || depths[b]<0 || depths[c]<0) { if(!fuse) s->rejected++;continue; }
float near=mn(depths[a],mn(depths[b],depths[c])),far_depth=mx(depths[a],mx(depths[b],depths[c]));
if(far_depth-near>s->opt.depth_jump+s->opt.depth_ratio*near) {
if(!fuse) { s->rejected++;s->discontinuities++; }continue;
}
Vec normal=regular ? normals[a] : vec(0,0,0);
if(regular && dot(normal,normal)<.5f) { if(!fuse) s->rejected++;continue; }
triangle(s,vertices[a],vertices[b],vertices[c],eye,normal,fuse);
}
if (error) break;
if(!fuse) { if(invalid) s->skipped_frames++;else s->far_vertices+=far; }
frames++;
}
if (!error && !frames) error = "scan contains no frames";
if (!error && !fuse) s->frames = frames;
done:
io_free(vertices,vs);
io_free(normals,vs);
io_free(indices,is);
io_free(depths,ds);
io_close(fd);
return error;
}
static float median(Scan *s, int axis)
{
/* In-place quickselect on the bounded reservoir; no dump-sized allocations. */
int lo=0, hi=s->nsamples-1, mid=s->nsamples/2;
while (lo < hi) {
float pivot = ((float *)&s->samples[(lo+hi)/2])[axis];
int i=lo,j=hi;
while (i<=j) {
while (((float *)&s->samples[i])[axis] < pivot) i++;
while (((float *)&s->samples[j])[axis] > pivot) j--;
if (i<=j) { Vec t=s->samples[i]; s->samples[i++]=s->samples[j]; s->samples[j--]=t; }
}
if (mid<=j) hi=j;
else if (mid>=i) lo=i;
else break;
}
return ((float *)&s->samples[mid])[axis];
}
const char *init_grid(Scan *s)
{
if (!s->nsamples) return "scan contains no usable triangles";
float x=median(s,0), y=median(s,1), z=median(s,2), v=s->opt.voxel;
Vec low=s->samples[0],high=low;
for(int i=1;i<s->nsamples;i++) {
Vec p=s->samples[i];
low=vec(mn(low.x,p.x),mn(low.y,p.y),mn(low.z,p.z));
high=vec(mx(high.x,p.x),mx(high.y,p.y),mx(high.z,p.z));
}
float pad=v*mx(GRID_PADDING_VOXELS,s->opt.truncation+2);
low=sub(low,vec(pad,pad,pad));high=add(high,vec(pad,pad,pad));
s->origin=vec(mx(flo(low.x/v)*v,flo((x-MAX_GRID_XZ/2)/v)*v),
mx(flo(low.y/v)*v,flo((y-MAX_GRID_Y/2)/v)*v),
mx(flo(low.z/v)*v,flo((z-MAX_GRID_XZ/2)/v)*v));
s->nx=(int)mn(-flo(-(high.x-s->origin.x)/v)+1,(int)(MAX_GRID_XZ/v)+1);
s->ny=(int)mn(-flo(-(high.y-s->origin.y)/v)+1,(int)(MAX_GRID_Y/v)+1);
s->nz=(int)mn(-flo(-(high.z-s->origin.z)/v)+1,(int)(MAX_GRID_XZ/v)+1);
usize count = (usize)s->nx*s->ny*s->nz;
if (count > MAX_CELLS) return "voxel grid exceeds memory budget; increase --voxel";
s->count = (int)count;
s->cells = io_alloc(count*sizeof(Cell));
return s->cells ? 0 : "grid allocation failed";
}
void finish_fusion(Scan *s)
{
/* A short look-around cannot expose each surface in many frames. Cap the target at ten percent of usable
* views (at least one); long recordings still require repeated evidence rather than transient depth. */
unsigned required=(unsigned)mn(s->opt.min_views,mx(1,(s->frames-s->skipped_frames)/10.f));
for (int i=0; i<s->count; i++) {
Cell *c=&s->cells[i];
if(c->views<required) { c->weight=0;continue; }
if (c->weight) { c->p=mul(c->p,1/c->weight); c->n=unit(c->n); }
}
if (s->clipped) warn(s,"scan clipped to 8 x 8 x 4 metre grid");
if (s->frames < FEW_FRAMES) warn(s,"few frames; scan more viewpoints");
if(s->skipped_frames) warn(s,"non-finite or invalid frames skipped");
if(s->far_vertices) warn(s,"out-of-range depth vertices skipped");
if (s->accepted < s->rejected) warn(s,"many triangles rejected; check depth quality");
}
+227
View File
@@ -0,0 +1,227 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
typedef struct { char data[65536]; usize n; int ok; } Text;
static void text(Text *t, const char *p)
{
while(*p) {
if(t->n>=sizeof(t->data)) { t->ok=0;return; }
t->data[t->n++]=*p++;
}
}
static void integer(Text *t, u64 value)
{
char rev[24];int n=0;
do { rev[n++]=(char)('0'+value%10);value/=10; } while(value);
while(n) { char a[2]={rev[--n],0};text(t,a); }
}
static void decimal(Text *t, float value)
{
if(value<0) { text(t,"-");value=-value; }
u64 scaled=(u64)(value*10000.f+.5f);
integer(t,scaled/10000);text(t,".");
u64 fraction=scaled%10000;
if(fraction<1000) text(t,"0");
if(fraction<100) text(t,"0");
if(fraction<10) text(t,"0");
integer(t,fraction);
}
static void space_number(Text *t, float n) { text(t," ");decimal(t,n); }
static int uuid(Text *t)
{
u8 bytes[16];if(!io_random(bytes,sizeof(bytes))) return 0;
bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;
const char *hex="0123456789abcdef";
for(int i=0;i<16;i++) { char pair[3]={hex[bytes[i]>>4],hex[bytes[i]&15],0};text(t,pair); }
return 1;
}
static int record(Text *t, const char *name)
{
text(t,name);text(t," ");return uuid(t);
}
static int path(char *out, const char *dir, const char *name)
{
usize n=0;
while(*dir) { if(n>=3900) return 0;out[n++]=*dir++; }
if(n && out[n-1]!='/') out[n++]='/';
while(*name) { if(n>=4095) return 0;out[n++]=*name++; }
out[n]=0;return 1;
}
static void le32(u8 *out,u32 v) { for(int i=0;i<4;i++) out[i]=(u8)(v>>(8*i)); }
static const char *write_files(Scan *s)
{
Text t={.ok=1};text(&t,"frameport-room 1\nuniverse ");integer(&t,s->opt.universe);
text(&t,"\nsource scan\ntime ");integer(&t,(u64)(io_time(0)/1000000000));text(&t,"\n");
if(!record(&t,"room")) return "getrandom failed";
text(&t,"\n");if(!record(&t,"floor")) return "getrandom failed";
space_number(&t,s->floor);text(&t,"\n");if(!record(&t,"ceiling")) return "getrandom failed";
space_number(&t,s->ceiling);text(&t,"\noutline ");integer(&t,(u64)s->noutline);
for(int i=0;i<s->noutline;i++) { space_number(&t,s->outline[i].x);space_number(&t,s->outline[i].z); }
text(&t,"\n");
for(int i=0;i<s->noutline;i++) {
Pt a=s->outline[i],b=s->outline[(i+1)%s->noutline];
if(!record(&t,"wall")) return "getrandom failed";
space_number(&t,a.x);space_number(&t,a.z);space_number(&t,b.x);space_number(&t,b.z);
space_number(&t,s->floor);space_number(&t,s->ceiling);
if(s->invisible[i]) text(&t," INVISIBLE_WALL_FACE");
text(&t,"\n");
}
for(int i=0;i<s->nvolumes;i++) {
Volume *v=&s->volumes[i];
if(!record(&t,"volume")) return "getrandom failed";
text(&t," ");text(&t,v->label);space_number(&t,v->p.x);space_number(&t,v->p.z);
space_number(&t,v->p.y);space_number(&t,v->yaw);space_number(&t,v->w);space_number(&t,v->d);space_number(&t,v->h);
text(&t,"\n");
}
if(!record(&t,"mesh")) return "getrandom failed";
text(&t," room-mesh.bin ");integer(&t,(u64)s->nv);text(&t," ");integer(&t,(u64)s->ni);text(&t,"\n");
if(!t.ok) return "room text exceeds output budget";
Text rname={.ok=1},mname={.ok=1};
text(&rname,"room.txt.");text(&mname,"room-mesh.bin.");
if(!uuid(&rname) || !uuid(&mname)) return "getrandom failed";
text(&rname,".tmp");text(&mname,".tmp");rname.data[rname.n]=0;mname.data[mname.n]=0;
char room[4096],mesh[4096],rtmp[4096],mtmp[4096];
if(!path(room,s->opt.out,"room.txt") || !path(mesh,s->opt.out,"room-mesh.bin") ||
!path(rtmp,s->opt.out,rname.data) || !path(mtmp,s->opt.out,mname.data)) return "output path too long";
if(!io_mkdir(s->opt.out)) return "cannot create output directory";
int fd=io_open(rtmp,1),ok;
if(fd<0) return "cannot open room temporary file";
ok=io_write(fd,t.data,t.n);if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);return "cannot write room temporary file"; }
fd=io_open(mtmp,1);
if(fd<0) { io_remove(rtmp);return "cannot open mesh temporary file"; }
u8 header[16]={'F','P','R','M'};le32(header+4,1);le32(header+8,(u32)s->nv);le32(header+12,(u32)s->ni);
ok=io_write(fd,header,16);
/* Both supported targets are little-endian IEEE float32; parser always decodes explicitly. */
ok=ok && io_write(fd,s->vertices,(usize)s->nv*sizeof(Vec));
ok=ok && io_write(fd,s->indices,(usize)s->ni*sizeof(u32));
if(io_close(fd)) ok=0;
if(!ok) { io_remove(rtmp);io_remove(mtmp);return "cannot write mesh temporary file"; }
if(io_rename(mtmp,mesh)) { io_remove(rtmp);io_remove(mtmp);return "cannot publish mesh"; }
if(io_rename(rtmp,room)) { io_remove(rtmp);return "cannot publish room"; }
return 0;
}
static int error_json(const char *error)
{
Text t={.ok=1};text(&t,"{\"ok\":false,\"error\":\"");text(&t,error);text(&t,"\"}\n");
io_write(1,t.data,t.n);return 2;
}
static int parse_float(const char *p,float *out)
{
int digits=0;float n=0,scale=1;
while(*p>='0' && *p<='9') { n=n*10+*p++-'0';digits++;if(n>10000) return 0; }
if(*p=='.') { p++;while(*p>='0' && *p<='9') { scale*=.1f;n+=(*p++-'0')*scale;digits++; } }
if(!digits || *p || !finite(n)) return 0;
*out=n;return 1;
}
static int parse_universe(const char *p,u64 *out)
{
u64 n=0;if(!*p) return 0;
for(;*p;p++) {
if(*p<'0' || *p>'9') return 0;
unsigned digit=(unsigned)(*p-'0');
if(n>(~(u64)0-digit)/10) return 0;
n=n*10+digit;
}
*out=n;return 1;
}
static const char *options(Options *o,int argc,char **argv)
{
*o=(Options){.voxel=.05f,.ceiling_default=2.5f,.truncation=3,.max_edge=.6f,.grazing=.12f,.range=4.f,
.depth_jump=.25f,.depth_ratio=.12f,.min_views=5,.patch_residual=.03f,
.plane_distance=.045f,.min_plane_area=.45f,.wall_height=1.5f,.snap_degrees=5,.floor_band=.35f,
.ceiling_min=1.8f,.min_volume=.05f,.cluster_gap=.10f,.gap=.65f,.horizontal=.85f};
for(int i=1;i<argc;i++) {
const char *arg=argv[i];
if(same(arg,"--json")) continue;
if(i+1==argc) return "option requires a value";
const char *value=argv[++i];
if(same(arg,"--offline")) o->input=value;
else if(same(arg,"--out")) o->out=value;
else if(same(arg,"--hint")) o->hint=value;
else if(same(arg,"--universe")) { if(!parse_universe(value,&o->universe)) return "invalid universe"; }
else {
float *dest=0,low=0,high=100;
if(same(arg,"--voxel")) { dest=&o->voxel;low=.04f;high=.2f; }
else if(same(arg,"--ceiling-default")) { dest=&o->ceiling_default;low=1.8f;high=3.8f; }
else if(same(arg,"--truncation")) { dest=&o->truncation;low=1;high=5; }
else if(same(arg,"--max-edge")) { dest=&o->max_edge;low=.05f;high=1.5f; }
else if(same(arg,"--grazing")) { dest=&o->grazing;low=.01f;high=.9f; }
else if(same(arg,"--range")) { dest=&o->range;low=1;high=10; }
else if(same(arg,"--depth-jump")) { dest=&o->depth_jump;low=.02f;high=1; }
else if(same(arg,"--depth-ratio")) { dest=&o->depth_ratio;high=.5f; }
else if(same(arg,"--min-views")) { dest=&o->min_views;low=1;high=16; }
else if(same(arg,"--patch-residual")) { dest=&o->patch_residual;low=.005f;high=.1f; }
else if(same(arg,"--plane-distance")) { dest=&o->plane_distance;low=.01f;high=.15f; }
else if(same(arg,"--min-plane-area")) { dest=&o->min_plane_area;low=.1f;high=4; }
else if(same(arg,"--wall-height")) { dest=&o->wall_height;low=.5f;high=3; }
else if(same(arg,"--snap-degrees")) { dest=&o->snap_degrees;high=15; }
else if(same(arg,"--floor-band")) { dest=&o->floor_band;low=.05f;high=1; }
else if(same(arg,"--ceiling-min")) { dest=&o->ceiling_min;low=1.5f;high=3; }
else if(same(arg,"--min-volume")) { dest=&o->min_volume;low=.005f;high=1; }
else if(same(arg,"--cluster-gap")) { dest=&o->cluster_gap;low=.04f;high=.2f; }
else if(same(arg,"--gap")) { dest=&o->gap;low=.1f;high=1.5f; }
else if(same(arg,"--horizontal")) { dest=&o->horizontal;low=.6f;high=.99f; }
else return "unknown option";
if(!parse_float(value,dest) || *dest<low || *dest>high) return "option outside supported range";
}
}
return o->input && o->out ? 0 : "required: --offline dump.fprs --out directory";
}
static void duration(Text *t,i64 a,i64 b) { decimal(t,(float)(b-a)/1000000); }
static void summary(Scan *s)
{
Text t={.ok=1};text(&t,"{\"ok\":true,\"frames\":");integer(&t,s->frames);
text(&t,",\"skipped_frames\":");integer(&t,s->skipped_frames);
text(&t,",\"far_vertices\":");integer(&t,s->far_vertices);
text(&t,",\"discontinuity_triangles\":");integer(&t,s->discontinuities);
text(&t,",\"walls\":");integer(&t,(u64)s->noutline);
text(&t,",\"detected_walls\":");integer(&t,(u64)s->nplanes);
text(&t,",\"volumes\":");integer(&t,(u64)s->nvolumes);
text(&t,",\"floor_y\":");decimal(&t,s->floor);text(&t,",\"ceiling_y\":");decimal(&t,s->ceiling);
text(&t,",\"coverage_percent\":");decimal(&t,s->coverage);
text(&t,",\"hint_used\":");text(&t,s->hint_used ? "true" : "false");
text(&t,",\"triangles\":");integer(&t,(u64)s->ni/3);
text(&t,",\"accepted_triangles\":");integer(&t,s->accepted);
text(&t,",\"rejected_triangles\":");integer(&t,s->rejected);
text(&t,",\"warnings\":[");
for(int i=0;i<s->nwarnings;i++) { if(i) text(&t,",");text(&t,"\"");text(&t,s->warnings[i]);text(&t,"\""); }
text(&t,"],\"timings_ms\":{\"read\":");duration(&t,s->t0,s->tread);
text(&t,",\"fusion\":");duration(&t,s->tread,s->tfuse);
text(&t,",\"model\":");duration(&t,s->tfuse,s->tmodel);
text(&t,",\"mesh\":");duration(&t,s->tmodel,s->tmesh);
text(&t,",\"total\":");duration(&t,s->t0,io_time(1));text(&t,"}}\n");io_write(1,t.data,t.n);
}
int main(int argc,char **argv)
{
if(argc==2 && same(argv[1],"--help")) {
const char usage[]="fp_roomscan --offline dump.fprs --out directory [--universe N] [--voxel 0.05]\n"
" [--ceiling-default 2.5] [--hint outline.txt] [--json]\n"
"Tuning: --truncation --max-edge --grazing --range --depth-jump --depth-ratio --plane-distance --min-plane-area\n"
" --min-views --patch-residual --wall-height --snap-degrees --floor-band --ceiling-min\n"
" --min-volume --cluster-gap --gap --horizontal\n"
"Always prints one JSON summary; errors exit 2. See docs/mr/scan.md for units and ranges.\n";
io_write(1,usage,sizeof(usage)-1);return 0;
}
Scan *s=io_alloc(sizeof(Scan));
if(!s) return error_json("scan allocation failed");
s->rng=0x714ce3u;s->t0=io_time(1);
const char *error=options(&s->opt,argc,argv);
if(!error) error=read_dump(s,0);
s->tread=io_time(1);
if(!error) error=init_grid(s);
if(!error) error=read_dump(s,1);
if(!error) finish_fusion(s);
s->tfuse=io_time(1);
if(!error) error=make_model(s);
s->tmodel=io_time(1);
if(!error) error=make_mesh(s);
s->tmesh=io_time(1);
if(!error) error=write_files(s);
int result=0;
if(error) result=error_json(error);else summary(s);
io_free(s->cells,(usize)s->count*sizeof(Cell));
io_free(s->vertices,(usize)MAX_MESH_VERTICES*sizeof(Vec));
io_free(s->indices,(usize)MAX_TRIANGLES*3*sizeof(u32));
io_free(s,sizeof(Scan));return result;
}
+112
View File
@@ -0,0 +1,112 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define TANGENT_RADIUS_SQUARED 2.25f
#define TANGENT_WEIGHT_RADIUS_SQUARED 2.5f
#define MAX_EXTRACTION_STRIDE 4
/* Marching cubes without a 256-case table: trace the zero contour on the six faces, then fan each closed loop.
* Ambiguous faces choose their diagonal from the face centre. Adjacent cubes therefore make the same choice. */
static const int corners[8][3]={{0,0,0},{1,0,0},{1,0,1},{0,0,1},{0,1,0},{1,1,0},{1,1,1},{0,1,1}};
static const int edges[12][2]={{0,1},{1,2},{2,3},{3,0},{4,5},{5,6},{6,7},{7,4},{0,4},{1,5},{2,6},{3,7}};
static const int faces[6][4]={{0,1,2,3},{4,5,6,7},{0,9,4,8},{1,10,5,9},{2,11,6,10},{3,8,7,11}};
static void tsdf(Scan *s)
{
float v=s->opt.voxel,trunc=v*s->opt.truncation;
int radius=(int)s->opt.truncation+2;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if (!c->weight) continue;
int x=i%s->nx,z=(i/s->nx)%s->nz,y=i/(s->nx*s->nz);
for (int dy=-radius;dy<=radius;dy++) for (int dz=-radius;dz<=radius;dz++) for (int dx=-radius;dx<=radius;dx++) {
int xx=x+dx,yy=y+dy,zz=z+dz;
if (xx<0 || yy<0 || zz<0 || xx>=s->nx || yy>=s->ny || zz>=s->nz) continue;
Vec delta=sub(grid_point(s,xx,yy,zz),c->p);
float d=dot(delta,c->n),tangent=dot(delta,delta)-d*d;
if (ab(d)>trunc || tangent>v*v*TANGENT_RADIUS_SQUARED) continue;
Cell *dest=&s->cells[(yy*s->nz+zz)*s->nx+xx];
float w=(1-tangent/(v*v*TANGENT_WEIGHT_RADIUS_SQUARED))*mn(c->weight/(v*v),1.f);
dest->sdf+=d*w;dest->sw+=w;
}
}
for (int i=0;i<s->count;i++) if(s->cells[i].sw) s->cells[i].sdf/=s->cells[i].sw;
}
static void connect(int links[12][2], int a, int b)
{
if(links[a][0]<0) links[a][0]=b;else links[a][1]=b;
if(links[b][0]<0) links[b][0]=a;else links[b][1]=a;
}
static int cube(Scan *s, int x, int y, int z, int stride)
{
float f[8];Vec p[8],normal=vec(0,0,0);
for(int k=0;k<8;k++) {
int xx=x+corners[k][0]*stride,yy=y+corners[k][1]*stride,zz=z+corners[k][2]*stride;
Cell *c=&s->cells[(yy*s->nz+zz)*s->nx+xx];
if (c->sw<=0) return 1; /* Unknown space never creates an invented surface. */
f[k]=c->sdf;p[k]=grid_point(s,xx,yy,zz);
}
Vec cuts[12];int crossing[12],links[12][2],visited[12]={0};
for(int e=0;e<12;e++) {
int a=edges[e][0],b=edges[e][1];
crossing[e]=(f[a]<0)!=(f[b]<0);links[e][0]=links[e][1]=-1;
if(crossing[e]) cuts[e]=add(p[a],mul(sub(p[b],p[a]),f[a]/(f[a]-f[b])));
}
/* Gradient points to positive TSDF: toward the observed free room, including furniture's exterior. */
normal.x=f[1]+f[2]+f[5]+f[6]-f[0]-f[3]-f[4]-f[7];
normal.y=f[4]+f[5]+f[6]+f[7]-f[0]-f[1]-f[2]-f[3];
normal.z=f[2]+f[3]+f[6]+f[7]-f[0]-f[1]-f[4]-f[5];
for(int face=0;face<6;face++) {
int hit[4],n=0;float centre=0;
for(int j=0;j<4;j++) {
int e=faces[face][j];
if(crossing[e]) hit[n++]=e;
centre+=f[edges[e][0]]+f[edges[e][1]];
}
if(n==2) connect(links,hit[0],hit[1]);
else if(n==4) {
/* The shared first edge's first corner fixes the phase on this face. */
int first=edges[hit[0]][0],second=edges[hit[0]][1];
int common=(edges[hit[1]][0]==second || edges[hit[1]][1]==second) ? second : first;
if((centre<0)==(f[common]<0)) {
connect(links,hit[0],hit[3]);connect(links,hit[1],hit[2]);
} else { connect(links,hit[0],hit[1]);connect(links,hit[2],hit[3]); }
}
}
for(int e=0;e<12;e++) if(crossing[e] && !visited[e]) {
int loop[12],n=0,prev=-1,cur=e;
while(cur>=0 && !visited[cur] && n<12) {
visited[cur]=1;loop[n++]=cur;
int next=links[cur][0]==prev ? links[cur][1] : links[cur][0];prev=cur;cur=next;
}
if(cur!=e) continue;
for(int k=1;k+1<n;k++) {
Vec a=cuts[loop[0]],b=cuts[loop[k]],c=cuts[loop[k+1]];
Vec norm=cross(sub(b,a),sub(c,a));
if(dot(norm,norm)<1e-14f) continue;
if(s->ni/3>=MAX_TRIANGLES) return 0;
if(dot(norm,normal)<0) { Vec t=b;b=c;c=t; }
s->vertices[s->nv]=a;s->vertices[s->nv+1]=b;s->vertices[s->nv+2]=c;
for(int j=0;j<3;j++) s->indices[s->ni++]=(u32)s->nv++;
}
}
return 1;
}
const char *make_mesh(Scan *s)
{
s->vertices=io_alloc((usize)MAX_MESH_VERTICES*sizeof(Vec));
s->indices=io_alloc((usize)MAX_TRIANGLES*3*sizeof(u32));
if(!s->vertices || !s->indices) return "mesh allocation failed";
tsdf(s);
for(int stride=1;stride<=MAX_EXTRACTION_STRIDE;stride++) {
s->nv=s->ni=0;int ok=1;
for(int y=0;y+stride<s->ny && ok;y+=stride)
for(int z=0;z+stride<s->nz && ok;z+=stride)
for(int x=0;x+stride<s->nx && ok;x+=stride) ok=cube(s,x,y,z,stride);
if(ok) {
if(stride>1) warn(s,"mesh decimated by coarsening extraction grid");
if(!s->ni) warn(s,"no mesh surface; scan denser views");
return 0;
}
}
return "mesh exceeds triangle budget";
}
+672
View File
@@ -0,0 +1,672 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "scan.h"
#define RANSAC_TRIALS 96
#define NORMAL_AGREEMENT .90f
#define MIN_WALL_SPAN .60f
#define FURNITURE_MARGIN .12f
#define NEAR_WALL .35f
#define MIN_TOP_AREA .25f
#define BOX_TRIM .02f
#define VERTICAL_NORMAL_Y .35f
#define BOX_SIDE_NORMAL_Y .30f
#define BOX_EDGE_STRENGTH .40f
#define MIN_CLUSTER_SURFELS 12
#define MIN_BOX_EDGE_VOTES 12
#define MIN_OUTLINE_EXTENT 1.f
#define MIN_OUTLINE_TWICE_AREA .5f
#define FLOOR_INFER_DISTANCE .3f
#define TABLE_MIN_HEIGHT .55f
#define TABLE_MAX_HEIGHT 1.1f
#define COUCH_MIN_HEIGHT .35f
#define COUCH_MAX_HEIGHT .9f
#define COUCH_MIN_LENGTH 1.4f
#define BED_MIN_HEIGHT .3f
#define BED_MAX_HEIGHT .8f
#define BED_MIN_AREA 2.f
#define STORAGE_MIN_HEIGHT 1.1f
#define SCREEN_MAX_DEPTH .22f
#define SCREEN_MIN_WIDTH .4f
#define SCREEN_MAX_WIDTH 2.f
#define DEFAULT_CIRCLE_MIN_POINTS 8
#define DEFAULT_CIRCLE_RADIAL_CV .05f
#define LOW_COVERAGE_PERCENT 80.f
#define WALL_MERGE_DISTANCE .25f
#define WALL_MERGE_COSINE .97f
#define RELATIVE_WALL_AREA .20f
#define DOMINANT_WALL_TOLERANCE 12.f
#define ORTHOGONAL_SUPPORT_FRACTION .65f
#define CEILING_MIN_AREA .08f /* m^2 of fused down-facing cells at one height (fused surfaces are sparse: the
* owner's floor was 0.7 m^2 of cells, the ceiling 0.11 m^2 after a 2 s look up) */
#define CEILING_MIN_HEIGHT 2.0f /* m above the floor: lower ones are shelves, door frames, table undersides */
#define CEILING_MAX_HEIGHT 4.0f
#define BOX_AXIS_COS .96f /* walls within ~16 deg of the two room axes (smeared depth tilted a real
* wall 12 deg in the owner's scan) */
#define BOX_SNAP .5f /* m: a rectangle side snaps to a detected wall this close */
#define BOX_LOW_SHARE .02f /* surfaces beyond the 2nd/98th percentile along an axis are outliers */
#define WALL_BAND_TOP 2.4f /* m above the floor: walls are fitted below this (and below the ceiling) */
#define MIN_FURNITURE_TOP .25f
#define WALL_FURNITURE_CLEARANCE .15f
#define MIN_TOP_DENSITY .30f
static void consolidate_walls(Scan *s)
{
if(!s->nplanes) return;
Plane result[MAX_PLANES]={0};int count=0;
float largest=0;
for(int i=0;i<s->nplanes;i++) {
Plane p=s->planes[i];int merge=-1;
largest=mx(largest,p.area);
for(int j=0;j<count;j++) if(dot(p.n,result[j].n)>WALL_MERGE_COSINE &&
ab(p.d-result[j].d)<WALL_MERGE_DISTANCE) { merge=j;break; }
if(merge<0) result[count++]=p;
else {
Plane *r=&result[merge];
r->area+=p.area;r->bottom=mn(r->bottom,p.bottom);r->top=mx(r->top,p.top);
r->lo=mn(r->lo,p.lo);r->hi=mx(r->hi,p.hi);
}
}
float base=angle(result[0].n.z,result[0].n.x),aligned=0,total=0;
for(int i=0;i<count;i++) {
Plane *p=&result[i];float a=angle(p->n.z,p->n.x)-base;
float delta=ab(a-flo(a/(PI/2)+.5f)*(PI/2));
total+=p->area;
if(delta<DOMINANT_WALL_TOLERANCE*PI/180) aligned+=p->area;
}
int orthogonal=aligned>total*ORTHOGONAL_SUPPORT_FRACTION && s->opt.snap_degrees>0;
s->nplanes=0;
for(int i=0;i<count;i++) {
Plane p=result[i];float a=angle(p.n.z,p.n.x)-base;
float delta=ab(a-flo(a/(PI/2)+.5f)*(PI/2));
if(p.area<largest*RELATIVE_WALL_AREA) continue;
if(orthogonal && delta>DOMINANT_WALL_TOLERANCE*PI/180) continue;
s->planes[s->nplanes++]=p;
}
}
static float plane_height(Scan *s, int ceiling, int *found)
{
float hist[512]={0}, step=s->opt.voxel*.5f;
int bins=(int)(4/step)+2;
if (bins>512) bins=512;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if (!c->weight || (ceiling ? c->n.y > -s->opt.horizontal : c->n.y < s->opt.horizontal)) continue;
if (ceiling ? c->p.y<s->opt.ceiling_min : ab(c->p.y)>s->opt.floor_band) continue;
int b=flo((c->p.y-s->origin.y)/step);
if (b>=0 && b<bins) hist[b]+=1;
}
int best=0;
float score=0;
for (int b=1;b<bins-1;b++) {
float h=hist[b-1]+hist[b]+hist[b+1];
if (h>score) { score=h; best=b; }
}
float y=s->origin.y+(best+.5f)*step, total=0, sum=0,upward=0;
for (int i=0;i<s->count;i++) {
Cell *c=&s->cells[i];
if(c->weight && c->n.y>s->opt.horizontal) upward++;
if (!c->weight || ab(c->p.y-y)>s->opt.plane_distance) continue;
if (ceiling ? c->n.y > -s->opt.horizontal : c->n.y < s->opt.horizontal) continue;
total++; sum+=c->p.y;
}
*found=(ceiling || score* s->opt.voxel*s->opt.voxel >= s->opt.min_plane_area) && total>0;
/* A ceiling is judged on its own area and height, not against the floor: players glance up for a second and look
* at the floor all the time, so a share-of-upward-surfaces rule rejected every real ceiling (owner's scans,
* 2026-10-16: down-facing surfaces at ~2.9 m in both recordings, "ceiling not seen"). */
(void)upward;
if(ceiling && (total*s->opt.voxel*s->opt.voxel<CEILING_MIN_AREA || y-s->floor<CEILING_MIN_HEIGHT ||
y-s->floor>CEILING_MAX_HEIGHT)) *found=0;
return *found ? sum/total : (ceiling ? s->floor+s->opt.ceiling_default : 0);
}
static int on_plane(Scan *s, Cell *c, Plane *p)
{
return ab(dot(c->p,p->n)-p->d)<s->opt.plane_distance && dot(c->n,p->n)>NORMAL_AGREEMENT;
}
static void refine(Scan *s, int *ids, int n, Plane *p)
{
Vec avg=vec(0,0,0), normal=vec(0,0,0);
float count=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) { avg=add(avg,c->p); normal=add(normal,c->n); count++; }
}
if (!count) return;
avg=mul(avg,1/count);
/* Fit positions, rather than averaging noisy per-triangle normals. */
float xx=0,zz=0,xz=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) {
float x=c->p.x-avg.x,z=c->p.z-avg.z;
xx+=x*x; zz+=z*z; xz+=x*z;
}
}
float yaw=.5f*angle(2*xz,xx-zz);
Vec nn=vec(-sine(yaw),0,cosine(yaw));
if (dot(nn,normal)<0) nn=mul(nn,-1);
p->n=nn; p->d=dot(nn,avg);
p->lo=1e10f; p->hi=-1e10f; p->bottom=1e10f; p->top=-1e10f;
count=0;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
if (!c->mark && on_plane(s,c,p)) {
float t=c->p.x*p->n.z-c->p.z*p->n.x;
p->lo=mn(p->lo,t); p->hi=mx(p->hi,t);
p->bottom=mn(p->bottom,c->p.y); p->top=mx(p->top,c->p.y); count++;
}
}
p->area=count*s->opt.voxel*s->opt.voxel;
}
static void walls(Scan *s, int *ids, int n)
{
/* Walls are fitted in a fixed band above the floor whatever the ceiling: Valve's smoothed depth bends where wall
* meets ceiling, and with a detected 2.9 m ceiling that band confused the fit (owner's scan, 2026-10-16). */
float top=mn(s->ceiling,s->floor+WALL_BAND_TOP)-FURNITURE_MARGIN;
for (int i=0;i<n;i++) {
Cell *c=&s->cells[ids[i]];
c->mark=ab(c->n.y)>VERTICAL_NORMAL_Y || c->p.y<s->floor+FURNITURE_MARGIN || c->p.y>top;
}
for (int attempt=0;attempt<MAX_PLANES*2 && s->nplanes<MAX_PLANES;attempt++) {
Plane best={0}; int best_count=0;
for (int trial=0;trial<RANSAC_TRIALS;trial++) {
Cell *seed=&s->cells[ids[random_next(s)%(unsigned)n]];
if (seed->mark) continue;
Plane p={0}; p.n=unit(vec(seed->n.x,0,seed->n.z)); p.d=dot(seed->p,p.n);
int count=0;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (!c->mark && on_plane(s,c,&p)) count++;
}
if (count>best_count) { best_count=count; best=p; }
}
if (best_count*s->opt.voxel*s->opt.voxel<s->opt.min_plane_area) break;
refine(s,ids,n,&best);
/* Height separates large furniture faces from room walls. */
int wall=best.area>=s->opt.min_plane_area && best.top-best.bottom>=s->opt.wall_height &&
best.hi-best.lo>=MIN_WALL_SPAN;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (!c->mark && on_plane(s,c,&best)) c->mark=wall ? 2 : 3;
}
if (wall) s->planes[s->nplanes++]=best;
/* Rejected candidates are consumed too, ensuring bounded progress. */
}
if (s->nplanes==MAX_PLANES) warn(s,"wall limit reached");
if (s->nplanes) {
float base=angle(s->planes[0].n.z,s->planes[0].n.x);
for (int k=0;k<s->nplanes;k++) {
Plane *p=&s->planes[k];
float a=angle(p->n.z,p->n.x), diff=a-base;
int quarter=flo(diff/(PI/2)+.5f);
float target=base+quarter*(PI/2);
if (ab(a-target)<s->opt.snap_degrees*PI/180) {
Vec nn=vec(cosine(target),0,sine(target));
float sum=0,count=0,lo=1e10f,hi=-1e10f;
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if (on_plane(s,c,p)) {
sum+=dot(c->p,nn); count++;
float t=c->p.x*nn.z-c->p.z*nn.x;
lo=mn(lo,t); hi=mx(hi,t);
}
}
if (count) { p->n=nn; p->d=sum/count; p->lo=lo; p->hi=hi; }
}
}
}
consolidate_walls(s);
}
static Pt endpoint(Plane *p, int end)
{
float t=end ? p->hi : p->lo;
return (Pt){p->n.x*p->d+p->n.z*t,p->n.z*p->d-p->n.x*t};
}
static float distance2(Pt a, Pt b) { float x=a.x-b.x,z=a.z-b.z; return x*x+z*z; }
static int intersect(Plane *a, Plane *b, Pt *p)
{
float det=a->n.x*b->n.z-a->n.z*b->n.x;
if (ab(det)<.1f) return 0;
p->x=(a->d*b->n.z-a->n.z*b->d)/det;
p->z=(a->n.x*b->d-a->d*b->n.x)/det;
return 1;
}
static void push_outline(Scan *s, Pt p, int invisible)
{
if (s->noutline && distance2(s->outline[s->noutline-1],p)<.0001f) return;
if (s->noutline<MAX_OUTLINE) {
s->outline[s->noutline]=p;
s->invisible[s->noutline++]=invisible;
}
}
static void rectangle(Scan *s, int *ids, int n)
{
float x0=1e10f,x1=-1e10f,z0=1e10f,z1=-1e10f;
for (int i=0;i<n;i++) {
Vec p=s->cells[ids[i]].p;
x0=mn(x0,p.x);x1=mx(x1,p.x);z0=mn(z0,p.z);z1=mx(z1,p.z);
}
if (x1-x0<MIN_OUTLINE_EXTENT) { float m=(x0+x1)*.5f; x0=m-MIN_OUTLINE_EXTENT/2; x1=m+MIN_OUTLINE_EXTENT/2; }
if (z1-z0<MIN_OUTLINE_EXTENT) { float m=(z0+z1)*.5f; z0=m-MIN_OUTLINE_EXTENT/2; z1=m+MIN_OUTLINE_EXTENT/2; }
push_outline(s,(Pt){x0,z0},1);push_outline(s,(Pt){x1,z0},1);
push_outline(s,(Pt){x1,z1},1);push_outline(s,(Pt){x0,z1},1);
warn(s,"incomplete walls; synthesized outline");
}
static int segment_cross(Pt a, Pt b, Pt c, Pt d)
{
float p=(b.x-a.x)*(c.z-a.z)-(b.z-a.z)*(c.x-a.x);
float q=(b.x-a.x)*(d.z-a.z)-(b.z-a.z)*(d.x-a.x);
float r=(d.x-c.x)*(a.z-c.z)-(d.z-c.z)*(a.x-c.x);
float t=(d.x-c.x)*(b.z-c.z)-(d.z-c.z)*(b.x-c.x);
return p*q<-.000001f && r*t<-.000001f;
}
static int valid_polygon(Scan *s)
{
float area=0;
for (int i=0;i<s->noutline;i++) {
Pt a=s->outline[i], b=s->outline[(i+1)%s->noutline];
area+=a.x*b.z-b.x*a.z;
for (int j=i+2;j<s->noutline;j++) {
if ((j+1)%s->noutline==i) continue;
if (segment_cross(a,b,s->outline[j],s->outline[(j+1)%s->noutline])) return 0;
}
}
return s->noutline>=3 && area>MIN_OUTLINE_TWICE_AREA;
}
static int convex_fallback(Scan *s)
{
if(s->nplanes<3 || s->nplanes>4) return 0;
/* A concave multi-wall room must use directed segments. A small set of enclosure planes can instead be
* clipped as inward half-planes when smeared endpoints prevent reliable corner ordering. */
Pt a[MAX_OUTLINE],b[MAX_OUTLINE];float x0=1e10f,x1=-1e10f,z0=1e10f,z1=-1e10f;
for(int i=0;i<s->nplanes;i++) for(int end=0;end<2;end++) {
Pt p=endpoint(&s->planes[i],end);x0=mn(x0,p.x);x1=mx(x1,p.x);z0=mn(z0,p.z);z1=mx(z1,p.z);
}
int n=4;a[0]=(Pt){x0,z0};a[1]=(Pt){x1,z0};a[2]=(Pt){x1,z1};a[3]=(Pt){x0,z1};
for(int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];int count=0;
for(int j=0;j<n;j++) {
Pt u=a[j],v=a[(j+1)%n];float du=p->n.x*u.x+p->n.z*u.z-p->d,dv=p->n.x*v.x+p->n.z*v.z-p->d;
if(du>=0) b[count++]=u;
if((du>=0)!=(dv>=0)) {
float t=du/(du-dv);b[count++]=(Pt){u.x+t*(v.x-u.x),u.z+t*(v.z-u.z)};
}
}
if(count<3 || count>=MAX_OUTLINE) return 0;
n=count;memcpy(a,b,(usize)n*sizeof(Pt));
}
s->noutline=n;
for(int j=0;j<n;j++) {
s->outline[j]=a[j];s->invisible[j]=1;Pt v=a[(j+1)%n];
for(int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];
if(ab(p->n.x*a[j].x+p->n.z*a[j].z-p->d)<s->opt.plane_distance &&
ab(p->n.x*v.x+p->n.z*v.z-p->d)<s->opt.plane_distance) s->invisible[j]=0;
}
}
if(!valid_polygon(s)) { s->noutline=0;return 0; }
warn(s,"wall endpoints uncertain; using plane intersections");return 1;
}
/* Value below which `share` of the projections fall (2 cm bins over +-8 m around the data). */
static float projection_percentile(Scan *s, int *ids, int n, Pt axis, float share)
{
static u32 hist[800];
memset(hist,0,sizeof(hist));
for (int i=0;i<n;i++) {
Vec p=s->cells[ids[i]].p;
int b=(int)((p.x*axis.x+p.z*axis.z+8)/.02f);
if (b>=0 && b<800) hist[b]++;
}
u64 total=0,run=0;
for (int b=0;b<800;b++) total+=hist[b];
for (int b=0;b<800;b++) { run+=hist[b]; if (run>=share*total) return b*.02f-8+.01f; }
return 8;
}
/* Most rooms are rectangles. When every detected wall runs along one of two perpendicular axes (2-4 walls), the
* outline is that rectangle: its sides at the 2nd/98th percentile of the surfaces along each axis, each snapped to a
* detected wall when one lies within BOX_SNAP. Directed wall chaining made a skewed quadrilateral of the owner's
* room from three smeared walls (2026-10-16). L-shaped rooms have more walls and keep the chaining below. */
static int orthogonal_box(Scan *s, int *ids, int n)
{
if (s->nplanes<2 || s->nplanes>4) return 0;
Vec u=s->planes[0].n; float len=root(u.x*u.x+u.z*u.z);
if (len<.5f) return 0;
Pt axis[2]={{u.x/len,u.z/len},{-u.z/len,u.x/len}};
float side[2][2]; int wall[2][2]={{0,0},{0,0}}, snapped=0;
for (int k=0;k<2;k++) {
side[k][0]=projection_percentile(s,ids,n,axis[k],BOX_LOW_SHARE);
side[k][1]=projection_percentile(s,ids,n,axis[k],1-BOX_LOW_SHARE);
}
for (int i=0;i<s->nplanes;i++) {
Plane *p=&s->planes[i];
float c0=p->n.x*axis[0].x+p->n.z*axis[0].z, c1=p->n.x*axis[1].x+p->n.z*axis[1].z;
int k=ab(c0)>=BOX_AXIS_COS ? 0 : ab(c1)>=BOX_AXIS_COS ? 1 : -1;
if (k<0) return 0; // a wall off both axes: not a rectangle
float offset=p->d*(k ? c1 : c0)/ab(k ? c1 : c0);
int end=ab(offset-side[k][0])<ab(offset-side[k][1]) ? 0 : 1;
if (ab(offset-side[k][end])<BOX_SNAP) { side[k][end]=offset; snapped+=!wall[k][end]; wall[k][end]=1; }
}
if (snapped<2 || side[0][1]-side[0][0]<MIN_OUTLINE_EXTENT || side[1][1]-side[1][0]<MIN_OUTLINE_EXTENT) return 0;
/* corners (s,t) -> world x = a0.x s + a1.x t, z = a0.z s + a1.z t; edge flags = whether its side is a wall */
int order[4][2]={{0,0},{1,0},{1,1},{0,1}}, edge[4][2]={{1,0},{0,1},{1,1},{0,0}}; // edge k lies on side[a][e]
float twice=0;
Pt c[4];
for (int j=0;j<4;j++) {
float a=side[0][order[j][0]], b=side[1][order[j][1]];
c[j]=(Pt){axis[0].x*a+axis[1].x*b, axis[0].z*a+axis[1].z*b};
}
for (int j=0;j<4;j++) twice+=c[j].x*c[(j+1)%4].z-c[(j+1)%4].x*c[j].z;
s->noutline=0;
if (twice>0) for (int j=0;j<4;j++) push_outline(s,c[j],!wall[edge[j][0]][edge[j][1]]);
else for (int j=0;j<4;j++) { // reversed: corner 0,3,2,1; edge from corner k to k-1 is the old edge k-1
int k=(4-j)%4, e=(k+3)%4;
push_outline(s,c[k],!wall[edge[e][0]][edge[e][1]]);
}
if (!valid_polygon(s)) { s->noutline=0; return 0; }
if (snapped<4) warn(s,"gaps closed with invisible walls");
return 1;
}
static void outline(Scan *s, int *ids, int n)
{
if (orthogonal_box(s,ids,n)) return;
if (s->nplanes<3) { rectangle(s,ids,n); return; }
int order[MAX_PLANES]={0};
s->planes[0].used=1;
for (int k=1;k<s->nplanes;k++) {
Pt end=endpoint(&s->planes[order[k-1]],1);
float best=1e10f; int which=-1;
for (int j=0;j<s->nplanes;j++) if (!s->planes[j].used) {
float d=distance2(end,endpoint(&s->planes[j],0));
if (d<best) { best=d;which=j; }
}
order[k]=which;s->planes[which].used=1;
}
Pt starts[MAX_PLANES], ends[MAX_PLANES];
for (int k=0;k<s->nplanes;k++) {
Plane *p=&s->planes[order[k]], *next=&s->planes[order[(k+1)%s->nplanes]];
ends[k]=endpoint(p,1); starts[(k+1)%s->nplanes]=endpoint(next,0);
Pt corner;
if (intersect(p,next,&corner) && distance2(corner,ends[k])<s->opt.gap*s->opt.gap &&
distance2(corner,starts[(k+1)%s->nplanes])<s->opt.gap*s->opt.gap)
ends[k]=starts[(k+1)%s->nplanes]=corner;
}
for (int k=0;k<s->nplanes;k++) {
push_outline(s,starts[k],0);
if (distance2(ends[k],starts[(k+1)%s->nplanes])>.0001f) {
push_outline(s,ends[k],1);warn(s,"gaps closed with invisible walls");
}
}
if (!valid_polygon(s)) {
s->noutline=0;
if(!convex_fallback(s)) { rectangle(s,ids,n);warn(s,"wall ordering ambiguous"); }
}
}
static float wall_distance(Scan *s, Vec p)
{
float best=1e10f;
for (int i=0;i<s->nplanes;i++) best=mn(best,ab(dot(s->planes[i].n,p)-s->planes[i].d));
return best;
}
static void trimmed_bounds(Scan *s, int *queue, int n, Vec axis, float *lo, float *hi)
{
int hist[512]={0};float start=*lo,range=*hi-start;
if(range<.001f) return;
for(int i=0;i<n;i++) {
float value=dot(s->cells[queue[i]].p,axis);
int bin=(int)clamp((value-start)/range*511,0,511);hist[bin]++;
}
int count=0,target=(int)(n*BOX_TRIM);
for(int b=0;b<512;b++) { count+=hist[b];if(count>target) { *lo=start+range*b/511;break; } }
count=0;
for(int b=511;b>=0;b--) { count+=hist[b];if(count>target) { *hi=start+range*(b+1)/511;break; } }
}
static float top_height(Scan *s, int *queue, int n, float low, float high)
{
int histogram[512]={0},bins=(int)mn((high-low)/(s->opt.voxel*.25f)+2,512);
float step=s->opt.voxel*.25f;
for(int i=0;i<n;i++) {
int bin=flo((s->cells[queue[i]].p.y-low)/step);
if(bin>=0 && bin<bins) histogram[bin]++;
}
int count=0;
for(int bin=0;bin<bins;bin++) {
count+=histogram[bin];
/* The median resists a softened lip without selecting one of several noisy sub-peaks as a new top. */
if(count>=n/2) return low+(bin+.5f)*step;
}
return (low+high)*.5f;
}
static void furniture(Scan *s, int *ids, int n, int *queue)
{
/* Tops remain separable when a softened sofa/table edge blends into its neighbouring wall. Vertical-only
* components are considered separately for screens; they cannot turn a whole wall into an OTHER volume. */
for(int mode=0;mode<2;mode++) {
float histogram[256]={0},levels[32];int nlevels=0;
if(!mode) {
for(int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
if(c->n.y>s->opt.horizontal && c->p.y>s->floor+MIN_FURNITURE_TOP &&
c->p.y<s->ceiling-FURNITURE_MARGIN && wall_distance(s,c->p)>WALL_FURNITURE_CLEARANCE) {
int bin=flo((c->p.y-s->floor)/s->opt.voxel);
if(bin>=0 && bin<256) histogram[bin]++;
}
}
for(int k=0;k<32;k++) {
int best=0;float score=0;
for(int bin=1;bin<255;bin++) {
float count=histogram[bin-1]+histogram[bin]+histogram[bin+1];
if(count>score) { score=count;best=bin; }
}
if(score*s->opt.voxel*s->opt.voxel<MIN_TOP_AREA) break;
levels[nlevels++]=s->floor+(best+.5f)*s->opt.voxel;
int radius=(int)(s->opt.plane_distance*2/s->opt.voxel)+1;
for(int bin=best-radius;bin<=best+radius;bin++) if(bin>=0 && bin<256) histogram[bin]=0;
}
}
for (int j=0;j<n;j++) {
Cell *c=&s->cells[ids[j]];
int normal=mode ? ab(c->n.y)<BOX_SIDE_NORMAL_Y : c->n.y>s->opt.horizontal;
c->mark=(normal && c->p.y>s->floor+MIN_FURNITURE_TOP && c->p.y<s->ceiling-FURNITURE_MARGIN &&
wall_distance(s,c->p)>WALL_FURNITURE_CLEARANCE) ? 1 : 0;
if(c->mark && !mode) {
c->mark=0;float best=s->opt.plane_distance*2;
for(int k=0;k<nlevels;k++) if(ab(c->p.y-levels[k])<best) {
best=ab(c->p.y-levels[k]);c->mark=k+1;
}
}
}
int radius=(int)(s->opt.cluster_gap/s->opt.voxel)+1;
for (int j=0;j<n;j++) {
int seed=ids[j];
if (s->cells[seed].mark<=0) continue;
int group=s->cells[seed].mark;
int head=0,tail=1;queue[0]=seed;s->cells[seed].mark=-1;
while (head<tail) {
int id=queue[head++],x=id%s->nx,z=(id/s->nx)%s->nz,y=id/(s->nx*s->nz);
for (int dy=-radius;dy<=radius;dy++) for (int dz=-radius;dz<=radius;dz++) for (int dx=-radius;dx<=radius;dx++) {
if (dx*dx+dy*dy+dz*dz>radius*radius) continue;
int xx=x+dx,yy=y+dy,zz=z+dz;
if (xx<0 || yy<0 || zz<0 || xx>=s->nx || yy>=s->ny || zz>=s->nz) continue;
int next=(yy*s->nz+zz)*s->nx+xx;
Cell *c=&s->cells[next];
if (c->mark==group) { c->mark=-1;queue[tail++]=next; }
}
}
if (tail<MIN_CLUSTER_SURFELS) continue;
Vec avg=vec(0,0,0);float top=-1e10f,bottom=1e10f;
for (int k=0;k<tail;k++) {
Vec p=s->cells[queue[k]].p;avg=add(avg,p);top=mx(top,p.y);bottom=mn(bottom,p.y);
}
avg=mul(avg,1.f/tail);
float xx=0,zz=0,xz=0,edge_x=0,edge_y=0,edge_count=0;
for (int k=0;k<tail;k++) {
Cell *c=&s->cells[queue[k]];
Vec p=sub(c->p,avg);xx+=p.x*p.x;zz+=p.z*p.z;xz+=p.x*p.z;
if(ab(c->n.y)<BOX_SIDE_NORMAL_Y) {
Vec norm=unit(vec(c->n.x,0,c->n.z));
float co2=norm.x*norm.x-norm.z*norm.z,si2=2*norm.x*norm.z;
edge_x+=co2*co2-si2*si2;edge_y+=2*co2*si2;edge_count++;
}
}
float yaw=.5f*angle(2*xz,xx-zz);
/* Opposing faces and perpendicular edges vote for the same fourth harmonic. Unlike PCA, this is not
* biased by one side being more visible. Non-box objects fall back to position covariance. */
if(edge_count>MIN_BOX_EDGE_VOTES && root(edge_x*edge_x+edge_y*edge_y)>edge_count*BOX_EDGE_STRENGTH)
yaw=.25f*angle(edge_y,edge_x);
if(!mode) {
float smallest=1e10f;
for(int degree=0;degree<90;degree++) {
float a=degree*PI/180,co=cosine(a),si=sine(a),u0=1e10f,u1=-1e10f,v0=1e10f,v1=-1e10f;
for(int k=0;k<tail;k++) {
Vec p=s->cells[queue[k]].p;float u=p.x*co+p.z*si,v=-p.x*si+p.z*co;
u0=mn(u0,u);u1=mx(u1,u);v0=mn(v0,v);v1=mx(v1,v);
}
float area=(u1-u0)*(v1-v0);
if(area<smallest) { smallest=area;yaw=a; }
}
}
float co=cosine(yaw),si=sine(yaw);
float u0=1e10f,u1=-1e10f,v0=1e10f,v1=-1e10f,top_count=0;
for (int k=0;k<tail;k++) {
Cell *c=&s->cells[queue[k]];Vec p=sub(c->p,avg);
float u=p.x*co+p.z*si,v=-p.x*si+p.z*co;
u0=mn(u0,u);u1=mx(u1,u);v0=mn(v0,v);v1=mx(v1,v);
if (c->n.y>s->opt.horizontal && c->p.y>top-s->opt.plane_distance*2) top_count++;
}
/* Stereo discontinuities leave a low-density halo even after long-edge rejection. Trim the outer
* two percent before measuring boxes; otherwise a table can become wide enough to look like a couch. */
float up=dot(avg,vec(co,0,si)),vp=dot(avg,vec(-si,0,co));
u0+=up;u1+=up;v0+=vp;v1+=vp;
trimmed_bounds(s,queue,tail,vec(co,0,si),&u0,&u1);
trimmed_bounds(s,queue,tail,vec(-si,0,co),&v0,&v1);
u0-=up;u1-=up;v0-=vp;v1-=vp;
trimmed_bounds(s,queue,tail,vec(0,1,0),&bottom,&top);
if(!mode) top=top_height(s,queue,tail,bottom,top);
float w=u1-u0,d=v1-v0,h=top-(bottom<s->floor+FLOOR_INFER_DISTANCE ? s->floor : mn(bottom,s->floor+FURNITURE_MARGIN));
if(d>w) { float t=w;w=d;d=t;yaw+=PI/2; }
while(yaw>PI/2) yaw-=PI;
if(!mode) h=top-s->floor;
if (w*d*h<s->opt.min_volume) continue;
float height=top-s->floor,area=w*d,top_area=(mode ? top_count : tail)*s->opt.voxel*s->opt.voxel;
if(!mode && (top_area<MIN_TOP_AREA || top_area<area*MIN_TOP_DENSITY)) continue;
const char *label="OTHER";
if (height>=BED_MIN_HEIGHT && height<=BED_MAX_HEIGHT && area>=BED_MIN_AREA) label="BED";
else if (height>=COUCH_MIN_HEIGHT && height<=COUCH_MAX_HEIGHT && mx(w,d)+2*s->opt.voxel>=COUCH_MIN_LENGTH) label="COUCH";
else if (height>=TABLE_MIN_HEIGHT && height<=TABLE_MAX_HEIGHT && top_area>=MIN_TOP_AREA) label="TABLE";
else if (mn(w,d)<SCREEN_MAX_DEPTH && mx(w,d)>=SCREEN_MIN_WIDTH && mx(w,d)<=SCREEN_MAX_WIDTH &&
wall_distance(s,avg)<NEAR_WALL) label="SCREEN";
else if (h>=STORAGE_MIN_HEIGHT && wall_distance(s,avg)<NEAR_WALL) label="STORAGE";
if(mode && !same(label,"SCREEN")) continue;
if(!mode && same(label,"OTHER") && height>TABLE_MAX_HEIGHT) continue;
if (s->nvolumes>=MAX_VOLUMES) { warn(s,"furniture limit reached");break; }
Vec centre=vec(avg.x+co*(u0+u1)*.5f-si*(v0+v1)*.5f,top,
avg.z+si*(u0+u1)*.5f+co*(v0+v1)*.5f);
/* ROOM_FORMAT yaw is an active right-handed rotation about +Y. */
s->volumes[s->nvolumes++]=(Volume){centre,-yaw*180/PI,w,d,h,label};
}
}
}
/* A hint is a fallback for missing walls, never a constraint on observed geometry. */
static const char *hint(Scan *s);
const char *make_model(Scan *s)
{
int found;
s->floor=plane_height(s,0,&found);
if (!found) warn(s,"floor not seen; using STAGE floor");
s->ceiling=plane_height(s,1,&found);
if (!found) warn(s,"ceiling not seen");
usize bytes=(usize)s->count*sizeof(int);
int *ids=io_alloc(bytes),*queue=io_alloc(bytes);
if (!ids || !queue) { io_free(ids,bytes);io_free(queue,bytes);return "model allocation failed"; }
int n=0;
for (int i=0;i<s->count;i++) if (s->cells[i].weight) ids[n++]=i;
if (!n) { io_free(ids,bytes);io_free(queue,bytes);return "no surfaces in reconstruction bounds"; }
walls(s,ids,n);outline(s,ids,n);
const char *error=hint(s);
if (!error) furniture(s,ids,n,queue);
float perimeter=0,observed=0;
for (int i=0;i<s->noutline;i++) {
float d=root(distance2(s->outline[i],s->outline[(i+1)%s->noutline]));perimeter+=d;
if (!s->invisible[i]) observed+=d;
}
s->coverage=perimeter ? 100*observed/perimeter : 0;
if (s->coverage<LOW_COVERAGE_PERCENT) warn(s,"low wall coverage; scan all walls");
io_free(ids,bytes);io_free(queue,bytes);
return error;
}
static int number(char **cursor, float *value)
{
char *p=*cursor;while (*p==' ' || *p=='\t' || *p=='\r') p++;
int sign=1,digits=0;if (*p=='-' || *p=='+') { if (*p=='-') sign=-1;p++; }
float v=0,scale=1;
while (*p>='0' && *p<='9') { v=v*10+*p++-'0';digits++;if(v>10000) return 0; }
if (*p=='.') { p++;while (*p>='0' && *p<='9') { scale*=.1f;v+=(*p++-'0')*scale;digits++; } }
if(*p=='e' || *p=='E') {
p++;int exponent=0,negative=0,edigits=0;
if(*p=='-' || *p=='+') { negative=*p=='-';p++; }
while(*p>='0' && *p<='9') { exponent=exponent*10+*p++-'0';edigits++;if(exponent>38) return 0; }
if(!edigits) return 0;
while(exponent--) v=negative ? v*.1f : v*10;
}
if (!digits || !finite(v) || v>10000) return 0;
*value=sign*v;*cursor=p;return 1;
}
static const char *hint(Scan *s)
{
if (!s->opt.hint) return 0;
int fd=io_open(s->opt.hint,0);
if (fd<0) return "cannot open outline hint";
char data[8192];usize size=0;
for (;;) {
long r=io_read(fd,data+size,sizeof(data)-1-size);
if (r<0 || (r>0 && size+(usize)r==sizeof(data)-1)) { io_close(fd);return "outline hint too large or unreadable"; }
if (!r) break;
size+=(usize)r;
}
io_close(fd);data[size]=0;
Pt pts[MAX_OUTLINE];int n=0;char *p=data;
while (*p) {
while (*p==' ' || *p=='\t' || *p=='\r' || *p=='\n') p++;
if (!*p) break;
if (*p=='#') { while (*p && *p!='\n') p++;continue; }
if (n==MAX_OUTLINE || !number(&p,&pts[n].x) || !number(&p,&pts[n].z)) return "invalid outline hint";
n++;while (*p==' ' || *p=='\t' || *p=='\r') p++;
if (*p && *p!='\n' && *p!='#') return "invalid outline hint";
if (*p=='#') while (*p && *p!='\n') p++;
}
if (n<3) return "outline hint needs at least three points";
Pt centre={0};for (int i=0;i<n;i++) { centre.x+=pts[i].x/n;centre.z+=pts[i].z/n; }
float mean=0,variance=0;
for (int i=0;i<n;i++) mean+=root(distance2(pts[i],centre))/n;
for (int i=0;i<n;i++) { float r=root(distance2(pts[i],centre))-mean;variance+=r*r/n; }
if (n>=DEFAULT_CIRCLE_MIN_POINTS && root(variance)<mean*DEFAULT_CIRCLE_RADIAL_CV) {
warn(s,"default circle hint ignored");return 0;
}
int missing=0;for(int i=0;i<s->noutline;i++) missing+=s->invisible[i];
if (!missing) return 0;
Pt saved[MAX_OUTLINE];int inv[MAX_OUTLINE],old=s->noutline;
memcpy(saved,s->outline,sizeof(saved));memcpy(inv,s->invisible,sizeof(inv));
float area=0;for(int i=0;i<n;i++) area+=pts[i].x*pts[(i+1)%n].z-pts[(i+1)%n].x*pts[i].z;
s->noutline=n;
for(int i=0;i<n;i++) { s->outline[i]=pts[area>0 ? i : n-1-i];s->invisible[i]=1; }
if (!valid_polygon(s)) {
memcpy(s->outline,saved,sizeof(saved));memcpy(s->invisible,inv,sizeof(inv));s->noutline=old;
return "invalid outline hint polygon";
}
/* Only use a hint compatible with every observed wall. Chaperone can be an inset play area. */
for(int i=0;i<s->nplanes;i++) {
Plane *w=&s->planes[i];int match=0;
for(int k=0;k<n;k++) {
Pt a=s->outline[k],b=s->outline[(k+1)%n];
if(ab(a.x*w->n.x+a.z*w->n.z-w->d)<s->opt.gap &&
ab(b.x*w->n.x+b.z*w->n.z-w->d)<s->opt.gap) { match=1;s->invisible[k]=0; }
}
if (!match) {
memcpy(s->outline,saved,sizeof(saved));memcpy(s->invisible,inv,sizeof(inv));s->noutline=old;
warn(s,"hint disagrees with observed walls; ignored");return 0;
}
}
s->hint_used=1;warn(s,"incomplete walls closed using outline hint");return 0;
}
+145
View File
@@ -0,0 +1,145 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "platform.h"
#ifdef FP_HOSTED
#define _DEFAULT_SOURCE 1
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/random.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
int io_open(const char *p, int w) { return open(p, w ? O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW : O_RDONLY, 0600); }
long io_read(int fd, void *p, usize n)
{
long r;
do r = read(fd, p, n); while (r < 0 && errno == EINTR);
return r;
}
static long put(int fd, const void *p, usize n)
{
long r;
do r = write(fd, p, n); while (r < 0 && errno == EINTR);
return r;
}
int io_close(int fd) { return close(fd); }
int io_rename(const char *a, const char *b) { return rename(a, b); }
void io_remove(const char *p) { unlink(p); }
int io_mkdir(const char *p) { return mkdir(p, 0700) == 0 || errno == EEXIST; }
void *io_alloc(usize n)
{
void *p = mmap(0, n, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
return p == MAP_FAILED ? 0 : p;
}
void io_free(void *p, usize n) { if (p) munmap(p, n); }
int io_random(void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = getrandom((u8 *)p + done, n - done, 0);
if (r < 0 && errno == EINTR) continue;
if (r <= 0) return 0;
done += (usize)r;
}
return 1;
}
i64 io_time(int mono)
{
struct timespec t;
if (clock_gettime(mono ? CLOCK_MONOTONIC : CLOCK_REALTIME, &t)) return 0;
return (i64)t.tv_sec * 1000000000 + t.tv_nsec;
}
#else
/* asm-generic Linux syscall numbers: this target has no libc or UAPI dependency. */
static long call(long n, long a, long b, long c, long d, long e, long f)
{
register long x8 __asm__("x8") = n;
register long x0 __asm__("x0") = a;
register long x1 __asm__("x1") = b;
register long x2 __asm__("x2") = c;
register long x3 __asm__("x3") = d;
register long x4 __asm__("x4") = e;
register long x5 __asm__("x5") = f;
__asm__ volatile("svc #0" : "+r"(x0) : "r"(x8), "r"(x1), "r"(x2), "r"(x3), "r"(x4), "r"(x5) : "memory", "cc");
return x0;
}
int io_open(const char *p, int w) { return (int)call(56, -100, (long)p, w ? 1|64|128|131072 : 0, 0600, 0, 0); }
long io_read(int fd, void *p, usize n)
{
long r;
do r = call(63, fd, (long)p, (long)n, 0, 0, 0); while (r == -4);
return r;
}
static long put(int fd, const void *p, usize n)
{
long r;
do r = call(64, fd, (long)p, (long)n, 0, 0, 0); while (r == -4);
return r;
}
int io_close(int fd) { return (int)call(57, fd, 0, 0, 0, 0, 0); }
int io_rename(const char *a, const char *b) { return (int)call(38, -100, (long)a, -100, (long)b, 0, 0); }
void io_remove(const char *p) { call(35, -100, (long)p, 0, 0, 0, 0); }
int io_mkdir(const char *p)
{
long r = call(34, -100, (long)p, 0700, 0, 0, 0);
return r == 0 || r == -17;
}
void *io_alloc(usize n)
{
long p = call(222, 0, (long)n, 3, 0x22, -1, 0);
return (unsigned long)p > (unsigned long)-4096 ? 0 : (void *)p;
}
void io_free(void *p, usize n) { if (p) call(215, (long)p, (long)n, 0, 0, 0, 0); }
int io_random(void *p, usize n)
{
usize done = 0;
while (done < n) {
long r = call(278, (long)((u8 *)p + done), (long)(n - done), 0, 0, 0, 0);
if (r == -4) continue;
if (r <= 0) return 0;
done += (usize)r;
}
return 1;
}
i64 io_time(int mono)
{
struct { long sec, ns; } t = {0, 0};
call(113, mono ? 1 : 0, (long)&t, 0, 0, 0, 0);
return (i64)t.sec * 1000000000 + t.ns;
}
void *memcpy(void *d, const void *s, usize n)
{
u8 *a = d;
const u8 *b = s;
while (n--) *a++ = *b++;
return d;
}
void *memset(void *d, int c, usize n)
{
u8 *a = d;
while (n--) *a++ = (u8)c;
return d;
}
extern int main(int argc, char **argv);
__attribute__((used, noreturn)) void start_c(long *sp)
{
int code = main((int)sp[0], (char **)(sp + 1));
for (;;) call(94, code, 0, 0, 0, 0, 0);
}
__asm__(".text\n.global _start\n.type _start,%function\n_start:\n"
"mov x29,#0\nmov x30,#0\nmov x0,sp\nbl start_c\nbrk #0\n");
#endif
int io_write(int fd, const void *p, usize n)
{
while (n) {
long r = put(fd, p, n);
if (r <= 0) return 0;
p = (const u8 *)p + r;
n -= (usize)r;
}
return 1;
}
+25
View File
@@ -0,0 +1,25 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef FP_ROOMSCAN_PLATFORM_H
#define FP_ROOMSCAN_PLATFORM_H
/* The reconstruction sources use only this boundary, on Linux and in hosted tests. */
typedef __SIZE_TYPE__ usize;
typedef unsigned char u8;
typedef unsigned int u32;
typedef unsigned long long u64;
typedef long long i64;
int io_open(const char *path, int write);
long io_read(int fd, void *data, usize size);
int io_write(int fd, const void *data, usize size);
int io_close(int fd);
int io_rename(const char *old, const char *new);
void io_remove(const char *path);
int io_mkdir(const char *path);
void *io_alloc(usize size);
void io_free(void *data, usize size);
int io_random(void *data, usize size);
i64 io_time(int monotonic);
void *memcpy(void *dst, const void *src, usize size);
void *memset(void *dst, int value, usize size);
#endif
+93
View File
@@ -0,0 +1,93 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#ifndef FP_ROOMSCAN_H
#define FP_ROOMSCAN_H
#include "platform.h"
/* Resource limits are independent of dump length. Two passes avoid retaining frames. */
#define MAX_FRAME_VERTICES 1000000u
#define MAX_FRAME_INDICES 3000000u
#define MAX_CELLS 4200000u
#define MAX_SAMPLES 32768
#define MAX_PLANES 32
#define MAX_OUTLINE 96
#define MAX_VOLUMES 128
#define MAX_TRIANGLES 200000
#define MAX_MESH_VERTICES 600000
#define PI 3.14159265358979323846f
typedef struct { float x, y, z; } Vec;
typedef struct { float x, z; } Pt;
typedef struct {
Vec p, n;
float weight, sdf, sw;
int mark;
unsigned views, last_frame;
} Cell;
typedef struct {
Vec n;
float d, lo, hi, bottom, top, area;
int used;
} Plane;
typedef struct { Vec p; float yaw, w, d, h; const char *label; } Volume;
typedef struct {
const char *input, *out, *hint;
u64 universe;
float voxel, ceiling_default, truncation, max_edge, grazing, range, depth_jump, depth_ratio, min_views;
float patch_residual;
float plane_distance, min_plane_area, wall_height, snap_degrees;
float floor_band, ceiling_min, min_volume, cluster_gap, gap, horizontal;
} Options;
typedef struct {
Options opt;
Cell *cells;
int nx, ny, nz, count;
Vec origin;
Vec samples[MAX_SAMPLES];
int nsamples;
u64 seen, accepted, rejected, clipped, far_vertices, discontinuities;
unsigned frames, skipped_frames, fuse_frame;
Plane planes[MAX_PLANES];
int nplanes;
Pt outline[MAX_OUTLINE];
int invisible[MAX_OUTLINE], noutline;
Volume volumes[MAX_VOLUMES];
int nvolumes;
float floor, ceiling, coverage;
const char *warnings[24];
int nwarnings, hint_used;
u32 rng;
Vec *vertices;
u32 *indices;
int nv, ni;
i64 t0, tread, tfuse, tmodel, tmesh;
} Scan;
static inline float ab(float x) { return x < 0 ? -x : x; }
static inline float mn(float a, float b) { return a < b ? a : b; }
static inline float mx(float a, float b) { return a > b ? a : b; }
static inline float clamp(float x, float a, float b) { return mn(mx(x, a), b); }
static inline Vec vec(float x, float y, float z) { return (Vec){x,y,z}; }
static inline Vec add(Vec a, Vec b) { return vec(a.x+b.x,a.y+b.y,a.z+b.z); }
static inline Vec sub(Vec a, Vec b) { return vec(a.x-b.x,a.y-b.y,a.z-b.z); }
static inline Vec mul(Vec a, float b) { return vec(a.x*b,a.y*b,a.z*b); }
static inline float dot(Vec a, Vec b) { return a.x*b.x+a.y*b.y+a.z*b.z; }
static inline Vec cross(Vec a, Vec b) { return vec(a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x); }
static inline int finite(float x) { return x == x && ab(x) < 1e20f; }
float root(float x);
float sine(float x);
float cosine(float x);
float angle(float y, float x);
Vec unit(Vec p);
int flo(float x);
u32 random_next(Scan *s);
void warn(Scan *s, const char *message);
int cell_index(Scan *s, Vec p);
Vec grid_point(Scan *s, int x, int y, int z);
const char *read_dump(Scan *s, int fuse);
const char *init_grid(Scan *s);
void finish_fusion(Scan *s);
const char *make_model(Scan *s);
const char *make_mesh(Scan *s);
int same(const char *a, const char *b);
#endif
+2
View File
@@ -7,6 +7,8 @@
// Meta's OVRPlugin finds xrGetInstanceProcAddr with dlopen("libopenxr_loader.so") + dlsym, so FramePort points that
// string in libOVRPlugin.so at this library instead (patches/frame/adapter.py). Names the adapter emulates are served
// by the adapter (framebridge_extension_proc); everything else goes to overport's xrGetInstanceProcAddr unchanged.
// The adapter also routes scene discovery/persistence/mesh and the core locate/event functions that consume
// emulated spaces. Keep this name-agnostic: another fixed table here would repeat the dispatcher's limitation.
//
// Haptics (GitHub #9, found by Klownicle in Lucky's Tale): overport's dispatcher converts an
// XrHapticAmplitudeEnvelopeVibrationFB with its nanosecond duration taken as seconds, so one vibration allocates an
+105
View File
@@ -0,0 +1,105 @@
"""Render the in-headset room-scan prompts (SteamVR overlay images) into artifacts/roomscan/.
The Frame has no imaging library, so the agent shows pre-rendered PNGs through IVROverlay while it records the
passthrough depth mesh (agent `room_capture`). One image per state; "look around" comes in 13 variants (0-12 of 12
directions seen) so the progress ring can advance without drawing on the Frame. Colours = the Portal theme.
uv run python scripts/mr/render_prompts.py [--check]
"""
from __future__ import annotations
import argparse
import hashlib
import io
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "artifacts" / "roomscan"
W, H = 1024, 400
BG, SURFACE, TEXT, TEXT_2 = (13, 14, 18, 235), (29, 31, 39, 255), (236, 238, 243, 255), (167, 172, 186, 255)
ACCENT, SECONDARY, OK = (255, 138, 31, 255), (58, 168, 255, 255), (74, 222, 128, 255)
DIRECTIONS = 12
PROMPTS = {
"start": ("Turn on passthrough", "The scan runs while you can see your room:\n"
"open the quick access menu and switch passthrough on."),
"ceiling": ("Look up at the ceiling", "Hold it for a few seconds,\n"
"then look back down and keep turning."),
"floor": ("Look down at the floor", "Hold it for a few seconds,\nthen keep turning."),
"done": ("Room scanned", "You can take the headset off or start a mixed reality game."),
"failed": ("Scan stopped", "The headset's cameras sent no room view.\nStart the scan again from FramePort."),
}
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
for name in ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf",):
for base in ("/usr/share/fonts/truetype/dejavu", "C:/Windows/Fonts", "/Library/Fonts"):
p = Path(base) / name
if p.is_file():
return ImageFont.truetype(str(p), size)
return ImageFont.load_default(size=size)
def panel(title: str, body: str, seen: int | None = None, accent=ACCENT) -> Image.Image:
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle((4, 4, W - 4, H - 4), radius=48, fill=BG, outline=SURFACE, width=4)
d.rounded_rectangle((4, 4, 20, H - 4), radius=8, fill=accent) # portal edge
x = 64
if seen is not None: # progress ring: one segment per 30 degree direction seen
cx, cy, r = 150, H // 2, 92
for i in range(DIRECTIONS):
a0 = -90 + i * 360 / DIRECTIONS + 3
d.arc((cx - r, cy - r, cx + r, cy + r), a0, a0 + 360 / DIRECTIONS - 6,
fill=(SECONDARY if i < seen else SURFACE), width=26)
d.text((cx, cy), f"{seen}/{DIRECTIONS}", font=font(44, True), fill=TEXT, anchor="mm")
x = 290
d.text((x, 108), title, font=font(54, True), fill=TEXT, anchor="lm")
y = 180
for line in body.split("\n"):
d.text((x, y), line, font=font(32), fill=TEXT_2, anchor="lm")
y += 48
return img
def images() -> dict[str, bytes]:
out = {}
for key, (title, body) in PROMPTS.items():
out[key] = panel(title, body, accent=OK if key == "done" else ACCENT)
for n in range(DIRECTIONS + 1):
out[f"look_{n:02d}"] = panel("Look around the room", "Turn slowly until the ring is full:\n"
"every wall and your furniture.", seen=n, accent=SECONDARY)
data = {}
for key, img in out.items():
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
data[f"{key}.png"] = buf.getvalue()
return data
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--check", action="store_true", help="fail if artifacts/roomscan is out of date")
args = ap.parse_args()
data = images()
if args.check:
stale = [n for n, b in data.items() if not (OUT / n).is_file() or (OUT / n).read_bytes() != b]
if stale:
print("out of date:", ", ".join(stale))
return 1
return 0
OUT.mkdir(parents=True, exist_ok=True)
for name, b in data.items():
p = OUT / name
if not p.is_file() or p.read_bytes() != b:
p.write_bytes(b)
print(f"{len(data)} prompts in {OUT.relative_to(ROOT)} "
f"({sum(map(len, data.values())) // 1024} KiB, {hashlib.sha256(b''.join(data.values())).hexdigest()[:12]})")
return 0
if __name__ == "__main__":
sys.exit(main())
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Render deterministic, noisy head-centred depth meshes into the FPRS v1 contract (stdlib only)."""
from __future__ import annotations
import argparse
import math
import random
import struct
from pathlib import Path
BOX_OUTLINE = [(-2, -1.5), (2, -1.5), (2, 1.5), (-2, 1.5)]
L_OUTLINE = [(-2, -2), (2, -2), (2, 0), (0, 0), (0, 2), (-2, 2)]
TABLE = ((-.6, 0, -.25), (1.2, .75, .7))
COUCH = ((.6, 0, .95), (1.6, .65, .65))
ROOMVIEW_WIDTH, ROOMVIEW_HEIGHT = 132, 128
ROOMVIEW_NEAR, ROOMVIEW_FAR = .3, 4.0
def fan(width, height, roomview):
"""RoomView camera axes are +Z forward / -Y image up; poses convert them to STAGE."""
sx, sy = (1.96, 1.94) if roomview else (math.tan(math.radians(55)), math.tan(math.radians(38)))
for row in range(height):
for col in range(width):
x = (2 * col / (width - 1) - 1) * sx
y = (2 * row / (height - 1) - 1) * sy
if not roomview:
y = -y
length = math.sqrt(x * x + y * y + 1)
yield x / length, y / length, (1 if roomview else -1) / length
def smooth_depths(values, width, height, radius=3):
"""Separable box smoothing models soft stereo boundaries, without a NumPy/scipy dependency."""
result = list(values)
for vertical in (False, True):
source, result = result, [0.0] * len(values)
lines, length, step = (width, height, width) if vertical else (height, width, 1)
for line in range(lines):
start = line if vertical else line * width
prefix = [0.0]
for i in range(length):
prefix.append(prefix[-1] + source[start + i * step])
for i in range(length):
lo, hi = max(0, i - radius), min(length, i + radius + 1)
result[start + i * step] = (prefix[hi] - prefix[lo]) / (hi - lo)
return result
def inside(x, z, polygon):
odd = False
for (ax, az), (bx, bz) in zip(polygon, polygon[1:] + polygon[:1], strict=False):
if (az > z) != (bz > z) and x < (bx - ax) * (z - az) / (bz - az) + ax:
odd = not odd
return odd
def rotate(v, q):
x, y, z = v
qx, qy, qz, qw = q
tx, ty, tz = 2 * (qy * z - qz * y), 2 * (qz * x - qx * z), 2 * (qx * y - qy * x)
return (x + qw * tx + qy * tz - qz * ty,
y + qw * ty + qz * tx - qx * tz,
z + qw * tz + qx * ty - qy * tx)
def cast(eye, direction, polygon, ceiling, furniture, missing_wall):
ex, ey, ez = eye
dx, dy, dz = direction
nearest = 5.0
hit = False
for height in (0, ceiling):
if abs(dy) > 1e-8:
t = (height - ey) / dy
if .3 < t < nearest and inside(ex + t * dx, ez + t * dz, polygon):
nearest, hit = t, True
for index, ((ax, az), (bx, bz)) in enumerate(zip(polygon, polygon[1:] + polygon[:1], strict=False)):
if index == missing_wall:
continue
sx, sz = bx - ax, bz - az
det = dx * sz - dz * sx
if abs(det) < 1e-8:
continue
t = ((ax - ex) * sz - (az - ez) * sx) / det
u = ((ax - ex) * dz - (az - ez) * dx) / det
if .3 < t < nearest and 0 <= u <= 1 and 0 <= ey + t * dy <= ceiling:
nearest, hit = t, True
for (cx, bottom, cz), (width, height, depth) in furniture:
near, far = -1e20, 1e20
for pos, ray, lo, hi in zip(eye, direction,
(cx - width / 2, bottom, cz - depth / 2),
(cx + width / 2, bottom + height, cz + depth / 2), strict=False):
if abs(ray) < 1e-8:
if not lo <= pos <= hi:
far = -1e20
break
else:
a, b = (lo - pos) / ray, (hi - pos) / ray
near, far = max(near, min(a, b)), min(far, max(a, b))
if .3 < near < nearest and near <= far:
nearest, hit = near, True
return nearest if hit else None
def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise=.004,
holes=.025, smear=.012, floor_bias=.008, no_ceiling=False, seed=7,
yaw=0.0, offset=(0.0, 0.0, 0.0), missing_wall=-1, objects=True, profile="roomview",
invalid_start=None, smoothing=3):
"""Each frame has its own moving pose; no ground-truth room data is put in the dump."""
rng = random.Random(seed)
roomview = profile == "roomview"
width = width or (ROOMVIEW_WIDTH if roomview else 56)
height = height or (ROOMVIEW_HEIGHT if roomview else 40)
invalid_start = (2 if roomview else 0) if invalid_start is None else invalid_start
polygon = BOX_OUTLINE if shape == "box" else L_OUTLINE
ceiling = 2.6
furniture = [TABLE, COUCH] if objects and shape == "box" else []
rotation = (0, math.sin(yaw / 2), 0, math.cos(yaw / 2))
rays = list(fan(width, height, roomview))
with path.open("wb") as out:
out.write(struct.pack("<4sI", b"FPRS", 1))
for frame in range(frames):
heading = 2 * math.pi * frame / max(frames, 1)
pitch = -.85 if no_ceiling else (-.60, 0, .70)[frame % 3]
if roomview and no_ceiling:
pitch = -1.2
# The L's two arms need viewpoints on both sides of the re-entrant corner.
centre = (-.65, -.65) if shape == "l" else (0, 0)
eye = (centre[0] + .35 * math.cos(heading), 1.6 + .04 * math.sin(heading * 2),
centre[1] + .35 * math.sin(heading))
if roomview:
eye = eye[0], 1.3 + .4 * math.sin(heading), eye[2]
sy, cy = math.sin(heading / 2), math.cos(heading / 2)
sp, cp = math.sin(pitch / 2), math.cos(pitch / 2)
q = (cy * sp, sy * cp, -sy * sp, cy * cp)
if roomview:
q = q[3], q[2], -q[1], -q[0] # head rotation composed with camera's 180 degree X rotation
depths = [cast(eye, rotate(ray, q), polygon, ceiling, furniture, missing_wall) for ray in rays]
if roomview:
# Smooth both valid depth and inpainted far pixels. Low-frequency noise survives the filter,
# while isolated high-frequency speckle is small, as in a stereo patch fit.
coarse_width = (width + 11) // 12
coarse = [rng.gauss(0, .012) for _ in range(coarse_width * ((height + 11) // 12))]
depths = [(depth if depth is not None else 8 + rng.random() * 2)
+ coarse[(i // width // 12) * coarse_width + (i % width // 12)]
for i, depth in enumerate(depths)]
depths = smooth_depths(depths, width, height, smoothing)
vertices, valid = [], []
for index, (ray, depth) in enumerate(zip(rays, depths, strict=False)):
keep = depth is not None and rng.random() >= holes
valid.append(keep or roomview)
if roomview and not keep:
depth = 8 + rng.random() * 2
if depth is None:
depth = 0
else:
# Stereo edges blend two depths, producing stretched grazing triangles.
other = depths[index + 1] if (index + 1) % width and index + 1 < len(depths) else depth
if other is not None and abs(other - depth) > .3 and rng.random() < smear:
depth = (depth + other) / 2
world_ray = rotate(ray, q)
if eye[1] + depth * world_ray[1] < .15:
depth += floor_bias * rng.random()
depth += rng.gauss(0, noise)
vertices.extend(c * depth for c in ray)
indices = []
for row in range(height - 1):
for col in range(width - 1):
a = row * width + col
for triangle in ((a, a + width, a + 1), (a + 1, a + width, a + width + 1)):
if all(valid[i] for i in triangle):
indices.extend(triangle)
# Compose the global room transform with each independent mesh -> room pose.
pose_eye = tuple(a + b for a, b in zip(rotate(eye, rotation), offset, strict=False))
ax, ay, az, aw = rotation
bx, by, bz, bw = q
pose_q = (aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz)
if frame < invalid_start:
pose_eye = (float("nan"),) * 3
out.write(struct.pack("<d7fII", frame / (3 if roomview else 15),
*pose_eye, *pose_q, len(rays), len(indices)))
out.write(struct.pack(f"<{len(vertices)}f", *vertices))
out.write(struct.pack(f"<{len(indices)}I", *indices))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("out", type=Path)
ap.add_argument("--shape", choices=("box", "l"), default="box")
ap.add_argument("--frames", type=int, default=30)
ap.add_argument("--profile", choices=("roomview", "legacy"), default="roomview")
ap.add_argument("--width", type=int)
ap.add_argument("--height", type=int)
ap.add_argument("--invalid-start", type=int, help="untracked initial frames (RoomView default: 2)")
ap.add_argument("--smoothing", type=int, default=3, help="RoomView depth filter radius in pixels")
ap.add_argument("--noise", type=float, default=.004)
ap.add_argument("--holes", type=float, default=.025)
ap.add_argument("--smear", type=float, default=.012)
ap.add_argument("--floor-bias", type=float, default=.008)
ap.add_argument("--no-ceiling", action="store_true")
ap.add_argument("--no-objects", action="store_true")
ap.add_argument("--missing-wall", type=int, default=-1)
ap.add_argument("--yaw", type=float, default=0, help="global room yaw in degrees")
ap.add_argument("--seed", type=int, default=7)
args = ap.parse_args()
if args.frames < 1 or (args.width is not None and args.width < 3) or (args.height is not None and args.height < 3):
ap.error("positive frame count and a grid of at least 3 x 3 required")
render(args.out, shape=args.shape, frames=args.frames, width=args.width, height=args.height,
noise=args.noise, holes=args.holes, smear=args.smear, floor_bias=args.floor_bias,
no_ceiling=args.no_ceiling, seed=args.seed, yaw=math.radians(args.yaw),
missing_wall=args.missing_wall, objects=not args.no_objects, profile=args.profile,
invalid_start=args.invalid_start, smoothing=args.smoothing)
if __name__ == "__main__":
main()
+48 -2
View File
@@ -101,6 +101,8 @@ def main() -> int:
ap.add_argument("--viewport", default="1280x820", help="browser size, e.g. 2560x1440")
ap.add_argument("--update", action="store_true", help="pretend a new FramePort release exists (update UI: "
"changelog dialog, what's new bar + dialog, Settings changelog)")
ap.add_argument("--announcement", action="store_true", help="show the announcement pop-up (with a picture, "
"then text only); nothing is fetched")
ap.add_argument("--fake-frame", action="store_true", help="pretend a Steam Frame is connected (no device needed)")
ap.add_argument("--hover", default=None, help="x,y: move the mouse there before the Library screenshot")
ap.add_argument("--install-questions", nargs="+", metavar="PKG", default=None,
@@ -137,7 +139,8 @@ def main() -> int:
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
("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-index", lambda a: a.page.run_task(a.settings_view.show_section, "pc")),
("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)))
steps.insert(2, ("game-customize", lambda a: a.open_game(game, advanced=True)))
@@ -150,7 +153,8 @@ def main() -> int:
if args.fake_frame: # GitHub #90: the Move to… dialog (drives load in the background)
steps.append(("move-dialog", lambda a: (a.page.pop_dialog(), a.move_game(game))))
steps.append(("uninstall-dialog", lambda a: (a.page.pop_dialog(), a.uninstall(game))))
# last: the right-click menu stays open over whatever comes next
# last: the menus stay open over whatever comes next (the page's "…" menu, then the right-click menu)
steps.append(("game-menu", lambda a: (a.show_activity(False), a.open_game_menu(game))))
steps.append(("library-menu", lambda a: (a.show_activity(False), a.navigate(0), time.sleep(3),
a.library_view.open_menu(game))))
if args.docs:
@@ -278,6 +282,7 @@ def main() -> int:
("linux-change-program", lambda a: a.choose_exe(LINUX_FOLDER)),
("frame", lambda a: (a.page.pop_dialog(), a.navigate(1))),
("linux-move", lambda a: a.move_game(LINUX_APPIMAGE) if args.fake_frame else None),
("linux-game-menu", lambda a: (a.page.pop_dialog(), a.open_game_menu(LINUX_FOLDER))),
("linux-menu", lambda a: (a.page.pop_dialog(), a.navigate(0), time.sleep(3),
a.library_view.open_menu(LINUX_APPIMAGE)))]
if args.links:
@@ -418,6 +423,26 @@ def main() -> int:
print(f"open dialogs: {len([d for d in a.page._dialogs.controls if d.open])}, queued: {queued}", flush=True)
steps = [("library", lambda a: a.navigate(0)), ("questions", open_questions),
("after-1st-double", double_click), ("after-2nd-double", double_click), ("end", report)]
if args.announcement:
import shutil
from frameport import announcements
from frameport.core.paths import user_data_dir
text = ("---\nid: smoke\ntitle: FramePort 1.3 brings the Monitor to every Frame\ndate: 2026-10-14T12:00:00Z\n"
"---\n"
"The **Monitor** tab now shows fps, battery and temperatures for any game the Frame runs, including "
"Steam's own. Open it from the sidebar's Frame card.\n\n"
"- Install links from FrameDrop buttons open in FramePort\n"
"- Uploads over the USB cable are about **3× faster**\n\n"
"Questions? Ask in [Discussions](https://example.invalid).")
news = announcements.parse(text)
pic = user_data_dir() / "announcements" / "smoke.png"
pic.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(Path(__file__).resolve().parents[1] / "docs" / "images" / "library.png", pic)
steps = [("announcement-image", lambda a: (a.navigate(0), a.announcer.show(news, pic))),
("announcement-text", lambda a: (a.page.pop_dialog(), time.sleep(1.5), a.announcer.show(
announcements.parse(text.replace("id: smoke", "id: smoke2")))))]
if args.update:
from frameport import __version__, updates
@@ -503,6 +528,27 @@ def main() -> int:
app = FramePortApp(page)
if args.fake_frame:
attach_fake_frame(app)
# The Room card uses local fixtures too; --fake-frame never contacts a headset.
from frameport.mr.room import box_room, dumps, summary
room = box_room(4, 3)
room.source, room.universe = "scan", 7
room_answer = dict(rooms=[], universe=7, room=dict(universe=7, source="scan", summary=summary(room)),
room_text=dumps(room), aligned=True, default_circle=False, scanner_installed=True)
original_agent = app.target.frame.agent
def room_agent(command, **kwargs):
if command == "room_capture":
return {"error": "not implemented yet"}
if command == "room_delete":
room_answer.update(room=None, room_text=None, aligned=False)
if command == "room_from_boundary":
room.source = "boundary"
room_answer.update(room=dict(universe=7, source="boundary", summary=summary(room)),
room_text=dumps(room), aligned=True)
if command.startswith("room_"):
return dict(room_answer)
return original_agent(command, **kwargs)
app.target.frame.agent = room_agent
except Exception: # noqa: BLE001
ERRORS.append("startup: " + traceback.format_exc())
return
+1 -1
View File
@@ -241,7 +241,7 @@
},
"adapter.scene_emul": {
"anchor": "adapter-scene_emul",
"title": "Pretend room layout",
"title": "Use room layout",
"group": "Game settings"
},
"adapter.scene_height": {
+218
View File
@@ -0,0 +1,218 @@
"""Announcements: one short message from the maintainer, shown once as a pop-up in the app.
Source of truth: `announcements/current.md` on GitHub `main` (front matter + Markdown, announcements/README.md), read
through raw.githubusercontent like the catalog (cached, every 6 h), so a push reaches users without a release.
Who sees it: only people who were already using FramePort before the announcement's `date` (a first start never
shows one, and nor does one published before FramePort was installed). Closing it in any way (the X, a click outside,
Escape, its button) dismisses that `id` for good; a new announcement = a new id. No Flet here.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import logging
import os
import re
import time
from dataclasses import dataclass
from pathlib import Path
import yaml
from . import REPO_URL
from .core import cache, library
from .core.paths import user_data_dir
_log = logging.getLogger("frameport.announcements")
REPO = REPO_URL.removeprefix("https://github.com/").strip("/")
PATH = "announcements/current.md"
RAW = f"https://raw.githubusercontent.com/{REPO}/main/"
URL = RAW + PATH
CHECK_EVERY = 6 * 3600
KEEP_DISMISSED = 50 # ids remembered (oldest dropped)
IMAGE_MAX = 5 * 2**20
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$")
# settings only an earlier GUI run writes: one of them when announcements first run = an existing user (not
# recipes.app_version: library.load() writes that on the very first load)
USED_BEFORE = ("update.last_seen_version", "update.last_check", "ui.welcome_done", "ui.window")
@dataclass(frozen=True)
class Announcement:
id: str
title: str
body: str # Markdown
date: float # unix time it was published
image: str | None = None # https URL (repo paths are resolved to raw GitHub)
image_alt: str = ""
button: str | None = None # optional action: label + url
url: str | None = None
expires: float | None = None
class AnnouncementError(ValueError):
pass
def _when(value, end_of_day: bool = False) -> float:
"""A front-matter date (YAML date/datetime or ISO text) as unix time; a bare date is UTC midnight (or the end of
that day for `expires`). Naive times are UTC."""
if isinstance(value, str):
text = value.strip().replace("Z", "+00:00")
try:
value = dt.datetime.fromisoformat(text) if "T" in text or " " in text else dt.date.fromisoformat(text)
except ValueError as exc:
raise AnnouncementError(f"not a date: {value!r}") from exc
if isinstance(value, dt.datetime):
return (value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)).timestamp()
if isinstance(value, dt.date):
day = dt.datetime(value.year, value.month, value.day, tzinfo=dt.timezone.utc)
return (day + dt.timedelta(days=1 if end_of_day else 0)).timestamp()
raise AnnouncementError(f"not a date: {value!r}")
def _link(value) -> str | None:
"""An image/button target: https URLs as they are, a repo path (announcements/x.png) → its raw GitHub URL."""
if value in (None, ""):
return None
text = str(value).strip()
if text.startswith("https://"):
return text
if "://" in text or text.startswith(("/", "..")) or ".." in text.split("/"):
raise AnnouncementError(f"use an https URL or a path in the repo: {text!r}")
return RAW + text
def parse(text: str | None) -> Announcement | None:
"""The file's announcement; None when there is none (empty file, `active: false`). Raises AnnouncementError for a
malformed one (the app then shows nothing; tests/test_announcements.py checks the committed file)."""
if not text or not text.strip():
return None
m = re.match(r"\A?---\s*\n(.*?)\n---\s*(?:\n|\Z)(.*)\Z", text, re.S)
if not m:
raise AnnouncementError("missing the --- front matter")
try:
meta = yaml.safe_load(m.group(1)) or {}
except yaml.YAMLError as exc:
raise AnnouncementError(f"front matter: {exc}") from exc
if not isinstance(meta, dict):
raise AnnouncementError("front matter must be key: value lines")
active = meta.get("active", True)
if active is False and not meta.get("id"):
return None
if not isinstance(active, bool):
raise AnnouncementError("active: true or false")
unknown = set(meta) - {"id", "title", "date", "image", "image_alt", "button", "url", "expires", "active"}
if unknown:
raise AnnouncementError(f"unknown fields: {', '.join(sorted(unknown))}")
ident, title = str(meta.get("id") or "").strip(), str(meta.get("title") or "").strip()
if not ID_RE.match(ident):
raise AnnouncementError("id: letters, digits, '.', '_' or '-' (change it to show a new announcement)")
if not title:
raise AnnouncementError("title is required")
if "date" not in meta:
raise AnnouncementError("date is required (only people using FramePort before it see the announcement)")
button, url = meta.get("button"), meta.get("url")
if bool(button) != bool(url):
raise AnnouncementError("button and url go together")
if not active: # switched off, but still checked (tests catch mistakes before it's switched on)
_check_dates(meta)
return None
return Announcement(id=ident, title=title, body=m.group(2).strip(), date=_when(meta["date"]),
image=_link(meta.get("image")), image_alt=str(meta.get("image_alt") or ""),
button=str(button).strip() if button else None, url=_link(url),
expires=_when(meta["expires"], end_of_day=True) if meta.get("expires") else None)
def _check_dates(meta: dict) -> None:
_when(meta["date"])
if meta.get("expires"):
_when(meta["expires"], end_of_day=True)
_link(meta.get("image"))
_link(meta.get("url"))
def disabled() -> bool:
return bool(os.environ.get("FRAMEPORT_NO_ANNOUNCEMENTS"))
def fetch(force: bool = False) -> Announcement | None:
"""The current announcement (cached 6 h; offline → the last copy; never raises)."""
try:
return parse(cache.cached_text("announcement.md", URL, max_age=0 if force else CHECK_EVERY, fallback=""))
except Exception as exc: # noqa: BLE001
_log.warning("announcement skipped: %s", exc)
return None
# ------------------------------------------------------------------------------------------ who sees it
def since() -> float:
"""When this user started using FramePort, as far as announcements are concerned (setting announce.since). Set
once, the first time it's asked: 0 for someone who used FramePort before announcements existed, else now (a first
start). Call it early at start, before anything records this start in the settings."""
value = library.setting("announce.since")
if isinstance(value, (int, float)):
return float(value)
value = 0.0 if any(library.setting(k) is not None for k in USED_BEFORE) or library.games() else time.time()
library.set_setting("announce.since", value)
return value
def dismissed() -> list[str]:
value = library.setting("announce.dismissed")
return [v for v in value if isinstance(v, str)] if isinstance(value, list) else []
def dismiss(ident: str) -> None:
"""Never show this announcement again (any way of closing it)."""
library.update_setting("announce.dismissed",
lambda cur: ([v for v in (cur or []) if v != ident] + [ident])[-KEEP_DISMISSED:], [])
def due(a: Announcement | None, now: float | None = None, user_since: float | None = None) -> bool:
"""Whether to show `a` now: published, not expired, not dismissed, and published after this
user started using FramePort."""
if a is None:
return False
now = time.time() if now is None else now
if a.expires is not None and now >= a.expires:
return False
if a.date > now: # scheduled for later
return False
if a.id in dismissed():
return False
return a.date > (since() if user_since is None else user_since)
def image_file(a: Announcement) -> Path | None:
"""The announcement's image, downloaded once into <data>/announcements/ and checked (Pillow); None without one or
when it can't be loaded (the pop-up then shows no image). Shown by asset URL like artwork, never as bytes."""
if not a.image:
return None
folder = user_data_dir() / "announcements"
stem = hashlib.sha256(a.image.encode()).hexdigest()[:16]
for done in folder.glob(f"{stem}.*"):
if done.suffix != ".part":
return done
try:
from PIL import Image
tmp = folder / f"{stem}.part"
cache.download(a.image, tmp)
if tmp.stat().st_size > IMAGE_MAX:
raise AnnouncementError("image larger than 5 MiB")
with Image.open(tmp) as im:
im.verify()
fmt = (im.format or "").lower()
ext = {"jpeg": ".jpg", "png": ".png", "webp": ".webp", "gif": ".gif"}.get(fmt)
if not ext:
raise AnnouncementError(f"image format {fmt or '?'} (use PNG, JPEG, WebP or GIF)")
out = folder / f"{stem}{ext}"
tmp.replace(out)
return out
except Exception as exc: # noqa: BLE001
_log.warning("announcement image skipped: %s", exc)
(folder / f"{stem}.part").unlink(missing_ok=True)
return None
+136
View File
@@ -398,6 +398,30 @@ def rename(package: str = typer.Argument(..., help="the game (package or part of
pipeline.sync_title(pkg, _target(None, to="pc"), rep)
@app.command("new-version")
def new_version(package: str = typer.Argument(..., help="the game (package or part of its title)"),
path: Path = typer.Argument(..., exists=True, help="the newer version: an APK (Quest/Android games), "
"an AppImage/archive/folder (Linux apps) or the game folder (PC VR)")):
"""Point a game at a newer version of it (GitHub #161). Its name, artwork, tags and your own recipe stay; install
it again (frameport install) to update it on the Frame."""
[pkg] = _pkgs(package, False)
try:
res = pipeline.replace_source(pkg, path, printing_reporter(verbose=False))
except pipeline.WrongPackage as exc:
raise typer.BadParameter(f"that APK is a different app: {exc}") from None
except ValueError as exc:
raise typer.BadParameter(str(exc)) from None
g = res["entry"]
typer.echo(f"{pkg}: now {path}" + (f" (program {g.get('exe')})" if g.get("kind") in ("rift", "linux") else ""))
if res.get("older"):
typer.echo("Heads-up: that APK is an older version than the one before.", err=True)
if res.get("different"):
typer.echo(f"Heads-up: {res.get('name')} looks like a different app than {g.get('title')}.", err=True)
if g.get("kind") == "rift" and g.get("exe_confirmed") is False:
typer.echo(f"More than one program could start it; FramePort picked {g.get('exe')}. Another one: "
f"frameport recipe {pkg} --exe <program>", err=True)
@app.command()
def patches():
"""List every available patch."""
@@ -685,6 +709,93 @@ def frame_move(package: str = typer.Argument(..., help="an installed game"),
typer.echo(f"moved to {st.get('base') or st.get('to')}")
# ---------------------------------------------------------------- mixed-reality room commands
room_app = typer.Typer(help="Store a room for mixed-reality games.", no_args_is_help=True)
frame_app.add_typer(room_app, name="room")
@room_app.command("status")
def room_status(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Room summaries, play-area alignment and scanner availability."""
from .install import rooms
typer.echo(json.dumps(rooms.status(_target(frame).frame), indent=2))
@room_app.command("from-boundary")
def room_from_boundary(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
height: float = typer.Option(2.5, help="ceiling height in metres")):
"""Use your SteamVR play area as a room (refuses Valve's default circle)."""
from .install import rooms
typer.echo(json.dumps(rooms.from_boundary(_target(frame).frame, height), indent=2))
@room_app.command("import")
def room_import(path: Path = typer.Argument(..., exists=True, dir_okay=False),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Import room.txt and its mesh sidecar."""
from .install import rooms
typer.echo(json.dumps(rooms.put(_target(frame).frame, path), indent=2))
@room_app.command("export")
def room_export(directory: Path = typer.Argument(...),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
"""Download the selected room and mesh to a directory."""
from .install import rooms
typer.echo(str(rooms.export(_target(frame).frame, directory)))
@room_app.command("delete")
def room_delete(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
universe: Optional[int] = typer.Option(None, help="stored universe (default: selected room)")):
"""Delete a stored room; saved game anchors are kept."""
from .install import rooms
typer.echo(json.dumps(rooms.delete(_target(frame).frame, universe), indent=2))
@room_app.command("build")
def room_build(dump: Path = typer.Argument(..., exists=True, dir_okay=False),
frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
universe: Optional[int] = typer.Option(None, help="universe the dump was recorded in")):
"""Upload a recorded FPRS dump and run the bundled scanner on the Frame."""
from .install import rooms
typer.echo(json.dumps(rooms.build(_target(frame).frame, dump, universe), indent=2))
@room_app.command("scan")
def room_scan(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
seconds: float = typer.Option(90, help="how long to record once passthrough is on"),
wait: float = typer.Option(180, help="how long to wait for the player to turn passthrough on")):
"""Scan the room in the headset: put it on, turn on passthrough and follow the prompts."""
from .install import rooms
def progress(st):
typer.echo(f"{st.get('state')}: {st.get('directions', 0)}/12 directions, "
f"ceiling {'seen' if st.get('looked_up', 0) >= 5 else 'not yet'}", err=True)
typer.echo(json.dumps(rooms.scan(_target(frame).frame, seconds, wait, progress=progress), indent=2))
@room_app.command("show")
def room_show(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
output: Optional[Path] = typer.Option(None, help="SVG preview path")):
"""Print a room summary and write a top-down SVG preview."""
from .core.paths import user_data_dir
from .install import rooms
output = output or user_data_dir() / "rooms" / "preview.svg"
typer.echo(json.dumps(rooms.show(_target(frame).frame, output), indent=2))
typer.echo(str(output))
# ---------------------------------------------------------- end mixed-reality room commands
@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)."""
@@ -706,6 +817,31 @@ def frame_trust_key(frame: Optional[str] = typer.Option(None, help=FRAME_HELP)):
typer.echo(f"connected: {info.get('hostname') or t.host} (new key saved)")
@frame_app.command("ssh-keys")
def frame_ssh_keys(add: list[str] = typer.Option([], "--add", help="a private key file to log in with too"),
remove: list[str] = typer.Option([], "--remove", help="stop using this key file (it stays)")):
"""Private SSH key files FramePort logs in with besides its own key (Settings → Installing → SSH keys): for a
Frame that accepts only your own key. The Frame connection and OpenSSH uploads both use them. Lists them."""
from .frame.connection import KeyFileError, add_extra_key, extra_key_status, remove_extra_key
failed = False
for path in remove:
typer.echo(f"removed {path}" if remove_extra_key(path) else f"not in the list: {path}")
for path in add:
try:
typer.echo(f"added {add_extra_key(path)}")
except KeyFileError as exc:
typer.echo(f"can't use {path}: {exc}", err=True)
failed = True
status = extra_key_status()
for path, problem in status:
typer.echo(f"{path}" + (f" (not usable: {problem})" if problem else ""))
if not status:
typer.echo("no extra keys: FramePort's own key and your SSH agent's keys only")
if failed:
raise typer.Exit(1)
@frame_app.command("usb-check")
def frame_usb_check(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
no_speed: bool = typer.Option(False, help="skip the upload speed test (2 × 128 MB)")):
+15
View File
@@ -307,6 +307,21 @@ 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():
+110 -8
View File
@@ -1,8 +1,8 @@
"""SSH connection to a Steam Frame and the remote agent protocol.
Authentication: FramePort's own keys first (<user data>/ssh/id_ed25519, installed by the bootstrap script, and
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then the user's SSH agent/keys, then a
password if given.
id_rsa, registered through Valve's devkit pairing, which only takes RSA keys), then key files the user added in
Settings (EXTRA_KEYS_SETTING), then the user's SSH agent/keys, then a password if given.
"""
from __future__ import annotations
@@ -163,6 +163,96 @@ def app_keys() -> list[paramiko.PKey]:
return keys
EXTRA_KEYS_SETTING = "ssh.extra_keys" # library setting: private key files the user added (Settings → Installing)
class KeyFileError(ValueError):
"""A key file the user named can't be used (missing, not a private key, protected with a passphrase)."""
def load_key_file(path) -> paramiko.PKey:
"""A private key FramePort can log in with, or KeyFileError with a sentence saying why not."""
p = Path(path).expanduser()
if not p.is_file():
raise KeyFileError(f"{p}: file not found")
try:
return paramiko.PKey.from_path(p)
except (paramiko.SSHException, ValueError, OSError, UnicodeDecodeError, TypeError) as exc:
# paramiko says so with PasswordRequiredException (PEM) or a plain SSHException (OpenSSH format)
if isinstance(exc, paramiko.PasswordRequiredException) or "password" in str(exc).lower():
raise KeyFileError(f"{p.name} is protected with a passphrase, which FramePort can't ask for: add the "
"key to your SSH agent instead (FramePort also logs in with the agent's keys)") from None
raise KeyFileError(f"{p.name} isn't a private SSH key FramePort can read "
f"(OpenSSH or PEM format; a .pub file is the public half): {exc}") from None
def extra_key_paths() -> list[Path]:
"""The key files from Settings, as stored (each may be missing or unreadable by now: see extra_keys)."""
try:
from ..core import library
stored = library.peek_setting(EXTRA_KEYS_SETTING, [])
except Exception: # noqa: BLE001 - no library yet
return []
if isinstance(stored, str):
stored = [stored]
paths: list[Path] = []
for value in stored if isinstance(stored, list) else []:
if isinstance(value, str) and value.strip():
p = Path(value.strip()).expanduser()
if p not in paths:
paths.append(p)
return paths
def add_extra_key(path) -> Path:
"""Check a key file (KeyFileError if FramePort can't use it) and add it to Settings. Returns the stored path."""
p = Path(path).expanduser()
if p.name.endswith(".pub") and p.with_name(p.name[:-4]).is_file():
raise KeyFileError(f"{p.name} is the public half: pick {p.name[:-4]} (the file without .pub) instead")
load_key_file(p)
p = p.resolve()
from ..core import library
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
if Path(v).expanduser() != p] + [str(p)], [])
return p
def remove_extra_key(path) -> bool:
"""Take a key file out of Settings (the file itself stays). False if it wasn't there."""
from ..core import library
p = Path(path).expanduser()
before = extra_key_paths()
library.update_setting(EXTRA_KEYS_SETTING, lambda old: [v for v in (old if isinstance(old, list) else [])
if Path(v).expanduser() != p], [])
return p in before
def extra_key_status() -> list[tuple[Path, str]]:
"""Each key file from Settings with "" when usable, else why not (for Settings and the CLI)."""
status = []
for path in extra_key_paths():
try:
load_key_file(path)
status.append((path, ""))
except KeyFileError as exc:
status.append((path, str(exc)))
return status
def extra_keys() -> list[tuple[Path, paramiko.PKey]]:
"""The usable keys from Settings; unusable ones are logged and skipped (a login still tries the others)."""
keys = []
for path in extra_key_paths():
try:
keys.append((path, load_key_file(path)))
except KeyFileError as exc:
log.warning("SSH key from Settings skipped: %s", exc)
return keys
@dataclass
class FrameTarget:
host: str
@@ -268,6 +358,15 @@ def save_target(target: FrameTarget) -> None:
FAST_LINKS = {"usb0": "USB cable", "wlanap": "the Frame's own Wi-Fi hotspot"}
def install_key_command(pub: str) -> str:
"""Shell command that adds `pub` to ~/.ssh/authorized_keys once. A file without a final newline would glue the key
onto its last line, where sshd never matches it (GitHub #242: OpenSSH uploads refused FramePort's key)."""
q, f = sh_quote(pub), "~/.ssh/authorized_keys"
return (f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch {f} && "
f"{{ grep -qxF {q} {f} || {{ [ -n \"$(tail -c1 {f})\" ] && echo >> {f}; echo {q} >> {f}; }}; }}"
f" && chmod 600 {f}")
def bundled_agent_version() -> int | None:
"""AGENT_VERSION of the agent this app ships (it's uploaded to the Frame whenever it differs)."""
try:
@@ -409,6 +508,10 @@ class Frame:
_video_codec_digest: str = "" # "<manifest digest>:<on|off>" handled on this connection (incl. a failed install)
_video_codec_lock: threading.Lock = field(default_factory=threading.Lock)
home: str = ""
# how the last login worked: "app_key" (FramePort's own key), "user_key" (a key file from Settings),
# "agent" (this PC's SSH agent / ~/.ssh keys) or "password"; OpenSSH uploads follow it (native_upload)
auth: str = ""
openssh_relaxed: bool | None = None # native_upload: True once OpenSSH needed this PC's own keys/agent
# ------------------------------------------------------------------ connect
def connect(self, timeout: float = 10) -> Frame:
@@ -459,7 +562,8 @@ class Frame:
kwargs = dict(hostname=self.target.host, port=self.target.port, username=self.target.user, timeout=timeout,
banner_timeout=timeout, auth_timeout=timeout)
errors = []
attempts = [("app_key", k) for k in app_keys()] + [("agent", None), ("password", None)]
attempts = ([("app_key", k) for k in app_keys()] + [("user_key", k) for _, k in extra_keys()]
+ [("agent", None), ("password", None)])
for attempt, pkey in attempts:
if attempt == "password" and not self.password:
continue
@@ -467,7 +571,7 @@ class Frame:
client.load_host_keys(str(known))
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # trust on first use (pairing)
try:
if attempt == "app_key":
if attempt in ("app_key", "user_key"):
client.connect(pkey=pkey, allow_agent=False, look_for_keys=False, **kwargs)
elif attempt == "agent":
client.connect(allow_agent=True, look_for_keys=True, **kwargs)
@@ -492,6 +596,7 @@ class Frame:
raise
else:
raise FrameNotPaired(bool(self.password), errors)
self.auth = attempt
client.save_host_keys(str(known))
transport = client.get_transport()
transport.set_keepalive(15)
@@ -579,10 +684,7 @@ class Frame:
def install_key(self) -> None:
"""After a password login: add FramePort's public key to authorized_keys."""
pub = app_public_key()
self.run("mkdir -p ~/.ssh && chmod 700 ~/.ssh && touch ~/.ssh/authorized_keys && "
f"grep -qxF {sh_quote(pub)} ~/.ssh/authorized_keys || echo {sh_quote(pub)} >> ~/.ssh/authorized_keys"
" && chmod 600 ~/.ssh/authorized_keys")
self.run(install_key_command(app_public_key()))
# ------------------------------------------------------------------ agent
def ensure_agent(self) -> str:
+156 -29
View File
@@ -2,10 +2,9 @@
from __future__ import annotations
import contextlib
import csv
import io
import os
import posixpath
import re
import shutil
import signal
import subprocess
@@ -13,19 +12,30 @@ import tarfile
import tempfile
import threading
import time
from pathlib import Path
from pathlib import Path, PureWindowsPath
from .connection import sh_quote, ssh_dir
from .connection import extra_keys, sh_quote, ssh_dir
def quote(value) -> str:
def quote(value, windows=False) -> str:
"""One sftp batch argument, taken literally.
Inside double quotes sftp escapes glob characters (* ? [) itself and undoes that for put's destination, so
they must NOT be escaped again: an extra backslash became part of the name (GitHub #324: "[ENG]" in a folder
name made every OpenSSH upload fail, on Linux too). Only backslash and the quote are escaped. ``windows``: a
local Windows path goes with forward slashes; Win32-OpenSSH's sftp turns every backslash of a local path into
'/', escapes included ("C:\\\\Users" → "C://Users").
"""
value = str(value)
if any(c in value for c in "\r\n\x00"):
raise ValueError("SFTP paths cannot contain newlines or NUL")
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
for char in "*?[":
escaped = escaped.replace(char, "\\" + char)
return '"' + escaped + '"'
if windows:
value = PureWindowsPath(value).as_posix()
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def quote_local(path) -> str:
return quote(path, windows=os.name == "nt")
def size_of(sftp, path):
@@ -35,27 +45,74 @@ def size_of(sftp, path):
return 0
def key_files(relaxed=False) -> list[Path]:
"""The private keys OpenSSH offers: FramePort's own (<data>/ssh) and the usable key files from Settings;
``relaxed`` also this PC's default keys (default_key_files)."""
keys = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
for path in [path for path, _ in extra_keys()] + (default_key_files() if relaxed else []):
if path not in keys:
keys.append(path)
return keys
def default_key_files() -> list[Path]:
"""This PC's own keys that Paramiko's "agent" login tries (look_for_keys: ~/.ssh/id_rsa, id_dsa, id_ecdsa,
id_ed25519; on Windows ~/ssh too), usable without a passphrase. OpenSSH needs them named: it skips its default
keys as soon as one -i is given, and Windows OpenSSH refuses a key file others can read (a private copy can't
be refused)."""
from .connection import KeyFileError, load_key_file
folders = [Path.home() / ".ssh"] + ([Path.home() / "ssh"] if os.name == "nt" else [])
found = []
for folder in folders:
for name in ("id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"):
path = folder / name
if not path.is_file():
continue
try:
load_key_file(path)
except KeyFileError:
continue # passphrase-protected or not a key: only the agent can use it
found.append(path)
return found
def current_sid() -> str:
"""This Windows account's SID. whoami prints in the console code page: read bytes, so a user name with
accents can't break decoding (only the ASCII SID is used)."""
out = subprocess.check_output(["whoami", "/user", "/fo", "csv", "/nh"], **hidden())
match = re.search(rb"S-1-[0-9]+(?:-[0-9]+)+", out)
if not match:
raise OSError("cannot find this Windows account's SID for the key copies' permissions")
return match.group().decode()
@contextlib.contextmanager
def credentials(frame, port_flag="-P"):
"""Private temporary copies avoid modifying pairing keys, including their Windows ACLs."""
key_paths = [ssh_dir() / name for name in ("id_ed25519", "id_rsa") if (ssh_dir() / name).exists()]
if not key_paths:
def credentials(frame, port_flag="-P", relaxed=False):
"""OpenSSH arguments that log in like the Frame connection and trust only the host key it pinned.
Keys are offered as private temporary copies (that avoids modifying pairing keys, including their Windows
ACLs; Windows OpenSSH refuses key files others can read). Strict (default): FramePort's keys and the key
files from Settings only (IdentitiesOnly). ``relaxed``: also this PC's default ~/.ssh keys and SSH agent, the
way Paramiko's "agent" login does (GitHub #242: a Frame that only authorizes the user's own key). Verbose
(-v), so a refused login can say which keys were offered (failure()).
"""
key_paths = key_files(relaxed)
if not key_paths and not relaxed:
raise OSError("no FramePort key available for OpenSSH")
key = frame.client.get_transport().get_remote_server_key()
with tempfile.TemporaryDirectory(prefix="frameport-upload-") as directory:
directory = Path(directory)
sid = None
if os.name == "nt":
row = next(csv.reader(io.StringIO(subprocess.check_output(
["whoami", "/user", "/fo", "csv", "/nh"], text=True, **hidden()))))
sid = row[-1]
sid = current_sid()
subprocess.run(["icacls", str(directory), "/inheritance:r", "/grant:r", f"*{sid}:(OI)(CI)F"],
check=True, stdout=subprocess.DEVNULL, **hidden())
else:
directory.chmod(0o700)
copies = []
for path in key_paths:
copy = directory / path.name
for n, path in enumerate(key_paths):
copy = directory / f"key{n}" # own names: a key from Settings may be called id_ed25519 too
shutil.copyfile(path, copy)
if sid:
subprocess.run(["icacls", str(copy), "/inheritance:r", "/remove:g", "*S-1-3-4",
@@ -66,13 +123,72 @@ def credentials(frame, port_flag="-P"):
known = directory / "known_hosts"
host = frame.target.host if frame.target.port == 22 else f"[{frame.target.host}]:{frame.target.port}"
known.write_text(f"{host} {key.get_name()} {key.get_base64()}\n")
yield ["-F", os.devnull, *copies, "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes",
yield ["-F", os.devnull, "-v", *copies, "-o", "BatchMode=yes",
*([] if relaxed else ["-o", "IdentitiesOnly=yes"]),
"-o", "StrictHostKeyChecking=yes", "-o", f'UserKnownHostsFile="{known.as_posix()}"',
"-o", f"GlobalKnownHostsFile={os.devnull}", "-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3",
port_flag, str(frame.target.port)]
class OpenSSHAuthFailed(OSError):
"""OpenSSH reached the Frame but the login was refused (the keys it offered aren't authorized there)."""
AUTH_REFUSED = ("Permission denied (", "Too many authentication failures", "No more authentication methods")
# debug lines (-v) worth keeping: which keys were found, offered, refused or unreadable
AUTH_DEBUG = ("identity file", "Offering public key", "Server accepts key", "Will attempt key",
"Authentications that can continue", "Authenticated to", "Load key", "no such identity",
"Skipping", "agent", "Next authentication method")
def failure(what: str, code, stderr: bytes) -> OSError:
"""The error for a failed OpenSSH run: OpenSSH's own messages (all non-debug lines: warnings such as
"UNPROTECTED PRIVATE KEY FILE" included) plus the -v lines about keys; OpenSSHAuthFailed for a refused login."""
text = stderr.decode(errors="replace").replace("\r\n", "\n")
lines = [line for line in text.splitlines() if line.strip() and (
not line.startswith("debug") or any(k in line for k in AUTH_DEBUG))]
detail = "\n".join(lines)
if len(detail) > 4000:
detail = "…" + detail[-4000:]
refused = code == 255 and any(marker in text for marker in AUTH_REFUSED)
return (OpenSSHAuthFailed if refused else OSError)(f"{what} failed ({code}): {detail}")
def login_modes(frame) -> list[bool]:
"""How OpenSSH logs in, in order (False = FramePort's keys only, True = also this PC's keys and agent).
A Frame connection that logged in with this PC's own keys/agent (Paramiko's "agent" attempt) goes straight to
relaxed: FramePort's key isn't authorized there. Else strict first, relaxed once more after a refused login.
A mode that worked is kept for the rest of the connection. FramePort doesn't add its key to the Frame for
this: it only does that after a password login, where the user typed the Frame's password for it."""
known = getattr(frame, "openssh_relaxed", None)
if known is not None:
return [bool(known)]
if getattr(frame, "auth", "") == "agent":
return [True]
return [False, True]
def with_login_modes(frame, reporter, run):
"""run(relaxed) in each login mode until one isn't refused (see login_modes)."""
modes = login_modes(frame)
if modes == [True] and getattr(frame, "openssh_relaxed", None) is None:
reporter.log("the Frame connection logged in with this PC's own SSH keys or agent: OpenSSH uses them too")
for n, relaxed in enumerate(modes):
try:
result = run(relaxed)
except OpenSSHAuthFailed as exc:
if n == len(modes) - 1:
raise
reporter.log(f"OpenSSH login with FramePort's SSH keys was refused; trying this PC's own SSH keys and "
f"agent too. {exc}")
continue
with contextlib.suppress(AttributeError):
frame.openssh_relaxed = relaxed
return result
def hidden():
return {"creationflags": subprocess.CREATE_NO_WINDOW} if os.name == "nt" else {}
@@ -99,6 +215,11 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
Only successful batches are promoted, after every process has exited successfully. All control operations
happen in the supervising thread, so Paramiko's SFTP client is never shared across threads.
"""
return with_login_modes(frame, reporter, lambda relaxed: _upload(
frame, items, remote_root, reporter, total, sent, workers, relaxed))
def _upload(frame, items, remote_root, reporter, total, sent, workers, relaxed):
executable = shutil.which("sftp")
if not executable:
raise OSError("system OpenSSH SFTP is unavailable")
@@ -111,7 +232,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
sizes, offsets, progress = {}, {}, {}
for path, rel, size in sorted(items, key=lambda item: item[2], reverse=True):
remote = posixpath.join(remote_root, rel)
quote(path)
quote_local(path)
quote(remote)
offset = size_of(frame.sftp, remote + ".part")
if offset > size:
@@ -127,7 +248,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
speed = Speed(total - sent - sum(offsets.values()))
reporter.log(f"uploading large files with OpenSSH SFTP ({len(groups)} workers)")
setup_started = time.monotonic()
with credentials(frame) as args, contextlib.ExitStack() as stack:
with credentials(frame, relaxed=relaxed) as args, contextlib.ExitStack() as stack:
monitor = stack.enter_context(frame.client.open_sftp())
monitor.get_channel().settimeout(2)
reporter.log(f"OpenSSH credentials and progress connection ready in {time.monotonic() - setup_started:.2f}s")
@@ -139,7 +260,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
for path, rel, _ in group:
remote = posixpath.join(remote_root, rel) + ".part"
command = "reput" if offsets[rel] else "put"
batch.write(f"{command} {quote(path)} {quote(remote)}\n".encode())
batch.write(f"{command} {quote_local(path)} {quote(remote)}\n".encode())
batch.seek(0)
errors = stack.enter_context(tempfile.TemporaryFile())
process = subprocess.Popen([executable, *args, "-b", "-",
@@ -152,8 +273,7 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
for process, errors in processes:
if process.poll() not in (None, 0):
errors.seek(0)
detail = errors.read().decode(errors="replace")[-1500:]
raise OSError(f"OpenSSH SFTP failed ({process.returncode}): {detail}")
raise failure("OpenSSH SFTP", process.returncode, errors.read())
for rel, size in sizes.items():
reporter.check_cancel()
progress[rel] = max(progress[rel], min(size, size_of(
@@ -187,11 +307,16 @@ def upload(frame, items, remote_root, reporter, total, sent, workers):
def put_tar(frame, files, remote_root, reporter, progress):
"""Stream a tar batch over paired OpenSSH; supervise writes so Cancel also stops a blocked pipe."""
return with_login_modes(frame, reporter,
lambda relaxed: _put_tar(frame, files, remote_root, reporter, progress, relaxed))
def _put_tar(frame, files, remote_root, reporter, progress, relaxed):
executable = shutil.which("ssh")
if not executable:
raise OSError("system OpenSSH SSH is unavailable")
reporter.check_cancel()
with credentials(frame, port_flag="-p") as args, tempfile.TemporaryFile() as errors:
with credentials(frame, port_flag="-p", relaxed=relaxed) as args, tempfile.TemporaryFile() as errors:
command = f"mkdir -p {sh_quote(remote_root)} && tar -xf - -C {sh_quote(remote_root)}"
process = subprocess.Popen([executable, *args, f"{frame.target.user}@{frame.target.host}", command],
stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errors,
@@ -222,12 +347,14 @@ def put_tar(frame, files, remote_root, reporter, progress):
if not writer.is_alive() and process.poll() is None:
time.sleep(0.1)
reporter.check_cancel()
if failures and process.poll() is None: # a refused login shows as a broken pipe first
with contextlib.suppress(subprocess.TimeoutExpired):
process.wait(timeout=2)
if process.poll() not in (None, 0):
errors.seek(0)
raise failure("OpenSSH tar", process.returncode, errors.read())
if failures:
raise failures[0]
if process.poll() != 0:
errors.seek(0)
raise OSError(f"OpenSSH tar failed ({process.returncode}): "
+ errors.read().decode(errors="replace")[-1500:])
finally:
stop_process(process)
writer.join()
+31 -4
View File
@@ -220,6 +220,28 @@ class PcvrPlan:
art_lookup: str | None = None # package whose store art to use
dest: str | None = None
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
source_id: str | None = None # which version of the game folder (GitHub #161: a newer one was added)
changed_since: float | None = None # a newer version: game files changed after this are sent again (see below)
def changed_since(path: Path, since: float | None) -> bool:
"""A newer version of a Linux app / PC VR game (GitHub #161): uploads skip files the Frame has with the same
size, so a changed file of the same size would stay old. Files written (or unpacked: ctime) after the last
install are sent again; since=None (no newer version) never, 0 (last install unknown) always."""
if since is None:
return False
try:
st = path.stat()
except OSError:
return True
return max(st.st_mtime, st.st_ctime) > since
def _recipe_record(recipe: dict | None, source_id: str | None) -> dict | None:
"""The deployment's recipe record, with the source version it was installed from (ui.components.source_replaced)."""
if not source_id:
return recipe
return {**(recipe or {}), "source_id": source_id}
def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
@@ -259,7 +281,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
manifests["helpers"] = {p.name: p.stat().st_size for p in sorted(helpers.iterdir()) if p.is_file()}
sources["helpers"] = helpers
to_send = [(t, rel) for t, m in manifests.items() for rel, size in m.items()
if prep["existing"].get(t, {}).get(rel) != size]
if prep["existing"].get(t, {}).get(rel) != size
or t == "game" and changed_since(plan.game_dir / rel, plan.changed_since)]
need = sum(manifests[t][rel] for t, rel in to_send)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
@@ -290,7 +313,8 @@ def install_pcvr(frame: Frame, plan: PcvrPlan, reporter: Reporter) -> dict:
game_args=game_args(plan.recipe), no_crash_reporter="pcvr.no_crash_reporter" in plan.recipe.patches,
libovr_redirect="pcvr.libovr_redirect" in plan.recipe.patches,
exe_sha256=plan.exe_sha256, revive_version=plan.revive_version, vr=not is_flat_windows(plan.package),
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, overlay=plan.overlay,
recipe=_recipe_record({"patches": sorted(plan.recipe.patches), "source": plan.recipe.source}, plan.source_id),
overlay=plan.overlay,
)
reporter.log(f"installed at {result['base']} (Proton {result['proton']}, Steam shortcut id {result['appid']})")
log_overlay(result.get("overlay"), reporter)
@@ -325,6 +349,8 @@ class LinuxPlan:
desktop_entry: bool = True # an entry in Desktop Mode's menu and on its desktop (GitHub #84)
desktop_icon: bool = True # with it, a copy on Desktop Mode's desktop (GitHub #212)
overlay: dict | None = None # a SteamVR overlay app: the agent's overlay argument (analysis/vroverlay.py)
source_id: str | None = None # which version of the app (GitHub #161: a newer one was added)
changed_since: float | None = None # a newer version: files changed after this are sent again
def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
@@ -339,7 +365,8 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
else:
manifest = local_data_manifest(plan.root)
manifest = {k: v for k, v in manifest.items() if not k.startswith(".unpacked")}
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size]
to_send = [rel for rel, size in manifest.items() if prep["existing"].get("app", {}).get(rel) != size
or changed_since(plan.root / rel, plan.changed_since)]
need = sum(manifest[rel] for rel in to_send)
if need > prep["free_bytes"] - (1 << 30):
raise RuntimeError(f"not enough space on the Frame: need {need / 2**30:.1f} GiB + 1 GiB headroom, "
@@ -358,7 +385,7 @@ def install_linux(frame: Frame, plan: LinuxPlan, reporter: Reporter) -> dict:
appimage=plan.appimage, openxr=plan.openxr, x86_64=plan.x86_64, manifests={"app": manifest},
executables=executables, tags=_tags(plan.package), dest=plan.dest,
desktop_entry=plan.desktop_entry, desktop_icon=plan.desktop_icon, overlay=plan.overlay,
timeout=900)
recipe=_recipe_record(None, plan.source_id), timeout=900)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
log_overlay(result.get("overlay"), reporter)
apply_app_icon(frame, plan.package, prep.get("anchor"), result, reporter)
+19 -13
View File
@@ -497,25 +497,31 @@ def ensure_helper(frame) -> bool:
if local is None:
return False
try:
from ..frame.connection import sh_quote
remote = f"{frame.home}/{HELPER_REMOTE}"
want = hashlib.sha256(local.read_bytes()).hexdigest()
_, out, _ = frame.run(f"sha256sum {sh_quote(remote)} 2>/dev/null")
if out.split()[:1] == [want]:
return True
frame.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
tmp = f"{remote}.new-{os.getpid()}-{threading.get_ident()}" # own name: two starts at once don't collide
frame.put(local, tmp, resume=False)
code, _, err = frame.run(f"chmod 755 {sh_quote(tmp)} && mv -f {sh_quote(tmp)} {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
sync_helper(frame, local, HELPER)
return True
except Exception as exc: # noqa: BLE001
log.warning("live view: couldn't put %s on the Frame (%s); using the software encoder", HELPER, exc)
return False
def sync_helper(frame, local: Path, name: str) -> str:
"""Install a bundled host helper by digest, atomically. Shared by live view and the room scanner."""
from ..frame.connection import sh_quote
remote = f"{frame.home}/.local/share/frameport/bin/{name}"
want = hashlib.sha256(local.read_bytes()).hexdigest()
_, out, _ = frame.run(f"sha256sum {sh_quote(remote)} 2>/dev/null")
if out.split()[:1] == [want]:
return remote
frame.run(f"mkdir -p {sh_quote(posixpath.dirname(remote))}")
tmp = f"{remote}.new-{os.getpid()}-{threading.get_ident()}" # own name: two starts at once don't collide
frame.put(local, tmp, resume=False)
code, _, err = frame.run(f"chmod 755 {sh_quote(tmp)} && mv -f {sh_quote(tmp)} {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
return remote
STOP_GRACE = 2.0 # s to wait for the script to end after stdin EOF before killing its process group
DROP_WARN = 0.05 # share of a stats window's frame slots that were late or skipped before the panel warns
+169
View File
@@ -0,0 +1,169 @@
"""Room storage and offline scanner operations on a connected Frame. No Flet."""
from __future__ import annotations
import tempfile
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from ..core.paths import artifacts_dir, user_data_dir
from ..frame.connection import sh_quote
from ..mr.room import dumps, parse, preview_svg, read_mesh
from .livestream import sync_helper
def status(frame) -> dict:
return frame.agent("room_status")
def delete(frame, universe: int | None = None) -> dict:
return frame.agent("room_delete", **({"universe": universe} if universe is not None else {}))
def from_boundary(frame, height: float = 2.5) -> dict:
return frame.agent("room_from_boundary", height=height)
def capture(frame, seconds: float = 90, wait: float = 180) -> dict:
"""Start a scan in the headset (the agent records the passthrough mesh, then builds the room)."""
ensure_scanner(frame)
ensure_prompts(frame)
result = frame.agent("room_capture", seconds=seconds, wait=wait)
if result.get("error"):
raise RuntimeError(result["error"])
return result
def capture_status(frame) -> dict:
return frame.agent("room_capture_status")
SCAN_DONE = ("done", "failed", "none")
def scan(frame, seconds: float = 90, wait: float = 180, progress=None, poll: float = 2.0,
sleep=time.sleep) -> dict:
"""capture() and wait for the result. progress(status) gets every status the agent reports (state waiting ->
recording -> building -> done|failed; directions seen, looked_up). Returns the final status; raises on failure."""
capture(frame, seconds, wait)
deadline = time.monotonic() + wait + seconds + 600 # + building time
while True:
st = capture_status(frame)
if progress:
progress(st)
if st.get("state") in SCAN_DONE:
break
if time.monotonic() > deadline:
raise RuntimeError("The room scan didn't finish")
sleep(poll)
if st.get("state") != "done":
raise RuntimeError(st.get("error") or "The room scan didn't finish")
return st
def ensure_prompts(frame) -> str:
"""The in-headset scan prompts (artifacts/roomscan/*.png) next to the scanner, synced by digest in one go."""
import hashlib
local = artifacts_dir() / "roomscan"
remote = f"{frame.home}/.local/share/frameport/bin/roomscan-prompts"
files = sorted(local.glob("*.png"))
if not files:
return remote
want = {f.name: hashlib.sha256(f.read_bytes()).hexdigest() for f in files}
_, out, _ = frame.run(f"mkdir -p {sh_quote(remote)} && cd {sh_quote(remote)} && sha256sum -- *.png 2>/dev/null")
have = {}
for line in out.splitlines():
digest, _, name = line.partition(" ")
have[name.strip()] = digest
for f in files:
if have.get(f.name) != want[f.name]:
frame.put(f, f"{remote}/{f.name}", resume=False)
return remote
@contextmanager
def _upload_dir(frame):
remote = f"{frame.home}/.local/share/frameport/rooms/.incoming-{uuid.uuid4().hex}"
code, _, err = frame.run(f"mkdir -p {sh_quote(remote)}")
if code:
raise RuntimeError(err.strip())
try:
yield remote
finally:
frame.run(f"rm -rf -- {sh_quote(remote)}")
def put(frame, path: Path) -> dict:
path = Path(path)
room = parse(path.read_text(encoding="utf-8"))
mesh_path = None
if room.mesh:
mesh_path = path.parent / room.mesh.filename
mesh = read_mesh(mesh_path.read_bytes())
if (mesh.vertex_count, mesh.index_count) != (room.mesh.vertex_count, room.mesh.index_count):
raise ValueError("room mesh counts do not match room.txt")
staging = user_data_dir() / "rooms"
staging.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="import-", dir=staging) as local, _upload_dir(frame) as remote:
canonical = Path(local) / "room.txt"
canonical.write_text(dumps(room), encoding="utf-8")
frame.put(canonical, f"{remote}/room.txt", resume=False)
if mesh_path:
frame.put(mesh_path, f"{remote}/{room.mesh.filename}", resume=False)
return frame.agent("room_put", path=f"{remote}/room.txt")
def export(frame, directory: Path) -> Path:
st = status(frame)
if not st.get("room_text"):
raise ValueError("No room is stored on the Frame")
room = parse(st["room_text"])
directory = Path(directory)
mesh_data = None
if room.mesh:
remote = f"{frame.home}/.local/share/frameport/rooms/{room.universe}/{room.mesh.filename}"
with frame.sftp.open(remote, "rb") as f:
mesh_data = f.read(64 * 1024 * 1024)
mesh = read_mesh(mesh_data)
if (mesh.vertex_count, mesh.index_count) != (room.mesh.vertex_count, room.mesh.index_count):
raise ValueError("room changed during export; try again")
directory.mkdir(parents=True, exist_ok=True)
if mesh_data is not None:
(directory / room.mesh.filename).write_bytes(mesh_data)
path = directory / "room.txt"
path.write_text(dumps(room), encoding="utf-8")
return path
def ensure_scanner(frame) -> str:
local = artifacts_dir() / "linux-arm64-bin" / "fp_roomscan"
if not local.is_file():
raise RuntimeError("This build does not include the room scanner yet")
return sync_helper(frame, local, "fp_roomscan")
def build(frame, dump: Path, universe: int | None = None) -> dict:
dump = Path(dump)
with dump.open("rb") as f:
if f.read(8) != b"FPRS\x01\0\0\0":
raise ValueError("expected FPRS version 1 scan dump")
ensure_scanner(frame)
with _upload_dir(frame) as remote:
frame.put(dump, f"{remote}/scan.fprs", resume=False)
return frame.agent("room_build", dump=f"{remote}/scan.fprs",
**({"universe": universe} if universe is not None else {}))
def show(frame, path: Path) -> dict:
from ..mr.room import summary
st = status(frame)
if not st.get("room_text"):
raise ValueError("No room is stored on the Frame")
room = parse(st["room_text"])
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(preview_svg(room), encoding="utf-8")
return summary(room)
+48 -3
View File
@@ -31,6 +31,7 @@
"(top folder)": "",
"+ {base_count} standard patches": "",
", and your signing keys were saved to {backup}.": "",
", ceiling seen": "",
", {n} already there": [
"",
""
@@ -94,6 +95,7 @@
"AC Nexus: no AppSW at 72 Hz": "",
"AC Nexus: no AppSW at 90 Hz": "",
"Activity": "",
"Add a key file…": "",
"Add a Linux app folder…": "",
"Add a Linux app…": "",
"Add a PC game folder…": "",
@@ -205,6 +207,7 @@
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
"Browse this game's files.": "",
"Build page": "",
"Building the room…": "",
"Built for PCs (x86_64): runs through a translator, slower": "",
"Built for the Frame (arm64)": "",
"Cable connected.": "",
@@ -315,6 +318,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 this PC": "",
"Couldn't connect: {exc}": "",
"Couldn't copy the screenshot: {error}": "",
@@ -354,6 +358,8 @@
"Default": "",
"Default (30% radius)": "",
"Delete": "",
"Delete room": "",
"Delete room?": "",
"Delete {names}?": "",
"Delete {n} items…": "",
"Delete {n} screenshot?": [
@@ -471,7 +477,6 @@
"Eye-tracked": "",
"failed": "",
"Failed": "",
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality games that build their level from the room (for example Demeter).": "",
"Fan": "",
"File": "",
"file": "",
@@ -557,6 +562,7 @@
"Frame OpenXR compatibility layer": "",
"FrameBridge OpenXR adapter": "",
"FramePort": "",
"FramePort also logs in with {name} from the next connection.": "",
"FramePort can't update itself in {value} (no permission) — opening the release page to download it": "",
"FramePort changelog": "",
"FramePort checks GitHub for a new version every few hours. Update now downloads and checks it, then restarts FramePort; your games and settings stay.": "",
@@ -568,6 +574,7 @@
"FramePort isn't sure which program starts this game. Open the game and use … → Change program… to pick it.": "",
"FramePort keeps its own copies; nothing is installed system-wide": "",
"FramePort live view": "",
"FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH agent instead.": "",
"FramePort looked at your last session of {n} game and has something to try.": [
"",
""
@@ -626,6 +633,7 @@
"Games see this folder as {value}": "",
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning it on needs a rebuild (it adds a small library in front of OVRPort's loader).": "",
"Games that fade to black by dimming their picture with a layer color scale (XR_KHR_composition_layer_color_scale_bias, for example Batman: Arkham Shadow's loading screens) can show the level loading instead of black on the Frame. FrameBridge then draws the fade itself: a black quad in front of the eyes whose opacity follows the game's dimming. 2 also removes the game's own dimming (if both show, the fade is doubled).": "",
"Games will use a simple room until you save another room.": "",
"Games with the Hardware video decoding patch play their videos on the Frame's video hardware. Turn it off if videos misbehave; games pick it up at their next start. For one game only, put FRAMEPORT_NO_HW_VIDEO=1 before %command% in its Steam launch options.": "",
"Gear VR": "",
"Gear VR / Oculus Go (32-bit)": "",
@@ -664,6 +672,7 @@
"Hardware video decoding (H.264, HEVC, VP9)": "",
"Hardware video decoding on the Frame": "",
"Heads-up: that APK is an older version than the one before.": "",
"Heads-up: {name} looks like a different app than {title}.": "",
"headset found": "",
"Hi there!": "",
"Hide Android's navigation bar": "",
@@ -740,6 +749,7 @@
"Installed games ({len})": "",
"Installed the theme \"{name}\"": "",
"Installed theme file · {file}": "",
"Installed when you build a recorded scan": "",
"Installed {join}": "",
"Installed · a newer build is ready": "",
"Installed · launch test failed": "",
@@ -847,6 +857,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": "",
"Link": "",
"Linux": "",
"Linux app": "",
@@ -910,7 +921,7 @@
"Minecraft asks for desktop OpenGL's GL_DEPTH_COMPONENT32 depth textures. Some builds of LTW, the OpenGL-to-OpenGL ES wrapper in Minecraft launchers such as QuestCraft, pass that on unchanged; Quest's driver accepts it, the Frame's Mesa rejects it (GL_INVALID_OPERATION in glTexImage2D), so the depth buffer never exists and the picture stays black. LTW then loads OpenGL ES through a small shim that asks for GL_DEPTH_COMPONENT32F (or 24-bit) instead.": "",
"Mirrored": "",
"Mixed reality": "",
"Mixed-reality games that build their level from your room get a simple room with walls.": "",
"Mixed-reality games use walls and furniture from a saved room. Use your play area for walls now, or import a recorded scan. Headset scanning is not available yet.": "",
"Modem": "",
"Monitor": "",
"Monitor is using {pct}% of the Frame's CPU": "",
@@ -941,6 +952,8 @@
"New recipe applied. Click Update on Frame to install it.": "",
"Newer APK for {title}": "",
"Newer APK of {title}": "",
"Newer version for {title}": "",
"Newer version of {title}": "",
"Next": "",
"Nice, it works! FramePort receives install links. (Demo: nothing was installed.)": "",
"No": "",
@@ -963,6 +976,7 @@
"No process matches.": "",
"no reason given": "",
"No release notes.": "",
"No room saved yet": "",
"No screenshots yet": "",
"No Unreal crash reporter": "",
"no viewer open": "",
@@ -1026,6 +1040,7 @@
"On your Frame": "",
"on: {names}": "",
"One PC game folder": "",
"Only FramePort's own key, and your SSH agent's keys.": "",
"Only if 360° pictures show wrongly.": "",
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
@@ -1134,10 +1149,10 @@
"Power": "",
"Power ICs": "",
"Prepare {title}": "",
"Pretend room layout": "",
"Pretty smooth": "",
"Prevents a start-up crash in some Unreal games.": "",
"Previous": "",
"Private SSH key file (not the .pub file)": "",
"Problems and feedback": "",
"Processes": "",
"Processor": "",
@@ -1151,6 +1166,7 @@
"Provides stand-ins for Meta store functions the game expects, so it doesn't crash at start.": "",
"Provides stand-ins for Quest system calls some games make at start.": "",
"Put a password, link or note in the box and press Type it.": "",
"Put on the headset and turn on passthrough. The scan starts by itself.": "",
"Put the Frame to sleep": "",
"Put the Frame to sleep?": "",
"Puts settings files next to the game on the Frame, for example to turn off an unsupported effect.": "",
@@ -1173,6 +1189,7 @@
"Reading the file list from the Frame…": "",
"Reading the install link": "",
"Reading the new APK": "",
"Reading the new version": "",
"ready": "",
"Ready": "",
"Ready to play": "",
@@ -1191,6 +1208,7 @@
"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": "",
@@ -1276,8 +1294,14 @@
"Revive {value} · {value2} backend": "",
"Revive: SteamVR (OpenVR) backend on PC": "",
"Rift": "",
"Room": "",
"Room depth": "",
"Room floor plan": "",
"Room height": "",
"Room positions belong to the play area where they were recorded. If SteamVR changes it, scan again or use the new boundary to line up virtual walls with your room.": "",
"Room scan saved": "",
"Room scanned": "",
"Room setup": "",
"Room width": "",
"Rotate panels 180°": "",
"Routes OVRPlugin through FramePort's extension shim, which turns Meta's amplitude-envelope vibrations into plain ones: OVRPort's loader reads their nanosecond duration as seconds and allocates gigabytes, freezing the Frame when the game vibrates a controller (for example Lucky's Tale's save slots, GitHub #9). Needs a rebuild.": "",
@@ -1295,6 +1319,7 @@
"Runs OVRPort and apksigner": "",
"Runs the game directly": "",
"Runs xrSyncActions one at a time with xrPollEvent and skips it for 250 ms after focus returns. For games that crash in the runtime's input code right after focus comes back (SIGSEGV in vrclient.so UpdateActionStateInternal / xrSyncActions, for example Myst).": "",
"Same play area": "",
"Save": "",
"Save as known-good recipe": "",
"Save the screenshots to which folder on this PC?": "",
@@ -1312,8 +1337,13 @@
"Saves what the game draws every few seconds, for testing without the headset.": "",
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.": "",
"Scan a folder…": "",
"Scan room in the headset": "",
"Scan your room for mixed reality": "",
"Scan {name}": "",
"Scanner installed": "",
"Scanning: {directions} of 12 directions seen{up}": "",
"Scans your game folders again and adds new games. 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.": "",
"Screenshot copied. Paste it anywhere.": "",
"Screenshot taken": "",
"Screenshots": "",
@@ -1333,7 +1363,10 @@
"Select all": "",
"Select all shown": "",
"Select games to install": "",
"Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved room, use a simple play-area box (for example Demeter).": "",
"Set by you.": "",
"Set up a play area in SteamVR first. Valve's default circle does not describe your room.": "",
"Set up a room in your current play area": "",
"Set up for FramePort": "",
"Set up with a USB cable": "",
"Set up your Frame": "",
@@ -1412,6 +1445,7 @@
"Sound on": "",
"Source": "",
"square, 256 × 256": "",
"SSH keys": "",
"Stable (default)": "",
"Stand in for cube layers": "",
"Standard for every game": "",
@@ -1462,6 +1496,7 @@
"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 using this key (the file stays)": "",
"Stopped: {why}": "",
"Stops a black picture in Minecraft launchers (for example QuestCraft).": "",
"Stops a crash a few seconds after start in some Unreal games with anti-aliasing (for example Star Wars Pinball VR).": "",
@@ -1513,6 +1548,7 @@
"Test Proton on the Frame": "",
"Text and layout size": "",
"That APK is a different app, not {title}.": "",
"That key can't be used: {error}": "",
"That theme file can't be used: {error}": "",
"That's me!": "",
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
@@ -1570,6 +1606,7 @@
"The PC VR counterpart of OVRPort's 'Patch Oculus detection for Unreal'. Unreal's Oculus plugin only starts when the Oculus service announces a headset (the Windows event 'OculusHMDConnected'); without it the game runs as a flat window. On the Frame the launcher runs the game through FramePort's small helper (fp_oculushmd.exe) that provides that event while the game runs, instead of relying only on Revive's hook of the check. Ignored on this PC (the Oculus app or Revive handle it there).": "",
"the previous version, kept as a backup": "",
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games (Gear VR, Oculus Go) run through FramePort's 32-bit bridge (experimental).": "",
"The scanner turns a recorded depth-mesh dump into a room. FramePort installs it when you build a recorded scan.": "",
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
"The site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or turn off HTTPS scanning.": "",
"The store's name": "",
@@ -1711,6 +1748,7 @@
"Update the app only?": "",
"Update tools": "",
"Update with a newer APK…": "",
"Update with a newer version…": "",
"Updates": "",
"Updates every game marked \"Update ready\", one after another.": "",
"Updates the game, frame rate and battery every 5 seconds. Turn off to save the Frame's power.": "",
@@ -1733,6 +1771,8 @@
"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": "",
@@ -1740,10 +1780,12 @@
"Use this PC's keyboard on the Frame.": "",
"Use this program": "",
"Use your own artwork…": "",
"Use your SteamVR play area as walls, without furniture.": "",
"Uses its own Revive — experimental on the Frame": "",
"Uses OVRPort, Revive (LibreVR), Valve's Lepton and Proton. Not affiliated with Valve or Meta.": "",
"Uses the store title/icon (OVRPort image service) for the app label.": "",
"Uses the store's name and icon for the game.": "",
"Using your play area; furniture is not included": "",
"Valve's Android container on the Frame. Quest games run in it, one container per game. It needs Developer Mode.": "",
"Valve's tool that runs Windows games. On the Frame it runs PC VR games; FramePort installs it when needed (Steam restarts once).": "",
"Vermillion shows a canvas being painted as a separate compositor layer, which the Frame draws in front of everything: the hand and brush disappear over the canvas while they still paint. Rewrites PaintableMesh.HasCompositorLayerAvailable and ShouldUseCompositorOverlay -> false in libil2cpp.so (found with Cpp2IL), so the game uses its own fallback and draws the canvas in the scene. The canvas may look a little less sharp.": "",
@@ -1971,6 +2013,8 @@
"{title}: name updated in Steam": "",
"{title}: newer APK added": "",
"{title}: newer APK added. Press Update on Frame to install it.": "",
"{title}: newer version added": "",
"{title}: newer version added. Press Update to install it.": "",
"{title}: ready": "",
"{title}: Steam artwork updated on the Frame": "",
"{title}: {line}": "",
@@ -1983,6 +2027,7 @@
"{value} failed ({returncode})": "",
"{value} installed, but didn't start": "",
"{value} took too long — update by hand: {join}": "",
"{walls} walls · {area:.1f} m² · {height:.1f} m high": "",
"• On the Frame (if selected below): {n} game ({size}), its Steam entry and FramePort's files": [
"",
""
+1
View File
@@ -0,0 +1 @@
"""Portable room files shared by the CLI and GUI."""
+308
View File
@@ -0,0 +1,308 @@
"""FramePort room format v1, in SteamVR STAGE coordinates (metres, +Y up).
Unknown text records are ignored so a newer scanner can work with an older app.
Known records are validated before upload; mesh paths never escape the room folder.
"""
from __future__ import annotations
import math
import re
import struct
import time
import uuid
from collections import Counter
from dataclasses import dataclass, field
from html import escape
def _id() -> str:
return uuid.uuid4().hex
@dataclass
class Wall:
uuid: str
x0: float
z0: float
x1: float
z1: float
y0: float = 0
y1: float = 2.5
label: str = "WALL_FACE" # INVISIBLE_WALL_FACE: an edge the scanner closed without seeing a wall
WALL_LABELS = ("WALL_FACE", "INVISIBLE_WALL_FACE")
@dataclass
class Volume:
uuid: str
label: str
cx: float
cz: float
top_y: float
yaw_deg: float
w: float
d: float
h: float
@dataclass
class Plane:
uuid: str
label: str
px: float
py: float
pz: float
qx: float
qy: float
qz: float
qw: float
w: float
h: float
@dataclass
class Mesh:
uuid: str = field(default_factory=_id)
filename: str = "room-mesh.bin"
vertex_count: int = 0
index_count: int = 0
vertices: list[tuple[float, float, float]] = field(default_factory=list)
indices: list[int] = field(default_factory=list)
def __post_init__(self):
if self.vertices:
self.vertex_count = len(self.vertices)
if self.indices:
self.index_count = len(self.indices)
@dataclass
class Room:
universe: int = 0
source: str = "manual"
time: int = field(default_factory=lambda: int(time.time()))
uuid: str = field(default_factory=_id)
floor_uuid: str = field(default_factory=_id)
ceiling_uuid: str = field(default_factory=_id)
floor: float = 0
ceiling: float = 2.5
outline: list[tuple[float, float]] = field(default_factory=list)
walls: list[Wall] = field(default_factory=list)
volumes: list[Volume] = field(default_factory=list)
planes: list[Plane] = field(default_factory=list)
mesh: Mesh | None = None
play_area: list[tuple[float, float]] = field(default_factory=list)
def _area(points) -> float:
return sum(x * b - a * z for (x, z), (a, b) in zip(points, points[1:] + points[:1], strict=True)) / 2
def _uuid(value: str) -> str:
if not re.fullmatch(r"[0-9a-f]{32}", value):
raise ValueError("UUID must be 32 lowercase hex digits")
return value
def _numbers(values):
result = [float(v) for v in values]
if not all(math.isfinite(v) for v in result):
raise ValueError("coordinates must be finite")
return result
def _finish(room: Room) -> Room:
if not room.outline and room.walls:
xs = [x for w in room.walls for x in (w.x0, w.x1)]
zs = [z for w in room.walls for z in (w.z0, w.z1)]
room.outline = [(min(xs), min(zs)), (max(xs), min(zs)),
(max(xs), max(zs)), (min(xs), max(zs))]
if len(room.outline) < 3 or abs(_area(room.outline)) < 1e-8:
raise ValueError("room needs an outline with nonzero area or walls")
if _area(room.outline) < 0:
room.outline.reverse()
if room.ceiling <= room.floor:
raise ValueError("ceiling must be above floor")
if not room.walls:
room.walls = [Wall(_id(), *a, *b, room.floor, room.ceiling)
for a, b in zip(room.outline, room.outline[1:] + room.outline[:1], strict=True)]
edges = list(zip(room.outline, room.outline[1:] + room.outline[:1], strict=True))
for wall in room.walls:
if wall.y1 <= wall.y0 or (wall.x0, wall.z0) == (wall.x1, wall.z1):
raise ValueError("wall must have positive height and length")
if ((wall.x1, wall.z1), (wall.x0, wall.z0)) in edges:
wall.x0, wall.x1 = wall.x1, wall.x0
wall.z0, wall.z1 = wall.z1, wall.z0
ids = [room.uuid, room.floor_uuid, room.ceiling_uuid]
ids += [v.uuid for v in [*room.walls, *room.volumes, *room.planes]]
if room.mesh:
ids.append(room.mesh.uuid)
if len(ids) != len(set(ids)):
raise ValueError("entity UUIDs must be unique")
return room
def parse(text: str) -> Room:
"""Read v1 text, tolerating comments, blank lines and future record types."""
lines = [line.split("#", 1)[0].split() for line in text.splitlines()]
lines = [line for line in lines if line]
if not lines or lines[0] != ["frameport-room", "1"]:
raise ValueError("expected frameport-room 1")
room, ceiling_seen = Room(), False
for parts in lines[1:]:
kind, values = parts[0], parts[1:]
try:
if kind in ("universe", "time"):
if len(values) != 1 or not values[0].isdigit():
raise ValueError("expected a nonnegative integer")
n = int(values[0])
if kind == "universe" and n >= 2**64:
raise ValueError("universe exceeds u64")
setattr(room, kind, n)
elif kind == "source":
if len(values) != 1 or values[0] not in ("scan", "boundary", "box", "manual"):
raise ValueError("invalid source")
room.source = values[0]
elif kind == "room":
if len(values) != 1:
raise ValueError("expected UUID")
room.uuid = _uuid(values[0])
elif kind in ("floor", "ceiling"):
ident, height = values
setattr(room, kind + "_uuid", _uuid(ident))
setattr(room, kind, _numbers([height])[0])
ceiling_seen |= kind == "ceiling"
elif kind == "outline":
n = int(values[0])
coords = _numbers(values[1:])
if n < 3 or len(coords) != n * 2:
raise ValueError("outline count does not match coordinates")
room.outline = list(zip(coords[::2], coords[1::2], strict=True))
elif kind == "wall":
if len(values) not in (7, 8) or (len(values) == 8 and values[7] not in WALL_LABELS):
raise ValueError("expected UUID, 6 coordinates and an optional wall label")
room.walls.append(Wall(_uuid(values[0]), *_numbers(values[1:7]), *values[7:]))
elif kind in ("volume", "plane"):
count = 9 if kind == "volume" else 11
if len(values) != count or not re.fullmatch(r"[A-Z][A-Z0-9_]*", values[1]):
raise ValueError("invalid label or coordinate count")
obj = (Volume if kind == "volume" else Plane)(_uuid(values[0]), values[1], *_numbers(values[2:]))
if min(obj.w, obj.h, obj.d if kind == "volume" else obj.w) <= 0:
raise ValueError("dimensions must be positive")
if kind == "plane" and sum(q*q for q in (obj.qx, obj.qy, obj.qz, obj.qw)) < 1e-12:
raise ValueError("plane quaternion is zero")
(room.volumes if kind == "volume" else room.planes).append(obj)
elif kind == "mesh":
ident, filename, nv, ni = values
if not re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]*", filename) or filename in ("room.txt", "scan.log"):
raise ValueError("mesh must be a sidecar filename")
nv, ni = int(nv), int(ni)
if nv < 0 or ni < 0 or ni % 3 or ni > 600000:
raise ValueError("invalid mesh counts")
room.mesh = Mesh(_uuid(ident), filename, nv, ni)
except (ValueError, IndexError, TypeError) as exc:
raise ValueError(f"{kind}: {exc}") from exc
if not ceiling_seen:
room.ceiling = room.floor + 2.5
return _finish(room)
def dumps(room: Room) -> str:
"""Write canonical text; validate programmatically constructed rooms too."""
_finish(room)
def row(*values):
return " ".join(format(v, ".9g") if isinstance(v, float) else str(v) for v in values)
rows = ["frameport-room 1", row("universe", room.universe), row("source", room.source), row("time", room.time),
row("room", room.uuid), row("floor", room.floor_uuid, room.floor),
row("ceiling", room.ceiling_uuid, room.ceiling),
row("outline", len(room.outline), *(v for p in room.outline for v in p))]
for w in room.walls: # the label only when it isn't the default (older readers know 7 fields)
rows.append(row("wall", w.uuid, w.x0, w.z0, w.x1, w.z1, w.y0, w.y1,
*([w.label] if w.label != "WALL_FACE" else [])))
for kind, objects in (("volume", room.volumes), ("plane", room.planes)):
rows.extend(row(kind, *vars(obj).values()) for obj in objects)
if room.mesh:
m = room.mesh
rows.append(row("mesh", m.uuid, m.filename, m.vertex_count, m.index_count))
text = "\n".join(rows) + "\n"
parse(text)
return text
def read_mesh(data: bytes) -> Mesh:
if len(data) < 16:
raise ValueError("truncated mesh header")
magic, version, nv, ni = struct.unpack_from("<4sIII", data)
if magic != b"FPRM" or version != 1:
raise ValueError("expected FPRM version 1")
if ni % 3 or ni > 600000 or len(data) != 16 + nv * 12 + ni * 4:
raise ValueError("invalid mesh length or triangle count")
vertices = list(struct.iter_unpack("<fff", data[16:16 + nv * 12]))
indices = [v[0] for v in struct.iter_unpack("<I", data[16 + nv * 12:])]
if any(not math.isfinite(x) for p in vertices for x in p) or any(i >= nv for i in indices):
raise ValueError("invalid mesh coordinate or index")
return Mesh(vertex_count=nv, index_count=ni, vertices=vertices, indices=indices)
def write_mesh(mesh: Mesh) -> bytes:
data = struct.pack("<4sIII", b"FPRM", 1, len(mesh.vertices), len(mesh.indices))
data += b"".join(struct.pack("<fff", *p) for p in mesh.vertices)
data += b"".join(struct.pack("<I", i) for i in mesh.indices)
read_mesh(data)
return data
def from_boundary(outline_xz, height: float = 2.5) -> Room:
coords = [tuple(_numbers(p)) for p in outline_xz]
if any(len(p) != 2 for p in coords) or not math.isfinite(height) or height <= 0:
raise ValueError("invalid boundary or height")
return _finish(Room(source="boundary", ceiling=height, outline=coords))
def box_room(width: float, depth: float, height: float = 2.5) -> Room:
if not all(math.isfinite(v) and v > 0 for v in (width, depth, height)):
raise ValueError("room dimensions must be positive and finite")
w, d = width / 2, depth / 2
room = from_boundary([(-w, -d), (w, -d), (w, d), (-w, d)], height)
room.source = "box"
return room
def summary(room: Room) -> dict:
return {"walls": len(room.walls), "volumes": dict(Counter(v.label for v in room.volumes)),
"planes": len(room.planes), "floor_area": abs(_area(room.outline)),
"ceiling_height": room.ceiling - room.floor}
def preview_svg(room: Room) -> str:
"""Self-contained top-down preview; text labels are escaped, never interpreted as markup."""
points = room.outline + room.play_area
xmin, xmax = min(x for x, _ in points), max(x for x, _ in points)
zmin, zmax = min(z for _, z in points), max(z for _, z in points)
scale = 440 / max(xmax-xmin, zmax-zmin, .1)
def point(x, z):
return f"{30+(x-xmin)*scale:.2f},{30+(zmax-z)*scale:.2f}"
def polygon(poly):
return " ".join(point(x, z) for x, z in poly)
out = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500" role="img">',
'<title>Room floor plan</title>',
f'<polygon points="{polygon(room.outline)}" fill="#17232f" stroke="#f49a42" stroke-width="3"/>']
for wall in room.walls:
x0, y0 = point(wall.x0, wall.z0).split(",")
x1, y1 = point(wall.x1, wall.z1).split(",")
out.append(f'<line x1="{x0}" y1="{y0}" x2="{x1}" y2="{y1}" stroke="#f49a42" stroke-width="3"/>')
for v in room.volumes:
angle = math.radians(v.yaw_deg)
c, s = math.cos(angle), math.sin(angle)
corners = [(v.cx+c*x+s*z, v.cz-s*x+c*z)
for x, z in [(-v.w/2, -v.d/2), (v.w/2, -v.d/2), (v.w/2, v.d/2), (-v.w/2, v.d/2)]]
x, y = point(v.cx, v.cz).split(",")
out += [f'<polygon points="{polygon(corners)}" fill="#275772" stroke="#65bcec"/>',
f'<text x="{x}" y="{y}" text-anchor="middle" fill="#ffffff" font-size="12">{escape(v.label)}</text>']
if room.play_area:
out.append(f'<polygon points="{polygon(room.play_area)}" fill="none" stroke="#65bcec" stroke-dasharray="6 4"/>')
return "\n".join(out + ["</svg>"])
+1
View File
@@ -34,6 +34,7 @@ def meta_audio_libs(a: Analysis) -> list[str]:
def uses_scene(a: Analysis) -> bool:
"""Scene layouts and spatial anchors both need the Meta scene API on the Frame."""
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
return any(p.endswith(("USE_SCENE", "USE_ANCHOR_API")) for p in perms)
+12 -16
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)",
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality "
"games that build their level from the room (for example Demeter)."),
"Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved "
"room, use a simple play-area box (for example Demeter)."),
("controller_models", "int", 0, "Steam Frame controller models",
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get "
"the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and "
@@ -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="Pretend room layout",
help="Mixed-reality games that build their level from your room get a simple room with walls.",
"scene_emul": dict(group="room", level="common", label="Use room layout",
help="Scene games get your saved room. Without one, they get a simple room from the play area.",
control=("switch",)),
"scene_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,8 +448,10 @@ UI: dict[str, dict] = {
# settings that need the xrshim (frame/adapter.py: OVRPlugin's own arm64 OpenXR lookups routed to the adapter), so
# they don't work for 32-bit games on the bridge. surface_native: Batman's native video renderer hooks Vulkan
# functions OVRPort's dispatcher doesn't know
XRSHIM_SETTINGS = ("controller_models", "haptic_fix", "surface_native")
# functions OVRPort's dispatcher doesn't know. scene_emul: the dispatcher refuses every scene/anchor function
# ("Unknown proc addr: xrQuerySpacesFB", Pinball FX VR 2026-10-16), so the emulated room only reaches the game through
# the shim (Demeter/NOPE only got it because haptic_fix happened to be on)
XRSHIM_SETTINGS = ("controller_models", "haptic_fix", "surface_native", "scene_emul")
class AdapterSetting(Patch):
@@ -472,7 +474,7 @@ class AdapterSetting(Patch):
return UI.get(self.key, {}).get("help") or super().summary
def detect(self, a):
from .applicability import needs_scene, uses_equirect_layers, uses_render_models, uses_room_model
from .applicability import uses_equirect_layers, uses_render_models, uses_scene
if self.key == "controller_fix" and a.extra.get("hand_tracking_only"):
return Suggestion(True, "Hand tracking is required by the game: pass hands through instead of reporting "
@@ -508,15 +510,9 @@ class AdapterSetting(Patch):
# OVRPort's patch_disable_space_warp doesn't reach UE5). Off until confirmed in more games.
return Suggestion(False, "Unreal Engine 5 game with Meta's space warp: turn on if the picture flickers or "
"smears (for example Into The Radius 2).", {"value": 1})
if self.key == "scene_emul" and needs_scene(a):
return Suggestion(True, "Mixed-reality game that builds its level from the room model: emulate a "
"guardian-sized room (for example Demeter).", {"value": 1})
if self.key == "scene_emul" and uses_room_model(a):
# also VR games that only ask for the room (VR HOT's room setup retried forever without it)
return Suggestion(True, "The game asks Meta's system for your room layout (scene), which the Frame "
"doesn't have: emulate a room from the play area (for example VR HOT's room "
"setup).",
{"value": 1})
if self.key == "scene_emul" and uses_scene(a):
return Suggestion(True, "The game uses Meta's scene or spatial anchor APIs: serve the saved room on "
"the Frame, or a simple play-area room until one is scanned.", {"value": 1})
return None
def applies(self, a):
+123 -13
View File
@@ -137,18 +137,25 @@ def _existing_rift(folder: Path) -> dict | None:
def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe: str | None = None,
force: bool = False, art: bool = False) -> dict | None:
force: bool = False, art: bool = False, replace: dict | None = None) -> dict | None:
"""Analyze one Rift game folder and store it. Unchanged folders (same program, size, date) are not analyzed again;
the user's exe choice, recipe and tags survive rescans. Returns None for folders without a VR runtime (unless
force)."""
force). replace: the library entry this folder is a newer version of (replace_rift_game): it keeps its package and
name."""
from .analysis import rift
from .recommend import catalog
folder = Path(folder)
old = _existing_rift(folder)
if replace is None and not force and any(str(folder) in (g.get("replaced_dirs") or []) for g in library.games()
if g.get("kind") == "rift"):
if reporter: # the older copy of a game updated from another folder: a rescan must not bring it back
reporter.log(f"skipped {folder.name}: an older version of a game that was updated from another folder")
return None
old = replace or _existing_rift(folder)
old_extra = ((old or {}).get("analysis") or {}).get("extra") or {}
forced = exe is not None # set_exe: always analyze with the user's choice
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
forced = exe is not None or replace is not None # set_exe / a newer version: always analyze
if replace is None:
exe = exe or (old.get("exe") if old and old.get("exe_confirmed") else None)
if old and not forced and old_extra.get("fingerprint") and \
old_extra["fingerprint"] == rift.fingerprint(folder, old.get("exe") or ""):
if reporter:
@@ -177,7 +184,9 @@ def add_rift_game(folder: Path, reporter: Reporter | None = None, tree=None, exe
suggested=library.recipe_to_dict(recipe))
if not old or (old.get("recipe") or {}).get("source") != "user":
fields.update(recipe=library.recipe_to_dict(recipe), status=recipe.status)
if not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
if replace is not None:
pass # a newer version of the game: its name stays
elif not old or not (old.get("title_locked") or old.get("art_source") in ("oculusdb", "meta", "steam")):
fields["title"] = recipe.title or a.label # (store-matched titles are kept)
elif old.get("title_locked") and old.get("art_source") not in ("oculusdb", "meta", "steam"):
fields["auto_title"] = recipe.title or a.label # the user's name stays; a reset brings this one
@@ -400,14 +409,20 @@ def missing_obb(entry: dict) -> bool:
return bool(extra.get("expects_obb")) and not (entry.get("data_dir") and entry.get("data_bytes") != 0)
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None) -> dict:
def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str | None = None,
replace: str | None = None) -> dict:
"""Add a Linux app to the library (no conversion: it's installed as it is): arm64, or x86_64 (run through FEX on
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder)."""
the Frame). `exe` overrides the program FramePort picked (relative to the app's folder). replace: the package of
the library entry this is a newer version of (replace_linux_app): it keeps that package, its name and its art."""
from .analysis import linux
from .core.models import Recipe
path = Path(path)
info = linux.inspect(path)
if replace and not exe and not info["files"]: # a folder: the program the app started before, if it's still there
before = (library.game(replace) or {}).get("exe")
if before and before != info["exe"] and (Path(info["root"]) / before).is_file():
exe = before
if exe:
info["exe"] = exe
x86 = info["machine"] == linux.EM_X86_64
@@ -417,7 +432,7 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
if x86 and reporter:
reporter.log(f"{path.name} is an x86_64 build: the Frame runs it through FEX (x86 translation; an arm64 "
"build runs faster if there is one)")
package = f"linux.{linux.slug(info['title'])}"
package = replace or f"linux.{linux.slug(info['title'])}"
if reporter:
reporter.log(f"{info['title']}: program {info['exe']}{' (AppImage)' if info['appimage'] else ''}"
f"{', OpenXR (VR)' if info['openxr'] else ''}")
@@ -439,6 +454,8 @@ def add_linux_app(path: Path | str, reporter: Reporter | None = None, exe: str |
if not old:
fields.update(title=info["title"], recipe=library.recipe_to_dict(recipe), status="unknown")
library.upsert_game(package, **fields)
if replace: # a newer version: the artwork stays as it is
return library.game(package)
try:
from .artwork import fetch
@@ -580,7 +597,7 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
reporter, x86_64=bool(extra.get("x86_64")),
desktop_entry=entry.get("desktop_entry", True) is not False,
desktop_icon=entry.get("desktop_icon", True) is not False,
overlay=linux_overlay(entry))
overlay=linux_overlay(entry), **_source_args(entry, target.label))
if extra.get("x86_64"):
reporter.check("x86 translation", True, "runs through FEX on SteamOS's x86 system (its libraries come from "
"there; not checked ahead)")
@@ -591,10 +608,23 @@ def install_linux(package: str, target: Target, reporter: Reporter, add_to_libra
reporter.check("Libraries on the Frame", True, "everything the app needs is there")
if add_to_library:
target.add_to_library([package], reporter)
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
"source_id": entry.get("source_id")})
return result
def _source_args(entry: dict, label: str) -> dict:
"""source_id (recorded with the install) and changed_since for a Linux app / PC VR game install: after a newer
version was added (replace_linux_app/replace_rift_game), files changed since the last install there are sent
again even when the Frame has a file of the same size (0 = the last install is unknown: every file)."""
sid = entry.get("source_id")
if not sid:
return {}
rec = (entry.get("installs") or {}).get(label) or {}
return {"source_id": sid,
"changed_since": None if rec.get("source_id") == sid else float(rec.get("time") or 0)}
def add_game(src: SourceGame, reporter: Reporter | None = None) -> dict:
if reporter:
reporter.log(f"analyzing {src.apk.name}")
@@ -662,6 +692,85 @@ def replace_apk(package: str, apk: Path, reporter: Reporter | None = None) -> di
"new_version": new_vc}
def _looks_different(before: str | None, now: str | None) -> bool:
"""A newer version whose name has little in common with the old one's (maybe another app): the caller warns."""
import difflib
a, b = _norm_title(before or ""), _norm_title(now or "")
if not a or not b or a in b or b in a:
return False
return difflib.SequenceMatcher(None, a, b).ratio() < 0.5
def _mark_source_replaced(package: str, **fields) -> dict:
"""A Linux app or PC VR game got a newer version: a new source id, which installs record (in the deployment's
recipe); an install without it shows the game as outdated (ui.components.source_replaced)."""
import uuid
return library.upsert_game(package, source_id=uuid.uuid4().hex[:12], source_updated=time.time(), **fields)
def replace_linux_app(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of a Linux app already in the library (GitHub #161): an AppImage/archive or a folder, analysed
like "Add a Linux app…" (program, arm64/x86_64, AppImage, OpenXR). The app keeps its package (so its Steam
shortcut on the Frame), name, artwork, tags and switches (Desktop Mode entry, overlay app); a folder keeps the
program chosen before if it's still there. Returns {"entry", "different": its name looks like another app's,
"name": the new version's own name}."""
entry = library.game(package)
if entry is None or not is_linux(entry):
raise ValueError("only Linux apps can get a newer version this way")
before = ((entry.get("analysis") or {}).get("label")) or entry.get("title")
old_source = ((entry.get("analysis") or {}).get("extra") or {}).get("source")
if reporter:
reporter.log(f"reading {Path(path).name}")
stored = add_linux_app(path, reporter, replace=package)
name = (stored.get("analysis") or {}).get("label")
stored = _mark_source_replaced(package, replaced_source=old_source)
return {"entry": stored, "different": _looks_different(before, name), "name": name}
def replace_rift_game(package: str, folder: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of a PC VR (Rift) game already in the library (GitHub #161): its new game folder, analysed like
a scanned one (programs, VR runtimes, fingerprint). The game keeps its package, name, artwork, tags and the user's
own recipe; a program the user confirmed stays if the new folder has it at the same place, else FramePort picks
one again (exe_confirmed False when that's unclear: the caller asks). The old folder is left out of later scans.
Returns {"entry", "different", "name", "exe_changed"}."""
entry = library.game(package)
if entry is None or not is_rift(entry):
raise ValueError("only PC VR games can get a newer version folder")
folder = Path(folder)
if not folder.is_dir():
raise ValueError(f"{folder} isn't a folder")
other = _existing_rift(folder)
if other and other["package"] != package:
raise ValueError(f"{folder.name} is already in the library as {other.get('title') or other['package']}")
before = (entry.get("analysis") or {}).get("label") or entry.get("title")
old_dir, old_exe = entry.get("game_dir"), entry.get("exe")
keep = old_exe if entry.get("exe_confirmed") and old_exe and (folder / old_exe).is_file() else None
stored = add_rift_game(folder, reporter, exe=keep, force=True, replace=entry)
if stored is None:
raise ValueError(f"no game program found in {folder.name}")
replaced = [d for d in (entry.get("replaced_dirs") or []) + [old_dir] if d and d != str(folder)]
# build=None: the next install checks the new folder first (prepare_rift); the exe's checksum comes from there
stored = _mark_source_replaced(package, build=None, replaced_dirs=list(dict.fromkeys(replaced)))
name = (stored.get("analysis") or {}).get("label")
return {"entry": stored, "different": _looks_different(before, name), "name": name,
"exe_changed": stored.get("exe") != old_exe}
def replace_source(package: str, path: Path | str, reporter: Reporter | None = None) -> dict:
"""A newer version of any game in the library: an APK (Quest/Android), an app file or folder (Linux) or a game
folder (PC VR). See replace_apk, replace_linux_app, replace_rift_game."""
entry = library.game(package)
if entry is None:
raise ValueError(f"{package} isn't in the library")
if is_linux(entry):
return replace_linux_app(package, path, reporter)
if is_rift(entry):
return replace_rift_game(package, path, reporter)
return replace_apk(package, Path(path), reporter)
def _analyze_entry(entry: dict, reporter: Reporter | None = None):
src = source_of(entry)
if reporter:
@@ -1275,10 +1384,11 @@ def install_rift(package: str, target: Target, reporter: Reporter, add_to_librar
result = target.install_pcvr(package, title, Path(entry["game_dir"]), entry["exe"], recipe, reporter,
revive_dir=rdir, revive_version=revive.installed_version() if rdir else None,
exe_sha256=entry["build"].get("sha256"), art_lookup=entry.get("quest_package"),
overlay=rift_overlay(entry, recipe))
overlay=rift_overlay(entry, recipe), **_source_args(entry, target.label))
if add_to_library:
target.add_to_library([package], reporter)
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time()})
_record_install(package, target.label, {"exe": entry["exe"], "result": result, "time": time.time(),
"source_id": entry.get("source_id")})
return result
+2 -1
View File
@@ -36,7 +36,8 @@ class Target(ABC):
raise NotImplementedError(f"{self.label} can't launch games")
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True, overlay=None, desktop_icon=True) -> dict:
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None,
changed_since=None) -> dict:
"""Install a Linux app (only the Frame runs them; x86_64 ones through FEX)."""
raise NotImplementedError(f"{self.label} can't install Linux apps")
+4 -3
View File
@@ -86,14 +86,15 @@ class FrameLeptonTarget(Target):
def install_pcvr(self, package, title, game_dir, exe, recipe, reporter, **extra):
plan = installer.PcvrPlan(package, title, Path(game_dir), exe, recipe, extra.get("revive_dir"),
extra.get("exe_sha256"), extra.get("revive_version"), extra.get("art_lookup"),
dest=self.install_dest(), overlay=extra.get("overlay"))
dest=self.install_dest(), overlay=extra.get("overlay"),
source_id=extra.get("source_id"), changed_since=extra.get("changed_since"))
return installer.install_pcvr(self.connect().frame, plan, reporter)
def install_linux(self, package, title, root, exe, files, appimage, openxr, reporter, x86_64=False,
desktop_entry=True, overlay=None, desktop_icon=True):
desktop_entry=True, overlay=None, desktop_icon=True, source_id=None, changed_since=None):
plan = installer.LinuxPlan(package, title, Path(root), exe, files, appimage, openxr, x86_64,
dest=self.install_dest(), desktop_entry=desktop_entry, overlay=overlay,
desktop_icon=desktop_icon)
desktop_icon=desktop_icon, source_id=source_id, changed_since=changed_since)
return installer.install_linux(self.connect().frame, plan, reporter)
def drives(self) -> list[dict]:
+3 -1
View File
@@ -191,7 +191,9 @@ class PcReviveTarget(Target):
"tuning": dict(recipe.params("pcvr.steamvr_tuning"), enabled=True)
if "pcvr.steamvr_tuning" in recipe.patches else None,
"sha256": extra.get("exe_sha256"), "art_lookup": extra.get("art_lookup"),
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source}, "time": time.time()}
"recipe": {"patches": sorted(recipe.patches), "source": recipe.source,
**({"source_id": extra["source_id"]} if extra.get("source_id") else {})},
"time": time.time()}
exe_field, _, _ = shortcut_fields(dep)
d = pc_dir() / package
try: # a reinstall keeps the shortcut's appid: Steam's entry is matched by its Exe, whatever its name now
+160
View File
@@ -0,0 +1,160 @@
"""The announcement pop-up (frameport.announcements does the fetching and the who-sees-it rules): checked in the
background a little after start and every 6 h, shown once when no other dialog is open and the welcome screen is done.
Any way of closing it (the X, a click outside, Escape, its button) dismisses that announcement for good."""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING
import flet as ft
from .. import announcements
from ..core import applog
from ..i18n import tr
from . import components as C
from . import theme as T
if TYPE_CHECKING:
from ..announcements import Announcement
from .app import FramePortApp
FIRST_DELAY = 20 # seconds after start: after the start-up work and the update check
BUSY_RETRY = 15 # another dialog is open / the welcome screen is shown: look again
LONG_BODY = 900 # characters: longer bodies scroll inside the pop-up
class Announcer:
def __init__(self, app: FramePortApp):
self.app = app
self.shown: str | None = None # id on screen (a check while it's open doesn't show it twice)
try:
announcements.since() # first start → only announcements published after it (before anything is saved)
except Exception: # noqa: BLE001
applog.log.exception("announcements: recording the first start failed")
def start(self) -> None:
if not announcements.disabled():
threading.Thread(target=self._loop, daemon=True).start()
def _loop(self) -> None:
time.sleep(FIRST_DELAY)
while True:
try:
a = announcements.fetch()
if announcements.due(a):
self._offer(a)
except Exception: # noqa: BLE001
applog.log.exception("announcement check failed")
time.sleep(announcements.CHECK_EVERY)
def _offer(self, a: Announcement) -> None:
"""Wait for a quiet moment (no dialog, not the welcome screen), then show it (background thread)."""
image = announcements.image_file(a)
while self.app.route[0] == "welcome" or C.dialog_open(self.app.page):
time.sleep(BUSY_RETRY)
if announcements.due(a) and self.shown != a.id:
self.show(a, image)
# ---------------------------------------------------------------- the pop-up
def show(self, a: Announcement, image=None) -> None:
page = self.app.page
closed = []
def dismiss(e=None) -> None:
if closed:
return
closed.append(True)
self.shown = None
try:
announcements.dismiss(a.id)
except Exception: # noqa: BLE001
applog.log.exception("announcement: saving the dismissal failed")
def close(e=None) -> None:
dismiss()
page.pop_dialog()
def act(e=None) -> None:
close()
self.app.open_url(a.url)
self.shown = a.id
page.show_dialog(C.dialog(None, announcement_card(page, a, image, close, act if a.button else None),
content_padding=0, actions_padding=0, inset_padding=self._insets(),
clip_behavior=ft.ClipBehavior.ANTI_ALIAS,
on_dismiss=dismiss, width=T.DIALOG_M))
def _insets(self) -> ft.Padding:
"""Centred over the content area (right of the sidebar, left of an open activity panel), not the whole
window; where that area is narrower than the pop-up, the panel and then the sidebar are left out."""
gap = T.S5
try:
left, right = self.app.content_insets()
width = float(self.app.page.width or 0)
except Exception: # noqa: BLE001
return ft.Padding.all(gap)
need = T.DIALOG_M + 2 * gap
if width and width - left - right < need:
right = 0
if width and width - left - right < need:
left = 0
return ft.Padding(left + gap, gap, right + gap, gap)
def announcement_card(page: ft.Page, a: Announcement, image, on_close, on_action=None) -> ft.Control:
"""The pop-up's content: the portal edge, the picture (if any) with the close button over it, title, date, the
Markdown body and the optional action."""
from ..artwork.thumbs import asset_url
from .updater import _notes_style, _open_url
width = T.DIALOG_M
close_tip = tr("Dismiss")
title = ft.Text(a.title, size=T.px(20), weight=ft.FontWeight.W_700, color=T.TEXT, selectable=True)
date = C.meta(time.strftime("%Y-%m-%d", time.localtime(a.date)))
body = ft.Markdown(a.body, selectable=True, extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
md_style_sheet=_notes_style(),
on_tap_link=lambda e: _open_url(page, e.data))
if len(a.body) > LONG_BODY:
body = ft.Column([body], scroll=ft.ScrollMode.AUTO, height=T.px(320))
head: list[ft.Control] = [ft.Container(height=T.px(3), gradient=C.portal_gradient())]
if image is not None:
head.append(ft.Stack([
ft.Image(src=asset_url(image), width=width, height=_image_height(image, width), fit=ft.BoxFit.COVER,
semantics_label=a.image_alt or None),
ft.Container(
ft.Icon(ft.Icons.CLOSE_ROUNDED, size=T.px(18), color=T.TEXT),
width=T.px(34), height=T.px(34), border_radius=T.px(17), alignment=ft.Alignment.CENTER,
bgcolor=ft.Colors.with_opacity(0.62, T.BG), border=ft.Border.all(1, T.soft(T.TEXT, 0.18)),
tooltip=close_tip, ink=True, on_click=on_close, right=T.S3, top=T.S3),
], width=width))
title_row: ft.Control = title
else:
title_row = ft.Row([ft.Container(title, expand=True, padding=ft.Padding(0, T.px(6), 0, 0)),
C.icon_btn(ft.Icons.CLOSE_ROUNDED, close_tip, on_click=on_close)],
vertical_alignment=ft.CrossAxisAlignment.START, spacing=T.S2)
text = [ft.Column([title_row, date], spacing=T.px(2), tight=True)]
if a.body:
text.append(body)
if on_action is not None:
text.append(ft.Row([C.primary(a.button, icon=ft.Icons.OPEN_IN_NEW_ROUNDED, on_click=on_action)],
alignment=ft.MainAxisAlignment.END))
pad = ft.Padding(T.S5, T.S4 if image is None else T.S5, T.S4 if image is None else T.S5, T.S5)
return ft.Container(
ft.Column([*head, ft.Container(ft.Column(text, spacing=T.S4, tight=True), padding=pad)], spacing=0, tight=True),
width=width)
def _image_height(path, width: float) -> float:
"""The picture's height at the pop-up's width, kept between a banner and a third of a 16:9 screen."""
try:
from PIL import Image
with Image.open(path) as im:
ratio = im.height / im.width
except Exception: # noqa: BLE001
ratio = 9 / 21
return max(T.px(140), min(width * ratio, T.px(320)))
+71 -3
View File
@@ -96,6 +96,9 @@ def reduce_motion_setting() -> bool:
return bool(library.setting(REDUCE_MOTION, False))
SIDEBAR_WIDTH = 236 # logical px before the UI scale (content_insets)
class FramePortApp:
def __init__(self, page: ft.Page):
from .views.library import load_filters
@@ -145,9 +148,12 @@ class FramePortApp:
self.stop_live(), # and stop a live view (it would keep the Frame encoding)
self.stop_keyboard(), self.stop_monitor(), self.monitor_hub.close(),
_link_state.update(owner=None) if _link_state["owner"] is self else None)
from .announcer import Announcer
from .updater import Updater
self.updater = Updater(self) # new FramePort releases (sidebar card, Library bar, one-click update)
# maintainer announcements (pop-up once, not on a first start); before updater.start() saves this start
self.announcer = Announcer(self)
page.title = tr("FramePort")
for problem in T.load_user_themes(themes_dir()): # installed theme files (Settings → Appearance)
@@ -182,6 +188,7 @@ class FramePortApp:
self._start_auto_pair()
threading.Thread(target=self._backfill_covers, daemon=True).start()
self.updater.start()
self.announcer.start()
self.run_bg(self._refresh_catalog) # confirmed game configs from GitHub main (no release needed)
_link_state["owner"] = self
if _link_state["thread"] is None:
@@ -236,13 +243,18 @@ class FramePortApp:
self.activity_card,
self.conn_card,
self.power_row, # under the Frame card (owner's choice)
], spacing=T.S2), width=T.px(236), bgcolor=T.SIDEBAR, padding=T.S4,
], spacing=T.S2), width=T.px(SIDEBAR_WIDTH), bgcolor=T.SIDEBAR, padding=T.S4,
border=None if T.DUAL else ft.Border(right=ft.BorderSide(1, T.BORDER)))
# dual themes: the sidebar's edge is the portal, blue at the top into orange at the bottom
edge = [ft.Container(width=2, gradient=C.portal_gradient(vertical=True, opacity=0.7))] if T.DUAL else []
self.page.controls = [ft.Row([sidebar, *edge, self.body, self.activity.root], expand=True, spacing=0,
vertical_alignment=ft.CrossAxisAlignment.STRETCH)]
def content_insets(self) -> tuple[float, float]:
"""How much of the window's left (the sidebar + its edge) and right (the activity panel, when open) isn't the
content area: pop-ups about the content (announcements) centre over what's between."""
return T.px(SIDEBAR_WIDTH) + (2 if T.DUAL else 0), float(self.activity.root.width or 0)
def restyle(self, theme: str) -> None:
"""Switch the colour theme while running (Settings → Appearance): new tokens, the window rebuilt, the views
FramePort keeps between visits recreated (the files/screenshots views start at their first location again;
@@ -880,7 +892,7 @@ class FramePortApp:
self.toast(msg, error=True, action=tr("Details"),
on_action=lambda e: self.show_activity(True))
self.refresh_view()
if job.kind == "scan" and self.exe_queue:
if job.kind in ("scan", "task") and self.exe_queue: # (a task: a newer PC VR game version)
self.page.run_thread(self.next_exe_choice)
elif job and job.active and job.package and self.route[:2] == ("game", job.package):
pkg, transit = self.hero_transit or (None, None)
@@ -1152,6 +1164,7 @@ class FramePortApp:
"move": lambda e: self.move_game(pkg),
"update_app": lambda e: self.update_app_only(pkg),
"new_apk": lambda e: self.page.run_task(self.update_with_apk, pkg),
"new_version": lambda e: self.page.run_task(self.update_with_version, pkg),
"uninstall_frame": lambda e: self.uninstall(pkg, "frame"),
"uninstall_pc": lambda e: self.uninstall(pkg, "pc"),
"remove": lambda e: self.remove_from_library(pkg),
@@ -1168,7 +1181,8 @@ class FramePortApp:
settings=self.has_game_settings(g), programs=rift or (linux and bool(self.linux_programs(g))),
selectable=lv is not None,
media_button=C.is_media_player(g) and not rift,
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux)
movable=connected and C.frame_drive(g, self.frame_info) is not None, android=not rift and not linux,
new_version=rift or linux)
return menu_sections(g, st, handlers.__getitem__)
def open_game_menu(self, pkg: str) -> None:
@@ -1621,6 +1635,60 @@ class FramePortApp:
return tr("{title}: newer APK added").format(title=self._title(pkg))
self.submit(tr("Newer APK for {title}").format(title=self._title(pkg)), run, pkg, "task")
async def update_with_version(self, pkg: str) -> None:
"""Game menu "Update with a newer version…" for Linux apps and PC VR games (GitHub #161): pick the new app
file or folder (how the app was added) / the new game folder; the game keeps its name, art, tags and switches,
and its page then offers Update on Frame (or on this PC)."""
from ..analysis import linux as linux_analysis
if self.jobs.busy_with(pkg):
return
g = library.game(pkg) or {}
rift = g.get("kind") == "rift"
title = tr("Newer version of {title}").format(title=self._title(pkg))
if rift or self._linux_folder_app(g):
path = await C.pick_dir(self.page, dialog_title=title)
if not path:
return
else:
paths = await C.pick_files(self.page, dialog_title=title)
if not paths:
return
path = paths[0]
if not linux_analysis.looks_like_linux_app(Path(path)):
self.toast(tr("{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)")
.format(name=Path(path).name), error=True)
return
on_frame = self.frame_state == "connected" and \
C.install_state(g, self.frame_info) in ("installed", "outdated")
on_pc = rift and pkg in self.pc_installs()
def run(job: Job):
job.reporter.stage(tr("Reading the new version"))
res = pipeline.replace_source(pkg, Path(path), job.reporter)
if res.get("different"):
self.toast(tr("Heads-up: {name} looks like a different app than {title}.").format(
name=res.get("name") or Path(path).name, title=self._title(pkg)))
if rift and res["entry"].get("exe_confirmed") is False: # which program starts it? (asked after the job)
self.exe_queue.append(pkg)
self.open_game(pkg)
if on_frame or on_pc:
return tr("{title}: newer version added. Press Update to install it.").format(
title=self._title(pkg))
return tr("{title}: newer version added").format(title=self._title(pkg))
self.submit(tr("Newer version for {title}").format(title=self._title(pkg)), run, pkg, "task")
@staticmethod
def _linux_folder_app(g: dict) -> bool:
"""A Linux app added from a folder (its newer version is a folder too), not from an AppImage/archive."""
from ..analysis import linux as linux_analysis
extra = (g.get("analysis") or {}).get("extra") or {}
source = Path(extra.get("source") or g.get("game_dir") or "")
if source.exists():
return source.is_dir()
return not extra.get("files") and not source.name.lower().endswith(linux_analysis.ARCHIVES)
def updatable(self) -> list[tuple[str, str]]:
"""[(package, "frame" | "pc")] installs with an update ready (a newer build or changed patch settings)."""
out = []
+13 -1
View File
@@ -178,6 +178,8 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
# still installed (GitHub #90)
if not dep or (dep.get("apk_present") is False and not dep.get("drive_missing")):
return "missing"
if source_replaced(game, dep):
return "outdated" # a newer version of the Linux app / PC VR game was added since this install (GitHub #161)
b = game.get("build") or {}
if game.get("kind") not in ("linux", "rift") and b.get("apk_recipe_fp") and dep.get("configuration_fp"):
from ..game_update import plan_update
@@ -194,6 +196,16 @@ def install_state(game: dict, frame_info: dict | None) -> str | None:
return "outdated" if settings_diff(game, frame_info) else "installed"
def source_replaced(game: dict, dep: dict | None) -> bool:
"""A Linux app or PC VR game got a newer version ("Update with a newer version…", GitHub #161) that this install
(the Frame's or the PC's deployment record) doesn't have yet."""
sid = game.get("source_id")
if not sid or game.get("kind") not in ("linux", "rift"):
return False
rec = (dep or {}).get("recipe")
return (rec.get("source_id") if isinstance(rec, dict) else None) != sid
def recipe_changed(game: dict) -> bool:
from ..patches.base import recipe_fingerprint, revised_since_unrecorded
@@ -216,7 +228,7 @@ def pc_outdated(g: dict, dep: dict) -> bool:
args = game_args(recipe_from_dict(g["recipe"]))
except Exception: # noqa: BLE001
args = dep.get("game_args") or []
return installed != want or list(dep.get("game_args") or []) != args
return installed != want or list(dep.get("game_args") or []) != args or source_replaced(g, dep)
def one_choice():
+9
View File
@@ -19,6 +19,12 @@ class _Translated(dict):
HELP: dict[str, str] = _Translated({
"mr_room": "Mixed-reality games use walls and furniture from a saved room. 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.",
@@ -62,6 +68,9 @@ HELP: dict[str, str] = _Translated({
"hw_video_decode": "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.",
"ssh_keys": "FramePort logs in to the Frame with its own key. If your Frame accepts only one of your own keys, "
"add that private key file here; fast uploads use it too. A key with a passphrase: add it to your SSH "
"agent instead.",
"launch_test": "Starts the game on the Frame while nobody wears the headset and reads the log: did it start and "
"draw frames. It can't check the picture.",
"last_session": "After you play a game on the Frame, FramePort reads that session's log: crashes, frame rate and "
+5
View File
@@ -35,6 +35,7 @@ class MenuState:
media_button: bool = False # the game page shows "Add videos" already
movable: bool = False # the Frame knows which drive it's on (agent v63+, microSD): Move to…
android: bool = False # a Quest/Android game (not PC VR, not a Linux app): it can get a newer APK
new_version: bool = False # a Linux app or PC VR game: it can get a newer version (file or folder)
def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> list:
@@ -55,6 +56,8 @@ def menu_sections(g: dict, st: MenuState, act: Callable[[str], Callable]) -> lis
top.append((tr("Move to…"), ft.Icons.DRIVE_FILE_MOVE_ROUNDED, act("move")))
if st.android and not job: # GitHub #161: the game keeps its name, art and recipe
top.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
if st.new_version and not job: # the same for Linux apps and PC VR games
top.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
if st.on_frame and not rift and not linux and not job:
# the new build only; the Frame's game data stays (e.g. changed OBBs a normal update would replace)
top.append((tr("Update app only…"), ft.Icons.SYSTEM_UPDATE_ALT_ROUNDED, act("update_app")))
@@ -103,6 +106,8 @@ def quick_menu(st: MenuState, act: Callable[[str], Callable]) -> list:
out += st.installs[:1]
if st.android:
out.append((tr("Update with a newer APK…"), ft.Icons.UPGRADE_ROUNDED, act("new_apk")))
if st.new_version:
out.append((tr("Update with a newer version…"), ft.Icons.UPGRADE_ROUNDED, act("new_version")))
if st.settings:
out.append((tr("Game settings…"), ft.Icons.TUNE_ROUNDED, act("settings")))
out.append((tr("Rename…"), ft.Icons.DRIVE_FILE_RENAME_OUTLINE_OUTLINED, act("rename")))
+127 -1
View File
@@ -19,6 +19,45 @@ 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
@@ -181,6 +220,93 @@ 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 []
@@ -562,5 +688,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.storage(info), self.installed(info),
self.device_card(info), self.readiness(info), self.room_card(), self.storage(info), self.installed(info),
], spacing=T.S5, scroll=ft.ScrollMode.AUTO, expand=True)
+13
View File
@@ -332,6 +332,19 @@ 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:
+47 -1
View File
@@ -131,9 +131,55 @@ class SettingsView:
C.meta(tr("Large files upload together using OpenSSH when available. "
"Default: 3. Lower this on a busy network.")),
C.ghost(tr("Remove converted copies now"), ft.Icons.CLEANING_SERVICES_OUTLINED, clean,
tooltip=tr("Your own game files aren't touched."))],
tooltip=tr("Your own game files aren't touched.")),
ft.Container(height=T.S2), self.ssh_keys()],
spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
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)."""
from ...frame import connection
app = self.app
rows = ft.Column(spacing=T.S1)
def fill():
items = []
for path, problem in connection.extra_key_status():
items.append(ft.Row([
C.as_icon(ft.Icons.KEY_ROUNDED if not problem else ft.Icons.WARNING_AMBER_ROUNDED, T.px(18),
T.TEXT_2 if not problem else T.WARN),
ft.Column([C.body(path.name, T.TEXT), C.meta(problem or str(path), selectable=True)],
spacing=0, expand=True),
C.icon_btn(ft.Icons.DELETE_OUTLINE_ROUNDED, tr("Stop using this key (the file stays)"),
on_click=lambda e, p=path: remove(p)),
], spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER))
rows.controls = items or [C.meta(tr("Only FramePort's own key, and your SSH agent's keys."))]
C.update(rows)
def remove(path):
connection.remove_extra_key(path)
fill()
async def add(e):
for path in await C.pick_files(app.page, dialog_title=tr("Private SSH key file (not the .pub file)"),
multiple=True):
try:
stored = connection.add_extra_key(path)
except connection.KeyFileError as exc:
app.toast(tr("That key can't be used: {error}").format(error=exc), error=True)
continue
app.toast(tr("FramePort also logs in with {name} from the next connection.").format(name=stored.name))
fill()
fill()
return ft.Column([
ft.Row([C.body(tr("SSH keys"), T.TEXT, weight=ft.FontWeight.W_500), C.help_icon("ssh_keys")],
spacing=T.px(4), vertical_alignment=ft.CrossAxisAlignment.CENTER),
rows,
C.secondary(tr("Add a key file…"), ft.Icons.VPN_KEY_ROUNDED, add),
], spacing=T.S2, horizontal_alignment=ft.CrossAxisAlignment.START)
def updates_card(self) -> ft.Control:
"""Settings → Updates: FramePort's own updates (ui/updater.py, frameport/updates.py)."""
import time
+335
View File
@@ -0,0 +1,335 @@
// 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);
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
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more