Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c1a394ae0 | ||
|
|
02c72f6707 | ||
|
|
e2ef06375e | ||
|
|
e9bda15a87 | ||
|
|
fcd8432184 | ||
|
|
73ad29c7c5 | ||
|
|
dc06b1cb40 | ||
|
|
9c41acee58 | ||
|
|
ab53f3447f | ||
|
|
229ceb9d8e | ||
|
|
4638a8bc61 | ||
|
|
487e0781a7 | ||
|
|
ebb9255e59 | ||
|
|
578da5642e | ||
|
|
ebc9017980 | ||
|
|
21534589ec | ||
|
|
ef37ea5b48 | ||
|
|
d9ae479977 | ||
|
|
e90df7b278 | ||
|
|
25d138909d | ||
|
|
34dfc6f508 | ||
|
|
f3b8ece9a6 | ||
|
|
631e4bca5a | ||
|
|
2c50578f2f | ||
|
|
ce8b7b1e02 | ||
|
|
dac01dfccf | ||
|
|
aca93db6c9 | ||
|
|
f5a268853d | ||
|
|
f895a2f62a | ||
|
|
53b79a4eda | ||
|
|
0ec26ffd0b | ||
|
|
cf98f95ed2 | ||
|
|
8cb27832be | ||
|
|
e4e1a42cde | ||
|
|
4bf48d3f51 | ||
|
|
98ddfa7157 | ||
|
|
91bc6af2b7 | ||
|
|
04faa0be65 | ||
|
|
1399d7c5a3 | ||
|
|
fa5c5b32ce | ||
|
|
0228516a2a | ||
|
|
960da4469b | ||
|
|
e9720f83d2 | ||
|
|
729f77a9e3 | ||
|
|
4824b30e94 | ||
|
|
39e1762e53 | ||
|
|
3d260dccb1 | ||
|
|
26a34ddadb | ||
|
|
939845d379 | ||
|
|
35eb12df08 | ||
|
|
570a98b91a | ||
|
|
f2f92cce70 | ||
|
|
dd74c17212 | ||
|
|
5647e266bd | ||
|
|
a5a86e591d | ||
|
|
7cd78cfa26 | ||
|
|
e04edf3600 | ||
|
|
c98aad5056 | ||
|
|
ae228ea4ea | ||
|
|
0599e55831 | ||
|
|
2e83970ad5 | ||
|
|
33a778c195 |
No files matched your search
@@ -19,6 +19,8 @@ CLAUDE.local.md
|
||||
# outputs of `frameport report` / `parity` / ruff
|
||||
REPORT.md
|
||||
.ruff_cache/
|
||||
.eval-home/
|
||||
.eval-results/
|
||||
.claude/worktrees/
|
||||
|
||||
# project page (site/)
|
||||
|
||||
@@ -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
|
||||
@@ -1055,6 +1096,13 @@ GitHub identity + credentials are maintainer-local (`CLAUDE.local.md`): check `g
|
||||
GitHub write. **The owner approves before posting, labelling or closing anything.** Reply style: "I"/neutral, never
|
||||
we/our/us; never mention agent version numbers or internal tool versions; show the owner every draft in full with a
|
||||
short recap of the thread so far; read **all** comments on a thread first (a later one often has the zip or the answer).
|
||||
- **Every triage also sweeps for unanswered comments**, not just new threads: open AND recently closed issues, PRs
|
||||
(incl. merged/closed ones) and discussions (top-level comments and their replies) whose latest comment isn't the
|
||||
maintainer's, plus earlier user comments a later maintainer reply skipped. Users often ask follow-ups after a fix or
|
||||
after closing (#294: two questions after the reporter closed it; #249: a Q&A discussion with no reply for days;
|
||||
#161/#110: requests posted after the close). Quick check: `gh issue list --state all --search "updated:>=<date>"
|
||||
--json number,comments --jq '.[] | select(.comments[-1].author.login != "spoopyghosty0")'`; discussions via GraphQL
|
||||
(`discussions(orderBy:UPDATED_AT)` with `comments{replies}`).
|
||||
- **Issue templates & labels** (`.github/ISSUE_TEMPLATE/`): `[Game report]` → `game-compatibility` (a game that
|
||||
doesn't work right); `[Working recipe]` (`working-config.yml`) → `working-config` (a headset-verified config to add);
|
||||
`[App]` → `bug` (FramePort itself). Mislabel/relabel to match.
|
||||
@@ -1100,9 +1148,15 @@ MR-only (PASSTHROUGH required + BOUNDARYLESS_APP) → force_passthrough, + USE_S
|
||||
hand tracking required → controller_fix=0; OVRPlugin + ≥20 GiB → disable_space_warp; Unreal ≤4.21 or Unreal Meta XR
|
||||
Audio → nodebug; Oculus-OS class referenced by the Unreal audio build or ≥2 Meta libs → oculusos; legacy-VrApi Unity
|
||||
GLES with MSAA → unity_no_msaa; CryEngine → user.cfg r_variable_rate_shading=0; direct VrApi → bridge (+GL shim for
|
||||
GLAD/GLES); Unreal → alternate no-ForceQuit build. Score changes with
|
||||
`python scripts/eval_heuristics.py "<dumps>"` (catalog off vs verified recipes; currently 33/34 exact — Phantom's
|
||||
"use the no-ForceQuit build" is only detectable at runtime via triage). Add a rule → re-run the eval + `pytest -m games`.
|
||||
GLAD/GLES); Unreal → alternate no-ForceQuit build. Scoring (docs/HEURISTICS_EVAL.md, 2026-10-15): analyse the dumps
|
||||
once (`scripts/heuristics_snapshot.py <out.json> "<VR CyberDeck Portable>" "<PATCHED>"`, ~30 min, keep out of git),
|
||||
then `scripts/eval_heuristics.py --snapshot <out.json> --split dev|holdout|all [--fit-report]` (catalog-only truth,
|
||||
per-patch precision/recall, a fixed 20% holdout to catch rules fitted to one game). Read the **dated** score: an
|
||||
"extra" on an entry verified before the patch existed is a catalog gap, and adapter/device picks are inherited by
|
||||
catalog recipes anyway. 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6%; remaining misses are
|
||||
one-game runtime fixes. Don't key a rule on one game's code fingerprint (an Unreal splash/depth "detect" matching
|
||||
only Star Wars Tales + FNAF HW was rejected: it would also have bumped ANALYSIS_VERSION for every user). Add a rule →
|
||||
re-run dev + holdout + `pytest -m games`.
|
||||
|
||||
## Frame operations
|
||||
- Find/connect: `frameport frame discover` / `frame info` (the remembered Frame is in `<user data>/frames.json`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
c9e286ff25984992954c1d80eb1fc3530643421d859d20c8c1fd07ce1d584372 ./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
|
||||
ba2dc70263bc3cca82a313805c5b4890452b1f740570bc7b65320a825a4012d2 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
@@ -27,6 +27,25 @@ b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING
|
||||
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
|
||||
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
|
||||
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
|
||||
63af0050054ad3705b17a0b00ce0c076ba1af0b73abd673ba6c588f64b4591cd ./linux-arm64-bin/fp_roomscan
|
||||
b10b3a5c10c3339fc63fd9f6cb4d01ea29be11a863d0ebad1389ea69fd471940 ./linux-arm64-bin/fp_venc
|
||||
00c2dead0fd7497929833467f0e55699d57d26be1b85dce35402993852f7b111 ./roomscan/ceiling.png
|
||||
c1962de31ed2810d31c760cf9ee63316ac2b8c9528ccbcd9299ebf5ba39b2995 ./roomscan/done.png
|
||||
0dfaea4c68979b0a45a9fef06fdf1a92468ffc8f46f80a3a014fce1d805972d5 ./roomscan/failed.png
|
||||
1147391822be3f1aa5351374b8fd9ec85f8b5ce40e3cb9f3146d778e6fbc652a ./roomscan/floor.png
|
||||
eb3c8b55a9dbafa030797ca9f06a22c3ee3bbef96cb7adea26d433ebad43d43f ./roomscan/look_00.png
|
||||
6ba965ff6d22a469c558f2a68229bd2ff48fc28badb95889ca245310d1dd3e96 ./roomscan/look_01.png
|
||||
0a11cec25ebc76fe11d505d1f2ec162c9b4a6754393065800e73b1f81e2d94c8 ./roomscan/look_02.png
|
||||
fa5a4c94d20af56797b9fcc111148767847a6633ade9217cab373de232efad90 ./roomscan/look_03.png
|
||||
e80fa493e380fba39084be2ee62859adcff4e39aa66566abee10a4d6339c52e9 ./roomscan/look_04.png
|
||||
133617f0d8d1e69c37408aac80033f71856b592b62e3b780cc12b446bd974680 ./roomscan/look_05.png
|
||||
d5dd296ff23641c6973c57ac139147e99c7b783316ab60537adb2155dd196880 ./roomscan/look_06.png
|
||||
c08570aa78f966ae937b2e21d0bad887c5352fbca56aa579a2a7ef8541fb96ad ./roomscan/look_07.png
|
||||
3767e7db5fb6babc872fa62c42604f749c65936717d332101745b201cd450cd5 ./roomscan/look_08.png
|
||||
2924dc65e5c497545340209edc16060e12e1834d11af6871d1c1baa50b7b2562 ./roomscan/look_09.png
|
||||
cd0150dfc10b2aea39403c74eff72fd05fb0111178ef39b4c98657a6d16d105e ./roomscan/look_10.png
|
||||
011355e412f73ade3896e9037490e607c4365d0cf8ceb807d236bf68b414d3ed ./roomscan/look_11.png
|
||||
1b8b47ffb47568d540921f065b5958eeb44a6489498d58e0f33f5d1f535154e2 ./roomscan/look_12.png
|
||||
926c7bc6a103adee5b9bf938f5d4e39095ab77fd6270b5301be3b0428edd9ae6 ./roomscan/start.png
|
||||
c2c70f4af1a5c3e6089dff1130161dd417f8044f99ec639eeecf1083930aab16 ./win-x64/fp_oculushmd.exe
|
||||
60ebc5fea05b8945082e2ca8b9f2eb74616012a26eb50133af273dff0fcddc42 ./win-x64/fp_vrsettings.exe
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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
|
||||
|
||||
@@ -7,8 +7,14 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: Uses Meta XR Audio via FMOD (not covered by patch_meta_xr_audio); if audio is missing, that is the likely
|
||||
cause.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 63fff470777f2753d55ad50f8355573ced8fa48bcaee0f5e6a0c10369f951a38
|
||||
updated: '2026-10-15'
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: VrApi
|
||||
status: issues
|
||||
notes: Right eye distorts during movement (unresolved; swap, tracking, Valve layers, depth and pacing ruled out).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Legacy VrApi via OVRPlugin; overport swaps in an OpenXR OVRPlugin. Do not remove VrApi.
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 192ff250f5fbec38b9c4e898f0b8408edc25901f46bb4b227f7ca60bbd9f0bba
|
||||
updated: '2026-10-15'
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Needed the GLES/sRGB swapchain fallback.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Also available as a native PC SteamVR title.
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 158f7c4f5e8558029dc8cc5e8dcb4e67e846b261bf34e3e448fee7f657c74597
|
||||
updated: '2026-10-15'
|
||||
@@ -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,10 @@
|
||||
package: com.FlatHeadStudio.DeadlyDelivery
|
||||
title: Deadly Delivery
|
||||
status: unsupported
|
||||
notes: "Quits after Meta's entitlement check fails: it needs Meta's online services."
|
||||
tested_version: 1.0.30
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-15'
|
||||
issue: 322
|
||||
@@ -6,8 +6,14 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Recommended pilot title.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 2300f628ce04c1da60b52fed18335d6a31b88cc88313fd6d2c0e1a8e7785126f
|
||||
updated: '2026-10-15'
|
||||
@@ -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
|
||||
@@ -5,9 +5,14 @@ source_hint: 'Sniper Elite VR- Winter Warrior'
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 6b8170da5826b9a35326a20967a0e5a242d46fb569a0a3c669e1cbd368102dd3
|
||||
updated: '2026-10-15'
|
||||
@@ -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
|
||||
@@ -6,11 +6,14 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Passthrough + Meta scene/room emulated from the Steam guardian (scene_emul=1).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Mixed-reality-only; builds its level from the room model, which the adapter emulates from the Steam guardian.
|
||||
overport_extra:
|
||||
- patch_force_passthrough
|
||||
adapter:
|
||||
scene_emul: 1
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
@@ -18,3 +21,5 @@ verified:
|
||||
known_good_sha256: 9f900fc86fbb2663abe83c82ab79cc72bbf1f93fea54f7583fd274dd07e3d09c
|
||||
frame:
|
||||
- frame.meta_permissions
|
||||
- frame.unity_text_input
|
||||
updated: '2026-10-15'
|
||||
@@ -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
|
||||
@@ -6,12 +6,18 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: issues
|
||||
notes: Hand-tracking game; the Frame synthesizes hands from controllers, so it is janky.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Hand-tracking-only game; Steam Frame has no optical hand tracking. Built with controller_fix=0 so hand
|
||||
data passes through if the runtime ever provides it.
|
||||
adapter:
|
||||
controller_fix: 0
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: a19b80db0c300d422cbba35a4924ab59211b39bcb6485e200d310e3e1372ddb7
|
||||
updated: '2026-10-15'
|
||||
@@ -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
|
||||
@@ -7,10 +7,15 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: FrameBridge turns its upside-down UI panels upright (flip emulation, in place since FramePort 1.2.0). Ubisoft
|
||||
login may not work.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Ubisoft online login may not work. Data folder holds raw assets (start0..2, .climb, bundles), not .obb
|
||||
files; install to obb as-is.
|
||||
overport_extra:
|
||||
- patch_ac_nexus_no_appsw_90
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
@@ -18,5 +23,5 @@ verified:
|
||||
known_good_sha256: 89644a12bf8df956fa3d3a9a40540ba9e1fb62f63494714a05c9f05984383ac8
|
||||
no_appsw_90: '2026-10-02, OVRPort 1.2.5 (owner: 90 Hz felt better than 72 Hz; same known issues)'
|
||||
flip_in_place: '2026-10-14, owner in the headset: launch warnings and tutorial pop-ups upright (GitHub #138)'
|
||||
updated: '2026-10-14'
|
||||
updated: '2026-10-15'
|
||||
min_app: 1.2.0
|
||||
@@ -1,5 +1,5 @@
|
||||
package: com.asg.clockworkdev
|
||||
title: Clockwork
|
||||
title: The Last Clockwinder
|
||||
status: works
|
||||
tested_version: '1.29'
|
||||
engine: Unity
|
||||
|
||||
@@ -2,6 +2,7 @@ package: com.camouflaj.manta
|
||||
title: 'Batman: Arkham Shadow'
|
||||
status: works
|
||||
notes: Large data set (~27 GiB) incl. all language packs.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: 'Uses Meta XR Audio (Wwise), handled by patch_meta_xr_audio. Cutscenes are 8K HEVC panoramas: frame.hw_video_decode
|
||||
decodes them on the Frame''s hardware, adapter.surface_native shows them in stereo (PRs #96, #128). Includes all
|
||||
language packs (~28 GB). sync_guard (to test, GitHub #102) - a reporter''s game quit to Steam''s waiting screen
|
||||
@@ -13,15 +14,18 @@ overport_extra:
|
||||
- patch_disable_space_warp
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
- frame.unity_text_input
|
||||
adapter:
|
||||
sync_guard: 1
|
||||
surface_native: 1
|
||||
fade_quad: 1
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
|
||||
updated: '2026-10-13'
|
||||
updated: '2026-10-15'
|
||||
min_app: 1.1.0
|
||||
source_hint: Batman- Arkham Shadow
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Co-op unavailable.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: 'Release notes: for DLC use an older version. Co-op likely unavailable.'
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 06428a6328a49b3ca769e070dd0d2bb55c2122c8fba5d07a69b1b206a532c212
|
||||
updated: '2026-10-15'
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -6,11 +6,17 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Passthrough emulated with the Frame cameras (greyscale).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Mixed-reality-only title; relies on passthrough (emulated).
|
||||
overport_extra:
|
||||
- patch_force_passthrough
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 65ca3a8f46cc9acfd268949418f9adeecb0cf9f59d11591ba95fe2ba9ea966d8
|
||||
updated: '2026-10-15'
|
||||
@@ -2,10 +2,17 @@ package: com.nexxr.nexplayer
|
||||
title: NEX Player
|
||||
status: works
|
||||
notes: 'Confirmed in the headset by the owner (2026-10-01): 72 fps, local 2D/360/3D videos play (it browses /sdcard/Movies
|
||||
itself). USB-drive browsing can''t work in Lepton.'
|
||||
itself). USB-drive browsing can''t work in Lepton. Typing (frame.unity_text_input + device.text_input_window,
|
||||
owner in the headset 2026-10-15): SteamVR''s keyboard button works and typed text reaches the fields; the keyboard
|
||||
doesn''t open by itself when a field is selected.'
|
||||
tested_version: 1.3.1.0
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
verified:
|
||||
date: '2026-10-01'
|
||||
source_hint: NEX - NEXt-Gen Video Player
|
||||
updated: '2026-10-15'
|
||||
@@ -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
|
||||
@@ -6,8 +6,14 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Layers with failed swapchains are dropped by the adapter.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: c3f8cba1c22762231b6a6fbd881668db859d83224884ebeae0c1a3ba716f298c
|
||||
updated: '2026-10-15'
|
||||
@@ -6,9 +6,15 @@ engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: Use VR mode.
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
details: Has VR and MR modes; use VR mode (passthrough not forced).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 5ba09a2bc0046c8cb587272ab81e8d32739334fef523b10ab26531a733d54f12
|
||||
updated: '2026-10-15'
|
||||
@@ -1,13 +1,14 @@
|
||||
package: com.zenstudios.PFXVRQuest
|
||||
title: Pinball FX VR
|
||||
status: issues
|
||||
notes: Plays; mixed reality mode not working yet. Space warp off (fewer stutters).
|
||||
notes: "Plays; space warp off (fewer stutters). MR mode: needs a scanned room."
|
||||
tested_version: '1.9'
|
||||
engine: Unreal
|
||||
xr: OpenXR
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
adapter:
|
||||
scene_emul: 1
|
||||
equirect_emul: 1
|
||||
hide_space_warp: 1
|
||||
verified:
|
||||
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -5,9 +5,14 @@ source_hint: Toy Master
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: e951595c67b6e448f3fb4afd07e6b83e98e589094bb3b73374cfb28aa01e96ee
|
||||
updated: '2026-10-15'
|
||||
@@ -7,6 +7,9 @@ xr: OpenXR
|
||||
status: works
|
||||
notes: Needs Oculus OS telemetry class stubs and the no-ForceQuit build (the normal build quits right after
|
||||
starting).
|
||||
frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-10-02' # owner-confirmed with the no-ForceQuit build
|
||||
overport_cli: 1.2.3
|
||||
@@ -14,6 +17,8 @@ verified:
|
||||
known_good_sha256: 31469d50b8ee292597291db7cc3bc3eca8e851f533cd92bba0d64214893580cc
|
||||
frame:
|
||||
- frame.oculusos
|
||||
- frame.unity_text_input
|
||||
alt_overport:
|
||||
- patch_remove_unreal_force_quit
|
||||
use_alt: true
|
||||
updated: '2026-10-15'
|
||||
@@ -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
|
||||
@@ -5,9 +5,14 @@ source_hint: H.U.N.T
|
||||
engine: Unity
|
||||
xr: OpenXR
|
||||
status: works
|
||||
notes: ''
|
||||
notes: frame.unity_text_input and device.text_input_window added from detection (not headset-verified).
|
||||
frame:
|
||||
- frame.unity_text_input
|
||||
device:
|
||||
- device.text_input_window
|
||||
verified:
|
||||
date: '2026-09-28'
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: c35373d0f7c870ade148e3259abcc74098b6bc89fe2c2f3a2e55168df642e391
|
||||
updated: '2026-10-15'
|
||||
@@ -6,11 +6,31 @@
|
||||
# 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
|
||||
diagnosis: A DLC manager reports a Meta asset-file status error after VR starts (e.g. PixelRipped 1995). The asset-file check failed; the log does not identify the native call or asset ID. Try the experimental asset-file bypass (empty list, installed status with no file path); this does not supply DLC and has not been verified in the game.
|
||||
suggest: [frame.asset_files_stub]
|
||||
- id: meta-entitlement
|
||||
# Quest games (the pcvr oculus-entitlement signature covers Rift games). Only explicit failure results: a bare
|
||||
# ovr_PlatformInitialize error isn't matched. `error`, not fatal: offline games may log it and play on; an online
|
||||
# game that then quits (e.g. Deadly Delivery, GitHub #322) fails the test through its exit.
|
||||
pattern: '(?i)entitlement check failed|\bnot entitled to (use|play)|ovrPlatformInitialize_(NotEntitled|UnableToVerify|FileInvalid|SignatureInvalid)'
|
||||
severity: error
|
||||
diagnosis: The game's Meta licence check (entitlement) failed. It needs Meta's online login and licence check,
|
||||
which the Frame doesn't have; games that depend on it (often online games) quit or stay offline. FramePort
|
||||
doesn't get around such checks.
|
||||
suggest: []
|
||||
- id: lua-lightuserdata-pointer
|
||||
pattern: 'bad light userdata pointer'
|
||||
severity: fatal
|
||||
|
||||
@@ -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,11 +13,14 @@ 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 | |
|
||||
| BARTENDER VR SIMULATOR | Quest | ✅ Works | |
|
||||
| Batman: Arkham Shadow | Quest | ✅ Works | |
|
||||
| BattleGlide | Quest | ✅ Works | |
|
||||
| Battlemarked | Quest | ✅ Works | |
|
||||
| BattleSisters | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
| Beat Saber | Quest | ✅ Works | |
|
||||
@@ -27,16 +30,19 @@ 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 | |
|
||||
| Clockwork | 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 | |
|
||||
| Demeo | Quest | ✅ Works | |
|
||||
| Demeter | Quest | ✅ Works | |
|
||||
| Dinosaur Island | Quest | ✅ Works | |
|
||||
| Doom3Quest | Quest | ✅ Works | |
|
||||
@@ -46,10 +52,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Eleven Table Tennis | Quest | ✅ Works | |
|
||||
| Eleven: Table Tennis VR | PC VR | ✅ Works | |
|
||||
| Espire 2 | Quest | ✅ Works | |
|
||||
| Eye of the Temple | Quest | ✅ Works | |
|
||||
| FrenzyVR | Quest | ✅ Works | |
|
||||
| Fruit Ninja | Quest | ✅ Works | |
|
||||
| Fruit Salon | Quest | ✅ Works | |
|
||||
| Genotype | Quest | ✅ Works | |
|
||||
| Ghost Town | Quest | ✅ Works | |
|
||||
| Ghostbusters: Rise of the Ghost Lord | Quest | ✅ Works | |
|
||||
| GORN2 | Quest | ✅ Works | |
|
||||
| Gravity Lab | Quest | ✅ Works | |
|
||||
@@ -61,25 +69,33 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| In Death: Unchained | Quest | ✅ Works | |
|
||||
| Into The Radius 2 | Quest | ✅ Works | |
|
||||
| Job Simulator | Quest | ✅ Works | |
|
||||
| Journey of the Gods | Quest | ✅ Works | |
|
||||
| Jurassic World Aftermath Collection | Quest | ✅ Works | |
|
||||
| Keep Talking and Nobody Explodes | Quest | ✅ Works | |
|
||||
| Kill It With Fire VR | Quest | ✅ Works | |
|
||||
| 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 | |
|
||||
| Marvel's Iron Man VR | Quest | ✅ Works | |
|
||||
| Max Mustard | Quest | ✅ Works | |
|
||||
| Medieval Dynasty New Settlement | Quest | ✅ Works | |
|
||||
| Metro Awakening | Quest | ✅ Works | |
|
||||
| Mini Motor Racing X | Quest | ✅ Works | |
|
||||
| Mobile Suit Gundam: Silver Phantom | Quest | ✅ Works | |
|
||||
| Moss | Quest | ✅ Works | |
|
||||
| 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 | |
|
||||
| Ocean Rift | Quest | ✅ Works | |
|
||||
| Operation Wolf Returns: First Mission VR | Quest | ✅ Works | |
|
||||
| palazzo_santacruz | Quest | ✅ Works | |
|
||||
| Path of the Warrior | Quest | ✅ Works | |
|
||||
| Pistol Whip | Quest | ✅ Works | |
|
||||
@@ -97,9 +113,12 @@ 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 | |
|
||||
| SilentSlayer | Quest | ✅ Works | |
|
||||
| Smash Drums | Quest | ✅ Works | |
|
||||
| Sniper Elite VR | Quest | ✅ Works | |
|
||||
| Sniper Elite VR: Winter Warrior | Quest | ✅ Works | |
|
||||
| Space Pirate Trainer Quest | Quest | ✅ Works | |
|
||||
@@ -112,9 +131,12 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| SUPERHOT VR | Quest | ✅ Works | |
|
||||
| SynthRiders | Quest | ✅ Works | |
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| 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 | |
|
||||
| The Tale of Onogoro | Quest | ✅ Works | |
|
||||
| Time Crisis II VR (Test) | Quest | ✅ Works | |
|
||||
@@ -128,6 +150,8 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Vader Immortal: Episode II | Quest | ✅ Works | |
|
||||
| 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 | |
|
||||
@@ -141,26 +165,30 @@ 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. |
|
||||
| Phantom: Covert Ops | Quest | ⚠️ Works with issues | DLC/store button crashes (no Meta store). |
|
||||
| Pinball FX VR | Quest | ⚠️ Works with issues | Plays; mixed reality mode not working yet. |
|
||||
| Plezy | Quest | ⚠️ Works with issues | Some videos play with strong artifacts and wrong colours; the cause is unknown, possibly hardware video decoding. |
|
||||
| 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. |
|
||||
| GOLF+ | Quest | ❌ Doesn't run | Black screen. |
|
||||
| HITMAN 3 VR: Reloaded | Quest | ❌ Doesn't run | Vulkan driver crash (freedreno), even without Valve layers. |
|
||||
| Journey of the Gods | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
| Roblox | Quest | ❌ Doesn't run | Crashes on its first VR frame on the Frame. |
|
||||
| Shadow Point | Quest | ❌ Doesn't run | 32-bit only; the Frame has no AArch32. |
|
||||
@@ -0,0 +1,83 @@
|
||||
# Evaluating patch heuristics
|
||||
|
||||
`scripts/eval_heuristics.py` compares `engine.suggest(analysis, use_catalog=False)` with the bundled
|
||||
`catalog/games/*.yaml` entries. It does not merge personal or remote catalog recipes into the benchmark.
|
||||
It supports the original dumps command, or a precomputed `scripts/heuristics_snapshot.py` snapshot:
|
||||
|
||||
```sh
|
||||
.venv/bin/python scripts/eval_heuristics.py '<dumps folder>' --verbose
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
|
||||
.venv/bin/python scripts/eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
|
||||
```
|
||||
|
||||
Use a scratch `FRAMEPORT_HOME`, `FRAMEPORT_NO_CATALOG_UPDATE=1`, and an empty `FRAMEPORT_WINDOWS_DATA` for offline
|
||||
measurement. Snapshot mode reads no APKs or game data. Each snapshot read copies the source to a temporary file,
|
||||
then parses the copy, retrying incomplete JSON. To compare multiple splits against an actively growing snapshot,
|
||||
freeze one valid copy first and use that file for every run.
|
||||
|
||||
The default split is `dev`. `split_packages({package: analysis_engine})` sorts packages by SHA-256 within Unity,
|
||||
Unreal, and Other strata, then holds out the nearest 20% (half rounded up). Pass only packages with both an analysis
|
||||
and a catalog entry. The same input set always yields the same split regardless of iteration order; adding packages
|
||||
can move the cutoff. Very small strata can have no holdout. Unsupported entries participate in splitting.
|
||||
|
||||
## What is scored
|
||||
|
||||
Ground truth contains catalog choices and patch registry defaults, with no inherited heuristic adapter settings.
|
||||
Default expectations are determined per analysis, using each default patch's own detection in the same way as the
|
||||
engine's initial heuristic pass. Thus a conditional default that is not detected (e.g. LTW on a game without LTW)
|
||||
is not expected. Explicit catalog `overport_remove` and `frame_remove` entries remove expected defaults. An absent
|
||||
expected default is represented as `remove:<patch id>`; enabled defaults do not inflate patch scores.
|
||||
|
||||
Opt-in overport/frame/device/PC VR IDs, default removals, adapter values, device-file names, foveation parameters,
|
||||
and alternate-build patch IDs are compared. Alternate-build identities use `alt:<patch id>` so choosing one in a
|
||||
fallback is distinct from choosing it in the primary build. Device-file content is not compared. Adapter values
|
||||
must match: a wrong value contributes both an FP and an FN for `adapter.<key>`. Patch precision, recall and F1 are
|
||||
micro-averaged across games; an empty denominator is reported as zero. Exact-game accuracy also compares the
|
||||
unsupported status flag. Fields report matching games, including empty-field matches.
|
||||
|
||||
`RUNTIME_ONLY` lists choices excluded from the headline: `use_alt`, selected runtime adapter settings, Lepton
|
||||
environment variables, and config-sync settings. They have their own exact, field and patch metrics. Catalog
|
||||
entries with status `unsupported` are excluded from both headline and runtime supported-game metrics, and have
|
||||
separate metrics and per-game diffs. Converted analyses are included and marked. JSON contains all metrics per
|
||||
engine, all game signatures, split assignments, and missing/extra decisions.
|
||||
|
||||
## Dated score: catalog gaps vs. detection errors
|
||||
|
||||
The strict score treats the catalog as complete, but older entries were verified before many patches existed, and
|
||||
a catalog recipe inherits every heuristic adapter setting and device patch (except `device.text_input_window`)
|
||||
without listing them (`engine.suggest`), so the verified build had those. The dated score (`headline_dated`, printed
|
||||
under "Dated") sets aside, on both sides:
|
||||
|
||||
- patches first committed after the entry's `verified.date` (the verifier couldn't have chosen them), and
|
||||
- inherited adapter/device picks (`inherited_decisions`).
|
||||
|
||||
What remains are real disagreements: an extra on an entry verified after the patch existed, or a catalog choice
|
||||
the heuristics don't make. Gaps are counted per patch ("Catalog gaps by patch"). The dated score needs the intro
|
||||
date cache that `--fit-report` writes; without it only inherited picks are set aside.
|
||||
|
||||
Before tuning a rule to remove an "extra", check which kind it is: a gap on an old entry is a candidate for the
|
||||
catalog (fit report), not a detection error. Before adding a patch to a working entry, weigh its benefit (does it
|
||||
fix something the game hits?) against a rebuild prompt for everyone who installed it; adapter settings never need
|
||||
adding (inherited). Results 2026-10-15: strict 5/65 exact, F1 73.8%; dated 39/65, F1 88.6% (dev 90.5%, holdout
|
||||
79.5%). Remaining misses are one-game fixes found at runtime (e.g. Vader's gl_error_clear, BlazeRush's GL settings).
|
||||
|
||||
An evaluation writes JSON even when its headline has differences, then exits 1 for compatibility with the original
|
||||
script. It exits 0 when exact, or after producing a fit report.
|
||||
|
||||
## New patch fit
|
||||
|
||||
The fit report covers all catalog entries, independently of `--split`. Candidates come from the final heuristic
|
||||
recipe, preserving platform, flat-app, dependency, and 32-bit normalization rules. A candidate must also be opt-in,
|
||||
applicable and recommended by its own `detect()`. Entries already listing or explicitly removing the patch are
|
||||
excluded; a listed adapter key is excluded regardless of its value. This report proposes additions, not value edits.
|
||||
|
||||
Each candidate includes its reason, experimental/strict flags, first local git introduction date, catalog entry
|
||||
date (`verified.date`; `updated` only without one, since it also moves for unrelated edits), and whether the
|
||||
patch is newer. Unknown dates are marked unknown. Warnings
|
||||
come from the full catalog recipe plus that one candidate; they may include pre-existing conflicts. Missing-analysis
|
||||
entries appear as metadata-only rows. Intro dates, including unknown results, are cached in
|
||||
`patch-intro-dates.json` beside the snapshot (or dumps folder); subsequent runs only query uncached patch IDs.
|
||||
|
||||
`tests/test_eval_heuristics.py` uses synthetic data. The opt-in real-dump test in `tests/test_games.py` ratchets the
|
||||
supported all-split exact ratio and patch F1 against measured, rounded-down baseline constants. Machine-local
|
||||
`.eval-home/` and `.eval-results/` scratch outputs are ignored by git.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 | 85 |
|
||||
| [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 | 88 |
|
||||
| [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)
|
||||
@@ -904,7 +904,7 @@ Stops a gray or frozen screen in Unity games that turn on anti-aliasing while ru
|
||||
- `frame.unity_gl_shim`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity GLES game that may switch to MSAA at runtime: hide multisampled render-to-texture, which crashes the Frame's GL driver (for example The Room VR).
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 15 more.
|
||||
- **Catalog games (23):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 15 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unreal-msrtt-crash` (fatal): The game's graphics crashed a few seconds after start.
|
||||
- **Source:** [patches/frame/vrapi_bridge.py](../src/frameport/patches/frame/vrapi_bridge.py)
|
||||
@@ -973,7 +973,7 @@ Keeps a smoothing setting off that the game turns on itself and that can freeze
|
||||
- `frame.unity_runtime_msaa_off` · revision 2
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** GLES Unity game whose OVRManager turns 4x MSAA on at runtime: multisampled render-to-texture can hang the Frame's GPU.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, Clockwork, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight and 20 more.
|
||||
- **Catalog games (28):** Audica, BattleSisters, Beat Saber, COMPOUND, Cosmodread, Down the Rabbit Hole, Drunkn Bar Fight, End Space and 20 more.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `unity-render-crash` (fatal): The game's graphics crashed, so the picture freezes or stays gray.
|
||||
- `unity-runtime-msaa` (info): The game turns MSAA on at runtime, which can hang the Frame's GPU.
|
||||
@@ -989,7 +989,7 @@ Lets you type into this app's text fields on the Frame (they'd close at once).
|
||||
- `frame.unity_text_input` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity app with text fields (…): they close at once on the Frame because it has no system keyboard.
|
||||
- **Catalog games (85):** AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked, BattleSisters and 77 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 -> false and isKeyboardUsingEvents (TMP) / InPlaceEditing (uGUI) -> true in libil2cpp.so, found per game with Cpp2IL (downloaded on first use), so fields stay selected and take key presses: Steam's keyboard (with "Show the app window") or Type on Frame from the PC.
|
||||
@@ -1000,6 +1000,7 @@ Lets Unity 6 games with many native libraries start when they run out of thread
|
||||
|
||||
- `frame.unity_tls_key_pool` · also for 2D apps
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** Unity 6 with Firebase Firestore and Gecko: these native runtimes can exhaust Android's 128 thread keys alongside the Frame's graphics and XR libraries. Pool Unity's keys to leave room for them.
|
||||
- **Offered (off):** Unity 6 with …: turn on if the game aborts at start with "cannot create thread specific key" (for example Wander).
|
||||
- **Catalog games (1):** Wander.
|
||||
- **Suggested by launch-test signatures:**
|
||||
@@ -1091,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)
|
||||
@@ -1157,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)
|
||||
@@ -1188,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.
|
||||
@@ -1294,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)
|
||||
@@ -1632,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.
|
||||
@@ -1661,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)
|
||||
@@ -1780,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).
|
||||
@@ -1929,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
|
||||
|
||||
@@ -2271,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/ -> 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/<package>/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).
|
||||
@@ -2334,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 (88):** AFFECTED: The Manor, AgeOfJoy, AllInOneSports, Ancient_Dungeon, Arkio, Audica, AugmentedEmpire, Battlemarked and 80 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".
|
||||
|
||||
@@ -24,6 +24,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| A Unity OpenXR game closes right after the OpenXR instance is created: `F VrApiLoader: vrapi_SetPropertyInt was called before vrapi_Initialize()!`, SIGABRT on UnityMain (GitHub #62, Jurassic World Aftermath) | the APK still ships Meta's VrApi loader; OVRPlugin (1.89, on OpenXR) calls it without starting VrApi and Meta's loader aborts. The VrApi bridge can't replace it (39 of the loader's 114 functions: OVRPlugin wouldn't link) | `frame.vrapi_stub` (same exports, each returns 0); triage `vrapi-before-init` |
|
||||
| A VrApi game exits at once: `UnsatisfiedLinkError … dlopen failed: cannot locate symbol "vrapi_PollEvent"` (GitHub #57, BlazeRush) | the engine imports a VrApi function the VrApi bridge didn't implement | the bridge has `vrapi_PollEvent`, `RecenterPose`, `SetDisplayRefreshRate`, `GetSystemPropertyFloatArray` (rebuild the game); the static check "VrApi functions resolvable" names any other missing one; triage `vrapi-symbol-missing` |
|
||||
| A Team Beef port (Lambda1VR, RTCWQuest, …) exits at once: `UnsatisfiedLinkError: dlopen failed: library "libopenxr_loader_valve.so" not found` (GitHub #59) | TBXR loads `openxr_loader_<Build.MANUFACTURER>` (Lepton: valve) and takes the Meta path only when `strstr(OPENXR_HMD, "meta")` matches, else Pico's | `frame.tbxr_vendor` (empty `libopenxr_loader_valve.so` + the "meta" literal → "alve"). Game data (`xash/`, …) goes to /sdcard: upload it with the Files tab to the game's storage. Black eyes with `TBXR: Incomplete frame buffer object: GL_FRAMEBUFFER_INCOMPLETE_MULTISAMPLE`: TBXR always uses multisampled render-to-texture (even `--msaa 1`); `frame.tbxr_vendor` links the GL shim, which hands it single-sampled versions (log `GL shim: multisampled render-to-texture … drawn single-sampled`) |
|
||||
| A Quest game renders, then logs `Entitlement check failed` / `ovrPlatformInitialize_NotEntitled` (often with retries) and quits, e.g. `SIG: 9` after `[Photon] Disconnected` (GitHub #322, Deadly Delivery) | The game's Meta licence check (entitlement) needs Meta's online login, which the Frame doesn't have; online-only games quit (triage `meta-entitlement`) | None: FramePort doesn't get around entitlement checks. Offline games that log it and play on are fine |
|
||||
| Black screen after VR starts; `Asset status error (code 100)` in `DLC_Manager:AssetStatusCallback` (GitHub #203, e.g. PixelRipped 1995) | Meta asset-file status fails after entitlement succeeds; the log does not identify the native call or asset ID | Try `frame.asset_files_stub` (opt-in, triage-only): native/langpack answers GetList with an empty successful list and StatusById with installed status, an empty path and the requested ID. No DLC files are supplied; download requests retain their existing behavior. Disable with `FRAMEPORT_ASSET_FILES_STUB=0`. Host-tested only; whether this fixes startup needs a game/device test |
|
||||
| Missing sound or extra content in a game whose OBB folder has separate `*.pak` files (GitHub #61, Star Wars: Tales from the Galaxy's Edge: seasons + Wwise banks) | the game asks Meta's platform for these asset files (`ovr_AssetFile_GetList` / `DownloadById`); OVRPort's loader doesn't list them | copy every content/sound-bank PAK and use `frame.asset_files` (native/langpack lists the data's `*.pak` as installed `default` assets, IAP `free`, and answers DownloadById at once with the path; off: env FRAMEPORT_ASSET_FILES=0). Tales also needs the splash and depth-size fixes below |
|
||||
| A SideQuest backup has `apk/` and a sibling `obb/` with content/sound-bank PAKs, but the Frame only has the main OBB (GitHub #61, Tales from the Galaxy's Edge) | older scans treated that layout as loose expansion files and selected only the named OBBs; the asset-file patch then found no PAKs | the scanner chooses the OBB folder by version first, then keeps the whole sibling data folder when all its expansion files are selected for this APK; app updates repair affected library entries and refresh asset/language-pack detection. Reinstall to copy the missing files, or scan the backup again first. Folders with other packages or unselected OBB versions still send only the selected OBBs |
|
||||
@@ -62,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 |
|
||||
@@ -173,7 +176,10 @@ 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 |
|
||||
| Unity MR game rejects every room / its "use guardian" fallback does nothing; `ovrpBoundary_Outer is not supported`, `IndexOutOfRangeException` in a guardian check (e.g. Demeter's `RemoveObjectNotInGuardian`) | OVRPlugin calls the Frame's 1x1 m STAGE rect a configured boundary but can't give its outline | `adapter.scene_emul=1`: FrameBridge then answers the app's STAGE `xrGetReferenceSpaceBoundsRect` with `XR_SPACE_BOUNDS_UNAVAILABLE` (log `scene: STAGE bounds hidden`). Demeter also needs a floor of at least 2.5 x 2.5 m, as on a Quest |
|
||||
| MR uses an empty/simple room; FrameBridge `scene: capture requested -> guardian box` / `using guardian room`, `room from guardian`, or fallback box dimensions (`mr-no-room`) | No saved room, so FrameBridge uses its guardian box | Save a room in Frame → Room. A real SteamVR boundary supplies walls; Valve's default circle is refused. Import a scanner-built room for furniture/mesh; headset capture is not implemented yet |
|
||||
| `Unknown proc addr: xrDiscoverSpacesMETA` or MRUK `NoRoomsFound` (`mr-discovery-unsupported`) | Scene discovery is unavailable or no room was discovered | Enable `adapter.scene_emul`, rebuild/reinstall to get the updated extension shim, then save a room. All games declaring USE_SCENE or USE_ANCHOR_API now get this suggestion |
|
||||
| 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 |
|
||||
|
||||
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 668 KiB After Width: | Height: | Size: 663 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,49 @@
|
||||
# Phase 0: reading Valve's RoomView 3D mesh (dev Frame, SteamOS 0.4.5 build 20261007, 2026-10-16)
|
||||
|
||||
Result: **a third-party OpenVR client can read the passthrough depth mesh, and it carries enough of the room for
|
||||
a scan** (floor, table-height surfaces, orthogonal walls; the ceiling only weakly).
|
||||
|
||||
## Access
|
||||
- Background OpenVR app (`VR_InitInternal2(..., VRApplication_Background)`, never starts SteamVR) through
|
||||
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`.
|
||||
- `FnTable:IVRBlockQueue_005`: `Connect("/xrservice/roomview3d/meshData")` and `.../meshLayout` work (created by
|
||||
Valve's cv driver; no exclusivity seen next to the compositor's own reader). Read with
|
||||
`WaitAndAcquireReadOnlyBlock(queue, &block, &buffer, EBlockQueueReadType_New, timeout_ms)` + `ReleaseReadOnlyBlock`.
|
||||
- Metadata through the C++ interface `IVRPaths_002` (vtable slot 0 `ReadPathBatch(root, PathRead_t*, n)`, slot 2
|
||||
`StringToHandle(&handle, path)`; `FnTable:IVRPaths_001` doesn't exist), root = the **block** handle, except
|
||||
`/meshData/meshIsUpToDate` which is a property of the **queue** handle:
|
||||
|
||||
| path | tag | meaning |
|
||||
|---|---|---|
|
||||
| `/meshLayout/layoutId` | 3 (u64) | id of the index layout (static while the mesh resolution stays) |
|
||||
| `/meshLayout/vertexCount` | 2 (i32) | 16896 = 132 x 128 |
|
||||
| `/meshLayout/indexCount` | 2 | 99822 (u32 triangle indices into the grid, row width 132) |
|
||||
| `/meshData/meshId` | 3 | frame counter (~30 per second while passthrough runs) |
|
||||
| `/meshData/layoutId` | 3 | matches the layout block |
|
||||
| `/meshData/elementCount` | 2 | floats in the data block (vertexCount x 3) |
|
||||
| `/meshData/rawFromMeshPose` | 20 (HmdMatrix34) | mesh -> SteamVR raw tracking space |
|
||||
| `/meshData/posePredictionBaseTime` | 7 (f64) | seconds (system uptime clock) |
|
||||
| `/meshData/meshIsUpToDate` (queue) | 4 (bool) | false in standby / headset off |
|
||||
|
||||
- STAGE (= standing universe) = `IVRSystem_023` vtable slot 13 `GetRawZeroPoseToStandingAbsoluteTrackingPose()` x
|
||||
rawFromMeshPose. Check: the mesh origin is 6.7 cm from `GetDeviceToAbsoluteTrackingPose(Standing)`'s head
|
||||
position in every frame (camera vs head centre). **The raw->standing transform changes during a session** (its
|
||||
height went 1.60 -> 1.07 m while recording: Valve's floor estimate) -> capture raw poses and convert all frames
|
||||
with the transform at the end of the capture.
|
||||
|
||||
## The mesh
|
||||
- A fixed fan of view directions, +Z forward, rows top->bottom (row 0 = up in the image, -Y), ~126 deg across the
|
||||
corners (corner directions ~(+-0.67, +-0.66, 0.34)); one depth per grid point. Not head-locked in world terms:
|
||||
its pose is the passthrough camera pose at capture.
|
||||
- Range: reliable ~0.3-4 m; far values are inpainted (up to ~10 m) -> drop > 4 m. Strongly smoothed (soft edges,
|
||||
small objects lost); locally planar: plane-fit residual of 12x12 patches median 5-17 mm.
|
||||
- First frames after tracking starts can have NaN poses -> skip non-finite frames.
|
||||
- Fused normals (60 s of looking around, ~3 frames/s used): floor clearly at y ~= 0, a table-height surface at
|
||||
0.7 m, a lower one at 0.5 m; vertical surfaces in two orientation clusters ~85 deg apart (orthogonal walls), wall
|
||||
offsets readable; ceiling: few down-facing points (people rarely look straight up; prompt for it).
|
||||
- Data rate: 16896 x 12 B vertices + 99822 x 4 B indices per frame (the dump repeats the indices per frame).
|
||||
|
||||
## Consequences for the design
|
||||
- Capture = stdlib Python on the Frame (ctypes), as above; the C scanner works offline on the dump.
|
||||
- Scanner input quality: smooth, noisy at depth discontinuities; walls/floor via normals + RANSAC are robust;
|
||||
furniture only as coarse boxes; ceiling needs a guided "look up" step, else the default height.
|
||||
@@ -0,0 +1,82 @@
|
||||
# FramePort room files (mixed reality)
|
||||
|
||||
Quest MR games read a room model (floor, ceiling, walls, furniture, a global mesh) through Meta's scene APIs. The
|
||||
Steam Frame has no such data for apps (see docs/FRAME_RUNTIME.md "Room data"), so FramePort scans the room itself
|
||||
(`fp_roomscan`) and FrameBridge (`native/adapter/scene_emu.c`) serves it to games. This file is the contract between
|
||||
the scanner, the Frame agent, the PC app and FrameBridge.
|
||||
|
||||
## Coordinates
|
||||
- Everything is in **STAGE space** = SteamVR's standing universe = OpenXR `XR_REFERENCE_SPACE_TYPE_STAGE`:
|
||||
metres, right-handed, +Y up, floor at y = 0, -Z = the universe's forward.
|
||||
- A room belongs to one chaperone universe (`universe` line). The Frame sometimes starts a new universe without
|
||||
relocalizing; then the stored room may be misaligned (the agent reports `aligned: false`, nothing breaks).
|
||||
|
||||
## room.txt (UTF-8 text, one record per line, `#` starts a comment, fields separated by spaces)
|
||||
```
|
||||
frameport-room 1 # magic + format version (required, first line)
|
||||
universe <decimal u64> # chaperone universe id the room was scanned in (0 = unknown)
|
||||
source scan|boundary|box|manual # how it was made
|
||||
time <unix seconds>
|
||||
room <uuid> # the room anchor
|
||||
floor <uuid> <y> # floor height (normally 0)
|
||||
ceiling <uuid> <y> # ceiling height
|
||||
outline <n> x0 z0 x1 z1 ... x(n-1) z(n-1)
|
||||
# floor polygon, n >= 3, not closed, POSITIVE signed area:
|
||||
# sum(x[i]*z[i+1] - x[i+1]*z[i]) > 0 (turning from +X towards +Z; seen from
|
||||
# above with -Z forward that is clockwise). Readers reverse a negative outline.
|
||||
wall <uuid> x0 z0 x1 z1 <y0> <y1> [LABEL]
|
||||
# one wall face from (x0,z0) to (x1,z1), bottom y0, top y1.
|
||||
# Its normal points INTO the room: walls follow the outline's order, and the
|
||||
# inward normal is (-dz, dx)/|d| for d = (x1-x0, z1-z0).
|
||||
# LABEL (optional, default WALL_FACE): INVISIBLE_WALL_FACE for an edge the
|
||||
# scanner closed without seeing a wall (open side of a room, doorway gap).
|
||||
volume <uuid> <LABEL> cx cz top_y yaw_deg w d h
|
||||
# furniture box: top-face centre (cx, top_y, cz), yaw about +Y in degrees
|
||||
# (0 = local X along world X), width w along local X, depth d along the
|
||||
# horizontal local axis perpendicular to it, height h downwards from the top
|
||||
plane <uuid> <LABEL> px py pz qx qy qz qw w h
|
||||
# extra 2D plane (DOOR_FRAME, WINDOW_FRAME, WALL_ART, ...): pose of its centre,
|
||||
# Meta convention (bounds in local XY, local +Z = the plane's normal)
|
||||
mesh <uuid> <file name> <vertex count> <index count>
|
||||
# global mesh sidecar in the same folder (GLOBAL_MESH entity)
|
||||
```
|
||||
- `<uuid>` = 32 lowercase hex digits (no dashes). New ones per scan; FrameBridge uses them as the XrUuidEXT bytes.
|
||||
- Labels are Meta's semantic label strings: FLOOR, CEILING, WALL_FACE, INVISIBLE_WALL_FACE, TABLE, COUCH, BED,
|
||||
STORAGE, SCREEN, LAMP, PLANT, DOOR_FRAME, WINDOW_FRAME, WALL_ART, OTHER, GLOBAL_MESH.
|
||||
- Readers ignore unknown record types (forward compatibility) and reject a file whose first line isn't
|
||||
`frameport-room <version>` with a version they know. Missing ceiling -> 2.5 m above the floor. Missing walls ->
|
||||
walls from the outline edges. Missing outline -> the bounding rectangle of the walls.
|
||||
|
||||
### How FrameBridge maps records to Meta entities (scene_emu.c)
|
||||
- ROOM: ROOM_LAYOUT (floor, ceiling, walls in file order) + SPACE_CONTAINER (every other entity) + LOCATABLE/STORABLE.
|
||||
- floor/ceiling: pose at the outline centroid at height y; local +Z = +Y (floor) / -Y (ceiling);
|
||||
BOUNDED_2D = the outline's bounding rect in local XY; BOUNDARY_2D = the outline polygon in local XY.
|
||||
- wall: pose at the segment midpoint, height (y0+y1)/2; local +X along the wall (x0,z0)->(x1,z1), local +Y up,
|
||||
local +Z = inward normal; BOUNDED_2D = length x (y1-y0) centred.
|
||||
- volume: pose at the top-face centre, rotation = yaw about +Y then local +Z pointing up (+Y); BOUNDED_3D offset
|
||||
(-w/2, -d/2, -h), extent (w, d, h) (Meta: volumes hang below their anchor); BOUNDED_2D = the top face (w x d).
|
||||
- plane: pose as given; BOUNDED_2D = w x h centred.
|
||||
- mesh: entity at the identity pose labelled GLOBAL_MESH with TRIANGLE_MESH (XR_META_spatial_entity_mesh).
|
||||
|
||||
## room-mesh.bin (little-endian)
|
||||
```
|
||||
char[4] "FPRM", u32 version = 1, u32 vertex_count, u32 index_count,
|
||||
float32 xyz[vertex_count], u32 indices[index_count] (triangles; counter-clockwise seen from the room's inside)
|
||||
```
|
||||
Coordinates in STAGE space. Kept below 200k triangles (decimated).
|
||||
|
||||
## Where the files live
|
||||
- Frame: `~/.local/share/frameport/rooms/<universe>/room.txt` + `room-mesh.bin` (+ `scan.log`).
|
||||
`~/.local/share/frameport/rooms/current` = symlink to the newest room's folder.
|
||||
- Per game: launch.sh copies the current universe's room (else `current`) to `<app_dir>/room.txt` (+ mesh) before
|
||||
Lepton starts, next to `settings.conf`; FrameBridge reads `dirname($FRAMEBRIDGE_CONFIG)/room.txt`. No room ->
|
||||
the old guardian box.
|
||||
- A game's own saved anchors: `/sdcard/Android/data/<pkg>/files/framebridge-anchors.txt`
|
||||
(`anchor <uuid> px py pz qx qy qz qw` per line, STAGE space), written by FrameBridge on save/erase.
|
||||
|
||||
## Scan dumps (*.fprs) — recorded RoomView 3D meshes, input of `fp_roomscan --offline`
|
||||
```
|
||||
char[4] "FPRS", u32 version = 1, then frames until EOF:
|
||||
f64 time_s, float32 pose[7] (px py pz qx qy qz qw: mesh -> STAGE), u32 vertex_count, u32 index_count,
|
||||
float32 xyz[vertex_count] (mesh space), u32 indices[index_count]
|
||||
```
|
||||
@@ -0,0 +1,216 @@
|
||||
# Scene emulation stream
|
||||
|
||||
Implemented against `ROOM_FORMAT.md` without changing the contract, patch revisions, agent version or analysis version.
|
||||
No Frame access or external downloads were used. Headset behavior still needs maintainer verification.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `scene_emu.c` owns entities, component APIs, pose transforms, bounded query snapshots and the event queue.
|
||||
`scene_room.c` parses room files and meshes; `scene_meta.c` implements META discovery and batch persistence;
|
||||
`scene_anchors.c` implements per-game saved anchors. All are included by the adapter, with no new dependencies.
|
||||
- At session creation, read `dirname(FRAMEBRIDGE_CONFIG)/room.txt`. Invalid magic/version, unusable geometry or
|
||||
absent files retain the original guardian box, settings and seven deterministic UUIDs. Unknown records are
|
||||
ignored; malformed records are logged and skipped. Missing ceiling, outline and walls follow the contract.
|
||||
- Floor/ceiling use the polygon area centroid and real local-XY polygons. Walls retain file order, with local +X
|
||||
along their segment, +Y up and +Z inward. Furniture poses use yaw then +Z up; volume bounds extend downwards.
|
||||
The room container includes every other room-file entity, excluding independently created application anchors.
|
||||
- Geometry exposes BOUNDED_2D, BOUNDED_3D, semantic labels and TRIANGLE_MESH as appropriate. OpenXR has no separate
|
||||
`BOUNDARY_2D` component enum: `xrGetSpaceBoundary2DFB` belongs to BOUNDED_2D. Labels preserve comma-separated strings.
|
||||
All variable-sized outputs support count-only and insufficient-capacity calls, including independent mesh arrays.
|
||||
- Limits: 2,048 entities including application anchors, 1,024 outline points, 255 label bytes, 64 KiB record lines,
|
||||
one global mesh, 600,000 vertices/indices (200,000 triangles) and 16 MiB mesh payload. Sidecar filenames cannot
|
||||
escape the config directory. Magic, version, counts, exact byte size, finite coordinates and indices are checked.
|
||||
- FB queries and META discovery snapshot results under the mutation lock, before queuing available/complete events.
|
||||
META component/UUID filters intersect; unrecognized filters impose no restriction. FB max-result limits are honored.
|
||||
There are 64 retained query snapshots and 63 queued events; calls that cannot fit their events return
|
||||
`XR_ERROR_LIMIT_REACHED`. Geometry readers share the mutation lock with persistence.
|
||||
- Application anchors use kernel randomness (`getrandom`, then `/dev/urandom`), never index-derived UUIDs.
|
||||
Saved poses are in STAGE space. FB/META saves write a private temporary file, flush/fsync, then rename to
|
||||
`/sdcard/Android/data/<package>/files/framebridge-anchors.txt`. A process suffix such as `:game` is stripped.
|
||||
Erase removes application anchors from disk and the discoverable in-memory set. Failed writes report failure
|
||||
through completion events and restore memory. Room-file entities remain owned by the scan: save/erase are no-ops
|
||||
for those entities. Storage is local; there is no cloud synchronization.
|
||||
- A new session reloads the room and saved anchors. Fake handles fit both adapter ABIs and include a session
|
||||
generation; erased/stale fake spaces are never passed to SteamVR. Session generations wrap after 256 sessions.
|
||||
- Capture completes immediately and logs exactly `scene: capture requested -> scanned room` for a usable room file,
|
||||
or `scene: capture requested -> guardian box` for the fallback. The former covers all file sources, including manual
|
||||
and boundary files. The load summary preserves the actual `source` and `universe`.
|
||||
|
||||
The cached `openxr-f2448a8797` headers already contain discovery, persistence and mesh declarations and enum values.
|
||||
No local structure/enum substitutes were necessary. Both META extensions are added to the existing conditional
|
||||
extension advertisement/instance filtering. Their system capability structures report support when emulation is active.
|
||||
Passthrough and unrelated adapter settings are unchanged.
|
||||
|
||||
## Routing and integration
|
||||
|
||||
All of the following use `scene_emulation` through **both** the adapter's normal `xrGetInstanceProcAddr` and
|
||||
`framebridge_extension_proc`, reached through xrshim's existing name-agnostic lookup:
|
||||
|
||||
```
|
||||
xrQuerySpacesFB xrRetrieveSpaceQueryResultsFB
|
||||
xrGetSpaceUuidFB xrEnumerateSpaceSupportedComponentsFB
|
||||
xrSetSpaceComponentStatusFB xrGetSpaceComponentStatusFB
|
||||
xrGetSpaceContainerFB xrGetSpaceRoomLayoutFB
|
||||
xrGetSpaceBoundingBox2DFB xrGetSpaceBoundingBox3DFB
|
||||
xrGetSpaceBoundary2DFB xrGetSpaceSemanticLabelsFB
|
||||
xrRequestSceneCaptureFB xrCreateSpatialAnchorFB
|
||||
xrSaveSpaceFB xrEraseSpaceFB
|
||||
xrGetSpaceTriangleMeshMETA xrDiscoverSpacesMETA
|
||||
xrRetrieveSpaceDiscoveryResultsMETA xrSaveSpacesMETA
|
||||
xrEraseSpacesMETA
|
||||
```
|
||||
|
||||
The shim additionally routes `xrPollEvent`, `xrGetSystemProperties`, `xrLocateSpace`, `xrLocateSpaces`,
|
||||
`xrLocateSpacesKHR` and `xrDestroySpace` to the adapter while scene emulation is active. Other shim names continue to
|
||||
OVRPort, preserving existing haptics/render-model handling. The real shim is host-tested against a dispatcher that
|
||||
refuses every scene function; every emulated scene lookup succeeds and an actual discovery/retrieve round trip runs.
|
||||
|
||||
**Plumb-stream requirement:** ensure `scene_emul=1` also installs xrshim and rewrites OVRPlugin's loader string to it.
|
||||
The pre-existing adapter patch only did this for other shim settings. These files are outside this stream's ownership.
|
||||
Delivery of room files before Lepton starts and detection of universe misalignment also belong to plumb. FrameBridge
|
||||
serves the supplied coordinates; it does not silently transform a room belonging to another universe.
|
||||
|
||||
No contract changes are needed. Whether each MRUK version accepts a room without further permissions, how it chooses
|
||||
volume versus mesh geometry, and whether its visible placement matches the scan need headset tests. The existing
|
||||
single-active-session architecture is retained; simultaneous independent sessions are not implemented.
|
||||
|
||||
## Offline verification
|
||||
|
||||
- 25 host tests passed, compiling the actual emulation and shim. Coverage includes malformed records, unknown
|
||||
records, mesh corruption/path/cap checks, derived geometry, 300 walls, poses/normals, all buffer sizing idioms,
|
||||
discovery event order/filters/backpressure, UUID query/discovery of saved anchors, new sessions/processes,
|
||||
FB/META save/erase and atomic-write failure rollback.
|
||||
- ASan/UBSan: scanned room, generated walls and box cases passed. LeakSanitizer was disabled because this sandbox
|
||||
prohibits ptrace; its default exit failed with that environmental limitation.
|
||||
- `python native/build.py --only adapter,xrshim` succeeded from cached tools. Two final builds were byte-identical,
|
||||
including `SHA256SUMS`. The shim was rebuilt and is byte-identical to its previous artifact (the routing lives in
|
||||
the adapter). Artifact content changes are the two adapter binaries and two checksum lines.
|
||||
- Full pytest was attempted: **1,771 passed, 40 skipped, 11 failed, 16 errors**. All 27 failing/erroring tests create
|
||||
local HTTP sockets, which the sandbox prohibits (`PermissionError: [Errno 1] Operation not permitted`). They are
|
||||
in `test_deeplink`, `test_devkit`, `test_install_links_extra`, `test_livestream`, `test_setup_url` and `test_ui_state`.
|
||||
With exactly those 27 tests deselected, and the additional real-shim test included: **1,772 passed, 40 skipped,
|
||||
27 deselected**. An unrestricted maintainer run of the whole suite is still required.
|
||||
- The worktree had no usable venv. Verification reused the existing local dependencies through an ignored venv
|
||||
`.pth`, with `PYTHONPATH="$PWD/src"`, `UV_CACHE_DIR=/tmp/frameport-uv-cache`, `UV_OFFLINE=true` and
|
||||
`uv run --no-sync pytest`. No dependencies were downloaded. The build cache was made a local directory with
|
||||
symlinks to cached tools/headers so generated shader files stayed inside this writable worktree.
|
||||
|
||||
Adapter SHA-256:
|
||||
|
||||
```
|
||||
arm64-v8a: a90c8d58904857bc198a48fd7a411b7807f68f14fa58fd268c57e2c2ca87e389
|
||||
armeabi-v7a: 92daecaf5b86e1857ee8a61b76598e5645f8b427e5f373067c1f87bf8ff99d07
|
||||
```
|
||||
|
||||
## Maintainer device tests
|
||||
|
||||
These commands are a handoff, not commands run by this agent. Use an isolated populated `FRAMEPORT_HOME`, cached
|
||||
build tools, a paired Frame and legally obtained game sources. Stop games before changing their files. Back up the
|
||||
existing recipe, APK, room files and saved anchors first. Integrate plumb before testing normal room delivery.
|
||||
|
||||
On the PC, from the integrated checkout (replace `frame.local` if necessary):
|
||||
|
||||
```sh
|
||||
export FRAMEPORT_HOME="$PWD/.home"
|
||||
export FRAMEPORT_NO_UPDATE_CHECK=1 FRAMEPORT_NO_CATALOG_UPDATE=1
|
||||
FRAME=steamos@frame.local
|
||||
PKG=com.Novelab.Demeter
|
||||
uv run frameport recipe "$PKG" --set scene_emul=1
|
||||
uv run frameport install "$PKG" --frame "$FRAME" --force
|
||||
uv run frameport test "$PKG" --frame "$FRAME" --seconds 45
|
||||
ssh "$FRAME"
|
||||
```
|
||||
|
||||
In that Frame shell, find the deployment's actual base (also supports a game moved to another drive):
|
||||
|
||||
```sh
|
||||
PKG=com.Novelab.Demeter
|
||||
BASE=$(python3 -c 'import json,os,sys; p=os.path.expanduser("~/Applications/quest-frame/"+sys.argv[1]+"/deployment.json"); print(json.load(open(p)).get("base",os.path.dirname(p)))' "$PKG")
|
||||
grep -E 'scene:|extension shim:.*(Spaces|TriangleMesh|BoundingBox3D|PollEvent)' "$BASE/launch.log"
|
||||
```
|
||||
|
||||
With a current scan delivered beside `settings.conf`, expect:
|
||||
|
||||
```
|
||||
scene: room from scan file (N walls, M volumes, P planes, K mesh triangles, universe U)
|
||||
scene: capture requested -> scanned room
|
||||
```
|
||||
|
||||
The capture line appears only if the game requests capture. A source made from a boundary/manual file names that
|
||||
source in the summary. The shim logs each requested function, for example
|
||||
`extension shim: xrDiscoverSpacesMETA -> FrameBridge`. There must be no `Unknown proc addr` for a scene function
|
||||
that the game obtains through the shim. Repeat the rebuild/install/headset check for `com.gritGames.Nope` and
|
||||
`com.zenstudios.PFXVRQuest` with `scene_emul=1`; these exercise newer MRUK discovery/persistence paths.
|
||||
|
||||
Wear the headset and launch from Steam. Enter each game's MR mode and request room setup if offered. Verify the
|
||||
floor polygon, every wall, ceiling and furniture match the scan; tables sit below their top-face anchors; doors and
|
||||
windows have the correct orientation. Headless startup alone cannot prove tracking or visual placement.
|
||||
|
||||
Fallback/undo test, while stopped: preserve `$BASE/room.txt` and `$BASE/room-mesh.bin` under a separate backup
|
||||
folder, then disable plumb's room delivery for this run or move the source room out of `rooms/current` as well.
|
||||
Without that precaution launch.sh will restore the file. Launch again; expect the original box summary and
|
||||
`scene: capture requested -> guardian box`. Test `scene_width=3 scene_depth=4 scene_height=2.7` with the existing
|
||||
`frameport settings` command and expect `scene: room 3.00 x 4.00 x 2.70 m`. Restore the saved files, recipe/settings
|
||||
and normal room delivery afterward. Do not delete a real game's saved anchors to test the fallback.
|
||||
|
||||
### Android persistence probe (independent of a game's UI)
|
||||
|
||||
This probe uses the **real scene code with the fake runtime from the host harness**. It checks Android storage,
|
||||
process-name handling and both persistence APIs; the headset tests above check the real SteamVR integration.
|
||||
The following cross-compilation command was verified offline:
|
||||
|
||||
```sh
|
||||
native/.cache/ndk-27.2.12479018/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android29-clang \
|
||||
-std=gnu11 -O2 -Wall -Wextra -Werror -Wno-unused-function \
|
||||
-I native/.cache/openxr-f2448a8797 -I native/adapter tests/native/scene_emu_test.c \
|
||||
-lm -o /tmp/frameport-scene-test-android
|
||||
scp /tmp/frameport-scene-test-android "$FRAME":/tmp/
|
||||
```
|
||||
|
||||
Keep a freshly installed game running so its Lepton container exists, then run in the Frame shell:
|
||||
|
||||
```sh
|
||||
APPID=$(python3 -c 'import json,os,sys; print(json.load(open(os.path.expanduser("~/Applications/quest-frame/"+sys.argv[1]+"/deployment.json")))["appid"])' "$PKG")
|
||||
CONTAINER=lepton-steamlaunch-$APPID
|
||||
podman cp /tmp/frameport-scene-test-android "$CONTAINER":/data/local/tmp/frameport-scene-test
|
||||
podman exec "$CONTAINER" test ! -e /sdcard/Android/data/org.frameport.scenetest
|
||||
# Continue only if that test succeeds: use a fresh test-only package directory.
|
||||
podman exec "$CONTAINER" mkdir -p /sdcard/Android/data/org.frameport.scenetest/files /data/local/tmp/frameport-scene-probe
|
||||
for mode in save reload erase-failure reload erase empty; do
|
||||
podman exec "$CONTAINER" sh -c "FRAMEBRIDGE_CONFIG=/data/local/tmp/frameport-scene-probe/settings.conf exec -a org.frameport.scenetest /data/local/tmp/frameport-scene-test $mode" || break
|
||||
done
|
||||
podman exec "$CONTAINER" wc -l /sdcard/Android/data/org.frameport.scenetest/files/framebridge-anchors.txt
|
||||
```
|
||||
|
||||
Every invocation must exit 0 without `FAIL`; logs show `anchors saved`, `loaded 2 saved anchors`, a deliberate
|
||||
`anchor write failed` during `erase-failure` (then the following reload still finds two), and `anchors erased`.
|
||||
The final file has zero lines. `save` also recreates a session internally, while separate invocations validate
|
||||
new-process reload. Undo, before stopping this container:
|
||||
|
||||
```sh
|
||||
podman exec "$CONTAINER" rm -rf /sdcard/Android/data/org.frameport.scenetest /data/local/tmp/frameport-scene-probe
|
||||
podman exec "$CONTAINER" rm -f /data/local/tmp/frameport-scene-test
|
||||
rm -f /tmp/frameport-scene-test-android
|
||||
```
|
||||
|
||||
Remove the PC's temporary probe binary too. Restore backed-up game files/settings and install the previous APK if
|
||||
rolling the feature back. No probe command changes real application anchors.
|
||||
|
||||
## Commit handoff under the sandbox
|
||||
|
||||
This worktree's real Git metadata is on a read-only mount. Commits therefore live on `mr/emul` in
|
||||
`/tmp/frameport-mr-emul.git`, with a portable bundle at `/tmp/frameport-mr-emul.bundle`. The actual worktree contains
|
||||
all final source/artifact edits but its original branch metadata cannot be advanced here. No local instruction,
|
||||
app-data or credential files were staged. The base is `4bf48d3`.
|
||||
|
||||
After reviewing the worktree and verifying it contains exactly these changes, the maintainer can import the commits
|
||||
and align the existing worktree index without discarding its files:
|
||||
|
||||
```sh
|
||||
git bundle verify /tmp/frameport-mr-emul.bundle
|
||||
git fetch /tmp/frameport-mr-emul.bundle mr/emul
|
||||
git log --oneline 4bf48d3..FETCH_HEAD
|
||||
git reset --mixed FETCH_HEAD
|
||||
```
|
||||
|
||||
Run the unrestricted whole suite before merging. The bundle preserves the ordered implementation/test/build commits.
|
||||
@@ -0,0 +1,171 @@
|
||||
# Room plumbing stream
|
||||
|
||||
Implemented on `mr/plumb`; no Frame access or device verification. The shared `ROOM_FORMAT.md`, agent version,
|
||||
analysis version and patch revisions were left unchanged. No native binaries belong to this stream.
|
||||
|
||||
## What changed
|
||||
|
||||
- `frameport.mr.room`: room/entity dataclasses, v1 text and binary-mesh validation, canonical serialization,
|
||||
missing ceiling/wall/outline defaults, clockwise outline/wall repair, summaries and top-down SVG previews.
|
||||
Mesh counts, indices, finite coordinates, UUIDs and sidecar basenames are checked before import.
|
||||
- Standalone stdlib agent room commands live in one marked block. Rooms are staged under
|
||||
`~/.local/share/frameport/rooms/<universe>/`; a `current` symlink selects the latest saved room. Uploads validate
|
||||
before replacing a room. Deleting the current room selects the newest remaining room.
|
||||
- `room_status` returns `rooms`, current `universe`, selected `room`, `room_text`, `aligned`, `default_circle`
|
||||
(null when no usable boundary exists) and `scanner_installed`. Summaries include wall/plane counts, furniture
|
||||
counts by label, floor area and ceiling height. Status text lets the UI preview a room without another transfer.
|
||||
- Universe detection takes the last matching vrserver log line, then the newest chaperone record if no log match
|
||||
exists. Dummy universe 424242 means unknown. Boundary conversion chains collision quads' bottom edges and refuses
|
||||
the default 20-point, roughly 1 m circle. Boundary ceilings default to 2.5 m; SteamVR's 2.43 m wall extrusion
|
||||
does not measure the real ceiling.
|
||||
- Finalize records `scene_room` from adapter settings. A marked `_room_sync` launcher line follows the existing
|
||||
config-sync pattern, before Lepton starts. Launcher upgrades also handle old recipes listing
|
||||
`adapter.scene_emul`. Matching universe wins over `current`; absent rooms remove stale sidecars. Live setting
|
||||
changes update the launcher and clear copies when disabled. Non-scene launchers keep their existing layout.
|
||||
- PC room helpers implement status/import/export/delete/boundary/build/show/capture. Scanner upload reuses the
|
||||
SHA-256 helper extracted from live view; `fp_venc` retains its soft-failure behavior. Import uploads canonical
|
||||
text. Export checks mesh counts; build uploads FPRS dumps, runs the scanner and cleans its staging directory.
|
||||
- CLI: `frameport frame room status|from-boundary|import|export|delete|build|show`, with `--frame` on each command.
|
||||
`show --output <path.svg>` writes a preview (default below `FRAMEPORT_HOME/rooms`). Build accepts `--universe`.
|
||||
- Frame page Room card: checklist, asset-URL preview, boundary/delete/capture actions, refresh and recoverable errors.
|
||||
SSH and preview writes run in a background worker; state is cached per connection. Scene-game pages link to Room
|
||||
setup when the connected Frame has no room. `ui_smoke.py --fake-frame` supplies local room-command answers.
|
||||
- Every USE_SCENE/USE_ANCHOR_API game gets the scene-emulation suggestion. Migration `scene_emul_all` enables it once
|
||||
in automatic recipes, preserving user recipes. NOPE and Pinball FX keep their other recipe fields and now request
|
||||
scene emulation. Triage explains guardian fallback and missing discovery; patch docs and translation template
|
||||
were regenerated.
|
||||
|
||||
## Integration questions and limits
|
||||
|
||||
1. Headset capture is intentionally unfinished: `room_capture` returns `{"error":"not implemented yet"}`. The
|
||||
`capture_roomview(seconds, out_path)` hook documents the pending RoomView queue work. The GUI explains this and
|
||||
offers the boundary path. Do not present the capture button as a completed scan workflow yet.
|
||||
2. This branch has no `fp_roomscan` artifact; merge the scan stream before using `frame room build`. The exact scanner
|
||||
invocation is `fp_roomscan --offline <dump> --out <temporary directory> --universe U --json`. Stdout must contain
|
||||
one JSON value, stderr is saved as `scan.log`, and output must include a valid `room.txt` with the requested U.
|
||||
3. Merge the emulation/shim stream before expecting games to use saved rooms, meshes or new Meta entry points.
|
||||
Runtime log spelling is owned there; triage accepts both existing `using guardian room`/dimension logs and the
|
||||
brief's `guardian box`/`room from guardian` messages. Reconcile new success logs at integration.
|
||||
4. Confirm the chaperone JSON path, exact universe-switch logs and whether collision quads are already in STAGE on
|
||||
the device. This implementation follows the brief's path and coordinate contract; it does not apply a second
|
||||
standing-origin transform. A universe match alone cannot detect Valve changing an origin without changing its ID.
|
||||
5. Agent and desktop perform independent validation because the deployed agent cannot import the desktop package.
|
||||
The desktop canonicalizes outlines and fills defaults; the agent preserves scanner output and validates it.
|
||||
The format contract needed no changes.
|
||||
6. Rooms are shared across games; per-game saved anchors are not deleted or rewritten by these commands. Replacing
|
||||
a room creates new entity UUIDs, so a game may need its own room-setup reset. No automatic game reset was added.
|
||||
|
||||
## Offline validation
|
||||
|
||||
All commands used `FRAMEPORT_HOME` in this worktree (tests use temporary isolated homes), an existing dependency
|
||||
venv and offline/no-sync uv settings. No dependencies or native toolchains were downloaded.
|
||||
|
||||
- Room, PC/CLI, UI, heuristics, migration, MR triage and translation checks passed: 63 tests. The related
|
||||
agent/launcher/screenshots/codec/gamepad regression group passed 170 tests. The final deployment/configuration
|
||||
regression check passed 48 tests, including all four recipe/settings precedence cases.
|
||||
- Ruff over changed Python files and `scripts/patch_docs.py --check` passed. The UI detector reported no findings.
|
||||
`scripts/i18n_extract.py` regenerated `src/frameport/locales/template.json`.
|
||||
- `pytest -m games`: 3 skipped (no game dumps). Dev/holdout evaluation used only the checked-in demo analysis
|
||||
snapshot, 21 catalog matches: dev dated 9/17 exact, F1 81.1%; holdout dated 1/4 exact, F1 73.3%. These are limited
|
||||
fixture results, not a new score for the owner's complete collection; the evaluator exits 1 for recipe differences.
|
||||
- Final full suite: **1790 passed, 40 skipped, 11 failed, 16 errors** in 87.35 s. All 27 failures/errors are
|
||||
`PermissionError: [Errno 1] Operation not permitted` when existing tests create sockets. The whole-suite green
|
||||
requirement cannot be met in this sandbox; rerun `uv run pytest -q` on a host permitting loopback sockets.
|
||||
Browser UI smoke also requires a loopback server, so it
|
||||
was not run. New UI tests construct the real Flet controls and exercise deferred preview/error/cache states.
|
||||
|
||||
## Exact device checks (maintainer only)
|
||||
|
||||
Run these after integrating emul + scan. Substitute the paired Frame address below; keep the current worktree's
|
||||
`FRAMEPORT_HOME=.home` and use explicit `--frame` so the test does not open another app data directory. The source
|
||||
connection syncs the agent by digest even without a version bump, unless the Frame has a newer agent version.
|
||||
The maintainer must perform integration version bumps before testing against a newer installed agent.
|
||||
|
||||
```sh
|
||||
export FRAME='steamos@<frame>'
|
||||
export PKG='com.gritGames.Nope' # repeat with com.zenstudios.PFXVRQuest and a known scene game
|
||||
uv run frameport frame info --frame "$FRAME"
|
||||
uv run frameport frame room status --frame "$FRAME" > .home/mr-status.json
|
||||
# Back up ALL rooms on the Frame before destructive checks; use an unused backup name.
|
||||
ssh "$FRAME" 'test ! -e ~/.local/share/frameport/rooms.before-mr-test && { test ! -d ~/.local/share/frameport/rooms || cp -a ~/.local/share/frameport/rooms ~/.local/share/frameport/rooms.before-mr-test; }'
|
||||
```
|
||||
|
||||
1. Inspect status: no room gives `room: null`, `aligned: false`. With Valve's default circle, `default_circle: true`;
|
||||
`uv run frameport frame room from-boundary --frame "$FRAME"` must fail with the default-circle explanation.
|
||||
Set a real play area in SteamVR, repeat `from-boundary`: expect source `boundary`, one wall per boundary edge,
|
||||
correct floor area and `aligned: true`. Open Frame → Room: visible preview, warning for the unscanned room,
|
||||
aligned row ready; the capture button should show the friendly unavailable message without crashing.
|
||||
2. Exercise import/export and preview with a manual fixture in the current universe:
|
||||
|
||||
```sh
|
||||
uv run frameport frame room status --frame "$FRAME" > .home/mr-status.json
|
||||
uv run python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
from frameport.mr.room import Mesh, Volume, box_room, dumps, write_mesh
|
||||
p = Path('.home/mr-check'); p.mkdir(parents=True, exist_ok=True)
|
||||
r = box_room(4, 3, 2.5)
|
||||
r.source = 'manual'
|
||||
r.universe = json.loads(Path('.home/mr-status.json').read_text())['universe']
|
||||
r.volumes.append(Volume('a'*32, 'TABLE', .5, 0, .8, 20, 1.2, .7, .8))
|
||||
r.mesh = Mesh(vertices=[(-2, 0, -1.5), (2, 0, -1.5), (2, 0, 1.5)], indices=[0, 1, 2])
|
||||
(p/'room.txt').write_text(dumps(r))
|
||||
(p/'room-mesh.bin').write_bytes(write_mesh(r.mesh))
|
||||
PY
|
||||
uv run frameport frame room import .home/mr-check/room.txt --frame "$FRAME"
|
||||
uv run frameport frame room export .home/mr-export --frame "$FRAME"
|
||||
uv run frameport frame room show --output .home/mr-check/preview.svg --frame "$FRAME"
|
||||
cmp .home/mr-check/room-mesh.bin .home/mr-export/room-mesh.bin
|
||||
```
|
||||
|
||||
Expect four walls, one TABLE, area 12 m² and height 2.5 m. The preview must label TABLE and the game must show it
|
||||
hanging below its top-face anchor. Check the corresponding universe folder and `rooms/current` on the Frame.
|
||||
3. Use a recorded dump from the same standing universe (do not invent one). Copy it to `.home/mr-check/scan.fprs`,
|
||||
then run the command below. Expect the scanner JSON under `report`, source `scan`, aligned room data, `scan.log`
|
||||
on the Frame and an installed executable. Run twice: an unchanged scanner must not upload again.
|
||||
|
||||
```sh
|
||||
uv run frameport frame room build .home/mr-check/scan.fprs --frame "$FRAME"
|
||||
uv run frameport frame room status --frame "$FRAME"
|
||||
```
|
||||
|
||||
If the recording is from another universe, pass its original `--universe <id>`; do not relabel its coordinates.
|
||||
4. Prepare the game's source and its existing backed-up signing key in the isolated test library, then force a
|
||||
rebuild/reinstall after integration: `uv run frameport install "$PKG" --frame "$FRAME" --force --apk-only`.
|
||||
Repeat for both catalog games. In each deployment, expect `scene_room: true`. Run `frame info` twice to upgrade
|
||||
old launchers and check one marker only:
|
||||
|
||||
```sh
|
||||
uv run frameport frame info --frame "$FRAME"
|
||||
uv run frameport frame info --frame "$FRAME"
|
||||
ssh "$FRAME" "grep -c '# frameport-room-sync' ~/Applications/quest-frame/$PKG/launch.sh"
|
||||
uv run frameport settings "$PKG" scene_emul=1 --frame "$FRAME"
|
||||
```
|
||||
|
||||
Expected marker count: 1. Start the game with Steam's Play button in the headset, enter its MR mode, then inspect
|
||||
`<deployment.base>/room-sync.log`: JSON has the selected universe and alignment. The two room files must be
|
||||
beside `<deployment.base>/settings.conf`. FrameBridge must load that room; `xrDiscoverSpacesMETA` must resolve
|
||||
through the extension shim, with no `Unknown proc addr`/MRUK `NoRoomsFound`. Exact new native success log strings
|
||||
should be taken from the emul stream. Verify walls/table placement physically; never infer alignment from FPS.
|
||||
5. Exit the game. Switch `scene_emul=0` using Game settings (or the settings command above with 0): marker and game
|
||||
sidecars disappear. Switch it back to 1: one marker returns. Delete the room and launch again: stale copies are
|
||||
removed and existing FrameBridge guardian fallback logs trigger informational `mr-no-room` triage.
|
||||
6. To check a universe mismatch, back up/export the room, change its `universe` record to another valid ID and import
|
||||
it, then delete the original universe's room with `frame room delete --universe <original-id>`. Status must select
|
||||
`current` but report `aligned: false`; the Room card warns. Restore the original file before playing physically.
|
||||
The dummy universe 424242 should report current universe 0 rather than claim alignment.
|
||||
|
||||
## Undo device checks
|
||||
|
||||
Close all games. Restore the backed-up store (or remove only the fixture universes if there was no previous store):
|
||||
|
||||
```sh
|
||||
ssh "$FRAME" 'if test -d ~/.local/share/frameport/rooms.before-mr-test; then rm -rf ~/.local/share/frameport/rooms; mv ~/.local/share/frameport/rooms.before-mr-test ~/.local/share/frameport/rooms; fi'
|
||||
```
|
||||
|
||||
Set each tested game's `scene_emul` back to its recorded original value. To remove a test room without a backup:
|
||||
`uv run frameport frame room delete --universe <fixture-universe> --frame "$FRAME"`. Start a scene game once to
|
||||
refresh/remove its copies. The scanner can be removed with
|
||||
`ssh "$FRAME" 'rm -f ~/.local/share/frameport/bin/fp_roomscan'`; the next build reinstalls it. Game saves and
|
||||
`framebridge-anchors.txt` are untouched. Restore the previous app/agent/native artifacts through the maintainer's
|
||||
normal integration rollback if testing the combined native implementation fails.
|
||||
@@ -0,0 +1,338 @@
|
||||
# Offline room scanner (scan stream)
|
||||
|
||||
`fp_roomscan` converts recorded FPRS v1 meshes to the [room contract](ROOM_FORMAT.md), including the optional
|
||||
wall label adopted from upstream commit `8cb2783`:
|
||||
`room.txt` and `room-mesh.bin`. It does not capture RoomView queues, contact a headset, install rooms, or modify
|
||||
the current-room symlink. Scanner work changes `native/roomscan/`, its build target and artifact/checksum,
|
||||
`scripts/mr/synth_scan.py`, `tests/test_roomscan.py`, and this note. The upstream contract commit also brings its
|
||||
existing prompt assets and renderer unchanged. No runtime, agent, patch, or analysis versions were bumped.
|
||||
|
||||
## Build and run
|
||||
|
||||
Use the cached NDK; no downloads are needed for this target:
|
||||
|
||||
```sh
|
||||
python native/build.py --only roomscan
|
||||
```
|
||||
|
||||
The result is `artifacts/linux-arm64-bin/fp_roomscan`, a static aarch64 Linux ELF with its own `_start`, raw Linux
|
||||
syscalls, and no interpreter, dynamic section, libc, libm, UAPI headers, generated tables, or new dependencies.
|
||||
`platform.h` is the reconstruction code's entire I/O/allocation boundary. The same five C sources compile on the
|
||||
host:
|
||||
|
||||
```sh
|
||||
cc -std=c11 -O2 -Wall -Wextra -Werror -ffp-contract=off -fno-math-errno -DFP_HOSTED native/roomscan/*.c -o /tmp/fp_roomscan
|
||||
python scripts/mr/synth_scan.py /tmp/room.fprs
|
||||
/tmp/fp_roomscan --offline /tmp/room.fprs --out /tmp/scanned-room --universe 0 --json
|
||||
```
|
||||
|
||||
The output directory's parent must exist; the scanner creates the final directory if needed. It always prints
|
||||
exactly one JSON summary, whether `--json` is supplied or omitted. Success exits 0, errors exit 2 with
|
||||
`{"ok":false,"error":"..."}`. `--help` prints usage and exits 0. Successful summaries contain:
|
||||
|
||||
```json
|
||||
{"ok":true,"frames":30,"skipped_frames":0,"far_vertices":0,"discontinuity_triangles":0,
|
||||
"walls":4,"detected_walls":4,"volumes":2,"floor_y":-0.0023,
|
||||
"ceiling_y":2.5992,"coverage_percent":100.0,"hint_used":false,"triangles":70997,
|
||||
"accepted_triangles":118804,"rejected_triangles":704,"warnings":[],
|
||||
"timings_ms":{"read":0,"fusion":0,"model":0,"mesh":0,"total":0}}
|
||||
```
|
||||
|
||||
The example above illustrates the schema; actual counts and timings depend on the input. Heights are absolute
|
||||
STAGE coordinates. `frames` includes skipped frames; `skipped_frames`, `far_vertices`, and
|
||||
`discontinuity_triangles` count input exclusions once, not once per pass. `walls` counts output polygon
|
||||
edges including synthesized edges; `detected_walls` counts fitted physical planes. Coverage means the percentage
|
||||
of the output perimeter supported by observed wall planes, **not camera coverage of all room surfaces**. Timings
|
||||
include parsing, fusion, structural/furniture modelling, mesh generation, and total time including output.
|
||||
|
||||
All frame headers, timestamps/vertices/poses, approximately unit quaternions, counts, indices and EOF boundaries
|
||||
are checked before publication. A non-finite timestamp/pose/vertex or invalid tracking quaternion skips the entire
|
||||
frame after consuming and structurally validating its record, with a count and warning. A bad frame never erases
|
||||
usable frames elsewhere in the dump. Vertices outside the camera-relative range (default 4 m) and every triangle
|
||||
touching them are discarded. Triangles with a camera-depth jump over `--depth-jump + --depth-ratio * nearest_depth`
|
||||
are rejected before fusion; this complements long-edge and grazing rejection. Frame counts are capped at
|
||||
1 million vertices / 3 million indices;
|
||||
oversized records fail rather than allocating from untrusted counts. A header-only dump or one without usable
|
||||
triangles fails. Empty frames may accompany useful frames. Structural corruption (truncation, unsupported header,
|
||||
bad counts or indices) remains fatal, preserves any existing output byte for byte and creates no output directory.
|
||||
UUIDs come from `getrandom`, fresh per scan; mesh geometry is repeatable.
|
||||
|
||||
Both complete files are written to exclusive, randomly named temporary files, then individually renamed, with
|
||||
the mesh first and `room.txt` last. A write failure removes the temporary files. These are atomic **file**
|
||||
replacements, not a two-file filesystem transaction: the installer should scan into a fresh staging directory
|
||||
and publish that directory/symlink after success. Do not run concurrent writers into the same output directory.
|
||||
There is no fsync-based power-loss durability promise. Neither scan nor build writes application data outside
|
||||
the worktree's `FRAMEPORT_HOME`; fixtures and test output use temporary directories.
|
||||
|
||||
## Reconstruction
|
||||
|
||||
1. The first streaming pass validates every frame, rotates its mesh by the supplied quaternion, and translates
|
||||
it into STAGE. It rejects degenerate triangles, excessively long edges, out-of-range points, and grazing
|
||||
surfaces. Winding is corrected toward the observing camera. A deterministic bounded reservoir of 32,768
|
||||
accepted triangle centroids determines median position and padded bounds. No frames are retained across reads.
|
||||
2. The second pass rasterizes accepted triangles at 0.75 voxel spacing. Per-cell surfels average positions and
|
||||
inward normals, weighted by area and squared incidence. A capped accumulated weight lets later views correct
|
||||
earlier ones. The grid is sized from the sample bounds and capped at 8 x 8 x 4 metres about the data median.
|
||||
Unobserved cells have zero weight. This implementation uses a bounded dense backing array for the sparse
|
||||
observations, rather than a hash table; the 4.2-million-cell hard cap bounds all later work. Complete regular
|
||||
fans are recognized from their index topology, without hardcoding a camera resolution. Their normals come from
|
||||
12 x 12 neighbourhood covariance plane fits, rejecting patches above `--patch-residual` RMS; irregular meshes
|
||||
retain triangle normals. Each cell counts contributing frames once and needs `--min-views` evidence, capped at
|
||||
ten percent of usable frames (rounded down, at least one) for brief captures. This suppresses transient stereo
|
||||
geometry without requiring a short scan to see each surface five times.
|
||||
3. Horizontal normal/height histograms select the dominant floor within the STAGE floor band and a ceiling above
|
||||
the minimum ceiling height. Missing floor uses STAGE y=0 with a warning; missing ceiling uses floor plus
|
||||
`--ceiling-default`, warning exactly `ceiling not seen`. A ceiling must also have at least 15% as many supported
|
||||
cells as all upward-facing horizontal surfaces, so a few isolated down-facing patches do not invent a ceiling.
|
||||
4. RANSAC fits near-vertical planes using distance and normal agreement, then refines them by position covariance.
|
||||
Minimum height, span, and area reject furniture faces. Wall directions within the snap tolerance of the
|
||||
dominant wall's orthogonal directions are snapped, with offsets refitted. Directed wall segments are connected
|
||||
by nearest endpoints and adjacent line intersections. This preserves the L-shaped fixture's re-entrant corner.
|
||||
Parallel same-facing planes within 25 cm / approximately 14° merge, suppressing duplicate softened faces.
|
||||
Planes below 20% of the strongest original plane's area are discarded. When at least 65% of candidate wall area
|
||||
supports an orthogonal family, directions outside 12° of that family are discarded (`--snap-degrees 0` disables
|
||||
this orthogonal consensus filter). Large gaps close along observed endpoints. Ambiguous endpoints with three
|
||||
or four planes can use inward half-plane intersections; concave rooms retain segment ordering. Otherwise a
|
||||
warned observed-bounds rectangle is used. Polygon checks reject crossing edges. The output follows the contract's positive
|
||||
shoelace ordering in (x,z), with left-facing inward normals.
|
||||
5. Furniture tops are clustered separately from vertical faces, within supported horizontal height bands. A
|
||||
softened edge connecting a tabletop to a wall can no longer absorb the whole room into one box. Top boxes use
|
||||
a minimum-area horizontal orientation search and the median surface height; vertical screen candidates use
|
||||
face-direction harmonics / PCA. Bounds trim the outer 2% to suppress stereo halos. Top boxes extend down to the
|
||||
floor (the footprint and lower extent remain geometric estimates). Weak/sparse top patches are discarded.
|
||||
Labels follow height, area, long-side (with two voxels of measurement tolerance),
|
||||
thinness and near-wall rules, in order BED, COUCH, TABLE, SCREEN, STORAGE, OTHER. Only volumes are emitted;
|
||||
optional table plane records are not required. Floor/ceiling/wall surfaces are excluded before clustering.
|
||||
6. Surfel splats form an incidence/area-weighted signed distance field within three voxels of each surface. The
|
||||
sign is positive in observed free room space. Marching cubes traces zero crossings on cube faces, resolving
|
||||
ambiguous faces from their centre sign. It fans closed contours and orients triangles toward the positive
|
||||
gradient. Unknown corners suppress extraction rather than bridging unobserved space. If necessary, extraction
|
||||
is repeated on a coarser lattice (strides 2–4) until at most 200,000 triangles remain, with a decimation warning;
|
||||
excess beyond that fails. Vertices are unshared triangle vertices, so at most 600,000 vertices. Structural
|
||||
planes are semantic anchors, not substituted for the observed global mesh.
|
||||
|
||||
Peak allocation stays below 260 MiB, including maximum frame buffers, the grid, model worklists and mesh buffers
|
||||
(the latter phases do not retain frame buffers). Default synthetic scenes use a much smaller grid. Thresholds
|
||||
are CLI options below or named constants next to their algorithms. No room-specific coordinates or dimensions are
|
||||
used; on-device quality and speed still need validation.
|
||||
|
||||
## Tuning
|
||||
|
||||
Distances are metres, areas m², volumes m³. `--universe` accepts the full unsigned 64-bit range; 0 means unknown.
|
||||
|
||||
| Option | Default | Accepted range / meaning |
|
||||
| --- | ---: | --- |
|
||||
| `--voxel` | 0.05 | 0.04–0.20, spatial resolution; lower values cost more memory/work |
|
||||
| `--ceiling-default` | 2.5 | 1.8–3.8, height above floor when ceiling is unseen |
|
||||
| `--truncation` | 3 | 1–5, TSDF band in voxel units |
|
||||
| `--max-edge` | 0.6 | 0.05–1.5, reject longer triangle edges |
|
||||
| `--grazing` | 0.12 | 0.01–0.9, minimum absolute camera/normal cosine |
|
||||
| `--range` | 4 | 1–10, maximum camera-relative vertex distance; >4 m opts back into inpainted depth |
|
||||
| `--depth-jump` | 0.25 | 0.02–1, absolute part of a triangle's maximum depth discontinuity |
|
||||
| `--depth-ratio` | 0.12 | 0–0.5, additional fraction of nearest vertex depth |
|
||||
| `--min-views` | 5 | 1–16, temporal support target; brief scans cap it as described above |
|
||||
| `--patch-residual` | 0.03 | 0.005–0.1, maximum RMS residual of a regular-fan normal patch |
|
||||
| `--plane-distance` | 0.045 | 0.01–0.15, point-to-plane inlier tolerance |
|
||||
| `--min-plane-area` | 0.45 | 0.1–4, minimum occupied plane area estimate |
|
||||
| `--wall-height` | 1.5 | 0.5–3, minimum observed vertical wall span |
|
||||
| `--snap-degrees` | 5 | 0–15, orthogonal snap tolerance |
|
||||
| `--floor-band` | 0.35 | 0.05–1, floor search distance from STAGE y=0 |
|
||||
| `--ceiling-min` | 1.8 | 1.5–3, minimum absolute ceiling y |
|
||||
| `--min-volume` | 0.05 | 0.005–1, reject smaller furniture boxes |
|
||||
| `--cluster-gap` | 0.10 | 0.04–0.20, voxel neighbourhood gap (rounded up to cells) |
|
||||
| `--gap` | 0.65 | 0.1–1.5, maximum adjacent-wall endpoint extrapolation |
|
||||
| `--horizontal` | 0.85 | 0.6–0.99, minimum floor/ceiling/top normal cosine |
|
||||
|
||||
Additional constants in `model.c` include RANSAC trials, normal agreement, wall span, furniture margins,
|
||||
label height/area thresholds, box trimming and circle recognition. Fusion sampling/weight limits are in
|
||||
`fusion.c`; mesh splat radii/decimation limits in `mesh.c`; hard resource limits in `scan.h`.
|
||||
|
||||
`--hint outline.txt` accepts one `x z` point per line, decimal/scientific notation, optional `#` comments, either
|
||||
winding, at most 96 points / 8 KiB. At least eight points with radial coefficient of variation below 5% count as
|
||||
the default circle and are ignored. A non-circular hint is used only when walls are incomplete **and every
|
||||
observed wall has a compatible hint edge** within `--gap`. Complete scans are never constrained to chaperone.
|
||||
Incompatible inset play boundaries are ignored with a warning. Hint-completed edges retain synthesized status.
|
||||
|
||||
## Limits for integration
|
||||
|
||||
- Synthesized wall records now append `INVISIBLE_WALL_FACE` as their optional eighth field. Observed walls omit
|
||||
the label and default to `WALL_FACE`; comments are no longer used to carry semantics. This matches upstream's
|
||||
authorized contract change exactly; adapters need that same contract revision.
|
||||
- A private RoomView recording was used for offline robustness work. It, its derived geometry, and its results
|
||||
stay outside committed files. Synthetic fixtures independently exercise the input characteristics. They are
|
||||
regressions, not proof
|
||||
of headset accuracy. Furniture labels are geometric heuristics; adjacent/touching objects can merge, occluded
|
||||
objects may split, top-only clusters have inferred depth, and thin fragments can be screen candidates. Nearby
|
||||
parallel walls and small wall jogs can merge under coarse depth uncertainty. Large disconnected coplanar wall segments are
|
||||
currently fitted together; complicated rooms can fall back to a warned rectangle.
|
||||
- Cropped or sparse captures can produce an incomplete mesh and synthesized outline. Surfaces are not filled
|
||||
through unseen regions. Furniture is not an accessibility/safety boundary. Rooms beyond the grid cap are
|
||||
cropped with a warning. Default-circle recognition may also ignore an intentionally circular boundary.
|
||||
- Output paths are fixed by the contract. The plumbing stream should stage a new directory and switch `current`
|
||||
after scanner success, preserving the previous room for rollback. Assign the recording's actual universe id;
|
||||
this standalone scanner cannot query SteamVR or validate universe alignment.
|
||||
|
||||
## Offline validation
|
||||
|
||||
`tests/test_roomscan.py` builds with the host compiler (skips only if none exists). Its 41 tests cover the noisy
|
||||
4 x 3 x 2.6 m room with table/couch, six-wall L, moving and rotated/translated mesh poses, ceiling fallback, a single
|
||||
frame, missing walls, circular/matching hints, correct UUIDs/wire layout, interior mesh winding, triangle budget,
|
||||
repeatable mesh bytes with fresh UUIDs, truncated/corrupt dumps, invalid counts/indices/poses and invalid CLI values.
|
||||
The test module takes about fourteen seconds including synthetic rendering and compilation (under the 60 s budget).
|
||||
New cases cover non-finite frames before/after usable frames, per-vertex far rejection, depth jumps below the edge
|
||||
limit, weak ceiling evidence, wall labels and a complete camera-fan scan. The deterministic stdlib renderer now
|
||||
defaults to a 132 x 128 full index fan (99,822 indices), +Z forward / -Y image up, with poses mapping to STAGE.
|
||||
It adds separable depth smoothing, spatially correlated noise, far inpainting, two initial NaN poses, 3 Hz timestamps
|
||||
and head-height drift from 0.9 to 1.7 m. `--profile legacy` retains the smaller 110° x 76° test cone. New CLI controls
|
||||
are `--profile`, `--invalid-start`, `--smoothing` and optional resolution overrides. Use `--invalid-start 0` for a
|
||||
usable one-frame fixture; default RoomView warm-up frames otherwise have no valid tracking.
|
||||
|
||||
```sh
|
||||
UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run pytest -q tests/test_roomscan.py
|
||||
UV_OFFLINE=1 UV_CACHE_DIR=/tmp/frameport-uv-cache uv run ruff check scripts/mr/synth_scan.py tests/test_roomscan.py
|
||||
```
|
||||
|
||||
The synthetic camera fan passes AddressSanitizer + UndefinedBehaviorSanitizer. Two consecutive final native builds
|
||||
produce identical SHA-256 `0d88e5adfa1a21da7e0cfec2698a835d7767961e15c0b41c84235fa24ae1e751` (54,248 bytes),
|
||||
matching `artifacts/SHA256SUMS`. ELF inspection confirms aarch64, little-endian, no PT_INTERP / PT_DYNAMIC, and no
|
||||
embedded local build paths.
|
||||
|
||||
The required full `uv run pytest -q` run completed with **1,788 passed, 40 skipped, 11 failed, 16 errors** in 79 s.
|
||||
All 27 failures/errors are the sandbox's `PermissionError: [Errno 1] Operation not permitted` on socket creation
|
||||
in existing HTTP/pairing/live-stream tests (`test_deeplink`, `test_devkit`, `test_install_links_extra`,
|
||||
`test_livestream`, `test_setup_url`, `test_ui_state`). This environment forbids sockets, including loopback.
|
||||
The unchanged baseline had the identical 27 failures/errors before the scanner tests were added (1,747 passed).
|
||||
Consequently the whole-suite-pass requirement cannot be satisfied here; rerun it outside that restriction at
|
||||
integration. No tests were changed/skipped to hide the restriction and no network/device access was attempted.
|
||||
No patch/settings/catalog/triage files were touched, so the conditional patch documentation regeneration is
|
||||
unnecessary.
|
||||
|
||||
## Ten-minute capture budget (synthetic benchmark)
|
||||
|
||||
The scanner is streaming and single-threaded. On an Intel Core i7-14700K host, a **synthetic-only** 1,800-frame
|
||||
132 x 128 dump (1,083,751,208 bytes, equivalent to ten minutes at 3 Hz) took **7.41 s elapsed / 7.08 s CPU**, with
|
||||
**27,208 KiB maximum RSS** at the default voxel size. The file repeats a 30-view synthetic sweep, including its
|
||||
warm-up frames; it tests parsing/fusion throughput rather than 1,800 independent viewpoints. Geometry-dependent
|
||||
model/mesh work and storage caches affect timing. No private recording or derived room data is in that benchmark.
|
||||
|
||||
Reproduce with bounded copies rather than loading a gigabyte into Python:
|
||||
|
||||
```sh
|
||||
python scripts/mr/synth_scan.py /tmp/roomview.fprs
|
||||
python - <<'PY'
|
||||
import shutil
|
||||
with open('/tmp/roomview.fprs', 'rb') as source, open('/tmp/roomscan-1800.fprs', 'wb') as out:
|
||||
out.write(source.read(8))
|
||||
for repeat in range(60):
|
||||
source.seek(8)
|
||||
shutil.copyfileobj(source, out, 1024 * 1024)
|
||||
PY
|
||||
/usr/bin/time -v /tmp/fp_roomscan --offline /tmp/roomscan-1800.fprs --out /tmp/roomscan-benchmark --json
|
||||
```
|
||||
|
||||
For the Frame's Cortex-A720/X4-class cores, budgeting **3–5 times the host CPU time plus storage** gives roughly
|
||||
**23–38 s** for this workload, leaving headroom under the requested ~60 s. This is an estimate, not an ARM
|
||||
measurement or an 8-core speedup claim. Worst-case allocations are bounded below 260 MiB independently of frame
|
||||
count (48-byte cells, 4.2-million-cell cap, bounded frame buffers/worklists), below both the original 300 MB and
|
||||
follow-up 600 MB limits. A maximum-sized complex room can add model/mesh work beyond this small synthetic sweep.
|
||||
The maintainer must time a ten-minute real capture on the Frame and check CPU time, wall time and RSS before
|
||||
claiming the target is achieved on-device. Hardware square root instructions replace iterative division; neither
|
||||
build links libm. The build and host command both require `-fno-math-errno` for that intrinsic.
|
||||
|
||||
## Exact device checks (maintainer only; none executed by this stream)
|
||||
|
||||
1. On the development PC, build, then render the canonical fixtures:
|
||||
|
||||
```sh
|
||||
python native/build.py --only roomscan
|
||||
python scripts/mr/synth_scan.py /tmp/box.fprs
|
||||
python scripts/mr/synth_scan.py /tmp/l.fprs --shape l
|
||||
python scripts/mr/synth_scan.py /tmp/down.fprs --no-ceiling
|
||||
python scripts/mr/synth_scan.py /tmp/one.fprs --frames 1 --invalid-start 0
|
||||
scp artifacts/linux-arm64-bin/fp_roomscan /tmp/box.fprs /tmp/l.fprs /tmp/down.fprs /tmp/one.fprs steamos@<FRAME>:~/Downloads/
|
||||
```
|
||||
|
||||
2. In a terminal **on the Frame**, use an isolated directory; no game or current room is changed:
|
||||
|
||||
```sh
|
||||
mkdir -p "$HOME/.local/share/frameport/roomscan-test"
|
||||
cd "$HOME/.local/share/frameport/roomscan-test"
|
||||
cp "$HOME/Downloads/fp_roomscan" ./fp_roomscan
|
||||
chmod 700 ./fp_roomscan
|
||||
./fp_roomscan --help
|
||||
./fp_roomscan --offline "$HOME/Downloads/box.fprs" --out box --universe 0 --json > box.json
|
||||
cat box.json
|
||||
./fp_roomscan --offline "$HOME/Downloads/l.fprs" --out l --json > l.json
|
||||
./fp_roomscan --offline "$HOME/Downloads/down.fprs" --out down --ceiling-default 2.7 --json > down.json
|
||||
./fp_roomscan --offline "$HOME/Downloads/one.fprs" --out one --json > one.json
|
||||
```
|
||||
|
||||
Expected JSON: box has `ok:true`, `walls:4`, `detected_walls:4`, `volumes:2`, floor within 5 cm of 0,
|
||||
ceiling within 5 cm of 2.6, coverage 100; the only expected box warnings concern skipped warm-up frames and
|
||||
out-of-range vertices. `box/room.txt` includes TABLE and COUCH volume records.
|
||||
L has six walls and the concave corner near (0,0), rather than a rectangle. Down includes exactly the warning
|
||||
string `ceiling not seen` (other warnings are expected), with ceiling minus floor 2.7. One includes
|
||||
`few frames; scan more viewpoints`. Every mesh should have at most 200,000 triangles. JSON is the scanner log;
|
||||
there are no expected stderr status lines.
|
||||
|
||||
3. Verify sidecars, wall labels and the corrupt-input exit status on the Frame:
|
||||
|
||||
```sh
|
||||
python3 - <<'PY'
|
||||
import json, pathlib, struct
|
||||
for name in ('box', 'l', 'down', 'one'):
|
||||
report = json.loads(pathlib.Path(name + '.json').read_text())
|
||||
data = pathlib.Path(name, 'room-mesh.bin').read_bytes()
|
||||
magic, version, nv, ni = struct.unpack_from('<4sIII', data)
|
||||
assert report['ok'] and magic == b'FPRM' and version == 1
|
||||
assert ni % 3 == 0 and 0 < ni // 3 <= 200000
|
||||
assert len(data) == 16 + nv * 12 + ni * 4
|
||||
for line in pathlib.Path(name, 'room.txt').read_text().splitlines():
|
||||
if line.startswith('wall '):
|
||||
fields = line.split()
|
||||
assert len(fields) in (8, 9)
|
||||
if len(fields) == 9:
|
||||
assert fields[-1] == 'INVISIBLE_WALL_FACE'
|
||||
print(name, nv, ni // 3, report['warnings'])
|
||||
pathlib.Path('broken.fprs').write_bytes(b'FPRS\x01\0\0\0\0')
|
||||
PY
|
||||
./fp_roomscan --offline broken.fprs --out broken --json
|
||||
echo "$?"
|
||||
test ! -e broken/room.txt
|
||||
test ! -e broken/room-mesh.bin
|
||||
```
|
||||
|
||||
Expected: `{"ok":false,"error":"truncated frame header"}`, exit status 2, no `broken` outputs.
|
||||
|
||||
4. Record actual RoomView dumps with the maintainer's recorder (FPRS v1, mesh-to-STAGE poses, stable universe).
|
||||
Copy the recording to `~/Downloads/capture.fprs`. Look around slowly for about a minute, including floor,
|
||||
ceiling, every wall, and furniture from several directions. With the **recorded universe id** assigned below:
|
||||
|
||||
```sh
|
||||
scan_universe=0 # replace 0 with the recorder's actual decimal universe id
|
||||
/usr/bin/time -v ./fp_roomscan --offline "$HOME/Downloads/capture.fprs" --out real --universe "$scan_universe" --json > real.json 2> real-timing.log
|
||||
cat real.json real-timing.log
|
||||
cat real/room.txt
|
||||
```
|
||||
|
||||
Measure wall offsets/yaws, ceiling and table top physically and compare to the records (target 5 cm / 2°).
|
||||
Check resident memory below 300 MB, sensible coverage/warnings, tabletop labels and no false wall/furniture
|
||||
blobs from smeared edges. Repeat with `--max-edge 0.35 --grazing 0.2` into `real-stricter` if edges smear;
|
||||
try `--voxel 0.08 --plane-distance 0.07` into `real-coarser` if depth is noisy. Keep both JSON logs and raw
|
||||
dumps **outside git** for threshold calibration. These are proposed tuning probes, not established device fixes.
|
||||
|
||||
5. Repeat the synthetic 1,800-frame benchmark from the preceding section on the Frame, using the same command
|
||||
and `/usr/bin/time -v`. Then test a ten-minute independent real capture. Target: about 60 s elapsed or less,
|
||||
less than 600 MB RSS, successful JSON and bounded mesh size. Record CPU time as well as wall time so storage
|
||||
overhead is visible. With difficult stereo edges compare `--min-views 8 --patch-residual 0.02` in a fresh output
|
||||
directory; short captures cap view support automatically. Include the JSON's exclusion counters in triage;
|
||||
skipped frames may occur anywhere in the capture. Keep all personal results outside git.
|
||||
|
||||
6. After merging plumbing/emulation, publish this staged room through that stream's room-install path and try
|
||||
Demeter, then an MRUK discovery/mesh game. Check room loading, furniture alignment, and warning/fallback
|
||||
behaviour in-headset; the scanner alone cannot verify the Meta API path. Do not replace `rooms/current` by hand
|
||||
during these standalone checks.
|
||||
|
||||
Undo standalone checks: remove only the isolated `~/.local/share/frameport/roomscan-test` directory and the five
|
||||
test files copied to `~/Downloads` (plus `capture.fprs` if no longer needed). No Steam settings, games, agent,
|
||||
room universe folder, or current-room symlink were changed by these steps.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// Included by scene_emu.c. Persist only application anchors; scanned entities remain owned by the room file.
|
||||
#include <sys/random.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#ifndef SCENE_EXTERNAL_ROOT
|
||||
#define SCENE_EXTERNAL_ROOT "/sdcard/Android/data"
|
||||
#endif
|
||||
|
||||
static int anchor_path(char *path, size_t size) {
|
||||
char process[256] = {0};
|
||||
FILE *f = fopen("/proc/self/cmdline", "r");
|
||||
if (!f) return 0;
|
||||
size_t n = fread(process, 1, sizeof(process)-1, f);
|
||||
fclose(f);
|
||||
if (!n) return 0;
|
||||
char *colon = strchr(process, ':');
|
||||
if (colon) *colon = 0;
|
||||
// Only package-like process names: never let a command line become a path traversal.
|
||||
if (!strchr(process, '.') || !strcmp(process, ".") || !strcmp(process, "..")) return 0;
|
||||
for (const char *p = process; *p; ++p)
|
||||
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') || *p == '.' || *p == '_')) return 0;
|
||||
int len = snprintf(path, size, "%s/%s/files/framebridge-anchors.txt", SCENE_EXTERNAL_ROOT, process);
|
||||
return len > 0 && (size_t)len < size;
|
||||
}
|
||||
static int anchor_uuid(XrUuidEXT *u) {
|
||||
size_t have = 0;
|
||||
while (have < sizeof(u->data)) {
|
||||
ssize_t n = getrandom(u->data + have, sizeof(u->data) - have, 0);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) break;
|
||||
have += (size_t)n;
|
||||
}
|
||||
if (have < sizeof(u->data)) {
|
||||
FILE *f = fopen("/dev/urandom", "rb");
|
||||
if (!f) return 0;
|
||||
have = fread(u->data, 1, sizeof(u->data), f);
|
||||
fclose(f);
|
||||
if (have != sizeof(u->data)) return 0;
|
||||
}
|
||||
u->data[6] = (uint8_t)((u->data[6] & 15) | 0x40);
|
||||
u->data[8] = (uint8_t)((u->data[8] & 63) | 0x80);
|
||||
for (int i = 0; i < entity_count; ++i) if (uuid_equal(u, &entities[i].uuid)) return 0;
|
||||
return 1;
|
||||
}
|
||||
static int write_anchors(void) {
|
||||
char path[4096], tmp[4128];
|
||||
if (!anchor_path(path, sizeof(path))) return 0;
|
||||
snprintf(tmp, sizeof(tmp), "%s.tmp-XXXXXX", path);
|
||||
int fd = mkstemp(tmp);
|
||||
if (fd < 0) return 0;
|
||||
FILE *f = fdopen(fd, "w");
|
||||
if (!f) { close(fd); unlink(tmp); return 0; }
|
||||
int ok = 1;
|
||||
for (int i = 0; i < entity_count; ++i) {
|
||||
const scene_entity *e = &entities[i];
|
||||
if (!e->is_anchor || !e->saved || e->erased) continue;
|
||||
char uuid[33];
|
||||
for (int k = 0; k < 16; ++k) snprintf(uuid + k*2, 3, "%02x", e->uuid.data[k]);
|
||||
const XrPosef p = e->pose_in_stage;
|
||||
if (fprintf(f, "anchor %s %.9g %.9g %.9g %.9g %.9g %.9g %.9g\n", uuid,
|
||||
p.position.x, p.position.y, p.position.z, p.orientation.x, p.orientation.y, p.orientation.z, p.orientation.w) < 0) ok = 0;
|
||||
}
|
||||
if (fflush(f) || fsync(fd)) ok = 0;
|
||||
if (fclose(f)) ok = 0;
|
||||
if (ok && rename(tmp, path)) ok = 0;
|
||||
if (!ok) { LOG("scene: anchor write failed (%d)", errno); unlink(tmp); }
|
||||
return ok;
|
||||
}
|
||||
static void load_anchors(void) {
|
||||
char path[4096];
|
||||
if (!anchor_path(path, sizeof(path))) return;
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) return;
|
||||
char line[1024], *t[10];
|
||||
unsigned loaded = 0;
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
if (!strchr(line, '\n') && !feof(f)) { int c; while ((c=fgetc(f)) != EOF && c != '\n') {} continue; }
|
||||
XrUuidEXT uuid; float v[7];
|
||||
int n = room_tokens(line, t, 10);
|
||||
if (n != 9 || strcmp(t[0], "anchor") || !room_uuid(t[1], &uuid) || !room_values(t, 2, 7, v)) continue;
|
||||
float norm = sqrtf(v[3]*v[3]+v[4]*v[4]+v[5]*v[5]+v[6]*v[6]);
|
||||
if (norm < 0.0001f) continue;
|
||||
int duplicate = 0;
|
||||
for (int i = 0; i < entity_count; ++i) duplicate |= uuid_equal(&uuid, &entities[i].uuid);
|
||||
if (duplicate) continue;
|
||||
int i = add_entity((XrPosef){{v[3]/norm,v[4]/norm,v[5]/norm,v[6]/norm},{v[0],v[1],v[2]}}, NULL, 0, 0, 0, 1);
|
||||
if (i < 0) break;
|
||||
entities[i].uuid = uuid; entities[i].saved = 1; ++loaded;
|
||||
}
|
||||
fclose(f);
|
||||
LOG("scene: loaded %u saved anchors", loaded);
|
||||
}
|
||||
// Batch writes are atomic both on disk and in memory. Failure never silently destroys the old anchor set.
|
||||
static XrResult persist_spaces(uint32_t count, const XrSpace *spaces, uint32_t uuid_count, const XrUuidEXT *uuids, int erase) {
|
||||
unsigned char selected[MAX_ENTITIES] = {0}, old_saved[MAX_ENTITIES], old_erased[MAX_ENTITIES];
|
||||
for (uint32_t k = 0; k < count; ++k) {
|
||||
int i = fake_space_index(spaces[k]);
|
||||
if (i < 0) return XR_ERROR_HANDLE_INVALID;
|
||||
if (entities[i].is_anchor) selected[i] = 1;
|
||||
}
|
||||
if (erase) for (int i = 0; i < entity_count; ++i)
|
||||
if (entities[i].is_anchor && !entities[i].erased && uuid_matches(uuid_count, uuids, &entities[i])) selected[i] = 1;
|
||||
int changed = 0;
|
||||
for (int i = 0; i < entity_count; ++i) {
|
||||
old_saved[i] = (unsigned char)entities[i].saved; old_erased[i] = (unsigned char)entities[i].erased;
|
||||
if (selected[i]) { entities[i].saved = !erase; entities[i].erased = erase; changed = 1; }
|
||||
}
|
||||
if (changed && !write_anchors()) {
|
||||
for (int i = 0; i < entity_count; ++i) { entities[i].saved = old_saved[i]; entities[i].erased = old_erased[i]; }
|
||||
return XR_ERROR_RUNTIME_FAILURE;
|
||||
}
|
||||
if (changed) LOG("scene: anchors %s", erase ? "erased" : "saved");
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
@@ -1,16 +1,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");
|
||||
@@ -117,10 +153,14 @@ static void build_room(void) {
|
||||
add_entity((XrPosef){{0, 0, 0, 1}, {0, 0, 0}}, NULL, 0, 0, 1, 0); // ENT_ROOM
|
||||
add_entity((XrPosef){{-s, 0, 0, s}, {0, 0, 0}}, "FLOOR", width, depth, 0, 0); // +Z (normal) -> +Y
|
||||
add_entity((XrPosef){{s, 0, 0, s}, {0, h, 0}}, "CEILING", width, depth, 0, 0); // +Z -> -Y
|
||||
// Walls in loop order around the floor, as Meta's ROOM_LAYOUT lists them (Demeter walks them as an outline).
|
||||
add_entity((XrPosef){{0, 0, 0, 1}, {0, h / 2, -depth / 2}}, "WALL_FACE", width, h, 0, 0); // faces +Z
|
||||
add_entity((XrPosef){{0, -s, 0, s}, {width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces -X
|
||||
add_entity((XrPosef){{0, 1, 0, 0}, {0, h / 2, depth / 2}}, "WALL_FACE", width, h, 0, 0); // faces -Z
|
||||
add_entity((XrPosef){{0, s, 0, s}, {-width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces +X
|
||||
add_entity((XrPosef){{0, -s, 0, s}, {width / 2, h / 2, 0}}, "WALL_FACE", depth, h, 0, 0); // faces -X
|
||||
wall_count = 4;
|
||||
room_entity_count = entity_count;
|
||||
for (int i = ENT_WALL0; i < entity_count; ++i) entities[i].is_wall = 1;
|
||||
}
|
||||
|
||||
// Supported components per entity.
|
||||
@@ -128,12 +168,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 +193,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 +217,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 +228,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 +242,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 +340,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 +375,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 +426,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 +457,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 +537,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 +554,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 +575,113 @@ 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;
|
||||
}
|
||||
|
||||
// The app's STAGE bounds: the Frame reports a 1x1 m rect but OVRPlugin can't give its outline ("ovrpBoundary_Outer
|
||||
// is not supported"), so Unity games that trust a configured boundary index an empty array (Demeter's
|
||||
// RemoveObjectNotInGuardian). With an emulated room there is no play area for the app; build_room asks the runtime.
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL emu_stage_bounds(XrSession session, XrReferenceSpaceType type, XrExtent2Df *bounds) {
|
||||
if (type == XR_REFERENCE_SPACE_TYPE_STAGE) {
|
||||
if (!bounds) return XR_ERROR_VALIDATION_FAILURE;
|
||||
static int logged;
|
||||
if (!logged++) LOG("scene: STAGE bounds hidden from the app (no play-area outline on this runtime)");
|
||||
*bounds = (XrExtent2Df){0, 0};
|
||||
return XR_SPACE_BOUNDS_UNAVAILABLE;
|
||||
}
|
||||
PFN_xrGetReferenceSpaceBoundsRect fn =
|
||||
(PFN_xrGetReferenceSpaceBoundsRect)lookup(active_instance, "xrGetReferenceSpaceBoundsRect");
|
||||
return fn ? fn(session, type, bounds) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
|
||||
#include "scene_meta.c"
|
||||
|
||||
static PFN_xrVoidFunction scene_emulation(const char *name) {
|
||||
if (!emulate_scene) return NULL;
|
||||
@@ -468,11 +697,17 @@ static PFN_xrVoidFunction scene_emulation(const char *name) {
|
||||
{"xrGetSpaceBoundingBox2DFB", (PFN_xrVoidFunction)emu_get_bounding_box_2d},
|
||||
{"xrGetSpaceBoundingBox3DFB", (PFN_xrVoidFunction)emu_get_bounding_box_3d},
|
||||
{"xrGetSpaceBoundary2DFB", (PFN_xrVoidFunction)emu_get_boundary_2d},
|
||||
{"xrGetSpaceTriangleMeshMETA", (PFN_xrVoidFunction)emu_get_triangle_mesh},
|
||||
{"xrGetSpaceSemanticLabelsFB", (PFN_xrVoidFunction)emu_get_semantic_labels},
|
||||
{"xrRequestSceneCaptureFB", (PFN_xrVoidFunction)emu_request_scene_capture},
|
||||
{"xrCreateSpatialAnchorFB", (PFN_xrVoidFunction)emu_create_anchor},
|
||||
{"xrSaveSpaceFB", (PFN_xrVoidFunction)emu_save_space},
|
||||
{"xrEraseSpaceFB", (PFN_xrVoidFunction)emu_erase_space},
|
||||
{"xrDiscoverSpacesMETA", (PFN_xrVoidFunction)emu_discover_spaces},
|
||||
{"xrRetrieveSpaceDiscoveryResultsMETA", (PFN_xrVoidFunction)emu_retrieve_discovery},
|
||||
{"xrSaveSpacesMETA", (PFN_xrVoidFunction)emu_save_spaces},
|
||||
{"xrEraseSpacesMETA", (PFN_xrVoidFunction)emu_erase_spaces},
|
||||
{"xrGetReferenceSpaceBoundsRect", (PFN_xrVoidFunction)emu_stage_bounds},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(table) / sizeof(table[0]); ++i)
|
||||
if (!strcmp(name, table[i].name)) return table[i].fn;
|
||||
@@ -485,6 +720,16 @@ static void scene_on_create_session(XrSession session) {
|
||||
PFN_xrCreateReferenceSpace create = (PFN_xrCreateReferenceSpace)lookup(active_instance, "xrCreateReferenceSpace");
|
||||
XrReferenceSpaceCreateInfo ci = {XR_TYPE_REFERENCE_SPACE_CREATE_INFO, NULL, XR_REFERENCE_SPACE_TYPE_STAGE,
|
||||
{{0, 0, 0, 1}, {0, 0, 0}}};
|
||||
pthread_mutex_lock(&scene_lock);
|
||||
reset_room();
|
||||
scene_generation = (scene_generation + 1) & 255;
|
||||
event_head = event_tail = 0;
|
||||
for (int i = 0; i < MAX_QUERIES; ++i) { free(queries[i].results); memset(&queries[i], 0, sizeof(queries[i])); }
|
||||
query_next = 0;
|
||||
scene_session = session;
|
||||
scene_stage = XR_NULL_HANDLE;
|
||||
if (create && XR_SUCCEEDED(create(session, &ci, &scene_stage))) LOG("scene: emulation active (anchored to STAGE)");
|
||||
build_room();
|
||||
load_anchors();
|
||||
pthread_mutex_unlock(&scene_lock);
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]()
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "scan.h"
|
||||
|
||||
#define MIN_TRIANGLE_TWICE_AREA 1e-8f
|
||||
#define MIN_RANGE .1f
|
||||
#define MAX_COORDINATE 10000.f
|
||||
#define QUATERNION_NORM_TOLERANCE .1f
|
||||
#define SAMPLE_SPACING_VOXELS .75f
|
||||
#define MAX_SURFEL_WEIGHT 10.f
|
||||
#define FEW_FRAMES 8
|
||||
#define MAX_GRID_XZ 8.f
|
||||
#define MAX_GRID_Y 4.f
|
||||
#define GRID_PADDING_VOXELS 5.f
|
||||
#define NORMAL_PATCH_SIZE 12
|
||||
#define MIN_PATCH_POINTS 24
|
||||
|
||||
float root(float x)
|
||||
{
|
||||
if (x <= 0) return 0;
|
||||
/* -fno-math-errno makes this one hardware sqrt, with no libc/libm symbol on either build. */
|
||||
return __builtin_sqrtf(x);
|
||||
}
|
||||
Vec unit(Vec p) { float n = root(dot(p,p)); return n > 1e-12f ? mul(p,1/n) : vec(0,0,0); }
|
||||
int flo(float x) { int i = (int)x; return x < (float)i ? i-1 : i; }
|
||||
float sine(float x)
|
||||
{
|
||||
while (x > PI) x -= 2*PI;
|
||||
while (x < -PI) x += 2*PI;
|
||||
if (x > PI/2) x = PI-x;
|
||||
if (x < -PI/2) x = -PI-x;
|
||||
float q = x*x;
|
||||
return x*(1-q/6+q*q/120-q*q*q/5040+q*q*q*q/362880);
|
||||
}
|
||||
float cosine(float x) { return sine(x+PI/2); }
|
||||
float angle(float y, float x)
|
||||
{
|
||||
/* atan approximation, maximum error < .012 degrees. */
|
||||
float a = mn(ab(x),ab(y)) / mx(mx(ab(x),ab(y)),1e-20f), q = a*a;
|
||||
float r = a*(.999866f+q*(-.3302995f+q*(.180141f+q*(-.085133f+q*.0208351f))));
|
||||
if (ab(y) > ab(x)) r = PI/2-r;
|
||||
if (x < 0) r = PI-r;
|
||||
return y < 0 ? -r : r;
|
||||
}
|
||||
int same(const char *a, const char *b)
|
||||
{
|
||||
while (*a && *a == *b) a++,b++;
|
||||
return *a == *b;
|
||||
}
|
||||
u32 random_next(Scan *s) { s->rng ^= s->rng<<13; s->rng ^= s->rng>>17; s->rng ^= s->rng<<5; return s->rng; }
|
||||
void warn(Scan *s, const char *m)
|
||||
{
|
||||
for (int i = 0; i < s->nwarnings; i++) if (same(s->warnings[i],m)) return;
|
||||
if (s->nwarnings < 24) s->warnings[s->nwarnings++] = m;
|
||||
}
|
||||
static u32 u32le(const u8 *p) { return (u32)p[0]|(u32)p[1]<<8|(u32)p[2]<<16|(u32)p[3]<<24; }
|
||||
static float f32le(const u8 *p) { union { u32 u; float f; } v = {u32le(p)}; return v.f; }
|
||||
/* 1 = complete, 0 = clean EOF, -1 = I/O error or partial record. */
|
||||
static int exact(int fd, void *p, usize n)
|
||||
{
|
||||
usize done = 0;
|
||||
while (done < n) {
|
||||
long r = io_read(fd,(u8 *)p+done,n-done);
|
||||
if (r < 0 || (!r && done)) return -1;
|
||||
if (!r) return 0;
|
||||
done += (usize)r;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
Vec grid_point(Scan *s, int x, int y, int z)
|
||||
{
|
||||
return add(s->origin,vec(x*s->opt.voxel,y*s->opt.voxel,z*s->opt.voxel));
|
||||
}
|
||||
int cell_index(Scan *s, Vec p)
|
||||
{
|
||||
Vec q = mul(sub(p,s->origin),1/s->opt.voxel);
|
||||
int x = flo(q.x+.5f), y = flo(q.y+.5f), z = flo(q.z+.5f);
|
||||
if (x < 0 || y < 0 || z < 0 || x >= s->nx || y >= s->ny || z >= s->nz) return -1;
|
||||
return (y*s->nz+z)*s->nx+x;
|
||||
}
|
||||
static void sample(Scan *s, Vec p, Vec n, float w)
|
||||
{
|
||||
int i = cell_index(s,p);
|
||||
if (i < 0) { s->clipped++; return; }
|
||||
Cell *c = &s->cells[i];
|
||||
/* Cap repeated views' influence without throwing away later measurements. */
|
||||
float scale = c->weight > MAX_SURFEL_WEIGHT ? MAX_SURFEL_WEIGHT/c->weight : 1;
|
||||
c->p = add(mul(c->p,scale),mul(p,w));
|
||||
c->n = add(mul(c->n,scale),mul(n,w));
|
||||
c->weight = c->weight*scale+w;
|
||||
if(c->last_frame!=s->fuse_frame) { c->last_frame=s->fuse_frame;c->views++; }
|
||||
}
|
||||
static void triangle(Scan *s, Vec a, Vec b, Vec c, Vec eye, Vec patch_normal, int fuse)
|
||||
{
|
||||
Vec abv = sub(b,a), ac = sub(c,a), bc = sub(c,b);
|
||||
float longest = root(mx(dot(abv,abv),mx(dot(ac,ac),dot(bc,bc))));
|
||||
Vec p = mul(add(a,add(b,c)),1.f/3), n = cross(abv,ac);
|
||||
float twice_area = root(dot(n,n));
|
||||
Vec view = sub(eye,p);
|
||||
float distance = root(dot(view,view));
|
||||
if (twice_area < MIN_TRIANGLE_TWICE_AREA || longest > s->opt.max_edge || distance > s->opt.range || distance < MIN_RANGE) {
|
||||
if (!fuse) s->rejected++;
|
||||
return;
|
||||
}
|
||||
n = mul(n,1/twice_area);
|
||||
if(dot(patch_normal,patch_normal)>.5f) n=patch_normal;
|
||||
float facing = dot(n,view)/distance;
|
||||
if (ab(facing) < s->opt.grazing) { if (!fuse) s->rejected++; return; }
|
||||
if (facing < 0) n = mul(n,-1);
|
||||
if (!fuse) {
|
||||
s->accepted++;
|
||||
s->seen++;
|
||||
int slot = s->seen <= MAX_SAMPLES ? (int)s->seen-1 : (int)(random_next(s)%s->seen);
|
||||
if (slot < MAX_SAMPLES) s->samples[slot] = p;
|
||||
s->nsamples = (int)mn((float)s->seen,MAX_SAMPLES);
|
||||
} else {
|
||||
int steps = (int)(longest/(s->opt.voxel*SAMPLE_SPACING_VOXELS))+1;
|
||||
float weight = twice_area*facing*facing / ((steps+1)*(steps+2)*.5f);
|
||||
for (int j = 0; j <= steps; j++) for (int k = 0; k <= steps-j; k++) {
|
||||
Vec q = add(a,add(mul(abv,(float)j/steps),mul(ac,(float)k/steps)));
|
||||
sample(s,q,n,weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
static Vec patch_normal(Vec *points, float *depths, int width, int x0, int y0, int x1, int y1,
|
||||
float tolerance)
|
||||
{
|
||||
Vec mean=vec(0,0,0);int count=0;
|
||||
/* Shift before accumulating the covariance: room positions can be large relative to patch residuals. */
|
||||
Vec origin=points[y0*width+x0];
|
||||
for(int y=y0;y<y1;y++) for(int x=x0;x<x1;x++) if(depths[y*width+x]>0) {
|
||||
mean=add(mean,sub(points[y*width+x],origin));count++;
|
||||
}
|
||||
if(count<MIN_PATCH_POINTS) return vec(0,0,0);
|
||||
mean=mul(mean,1.f/count);
|
||||
float cov[3][3]={{0}},basis[3][3]={{1,0,0},{0,1,0},{0,0,1}};
|
||||
for(int y=y0;y<y1;y++) for(int x=x0;x<x1;x++) if(depths[y*width+x]>0) {
|
||||
Vec p=sub(sub(points[y*width+x],origin),mean);float a[3]={p.x,p.y,p.z};
|
||||
for(int i=0;i<3;i++) for(int j=0;j<3;j++) cov[i][j]+=a[i]*a[j];
|
||||
}
|
||||
for(int sweep=0;sweep<8;sweep++) {
|
||||
int p=0,q=1;
|
||||
if(ab(cov[0][2])>ab(cov[p][q])) { p=0;q=2; }
|
||||
if(ab(cov[1][2])>ab(cov[p][q])) { p=1;q=2; }
|
||||
if(ab(cov[p][q])<1e-10f) break;
|
||||
float phi=.5f*angle(2*cov[p][q],cov[q][q]-cov[p][p]),co=cosine(phi),si=sine(phi);
|
||||
float aa=cov[p][p],bb=cov[q][q],abv=cov[p][q];
|
||||
for(int k=0;k<3;k++) if(k!=p && k!=q) {
|
||||
float a=cov[k][p],b=cov[k][q];
|
||||
cov[k][p]=cov[p][k]=co*a-si*b;cov[k][q]=cov[q][k]=si*a+co*b;
|
||||
}
|
||||
cov[p][p]=co*co*aa-2*si*co*abv+si*si*bb;
|
||||
cov[q][q]=si*si*aa+2*si*co*abv+co*co*bb;cov[p][q]=cov[q][p]=0;
|
||||
for(int k=0;k<3;k++) {
|
||||
float a=basis[k][p],b=basis[k][q];basis[k][p]=co*a-si*b;basis[k][q]=si*a+co*b;
|
||||
}
|
||||
}
|
||||
int least=0;
|
||||
for(int i=1;i<3;i++) if(cov[i][i]<cov[least][least]) least=i;
|
||||
if(cov[least][least]/count>tolerance*tolerance) return vec(0,0,0);
|
||||
return unit(vec(basis[0][least],basis[1][least],basis[2][least]));
|
||||
}
|
||||
static int grid_normals(Scan *s, Vec *points, float *depths, Vec *normals, u32 nv, u32 ni, const u8 *indices)
|
||||
{
|
||||
if(ni<6 || u32le(indices)!=0 || u32le(indices+8)!=1) return 0;
|
||||
u32 width=u32le(indices+4);
|
||||
if(width<3 || width>nv || nv%width || ni!=6*(width-1)*(nv/width-1)) return 0;
|
||||
/* Only a complete regular fan uses patch normals. Other meshes retain ordinary triangle normals. */
|
||||
u32 at=0;
|
||||
for(u32 row=0;row+1<nv/width;row++) for(u32 col=0;col+1<width;col++) {
|
||||
u32 a=row*width+col,expected[6]={a,a+width,a+1,a+1,a+width,a+width+1};
|
||||
for(int k=0;k<6;k++) if(u32le(indices+4*at++)!=expected[k]) return 0;
|
||||
}
|
||||
int height=(int)(nv/width);
|
||||
for(int y=0;y<height;y+=NORMAL_PATCH_SIZE) for(int x=0;x<(int)width;x+=NORMAL_PATCH_SIZE) {
|
||||
int x1=(int)mn(x+NORMAL_PATCH_SIZE,(float)width),y1=(int)mn(y+NORMAL_PATCH_SIZE,height);
|
||||
Vec n=patch_normal(points,depths,(int)width,x,y,x1,y1,s->opt.patch_residual);
|
||||
for(int yy=y;yy<y1;yy++) for(int xx=x;xx<x1;xx++) normals[yy*width+xx]=n;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
const char *read_dump(Scan *s, int fuse)
|
||||
{
|
||||
int fd = io_open(s->opt.input,0);
|
||||
if (fd < 0) return "cannot open scan dump";
|
||||
u8 header[44];
|
||||
const char *error = 0;
|
||||
Vec *vertices = 0,*normals=0;
|
||||
float *depths = 0;
|
||||
u8 *indices = 0;
|
||||
usize vs = 0, is = 0, ds = 0;
|
||||
if (exact(fd,header,8) != 1 || header[0]!='F' || header[1]!='P' || header[2]!='R' || header[3]!='S' ||
|
||||
u32le(header+4) != 1) { error = "invalid FPRS header"; goto done; }
|
||||
unsigned frames = 0;
|
||||
for (;;) {
|
||||
int r = exact(fd,header,44);
|
||||
if (r == 0) break;
|
||||
if (r < 0) { error = "truncated frame header"; break; }
|
||||
/* Tracking can start before poses are available. Consume the entire bounded record before skipping,
|
||||
* so one NaN cannot hide a truncated payload or desynchronize the next frame. */
|
||||
int invalid=(u32le(header+4)&0x7ff00000u) == 0x7ff00000u;
|
||||
float pose[7];
|
||||
for (int i = 0; i < 7; i++) {
|
||||
pose[i] = f32le(header+8+i*4);
|
||||
if (!finite(pose[i]) || ab(pose[i]) > MAX_COORDINATE) invalid=1;
|
||||
}
|
||||
float norm = 0;
|
||||
for (int i = 3; i < 7; i++) norm += pose[i]*pose[i];
|
||||
if (!finite(norm) || ab(norm-1) > QUATERNION_NORM_TOLERANCE) invalid=1;
|
||||
if(!invalid) for (int i = 3; i < 7; i++) pose[i] /= root(norm);
|
||||
u32 nv = u32le(header+36), ni = u32le(header+40);
|
||||
if (nv > MAX_FRAME_VERTICES || ni > MAX_FRAME_INDICES || ni%3 || (!nv && ni)) {
|
||||
error = "invalid frame counts"; break;
|
||||
}
|
||||
usize needv = (usize)nv*12, needi = (usize)ni*4;
|
||||
if (needv > vs) {
|
||||
io_free(vertices,vs);io_free(normals,vs);vs=needv;vertices=io_alloc(vs);normals=io_alloc(vs);
|
||||
}
|
||||
if (needi > is) { io_free(indices,is); is=needi; indices=io_alloc(is); }
|
||||
if((usize)nv*4>ds) { io_free(depths,ds);ds=(usize)nv*4;depths=io_alloc(ds); }
|
||||
if ((nv && (!vertices || !depths || !normals)) || (ni && !indices)) { error = "frame allocation failed"; break; }
|
||||
if ((needv && exact(fd,vertices,needv)!=1) || (needi && exact(fd,indices,needi)!=1)) {
|
||||
error = "truncated frame data"; break;
|
||||
}
|
||||
Vec eye = vec(pose[0],pose[1],pose[2]), q = vec(pose[3],pose[4],pose[5]);
|
||||
u64 far=0;
|
||||
for (u32 i = 0; i < nv; i++) {
|
||||
u8 *p = (u8 *)&vertices[i];
|
||||
Vec v = vec(f32le(p),f32le(p+4),f32le(p+8));
|
||||
if (!finite(v.x) || !finite(v.y) || !finite(v.z) ||
|
||||
ab(v.x)>MAX_COORDINATE || ab(v.y)>MAX_COORDINATE || ab(v.z)>MAX_COORDINATE) {
|
||||
invalid=1;break;
|
||||
}
|
||||
depths[i]=root(dot(v,v));
|
||||
if(depths[i]>s->opt.range || depths[i]<MIN_RANGE) { depths[i]=-1;far++;continue; }
|
||||
Vec t = mul(cross(q,v),2);
|
||||
vertices[i] = add(eye,add(v,add(mul(t,pose[6]),cross(q,t))));
|
||||
}
|
||||
int regular=!invalid && grid_normals(s,vertices,depths,normals,nv,ni,indices);
|
||||
/* Structural errors are fatal even in a frame whose tracking/depth is unusable. */
|
||||
s->fuse_frame=frames+1;
|
||||
for (u32 i = 0; i < ni; i+=3) {
|
||||
u32 a=u32le(indices+4*i), b=u32le(indices+4*(i+1)), c=u32le(indices+4*(i+2));
|
||||
if (a>=nv || b>=nv || c>=nv) { error = "index outside vertex array"; break; }
|
||||
if(invalid) continue;
|
||||
if(depths[a]<0 || depths[b]<0 || depths[c]<0) { if(!fuse) s->rejected++;continue; }
|
||||
float near=mn(depths[a],mn(depths[b],depths[c])),far_depth=mx(depths[a],mx(depths[b],depths[c]));
|
||||
if(far_depth-near>s->opt.depth_jump+s->opt.depth_ratio*near) {
|
||||
if(!fuse) { s->rejected++;s->discontinuities++; }continue;
|
||||
}
|
||||
Vec normal=regular ? normals[a] : vec(0,0,0);
|
||||
if(regular && dot(normal,normal)<.5f) { if(!fuse) s->rejected++;continue; }
|
||||
triangle(s,vertices[a],vertices[b],vertices[c],eye,normal,fuse);
|
||||
}
|
||||
if (error) break;
|
||||
if(!fuse) { if(invalid) s->skipped_frames++;else s->far_vertices+=far; }
|
||||
frames++;
|
||||
}
|
||||
if (!error && !frames) error = "scan contains no frames";
|
||||
if (!error && !fuse) s->frames = frames;
|
||||
done:
|
||||
io_free(vertices,vs);
|
||||
io_free(normals,vs);
|
||||
io_free(indices,is);
|
||||
io_free(depths,ds);
|
||||
io_close(fd);
|
||||
return error;
|
||||
}
|
||||
static float median(Scan *s, int axis)
|
||||
{
|
||||
/* In-place quickselect on the bounded reservoir; no dump-sized allocations. */
|
||||
int lo=0, hi=s->nsamples-1, mid=s->nsamples/2;
|
||||
while (lo < hi) {
|
||||
float pivot = ((float *)&s->samples[(lo+hi)/2])[axis];
|
||||
int i=lo,j=hi;
|
||||
while (i<=j) {
|
||||
while (((float *)&s->samples[i])[axis] < pivot) i++;
|
||||
while (((float *)&s->samples[j])[axis] > pivot) j--;
|
||||
if (i<=j) { Vec t=s->samples[i]; s->samples[i++]=s->samples[j]; s->samples[j--]=t; }
|
||||
}
|
||||
if (mid<=j) hi=j;
|
||||
else if (mid>=i) lo=i;
|
||||
else break;
|
||||
}
|
||||
return ((float *)&s->samples[mid])[axis];
|
||||
}
|
||||
const char *init_grid(Scan *s)
|
||||
{
|
||||
if (!s->nsamples) return "scan contains no usable triangles";
|
||||
float x=median(s,0), y=median(s,1), z=median(s,2), v=s->opt.voxel;
|
||||
Vec low=s->samples[0],high=low;
|
||||
for(int i=1;i<s->nsamples;i++) {
|
||||
Vec p=s->samples[i];
|
||||
low=vec(mn(low.x,p.x),mn(low.y,p.y),mn(low.z,p.z));
|
||||
high=vec(mx(high.x,p.x),mx(high.y,p.y),mx(high.z,p.z));
|
||||
}
|
||||
float pad=v*mx(GRID_PADDING_VOXELS,s->opt.truncation+2);
|
||||
low=sub(low,vec(pad,pad,pad));high=add(high,vec(pad,pad,pad));
|
||||
s->origin=vec(mx(flo(low.x/v)*v,flo((x-MAX_GRID_XZ/2)/v)*v),
|
||||
mx(flo(low.y/v)*v,flo((y-MAX_GRID_Y/2)/v)*v),
|
||||
mx(flo(low.z/v)*v,flo((z-MAX_GRID_XZ/2)/v)*v));
|
||||
s->nx=(int)mn(-flo(-(high.x-s->origin.x)/v)+1,(int)(MAX_GRID_XZ/v)+1);
|
||||
s->ny=(int)mn(-flo(-(high.y-s->origin.y)/v)+1,(int)(MAX_GRID_Y/v)+1);
|
||||
s->nz=(int)mn(-flo(-(high.z-s->origin.z)/v)+1,(int)(MAX_GRID_XZ/v)+1);
|
||||
usize count = (usize)s->nx*s->ny*s->nz;
|
||||
if (count > MAX_CELLS) return "voxel grid exceeds memory budget; increase --voxel";
|
||||
s->count = (int)count;
|
||||
s->cells = io_alloc(count*sizeof(Cell));
|
||||
return s->cells ? 0 : "grid allocation failed";
|
||||
}
|
||||
void finish_fusion(Scan *s)
|
||||
{
|
||||
/* A short look-around cannot expose each surface in many frames. Cap the target at ten percent of usable
|
||||
* views (at least one); long recordings still require repeated evidence rather than transient depth. */
|
||||
unsigned required=(unsigned)mn(s->opt.min_views,mx(1,(s->frames-s->skipped_frames)/10.f));
|
||||
for (int i=0; i<s->count; i++) {
|
||||
Cell *c=&s->cells[i];
|
||||
if(c->views<required) { c->weight=0;continue; }
|
||||
if (c->weight) { c->p=mul(c->p,1/c->weight); c->n=unit(c->n); }
|
||||
}
|
||||
if (s->clipped) warn(s,"scan clipped to 8 x 8 x 4 metre grid");
|
||||
if (s->frames < FEW_FRAMES) warn(s,"few frames; scan more viewpoints");
|
||||
if(s->skipped_frames) warn(s,"non-finite or invalid frames skipped");
|
||||
if(s->far_vertices) warn(s,"out-of-range depth vertices skipped");
|
||||
if (s->accepted < s->rejected) warn(s,"many triangles rejected; check depth quality");
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "scan.h"
|
||||
|
||||
typedef struct { char data[65536]; usize n; int ok; } Text;
|
||||
static void text(Text *t, const char *p)
|
||||
{
|
||||
while(*p) {
|
||||
if(t->n>=sizeof(t->data)) { t->ok=0;return; }
|
||||
t->data[t->n++]=*p++;
|
||||
}
|
||||
}
|
||||
static void integer(Text *t, u64 value)
|
||||
{
|
||||
char rev[24];int n=0;
|
||||
do { rev[n++]=(char)('0'+value%10);value/=10; } while(value);
|
||||
while(n) { char a[2]={rev[--n],0};text(t,a); }
|
||||
}
|
||||
static void decimal(Text *t, float value)
|
||||
{
|
||||
if(value<0) { text(t,"-");value=-value; }
|
||||
u64 scaled=(u64)(value*10000.f+.5f);
|
||||
integer(t,scaled/10000);text(t,".");
|
||||
u64 fraction=scaled%10000;
|
||||
if(fraction<1000) text(t,"0");
|
||||
if(fraction<100) text(t,"0");
|
||||
if(fraction<10) text(t,"0");
|
||||
integer(t,fraction);
|
||||
}
|
||||
static void space_number(Text *t, float n) { text(t," ");decimal(t,n); }
|
||||
static int uuid(Text *t)
|
||||
{
|
||||
u8 bytes[16];if(!io_random(bytes,sizeof(bytes))) return 0;
|
||||
bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;
|
||||
const char *hex="0123456789abcdef";
|
||||
for(int i=0;i<16;i++) { char pair[3]={hex[bytes[i]>>4],hex[bytes[i]&15],0};text(t,pair); }
|
||||
return 1;
|
||||
}
|
||||
static int record(Text *t, const char *name)
|
||||
{
|
||||
text(t,name);text(t," ");return uuid(t);
|
||||
}
|
||||
static int path(char *out, const char *dir, const char *name)
|
||||
{
|
||||
usize n=0;
|
||||
while(*dir) { if(n>=3900) return 0;out[n++]=*dir++; }
|
||||
if(n && out[n-1]!='/') out[n++]='/';
|
||||
while(*name) { if(n>=4095) return 0;out[n++]=*name++; }
|
||||
out[n]=0;return 1;
|
||||
}
|
||||
static void le32(u8 *out,u32 v) { for(int i=0;i<4;i++) out[i]=(u8)(v>>(8*i)); }
|
||||
static const char *write_files(Scan *s)
|
||||
{
|
||||
Text t={.ok=1};text(&t,"frameport-room 1\nuniverse ");integer(&t,s->opt.universe);
|
||||
text(&t,"\nsource scan\ntime ");integer(&t,(u64)(io_time(0)/1000000000));text(&t,"\n");
|
||||
if(!record(&t,"room")) return "getrandom failed";
|
||||
text(&t,"\n");if(!record(&t,"floor")) return "getrandom failed";
|
||||
space_number(&t,s->floor);text(&t,"\n");if(!record(&t,"ceiling")) return "getrandom failed";
|
||||
space_number(&t,s->ceiling);text(&t,"\noutline ");integer(&t,(u64)s->noutline);
|
||||
for(int i=0;i<s->noutline;i++) { space_number(&t,s->outline[i].x);space_number(&t,s->outline[i].z); }
|
||||
text(&t,"\n");
|
||||
for(int i=0;i<s->noutline;i++) {
|
||||
Pt a=s->outline[i],b=s->outline[(i+1)%s->noutline];
|
||||
if(!record(&t,"wall")) return "getrandom failed";
|
||||
space_number(&t,a.x);space_number(&t,a.z);space_number(&t,b.x);space_number(&t,b.z);
|
||||
space_number(&t,s->floor);space_number(&t,s->ceiling);
|
||||
if(s->invisible[i]) text(&t," INVISIBLE_WALL_FACE");
|
||||
text(&t,"\n");
|
||||
}
|
||||
for(int i=0;i<s->nvolumes;i++) {
|
||||
Volume *v=&s->volumes[i];
|
||||
if(!record(&t,"volume")) return "getrandom failed";
|
||||
text(&t," ");text(&t,v->label);space_number(&t,v->p.x);space_number(&t,v->p.z);
|
||||
space_number(&t,v->p.y);space_number(&t,v->yaw);space_number(&t,v->w);space_number(&t,v->d);space_number(&t,v->h);
|
||||
text(&t,"\n");
|
||||
}
|
||||
if(!record(&t,"mesh")) return "getrandom failed";
|
||||
text(&t," room-mesh.bin ");integer(&t,(u64)s->nv);text(&t," ");integer(&t,(u64)s->ni);text(&t,"\n");
|
||||
if(!t.ok) return "room text exceeds output budget";
|
||||
Text rname={.ok=1},mname={.ok=1};
|
||||
text(&rname,"room.txt.");text(&mname,"room-mesh.bin.");
|
||||
if(!uuid(&rname) || !uuid(&mname)) return "getrandom failed";
|
||||
text(&rname,".tmp");text(&mname,".tmp");rname.data[rname.n]=0;mname.data[mname.n]=0;
|
||||
char room[4096],mesh[4096],rtmp[4096],mtmp[4096];
|
||||
if(!path(room,s->opt.out,"room.txt") || !path(mesh,s->opt.out,"room-mesh.bin") ||
|
||||
!path(rtmp,s->opt.out,rname.data) || !path(mtmp,s->opt.out,mname.data)) return "output path too long";
|
||||
if(!io_mkdir(s->opt.out)) return "cannot create output directory";
|
||||
int fd=io_open(rtmp,1),ok;
|
||||
if(fd<0) return "cannot open room temporary file";
|
||||
ok=io_write(fd,t.data,t.n);if(io_close(fd)) ok=0;
|
||||
if(!ok) { io_remove(rtmp);return "cannot write room temporary file"; }
|
||||
fd=io_open(mtmp,1);
|
||||
if(fd<0) { io_remove(rtmp);return "cannot open mesh temporary file"; }
|
||||
u8 header[16]={'F','P','R','M'};le32(header+4,1);le32(header+8,(u32)s->nv);le32(header+12,(u32)s->ni);
|
||||
ok=io_write(fd,header,16);
|
||||
/* Both supported targets are little-endian IEEE float32; parser always decodes explicitly. */
|
||||
ok=ok && io_write(fd,s->vertices,(usize)s->nv*sizeof(Vec));
|
||||
ok=ok && io_write(fd,s->indices,(usize)s->ni*sizeof(u32));
|
||||
if(io_close(fd)) ok=0;
|
||||
if(!ok) { io_remove(rtmp);io_remove(mtmp);return "cannot write mesh temporary file"; }
|
||||
if(io_rename(mtmp,mesh)) { io_remove(rtmp);io_remove(mtmp);return "cannot publish mesh"; }
|
||||
if(io_rename(rtmp,room)) { io_remove(rtmp);return "cannot publish room"; }
|
||||
return 0;
|
||||
}
|
||||
static int error_json(const char *error)
|
||||
{
|
||||
Text t={.ok=1};text(&t,"{\"ok\":false,\"error\":\"");text(&t,error);text(&t,"\"}\n");
|
||||
io_write(1,t.data,t.n);return 2;
|
||||
}
|
||||
static int parse_float(const char *p,float *out)
|
||||
{
|
||||
int digits=0;float n=0,scale=1;
|
||||
while(*p>='0' && *p<='9') { n=n*10+*p++-'0';digits++;if(n>10000) return 0; }
|
||||
if(*p=='.') { p++;while(*p>='0' && *p<='9') { scale*=.1f;n+=(*p++-'0')*scale;digits++; } }
|
||||
if(!digits || *p || !finite(n)) return 0;
|
||||
*out=n;return 1;
|
||||
}
|
||||
static int parse_universe(const char *p,u64 *out)
|
||||
{
|
||||
u64 n=0;if(!*p) return 0;
|
||||
for(;*p;p++) {
|
||||
if(*p<'0' || *p>'9') return 0;
|
||||
unsigned digit=(unsigned)(*p-'0');
|
||||
if(n>(~(u64)0-digit)/10) return 0;
|
||||
n=n*10+digit;
|
||||
}
|
||||
*out=n;return 1;
|
||||
}
|
||||
static const char *options(Options *o,int argc,char **argv)
|
||||
{
|
||||
*o=(Options){.voxel=.05f,.ceiling_default=2.5f,.truncation=3,.max_edge=.6f,.grazing=.12f,.range=4.f,
|
||||
.depth_jump=.25f,.depth_ratio=.12f,.min_views=5,.patch_residual=.03f,
|
||||
.plane_distance=.045f,.min_plane_area=.45f,.wall_height=1.5f,.snap_degrees=5,.floor_band=.35f,
|
||||
.ceiling_min=1.8f,.min_volume=.05f,.cluster_gap=.10f,.gap=.65f,.horizontal=.85f};
|
||||
for(int i=1;i<argc;i++) {
|
||||
const char *arg=argv[i];
|
||||
if(same(arg,"--json")) continue;
|
||||
if(i+1==argc) return "option requires a value";
|
||||
const char *value=argv[++i];
|
||||
if(same(arg,"--offline")) o->input=value;
|
||||
else if(same(arg,"--out")) o->out=value;
|
||||
else if(same(arg,"--hint")) o->hint=value;
|
||||
else if(same(arg,"--universe")) { if(!parse_universe(value,&o->universe)) return "invalid universe"; }
|
||||
else {
|
||||
float *dest=0,low=0,high=100;
|
||||
if(same(arg,"--voxel")) { dest=&o->voxel;low=.04f;high=.2f; }
|
||||
else if(same(arg,"--ceiling-default")) { dest=&o->ceiling_default;low=1.8f;high=3.8f; }
|
||||
else if(same(arg,"--truncation")) { dest=&o->truncation;low=1;high=5; }
|
||||
else if(same(arg,"--max-edge")) { dest=&o->max_edge;low=.05f;high=1.5f; }
|
||||
else if(same(arg,"--grazing")) { dest=&o->grazing;low=.01f;high=.9f; }
|
||||
else if(same(arg,"--range")) { dest=&o->range;low=1;high=10; }
|
||||
else if(same(arg,"--depth-jump")) { dest=&o->depth_jump;low=.02f;high=1; }
|
||||
else if(same(arg,"--depth-ratio")) { dest=&o->depth_ratio;high=.5f; }
|
||||
else if(same(arg,"--min-views")) { dest=&o->min_views;low=1;high=16; }
|
||||
else if(same(arg,"--patch-residual")) { dest=&o->patch_residual;low=.005f;high=.1f; }
|
||||
else if(same(arg,"--plane-distance")) { dest=&o->plane_distance;low=.01f;high=.15f; }
|
||||
else if(same(arg,"--min-plane-area")) { dest=&o->min_plane_area;low=.1f;high=4; }
|
||||
else if(same(arg,"--wall-height")) { dest=&o->wall_height;low=.5f;high=3; }
|
||||
else if(same(arg,"--snap-degrees")) { dest=&o->snap_degrees;high=15; }
|
||||
else if(same(arg,"--floor-band")) { dest=&o->floor_band;low=.05f;high=1; }
|
||||
else if(same(arg,"--ceiling-min")) { dest=&o->ceiling_min;low=1.5f;high=3; }
|
||||
else if(same(arg,"--min-volume")) { dest=&o->min_volume;low=.005f;high=1; }
|
||||
else if(same(arg,"--cluster-gap")) { dest=&o->cluster_gap;low=.04f;high=.2f; }
|
||||
else if(same(arg,"--gap")) { dest=&o->gap;low=.1f;high=1.5f; }
|
||||
else if(same(arg,"--horizontal")) { dest=&o->horizontal;low=.6f;high=.99f; }
|
||||
else return "unknown option";
|
||||
if(!parse_float(value,dest) || *dest<low || *dest>high) return "option outside supported range";
|
||||
}
|
||||
}
|
||||
return o->input && o->out ? 0 : "required: --offline dump.fprs --out directory";
|
||||
}
|
||||
static void duration(Text *t,i64 a,i64 b) { decimal(t,(float)(b-a)/1000000); }
|
||||
static void summary(Scan *s)
|
||||
{
|
||||
Text t={.ok=1};text(&t,"{\"ok\":true,\"frames\":");integer(&t,s->frames);
|
||||
text(&t,",\"skipped_frames\":");integer(&t,s->skipped_frames);
|
||||
text(&t,",\"far_vertices\":");integer(&t,s->far_vertices);
|
||||
text(&t,",\"discontinuity_triangles\":");integer(&t,s->discontinuities);
|
||||
text(&t,",\"walls\":");integer(&t,(u64)s->noutline);
|
||||
text(&t,",\"detected_walls\":");integer(&t,(u64)s->nplanes);
|
||||
text(&t,",\"volumes\":");integer(&t,(u64)s->nvolumes);
|
||||
text(&t,",\"floor_y\":");decimal(&t,s->floor);text(&t,",\"ceiling_y\":");decimal(&t,s->ceiling);
|
||||
text(&t,",\"coverage_percent\":");decimal(&t,s->coverage);
|
||||
text(&t,",\"hint_used\":");text(&t,s->hint_used ? "true" : "false");
|
||||
text(&t,",\"triangles\":");integer(&t,(u64)s->ni/3);
|
||||
text(&t,",\"accepted_triangles\":");integer(&t,s->accepted);
|
||||
text(&t,",\"rejected_triangles\":");integer(&t,s->rejected);
|
||||
text(&t,",\"warnings\":[");
|
||||
for(int i=0;i<s->nwarnings;i++) { if(i) text(&t,",");text(&t,"\"");text(&t,s->warnings[i]);text(&t,"\""); }
|
||||
text(&t,"],\"timings_ms\":{\"read\":");duration(&t,s->t0,s->tread);
|
||||
text(&t,",\"fusion\":");duration(&t,s->tread,s->tfuse);
|
||||
text(&t,",\"model\":");duration(&t,s->tfuse,s->tmodel);
|
||||
text(&t,",\"mesh\":");duration(&t,s->tmodel,s->tmesh);
|
||||
text(&t,",\"total\":");duration(&t,s->t0,io_time(1));text(&t,"}}\n");io_write(1,t.data,t.n);
|
||||
}
|
||||
int main(int argc,char **argv)
|
||||
{
|
||||
if(argc==2 && same(argv[1],"--help")) {
|
||||
const char usage[]="fp_roomscan --offline dump.fprs --out directory [--universe N] [--voxel 0.05]\n"
|
||||
" [--ceiling-default 2.5] [--hint outline.txt] [--json]\n"
|
||||
"Tuning: --truncation --max-edge --grazing --range --depth-jump --depth-ratio --plane-distance --min-plane-area\n"
|
||||
" --min-views --patch-residual --wall-height --snap-degrees --floor-band --ceiling-min\n"
|
||||
" --min-volume --cluster-gap --gap --horizontal\n"
|
||||
"Always prints one JSON summary; errors exit 2. See docs/mr/scan.md for units and ranges.\n";
|
||||
io_write(1,usage,sizeof(usage)-1);return 0;
|
||||
}
|
||||
Scan *s=io_alloc(sizeof(Scan));
|
||||
if(!s) return error_json("scan allocation failed");
|
||||
s->rng=0x714ce3u;s->t0=io_time(1);
|
||||
const char *error=options(&s->opt,argc,argv);
|
||||
if(!error) error=read_dump(s,0);
|
||||
s->tread=io_time(1);
|
||||
if(!error) error=init_grid(s);
|
||||
if(!error) error=read_dump(s,1);
|
||||
if(!error) finish_fusion(s);
|
||||
s->tfuse=io_time(1);
|
||||
if(!error) error=make_model(s);
|
||||
s->tmodel=io_time(1);
|
||||
if(!error) error=make_mesh(s);
|
||||
s->tmesh=io_time(1);
|
||||
if(!error) error=write_files(s);
|
||||
int result=0;
|
||||
if(error) result=error_json(error);else summary(s);
|
||||
io_free(s->cells,(usize)s->count*sizeof(Cell));
|
||||
io_free(s->vertices,(usize)MAX_MESH_VERTICES*sizeof(Vec));
|
||||
io_free(s->indices,(usize)MAX_TRIANGLES*3*sizeof(u32));
|
||||
io_free(s,sizeof(Scan));return result;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "scan.h"
|
||||
|
||||
#define TANGENT_RADIUS_SQUARED 2.25f
|
||||
#define TANGENT_WEIGHT_RADIUS_SQUARED 2.5f
|
||||
#define MAX_EXTRACTION_STRIDE 4
|
||||
/* Marching cubes without a 256-case table: trace the zero contour on the six faces, then fan each closed loop.
|
||||
* Ambiguous faces choose their diagonal from the face centre. Adjacent cubes therefore make the same choice. */
|
||||
static const int corners[8][3]={{0,0,0},{1,0,0},{1,0,1},{0,0,1},{0,1,0},{1,1,0},{1,1,1},{0,1,1}};
|
||||
static const int edges[12][2]={{0,1},{1,2},{2,3},{3,0},{4,5},{5,6},{6,7},{7,4},{0,4},{1,5},{2,6},{3,7}};
|
||||
static const int faces[6][4]={{0,1,2,3},{4,5,6,7},{0,9,4,8},{1,10,5,9},{2,11,6,10},{3,8,7,11}};
|
||||
|
||||
static void tsdf(Scan *s)
|
||||
{
|
||||
float v=s->opt.voxel,trunc=v*s->opt.truncation;
|
||||
int radius=(int)s->opt.truncation+2;
|
||||
for (int i=0;i<s->count;i++) {
|
||||
Cell *c=&s->cells[i];
|
||||
if (!c->weight) continue;
|
||||
int x=i%s->nx,z=(i/s->nx)%s->nz,y=i/(s->nx*s->nz);
|
||||
for (int dy=-radius;dy<=radius;dy++) for (int dz=-radius;dz<=radius;dz++) for (int dx=-radius;dx<=radius;dx++) {
|
||||
int xx=x+dx,yy=y+dy,zz=z+dz;
|
||||
if (xx<0 || yy<0 || zz<0 || xx>=s->nx || yy>=s->ny || zz>=s->nz) continue;
|
||||
Vec delta=sub(grid_point(s,xx,yy,zz),c->p);
|
||||
float d=dot(delta,c->n),tangent=dot(delta,delta)-d*d;
|
||||
if (ab(d)>trunc || tangent>v*v*TANGENT_RADIUS_SQUARED) continue;
|
||||
Cell *dest=&s->cells[(yy*s->nz+zz)*s->nx+xx];
|
||||
float w=(1-tangent/(v*v*TANGENT_WEIGHT_RADIUS_SQUARED))*mn(c->weight/(v*v),1.f);
|
||||
dest->sdf+=d*w;dest->sw+=w;
|
||||
}
|
||||
}
|
||||
for (int i=0;i<s->count;i++) if(s->cells[i].sw) s->cells[i].sdf/=s->cells[i].sw;
|
||||
}
|
||||
static void connect(int links[12][2], int a, int b)
|
||||
{
|
||||
if(links[a][0]<0) links[a][0]=b;else links[a][1]=b;
|
||||
if(links[b][0]<0) links[b][0]=a;else links[b][1]=a;
|
||||
}
|
||||
static int cube(Scan *s, int x, int y, int z, int stride)
|
||||
{
|
||||
float f[8];Vec p[8],normal=vec(0,0,0);
|
||||
for(int k=0;k<8;k++) {
|
||||
int xx=x+corners[k][0]*stride,yy=y+corners[k][1]*stride,zz=z+corners[k][2]*stride;
|
||||
Cell *c=&s->cells[(yy*s->nz+zz)*s->nx+xx];
|
||||
if (c->sw<=0) return 1; /* Unknown space never creates an invented surface. */
|
||||
f[k]=c->sdf;p[k]=grid_point(s,xx,yy,zz);
|
||||
}
|
||||
Vec cuts[12];int crossing[12],links[12][2],visited[12]={0};
|
||||
for(int e=0;e<12;e++) {
|
||||
int a=edges[e][0],b=edges[e][1];
|
||||
crossing[e]=(f[a]<0)!=(f[b]<0);links[e][0]=links[e][1]=-1;
|
||||
if(crossing[e]) cuts[e]=add(p[a],mul(sub(p[b],p[a]),f[a]/(f[a]-f[b])));
|
||||
}
|
||||
/* Gradient points to positive TSDF: toward the observed free room, including furniture's exterior. */
|
||||
normal.x=f[1]+f[2]+f[5]+f[6]-f[0]-f[3]-f[4]-f[7];
|
||||
normal.y=f[4]+f[5]+f[6]+f[7]-f[0]-f[1]-f[2]-f[3];
|
||||
normal.z=f[2]+f[3]+f[6]+f[7]-f[0]-f[1]-f[4]-f[5];
|
||||
for(int face=0;face<6;face++) {
|
||||
int hit[4],n=0;float centre=0;
|
||||
for(int j=0;j<4;j++) {
|
||||
int e=faces[face][j];
|
||||
if(crossing[e]) hit[n++]=e;
|
||||
centre+=f[edges[e][0]]+f[edges[e][1]];
|
||||
}
|
||||
if(n==2) connect(links,hit[0],hit[1]);
|
||||
else if(n==4) {
|
||||
/* The shared first edge's first corner fixes the phase on this face. */
|
||||
int first=edges[hit[0]][0],second=edges[hit[0]][1];
|
||||
int common=(edges[hit[1]][0]==second || edges[hit[1]][1]==second) ? second : first;
|
||||
if((centre<0)==(f[common]<0)) {
|
||||
connect(links,hit[0],hit[3]);connect(links,hit[1],hit[2]);
|
||||
} else { connect(links,hit[0],hit[1]);connect(links,hit[2],hit[3]); }
|
||||
}
|
||||
}
|
||||
for(int e=0;e<12;e++) if(crossing[e] && !visited[e]) {
|
||||
int loop[12],n=0,prev=-1,cur=e;
|
||||
while(cur>=0 && !visited[cur] && n<12) {
|
||||
visited[cur]=1;loop[n++]=cur;
|
||||
int next=links[cur][0]==prev ? links[cur][1] : links[cur][0];prev=cur;cur=next;
|
||||
}
|
||||
if(cur!=e) continue;
|
||||
for(int k=1;k+1<n;k++) {
|
||||
Vec a=cuts[loop[0]],b=cuts[loop[k]],c=cuts[loop[k+1]];
|
||||
Vec norm=cross(sub(b,a),sub(c,a));
|
||||
if(dot(norm,norm)<1e-14f) continue;
|
||||
if(s->ni/3>=MAX_TRIANGLES) return 0;
|
||||
if(dot(norm,normal)<0) { Vec t=b;b=c;c=t; }
|
||||
s->vertices[s->nv]=a;s->vertices[s->nv+1]=b;s->vertices[s->nv+2]=c;
|
||||
for(int j=0;j<3;j++) s->indices[s->ni++]=(u32)s->nv++;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
const char *make_mesh(Scan *s)
|
||||
{
|
||||
s->vertices=io_alloc((usize)MAX_MESH_VERTICES*sizeof(Vec));
|
||||
s->indices=io_alloc((usize)MAX_TRIANGLES*3*sizeof(u32));
|
||||
if(!s->vertices || !s->indices) return "mesh allocation failed";
|
||||
tsdf(s);
|
||||
for(int stride=1;stride<=MAX_EXTRACTION_STRIDE;stride++) {
|
||||
s->nv=s->ni=0;int ok=1;
|
||||
for(int y=0;y+stride<s->ny && ok;y+=stride)
|
||||
for(int z=0;z+stride<s->nz && ok;z+=stride)
|
||||
for(int x=0;x+stride<s->nx && ok;x+=stride) ok=cube(s,x,y,z,stride);
|
||||
if(ok) {
|
||||
if(stride>1) warn(s,"mesh decimated by coarsening extraction grid");
|
||||
if(!s->ni) warn(s,"no mesh surface; scan denser views");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return "mesh exceeds triangle budget";
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "scan.h"
|
||||
|
||||
#define RANSAC_TRIALS 96
|
||||
#define NORMAL_AGREEMENT .90f
|
||||
#define MIN_WALL_SPAN .60f
|
||||
#define FURNITURE_MARGIN .12f
|
||||
#define NEAR_WALL .35f
|
||||
#define MIN_TOP_AREA .25f
|
||||
#define BOX_TRIM .02f
|
||||
#define VERTICAL_NORMAL_Y .35f
|
||||
#define BOX_SIDE_NORMAL_Y .30f
|
||||
#define BOX_EDGE_STRENGTH .40f
|
||||
#define MIN_CLUSTER_SURFELS 12
|
||||
#define MIN_BOX_EDGE_VOTES 12
|
||||
#define MIN_OUTLINE_EXTENT 1.f
|
||||
#define MIN_OUTLINE_TWICE_AREA .5f
|
||||
#define FLOOR_INFER_DISTANCE .3f
|
||||
#define TABLE_MIN_HEIGHT .55f
|
||||
#define TABLE_MAX_HEIGHT 1.1f
|
||||
#define COUCH_MIN_HEIGHT .35f
|
||||
#define COUCH_MAX_HEIGHT .9f
|
||||
#define COUCH_MIN_LENGTH 1.4f
|
||||
#define BED_MIN_HEIGHT .3f
|
||||
#define BED_MAX_HEIGHT .8f
|
||||
#define BED_MIN_AREA 2.f
|
||||
#define STORAGE_MIN_HEIGHT 1.1f
|
||||
#define SCREEN_MAX_DEPTH .22f
|
||||
#define SCREEN_MIN_WIDTH .4f
|
||||
#define SCREEN_MAX_WIDTH 2.f
|
||||
#define DEFAULT_CIRCLE_MIN_POINTS 8
|
||||
#define DEFAULT_CIRCLE_RADIAL_CV .05f
|
||||
#define LOW_COVERAGE_PERCENT 80.f
|
||||
#define WALL_MERGE_DISTANCE .25f
|
||||
#define WALL_MERGE_COSINE .97f
|
||||
#define RELATIVE_WALL_AREA .20f
|
||||
#define DOMINANT_WALL_TOLERANCE 12.f
|
||||
#define ORTHOGONAL_SUPPORT_FRACTION .65f
|
||||
#define CEILING_MIN_AREA .08f /* m^2 of fused down-facing cells at one height (fused surfaces are sparse: the
|
||||
* owner's floor was 0.7 m^2 of cells, the ceiling 0.11 m^2 after a 2 s look up) */
|
||||
#define CEILING_MIN_HEIGHT 2.0f /* m above the floor: lower ones are shelves, door frames, table undersides */
|
||||
#define CEILING_MAX_HEIGHT 4.0f
|
||||
#define BOX_AXIS_COS .96f /* walls within ~16 deg of the two room axes (smeared depth tilted a real
|
||||
* wall 12 deg in the owner's scan) */
|
||||
#define BOX_SNAP .5f /* m: a rectangle side snaps to a detected wall this close */
|
||||
#define BOX_LOW_SHARE .02f /* surfaces beyond the 2nd/98th percentile along an axis are outliers */
|
||||
#define WALL_BAND_TOP 2.4f /* m above the floor: walls are fitted below this (and below the ceiling) */
|
||||
#define MIN_FURNITURE_TOP .25f
|
||||
#define WALL_FURNITURE_CLEARANCE .15f
|
||||
#define MIN_TOP_DENSITY .30f
|
||||
|
||||
static void consolidate_walls(Scan *s)
|
||||
{
|
||||
if(!s->nplanes) return;
|
||||
Plane result[MAX_PLANES]={0};int count=0;
|
||||
float largest=0;
|
||||
for(int i=0;i<s->nplanes;i++) {
|
||||
Plane p=s->planes[i];int merge=-1;
|
||||
largest=mx(largest,p.area);
|
||||
for(int j=0;j<count;j++) if(dot(p.n,result[j].n)>WALL_MERGE_COSINE &&
|
||||
ab(p.d-result[j].d)<WALL_MERGE_DISTANCE) { merge=j;break; }
|
||||
if(merge<0) result[count++]=p;
|
||||
else {
|
||||
Plane *r=&result[merge];
|
||||
r->area+=p.area;r->bottom=mn(r->bottom,p.bottom);r->top=mx(r->top,p.top);
|
||||
r->lo=mn(r->lo,p.lo);r->hi=mx(r->hi,p.hi);
|
||||
}
|
||||
}
|
||||
float base=angle(result[0].n.z,result[0].n.x),aligned=0,total=0;
|
||||
for(int i=0;i<count;i++) {
|
||||
Plane *p=&result[i];float a=angle(p->n.z,p->n.x)-base;
|
||||
float delta=ab(a-flo(a/(PI/2)+.5f)*(PI/2));
|
||||
total+=p->area;
|
||||
if(delta<DOMINANT_WALL_TOLERANCE*PI/180) aligned+=p->area;
|
||||
}
|
||||
int orthogonal=aligned>total*ORTHOGONAL_SUPPORT_FRACTION && s->opt.snap_degrees>0;
|
||||
s->nplanes=0;
|
||||
for(int i=0;i<count;i++) {
|
||||
Plane p=result[i];float a=angle(p.n.z,p.n.x)-base;
|
||||
float delta=ab(a-flo(a/(PI/2)+.5f)*(PI/2));
|
||||
if(p.area<largest*RELATIVE_WALL_AREA) continue;
|
||||
if(orthogonal && delta>DOMINANT_WALL_TOLERANCE*PI/180) continue;
|
||||
s->planes[s->nplanes++]=p;
|
||||
}
|
||||
}
|
||||
|
||||
static float plane_height(Scan *s, int ceiling, int *found)
|
||||
{
|
||||
float hist[512]={0}, step=s->opt.voxel*.5f;
|
||||
int bins=(int)(4/step)+2;
|
||||
if (bins>512) bins=512;
|
||||
for (int i=0;i<s->count;i++) {
|
||||
Cell *c=&s->cells[i];
|
||||
if (!c->weight || (ceiling ? c->n.y > -s->opt.horizontal : c->n.y < s->opt.horizontal)) continue;
|
||||
if (ceiling ? c->p.y<s->opt.ceiling_min : ab(c->p.y)>s->opt.floor_band) continue;
|
||||
int b=flo((c->p.y-s->origin.y)/step);
|
||||
if (b>=0 && b<bins) hist[b]+=1;
|
||||
}
|
||||
int best=0;
|
||||
float score=0;
|
||||
for (int b=1;b<bins-1;b++) {
|
||||
float h=hist[b-1]+hist[b]+hist[b+1];
|
||||
if (h>score) { score=h; best=b; }
|
||||
}
|
||||
float y=s->origin.y+(best+.5f)*step, total=0, sum=0,upward=0;
|
||||
for (int i=0;i<s->count;i++) {
|
||||
Cell *c=&s->cells[i];
|
||||
if(c->weight && c->n.y>s->opt.horizontal) upward++;
|
||||
if (!c->weight || ab(c->p.y-y)>s->opt.plane_distance) continue;
|
||||
if (ceiling ? c->n.y > -s->opt.horizontal : c->n.y < s->opt.horizontal) continue;
|
||||
total++; sum+=c->p.y;
|
||||
}
|
||||
*found=(ceiling || score* s->opt.voxel*s->opt.voxel >= s->opt.min_plane_area) && total>0;
|
||||
/* A ceiling is judged on its own area and height, not against the floor: players glance up for a second and look
|
||||
* at the floor all the time, so a share-of-upward-surfaces rule rejected every real ceiling (owner's scans,
|
||||
* 2026-10-16: down-facing surfaces at ~2.9 m in both recordings, "ceiling not seen"). */
|
||||
(void)upward;
|
||||
if(ceiling && (total*s->opt.voxel*s->opt.voxel<CEILING_MIN_AREA || y-s->floor<CEILING_MIN_HEIGHT ||
|
||||
y-s->floor>CEILING_MAX_HEIGHT)) *found=0;
|
||||
return *found ? sum/total : (ceiling ? s->floor+s->opt.ceiling_default : 0);
|
||||
}
|
||||
static int on_plane(Scan *s, Cell *c, Plane *p)
|
||||
{
|
||||
return ab(dot(c->p,p->n)-p->d)<s->opt.plane_distance && dot(c->n,p->n)>NORMAL_AGREEMENT;
|
||||
}
|
||||
static void refine(Scan *s, int *ids, int n, Plane *p)
|
||||
{
|
||||
Vec avg=vec(0,0,0), normal=vec(0,0,0);
|
||||
float count=0;
|
||||
for (int i=0;i<n;i++) {
|
||||
Cell *c=&s->cells[ids[i]];
|
||||
if (!c->mark && on_plane(s,c,p)) { avg=add(avg,c->p); normal=add(normal,c->n); count++; }
|
||||
}
|
||||
if (!count) return;
|
||||
avg=mul(avg,1/count);
|
||||
/* Fit positions, rather than averaging noisy per-triangle normals. */
|
||||
float xx=0,zz=0,xz=0;
|
||||
for (int i=0;i<n;i++) {
|
||||
Cell *c=&s->cells[ids[i]];
|
||||
if (!c->mark && on_plane(s,c,p)) {
|
||||
float x=c->p.x-avg.x,z=c->p.z-avg.z;
|
||||
xx+=x*x; zz+=z*z; xz+=x*z;
|
||||
}
|
||||
}
|
||||
float yaw=.5f*angle(2*xz,xx-zz);
|
||||
Vec nn=vec(-sine(yaw),0,cosine(yaw));
|
||||
if (dot(nn,normal)<0) nn=mul(nn,-1);
|
||||
p->n=nn; p->d=dot(nn,avg);
|
||||
p->lo=1e10f; p->hi=-1e10f; p->bottom=1e10f; p->top=-1e10f;
|
||||
count=0;
|
||||
for (int i=0;i<n;i++) {
|
||||
Cell *c=&s->cells[ids[i]];
|
||||
if (!c->mark && on_plane(s,c,p)) {
|
||||
float t=c->p.x*p->n.z-c->p.z*p->n.x;
|
||||
p->lo=mn(p->lo,t); p->hi=mx(p->hi,t);
|
||||
p->bottom=mn(p->bottom,c->p.y); p->top=mx(p->top,c->p.y); count++;
|
||||
}
|
||||
}
|
||||
p->area=count*s->opt.voxel*s->opt.voxel;
|
||||
}
|
||||
static void walls(Scan *s, int *ids, int n)
|
||||
{
|
||||
/* Walls are fitted in a fixed band above the floor whatever the ceiling: Valve's smoothed depth bends where wall
|
||||
* meets ceiling, and with a detected 2.9 m ceiling that band confused the fit (owner's scan, 2026-10-16). */
|
||||
float top=mn(s->ceiling,s->floor+WALL_BAND_TOP)-FURNITURE_MARGIN;
|
||||
for (int i=0;i<n;i++) {
|
||||
Cell *c=&s->cells[ids[i]];
|
||||
c->mark=ab(c->n.y)>VERTICAL_NORMAL_Y || c->p.y<s->floor+FURNITURE_MARGIN || c->p.y>top;
|
||||
}
|
||||
for (int attempt=0;attempt<MAX_PLANES*2 && s->nplanes<MAX_PLANES;attempt++) {
|
||||
Plane best={0}; int best_count=0;
|
||||
for (int trial=0;trial<RANSAC_TRIALS;trial++) {
|
||||
Cell *seed=&s->cells[ids[random_next(s)%(unsigned)n]];
|
||||
if (seed->mark) continue;
|
||||
Plane p={0}; p.n=unit(vec(seed->n.x,0,seed->n.z)); p.d=dot(seed->p,p.n);
|
||||
int count=0;
|
||||
for (int j=0;j<n;j++) {
|
||||
Cell *c=&s->cells[ids[j]];
|
||||
if (!c->mark && on_plane(s,c,&p)) count++;
|
||||
}
|
||||
if (count>best_count) { best_count=count; best=p; }
|
||||
}
|
||||
if (best_count*s->opt.voxel*s->opt.voxel<s->opt.min_plane_area) break;
|
||||
refine(s,ids,n,&best);
|
||||
/* Height separates large furniture faces from room walls. */
|
||||
int wall=best.area>=s->opt.min_plane_area && best.top-best.bottom>=s->opt.wall_height &&
|
||||
best.hi-best.lo>=MIN_WALL_SPAN;
|
||||
for (int j=0;j<n;j++) {
|
||||
Cell *c=&s->cells[ids[j]];
|
||||
if (!c->mark && on_plane(s,c,&best)) c->mark=wall ? 2 : 3;
|
||||
}
|
||||
if (wall) s->planes[s->nplanes++]=best;
|
||||
/* Rejected candidates are consumed too, ensuring bounded progress. */
|
||||
}
|
||||
if (s->nplanes==MAX_PLANES) warn(s,"wall limit reached");
|
||||
if (s->nplanes) {
|
||||
float base=angle(s->planes[0].n.z,s->planes[0].n.x);
|
||||
for (int k=0;k<s->nplanes;k++) {
|
||||
Plane *p=&s->planes[k];
|
||||
float a=angle(p->n.z,p->n.x), diff=a-base;
|
||||
int quarter=flo(diff/(PI/2)+.5f);
|
||||
float target=base+quarter*(PI/2);
|
||||
if (ab(a-target)<s->opt.snap_degrees*PI/180) {
|
||||
Vec nn=vec(cosine(target),0,sine(target));
|
||||
float sum=0,count=0,lo=1e10f,hi=-1e10f;
|
||||
for (int j=0;j<n;j++) {
|
||||
Cell *c=&s->cells[ids[j]];
|
||||
if (on_plane(s,c,p)) {
|
||||
sum+=dot(c->p,nn); count++;
|
||||
float t=c->p.x*nn.z-c->p.z*nn.x;
|
||||
lo=mn(lo,t); hi=mx(hi,t);
|
||||
}
|
||||
}
|
||||
if (count) { p->n=nn; p->d=sum/count; p->lo=lo; p->hi=hi; }
|
||||
}
|
||||
}
|
||||
}
|
||||
consolidate_walls(s);
|
||||
}
|
||||
static Pt endpoint(Plane *p, int end)
|
||||
{
|
||||
float t=end ? p->hi : p->lo;
|
||||
return (Pt){p->n.x*p->d+p->n.z*t,p->n.z*p->d-p->n.x*t};
|
||||
}
|
||||
static float distance2(Pt a, Pt b) { float x=a.x-b.x,z=a.z-b.z; return x*x+z*z; }
|
||||
static int intersect(Plane *a, Plane *b, Pt *p)
|
||||
{
|
||||
float det=a->n.x*b->n.z-a->n.z*b->n.x;
|
||||
if (ab(det)<.1f) return 0;
|
||||
p->x=(a->d*b->n.z-a->n.z*b->d)/det;
|
||||
p->z=(a->n.x*b->d-a->d*b->n.x)/det;
|
||||
return 1;
|
||||
}
|
||||
static void push_outline(Scan *s, Pt p, int invisible)
|
||||
{
|
||||
if (s->noutline && distance2(s->outline[s->noutline-1],p)<.0001f) return;
|
||||
if (s->noutline<MAX_OUTLINE) {
|
||||
s->outline[s->noutline]=p;
|
||||
s->invisible[s->noutline++]=invisible;
|
||||
}
|
||||
}
|
||||
static void rectangle(Scan *s, int *ids, int n)
|
||||
{
|
||||
float x0=1e10f,x1=-1e10f,z0=1e10f,z1=-1e10f;
|
||||
for (int i=0;i<n;i++) {
|
||||
Vec p=s->cells[ids[i]].p;
|
||||
x0=mn(x0,p.x);x1=mx(x1,p.x);z0=mn(z0,p.z);z1=mx(z1,p.z);
|
||||
}
|
||||
if (x1-x0<MIN_OUTLINE_EXTENT) { float m=(x0+x1)*.5f; x0=m-MIN_OUTLINE_EXTENT/2; x1=m+MIN_OUTLINE_EXTENT/2; }
|
||||
if (z1-z0<MIN_OUTLINE_EXTENT) { float m=(z0+z1)*.5f; z0=m-MIN_OUTLINE_EXTENT/2; z1=m+MIN_OUTLINE_EXTENT/2; }
|
||||
push_outline(s,(Pt){x0,z0},1);push_outline(s,(Pt){x1,z0},1);
|
||||
push_outline(s,(Pt){x1,z1},1);push_outline(s,(Pt){x0,z1},1);
|
||||
warn(s,"incomplete walls; synthesized outline");
|
||||
}
|
||||
static int segment_cross(Pt a, Pt b, Pt c, Pt d)
|
||||
{
|
||||
float p=(b.x-a.x)*(c.z-a.z)-(b.z-a.z)*(c.x-a.x);
|
||||
float q=(b.x-a.x)*(d.z-a.z)-(b.z-a.z)*(d.x-a.x);
|
||||
float r=(d.x-c.x)*(a.z-c.z)-(d.z-c.z)*(a.x-c.x);
|
||||
float t=(d.x-c.x)*(b.z-c.z)-(d.z-c.z)*(b.x-c.x);
|
||||
return p*q<-.000001f && r*t<-.000001f;
|
||||
}
|
||||
static int valid_polygon(Scan *s)
|
||||
{
|
||||
float area=0;
|
||||
for (int i=0;i<s->noutline;i++) {
|
||||
Pt a=s->outline[i], b=s->outline[(i+1)%s->noutline];
|
||||
area+=a.x*b.z-b.x*a.z;
|
||||
for (int j=i+2;j<s->noutline;j++) {
|
||||
if ((j+1)%s->noutline==i) continue;
|
||||
if (segment_cross(a,b,s->outline[j],s->outline[(j+1)%s->noutline])) return 0;
|
||||
}
|
||||
}
|
||||
return s->noutline>=3 && area>MIN_OUTLINE_TWICE_AREA;
|
||||
}
|
||||
static int convex_fallback(Scan *s)
|
||||
{
|
||||
if(s->nplanes<3 || s->nplanes>4) return 0;
|
||||
/* A concave multi-wall room must use directed segments. A small set of enclosure planes can instead be
|
||||
* clipped as inward half-planes when smeared endpoints prevent reliable corner ordering. */
|
||||
Pt a[MAX_OUTLINE],b[MAX_OUTLINE];float x0=1e10f,x1=-1e10f,z0=1e10f,z1=-1e10f;
|
||||
for(int i=0;i<s->nplanes;i++) for(int end=0;end<2;end++) {
|
||||
Pt p=endpoint(&s->planes[i],end);x0=mn(x0,p.x);x1=mx(x1,p.x);z0=mn(z0,p.z);z1=mx(z1,p.z);
|
||||
}
|
||||
int n=4;a[0]=(Pt){x0,z0};a[1]=(Pt){x1,z0};a[2]=(Pt){x1,z1};a[3]=(Pt){x0,z1};
|
||||
for(int i=0;i<s->nplanes;i++) {
|
||||
Plane *p=&s->planes[i];int count=0;
|
||||
for(int j=0;j<n;j++) {
|
||||
Pt u=a[j],v=a[(j+1)%n];float du=p->n.x*u.x+p->n.z*u.z-p->d,dv=p->n.x*v.x+p->n.z*v.z-p->d;
|
||||
if(du>=0) b[count++]=u;
|
||||
if((du>=0)!=(dv>=0)) {
|
||||
float t=du/(du-dv);b[count++]=(Pt){u.x+t*(v.x-u.x),u.z+t*(v.z-u.z)};
|
||||
}
|
||||
}
|
||||
if(count<3 || count>=MAX_OUTLINE) return 0;
|
||||
n=count;memcpy(a,b,(usize)n*sizeof(Pt));
|
||||
}
|
||||
s->noutline=n;
|
||||
for(int j=0;j<n;j++) {
|
||||
s->outline[j]=a[j];s->invisible[j]=1;Pt v=a[(j+1)%n];
|
||||
for(int i=0;i<s->nplanes;i++) {
|
||||
Plane *p=&s->planes[i];
|
||||
if(ab(p->n.x*a[j].x+p->n.z*a[j].z-p->d)<s->opt.plane_distance &&
|
||||
ab(p->n.x*v.x+p->n.z*v.z-p->d)<s->opt.plane_distance) s->invisible[j]=0;
|
||||
}
|
||||
}
|
||||
if(!valid_polygon(s)) { s->noutline=0;return 0; }
|
||||
warn(s,"wall endpoints uncertain; using plane intersections");return 1;
|
||||
}
|
||||
/* Value below which `share` of the projections fall (2 cm bins over +-8 m around the data). */
|
||||
static float projection_percentile(Scan *s, int *ids, int n, Pt axis, float share)
|
||||
{
|
||||
static u32 hist[800];
|
||||
memset(hist,0,sizeof(hist));
|
||||
for (int i=0;i<n;i++) {
|
||||
Vec p=s->cells[ids[i]].p;
|
||||
int b=(int)((p.x*axis.x+p.z*axis.z+8)/.02f);
|
||||
if (b>=0 && b<800) hist[b]++;
|
||||
}
|
||||
u64 total=0,run=0;
|
||||
for (int b=0;b<800;b++) total+=hist[b];
|
||||
for (int b=0;b<800;b++) { run+=hist[b]; if (run>=share*total) return b*.02f-8+.01f; }
|
||||
return 8;
|
||||
}
|
||||
/* Most rooms are rectangles. When every detected wall runs along one of two perpendicular axes (2-4 walls), the
|
||||
* outline is that rectangle: its sides at the 2nd/98th percentile of the surfaces along each axis, each snapped to a
|
||||
* detected wall when one lies within BOX_SNAP. Directed wall chaining made a skewed quadrilateral of the owner's
|
||||
* room from three smeared walls (2026-10-16). L-shaped rooms have more walls and keep the chaining below. */
|
||||
static int orthogonal_box(Scan *s, int *ids, int n)
|
||||
{
|
||||
if (s->nplanes<2 || s->nplanes>4) return 0;
|
||||
Vec u=s->planes[0].n; float len=root(u.x*u.x+u.z*u.z);
|
||||
if (len<.5f) return 0;
|
||||
Pt axis[2]={{u.x/len,u.z/len},{-u.z/len,u.x/len}};
|
||||
float side[2][2]; int wall[2][2]={{0,0},{0,0}}, snapped=0;
|
||||
for (int k=0;k<2;k++) {
|
||||
side[k][0]=projection_percentile(s,ids,n,axis[k],BOX_LOW_SHARE);
|
||||
side[k][1]=projection_percentile(s,ids,n,axis[k],1-BOX_LOW_SHARE);
|
||||
}
|
||||
for (int i=0;i<s->nplanes;i++) {
|
||||
Plane *p=&s->planes[i];
|
||||
float c0=p->n.x*axis[0].x+p->n.z*axis[0].z, c1=p->n.x*axis[1].x+p->n.z*axis[1].z;
|
||||
int k=ab(c0)>=BOX_AXIS_COS ? 0 : ab(c1)>=BOX_AXIS_COS ? 1 : -1;
|
||||
if (k<0) return 0; // a wall off both axes: not a rectangle
|
||||
float offset=p->d*(k ? c1 : c0)/ab(k ? c1 : c0);
|
||||
int end=ab(offset-side[k][0])<ab(offset-side[k][1]) ? 0 : 1;
|
||||
if (ab(offset-side[k][end])<BOX_SNAP) { side[k][end]=offset; snapped+=!wall[k][end]; wall[k][end]=1; }
|
||||
}
|
||||
if (snapped<2 || side[0][1]-side[0][0]<MIN_OUTLINE_EXTENT || side[1][1]-side[1][0]<MIN_OUTLINE_EXTENT) return 0;
|
||||
/* corners (s,t) -> world x = a0.x s + a1.x t, z = a0.z s + a1.z t; edge flags = whether its side is a wall */
|
||||
int order[4][2]={{0,0},{1,0},{1,1},{0,1}}, edge[4][2]={{1,0},{0,1},{1,1},{0,0}}; // edge k lies on side[a][e]
|
||||
float twice=0;
|
||||
Pt c[4];
|
||||
for (int j=0;j<4;j++) {
|
||||
float a=side[0][order[j][0]], b=side[1][order[j][1]];
|
||||
c[j]=(Pt){axis[0].x*a+axis[1].x*b, axis[0].z*a+axis[1].z*b};
|
||||
}
|
||||
for (int j=0;j<4;j++) twice+=c[j].x*c[(j+1)%4].z-c[(j+1)%4].x*c[j].z;
|
||||
s->noutline=0;
|
||||
if (twice>0) for (int j=0;j<4;j++) push_outline(s,c[j],!wall[edge[j][0]][edge[j][1]]);
|
||||
else for (int j=0;j<4;j++) { // reversed: corner 0,3,2,1; edge from corner k to k-1 is the old edge k-1
|
||||
int k=(4-j)%4, e=(k+3)%4;
|
||||
push_outline(s,c[k],!wall[edge[e][0]][edge[e][1]]);
|
||||
}
|
||||
if (!valid_polygon(s)) { s->noutline=0; return 0; }
|
||||
if (snapped<4) warn(s,"gaps closed with invisible walls");
|
||||
return 1;
|
||||
}
|
||||
static void outline(Scan *s, int *ids, int n)
|
||||
{
|
||||
if (orthogonal_box(s,ids,n)) return;
|
||||
if (s->nplanes<3) { rectangle(s,ids,n); return; }
|
||||
int order[MAX_PLANES]={0};
|
||||
s->planes[0].used=1;
|
||||
for (int k=1;k<s->nplanes;k++) {
|
||||
Pt end=endpoint(&s->planes[order[k-1]],1);
|
||||
float best=1e10f; int which=-1;
|
||||
for (int j=0;j<s->nplanes;j++) if (!s->planes[j].used) {
|
||||
float d=distance2(end,endpoint(&s->planes[j],0));
|
||||
if (d<best) { best=d;which=j; }
|
||||
}
|
||||
order[k]=which;s->planes[which].used=1;
|
||||
}
|
||||
Pt starts[MAX_PLANES], ends[MAX_PLANES];
|
||||
for (int k=0;k<s->nplanes;k++) {
|
||||
Plane *p=&s->planes[order[k]], *next=&s->planes[order[(k+1)%s->nplanes]];
|
||||
ends[k]=endpoint(p,1); starts[(k+1)%s->nplanes]=endpoint(next,0);
|
||||
Pt corner;
|
||||
if (intersect(p,next,&corner) && distance2(corner,ends[k])<s->opt.gap*s->opt.gap &&
|
||||
distance2(corner,starts[(k+1)%s->nplanes])<s->opt.gap*s->opt.gap)
|
||||
ends[k]=starts[(k+1)%s->nplanes]=corner;
|
||||
}
|
||||
for (int k=0;k<s->nplanes;k++) {
|
||||
push_outline(s,starts[k],0);
|
||||
if (distance2(ends[k],starts[(k+1)%s->nplanes])>.0001f) {
|
||||
push_outline(s,ends[k],1);warn(s,"gaps closed with invisible walls");
|
||||
}
|
||||
}
|
||||
if (!valid_polygon(s)) {
|
||||
s->noutline=0;
|
||||
if(!convex_fallback(s)) { rectangle(s,ids,n);warn(s,"wall ordering ambiguous"); }
|
||||
}
|
||||
}
|
||||
static float wall_distance(Scan *s, Vec p)
|
||||
{
|
||||
float best=1e10f;
|
||||
for (int i=0;i<s->nplanes;i++) best=mn(best,ab(dot(s->planes[i].n,p)-s->planes[i].d));
|
||||
return best;
|
||||
}
|
||||
static void trimmed_bounds(Scan *s, int *queue, int n, Vec axis, float *lo, float *hi)
|
||||
{
|
||||
int hist[512]={0};float start=*lo,range=*hi-start;
|
||||
if(range<.001f) return;
|
||||
for(int i=0;i<n;i++) {
|
||||
float value=dot(s->cells[queue[i]].p,axis);
|
||||
int bin=(int)clamp((value-start)/range*511,0,511);hist[bin]++;
|
||||
}
|
||||
int count=0,target=(int)(n*BOX_TRIM);
|
||||
for(int b=0;b<512;b++) { count+=hist[b];if(count>target) { *lo=start+range*b/511;break; } }
|
||||
count=0;
|
||||
for(int b=511;b>=0;b--) { count+=hist[b];if(count>target) { *hi=start+range*(b+1)/511;break; } }
|
||||
}
|
||||
static float top_height(Scan *s, int *queue, int n, float low, float high)
|
||||
{
|
||||
int histogram[512]={0},bins=(int)mn((high-low)/(s->opt.voxel*.25f)+2,512);
|
||||
float step=s->opt.voxel*.25f;
|
||||
for(int i=0;i<n;i++) {
|
||||
int bin=flo((s->cells[queue[i]].p.y-low)/step);
|
||||
if(bin>=0 && bin<bins) histogram[bin]++;
|
||||
}
|
||||
int count=0;
|
||||
for(int bin=0;bin<bins;bin++) {
|
||||
count+=histogram[bin];
|
||||
/* The median resists a softened lip without selecting one of several noisy sub-peaks as a new top. */
|
||||
if(count>=n/2) return low+(bin+.5f)*step;
|
||||
}
|
||||
return (low+high)*.5f;
|
||||
}
|
||||
static void furniture(Scan *s, int *ids, int n, int *queue)
|
||||
{
|
||||
/* Tops remain separable when a softened sofa/table edge blends into its neighbouring wall. Vertical-only
|
||||
* components are considered separately for screens; they cannot turn a whole wall into an OTHER volume. */
|
||||
for(int mode=0;mode<2;mode++) {
|
||||
float histogram[256]={0},levels[32];int nlevels=0;
|
||||
if(!mode) {
|
||||
for(int j=0;j<n;j++) {
|
||||
Cell *c=&s->cells[ids[j]];
|
||||
if(c->n.y>s->opt.horizontal && c->p.y>s->floor+MIN_FURNITURE_TOP &&
|
||||
c->p.y<s->ceiling-FURNITURE_MARGIN && wall_distance(s,c->p)>WALL_FURNITURE_CLEARANCE) {
|
||||
int bin=flo((c->p.y-s->floor)/s->opt.voxel);
|
||||
if(bin>=0 && bin<256) histogram[bin]++;
|
||||
}
|
||||
}
|
||||
for(int k=0;k<32;k++) {
|
||||
int best=0;float score=0;
|
||||
for(int bin=1;bin<255;bin++) {
|
||||
float count=histogram[bin-1]+histogram[bin]+histogram[bin+1];
|
||||
if(count>score) { score=count;best=bin; }
|
||||
}
|
||||
if(score*s->opt.voxel*s->opt.voxel<MIN_TOP_AREA) break;
|
||||
levels[nlevels++]=s->floor+(best+.5f)*s->opt.voxel;
|
||||
int radius=(int)(s->opt.plane_distance*2/s->opt.voxel)+1;
|
||||
for(int bin=best-radius;bin<=best+radius;bin++) if(bin>=0 && bin<256) histogram[bin]=0;
|
||||
}
|
||||
}
|
||||
for (int j=0;j<n;j++) {
|
||||
Cell *c=&s->cells[ids[j]];
|
||||
int normal=mode ? ab(c->n.y)<BOX_SIDE_NORMAL_Y : c->n.y>s->opt.horizontal;
|
||||
c->mark=(normal && c->p.y>s->floor+MIN_FURNITURE_TOP && c->p.y<s->ceiling-FURNITURE_MARGIN &&
|
||||
wall_distance(s,c->p)>WALL_FURNITURE_CLEARANCE) ? 1 : 0;
|
||||
if(c->mark && !mode) {
|
||||
c->mark=0;float best=s->opt.plane_distance*2;
|
||||
for(int k=0;k<nlevels;k++) if(ab(c->p.y-levels[k])<best) {
|
||||
best=ab(c->p.y-levels[k]);c->mark=k+1;
|
||||
}
|
||||
}
|
||||
}
|
||||
int radius=(int)(s->opt.cluster_gap/s->opt.voxel)+1;
|
||||
for (int j=0;j<n;j++) {
|
||||
int seed=ids[j];
|
||||
if (s->cells[seed].mark<=0) continue;
|
||||
int group=s->cells[seed].mark;
|
||||
int head=0,tail=1;queue[0]=seed;s->cells[seed].mark=-1;
|
||||
while (head<tail) {
|
||||
int id=queue[head++],x=id%s->nx,z=(id/s->nx)%s->nz,y=id/(s->nx*s->nz);
|
||||
for (int dy=-radius;dy<=radius;dy++) for (int dz=-radius;dz<=radius;dz++) for (int dx=-radius;dx<=radius;dx++) {
|
||||
if (dx*dx+dy*dy+dz*dz>radius*radius) continue;
|
||||
int xx=x+dx,yy=y+dy,zz=z+dz;
|
||||
if (xx<0 || yy<0 || zz<0 || xx>=s->nx || yy>=s->ny || zz>=s->nz) continue;
|
||||
int next=(yy*s->nz+zz)*s->nx+xx;
|
||||
Cell *c=&s->cells[next];
|
||||
if (c->mark==group) { c->mark=-1;queue[tail++]=next; }
|
||||
}
|
||||
}
|
||||
if (tail<MIN_CLUSTER_SURFELS) continue;
|
||||
Vec avg=vec(0,0,0);float top=-1e10f,bottom=1e10f;
|
||||
for (int k=0;k<tail;k++) {
|
||||
Vec p=s->cells[queue[k]].p;avg=add(avg,p);top=mx(top,p.y);bottom=mn(bottom,p.y);
|
||||
}
|
||||
avg=mul(avg,1.f/tail);
|
||||
float xx=0,zz=0,xz=0,edge_x=0,edge_y=0,edge_count=0;
|
||||
for (int k=0;k<tail;k++) {
|
||||
Cell *c=&s->cells[queue[k]];
|
||||
Vec p=sub(c->p,avg);xx+=p.x*p.x;zz+=p.z*p.z;xz+=p.x*p.z;
|
||||
if(ab(c->n.y)<BOX_SIDE_NORMAL_Y) {
|
||||
Vec norm=unit(vec(c->n.x,0,c->n.z));
|
||||
float co2=norm.x*norm.x-norm.z*norm.z,si2=2*norm.x*norm.z;
|
||||
edge_x+=co2*co2-si2*si2;edge_y+=2*co2*si2;edge_count++;
|
||||
}
|
||||
}
|
||||
float yaw=.5f*angle(2*xz,xx-zz);
|
||||
/* Opposing faces and perpendicular edges vote for the same fourth harmonic. Unlike PCA, this is not
|
||||
* biased by one side being more visible. Non-box objects fall back to position covariance. */
|
||||
if(edge_count>MIN_BOX_EDGE_VOTES && root(edge_x*edge_x+edge_y*edge_y)>edge_count*BOX_EDGE_STRENGTH)
|
||||
yaw=.25f*angle(edge_y,edge_x);
|
||||
if(!mode) {
|
||||
float smallest=1e10f;
|
||||
for(int degree=0;degree<90;degree++) {
|
||||
float a=degree*PI/180,co=cosine(a),si=sine(a),u0=1e10f,u1=-1e10f,v0=1e10f,v1=-1e10f;
|
||||
for(int k=0;k<tail;k++) {
|
||||
Vec p=s->cells[queue[k]].p;float u=p.x*co+p.z*si,v=-p.x*si+p.z*co;
|
||||
u0=mn(u0,u);u1=mx(u1,u);v0=mn(v0,v);v1=mx(v1,v);
|
||||
}
|
||||
float area=(u1-u0)*(v1-v0);
|
||||
if(area<smallest) { smallest=area;yaw=a; }
|
||||
}
|
||||
}
|
||||
float co=cosine(yaw),si=sine(yaw);
|
||||
float u0=1e10f,u1=-1e10f,v0=1e10f,v1=-1e10f,top_count=0;
|
||||
for (int k=0;k<tail;k++) {
|
||||
Cell *c=&s->cells[queue[k]];Vec p=sub(c->p,avg);
|
||||
float u=p.x*co+p.z*si,v=-p.x*si+p.z*co;
|
||||
u0=mn(u0,u);u1=mx(u1,u);v0=mn(v0,v);v1=mx(v1,v);
|
||||
if (c->n.y>s->opt.horizontal && c->p.y>top-s->opt.plane_distance*2) top_count++;
|
||||
}
|
||||
/* Stereo discontinuities leave a low-density halo even after long-edge rejection. Trim the outer
|
||||
* two percent before measuring boxes; otherwise a table can become wide enough to look like a couch. */
|
||||
float up=dot(avg,vec(co,0,si)),vp=dot(avg,vec(-si,0,co));
|
||||
u0+=up;u1+=up;v0+=vp;v1+=vp;
|
||||
trimmed_bounds(s,queue,tail,vec(co,0,si),&u0,&u1);
|
||||
trimmed_bounds(s,queue,tail,vec(-si,0,co),&v0,&v1);
|
||||
u0-=up;u1-=up;v0-=vp;v1-=vp;
|
||||
trimmed_bounds(s,queue,tail,vec(0,1,0),&bottom,&top);
|
||||
if(!mode) top=top_height(s,queue,tail,bottom,top);
|
||||
float w=u1-u0,d=v1-v0,h=top-(bottom<s->floor+FLOOR_INFER_DISTANCE ? s->floor : mn(bottom,s->floor+FURNITURE_MARGIN));
|
||||
if(d>w) { float t=w;w=d;d=t;yaw+=PI/2; }
|
||||
while(yaw>PI/2) yaw-=PI;
|
||||
if(!mode) h=top-s->floor;
|
||||
if (w*d*h<s->opt.min_volume) continue;
|
||||
float height=top-s->floor,area=w*d,top_area=(mode ? top_count : tail)*s->opt.voxel*s->opt.voxel;
|
||||
if(!mode && (top_area<MIN_TOP_AREA || top_area<area*MIN_TOP_DENSITY)) continue;
|
||||
const char *label="OTHER";
|
||||
if (height>=BED_MIN_HEIGHT && height<=BED_MAX_HEIGHT && area>=BED_MIN_AREA) label="BED";
|
||||
else if (height>=COUCH_MIN_HEIGHT && height<=COUCH_MAX_HEIGHT && mx(w,d)+2*s->opt.voxel>=COUCH_MIN_LENGTH) label="COUCH";
|
||||
else if (height>=TABLE_MIN_HEIGHT && height<=TABLE_MAX_HEIGHT && top_area>=MIN_TOP_AREA) label="TABLE";
|
||||
else if (mn(w,d)<SCREEN_MAX_DEPTH && mx(w,d)>=SCREEN_MIN_WIDTH && mx(w,d)<=SCREEN_MAX_WIDTH &&
|
||||
wall_distance(s,avg)<NEAR_WALL) label="SCREEN";
|
||||
else if (h>=STORAGE_MIN_HEIGHT && wall_distance(s,avg)<NEAR_WALL) label="STORAGE";
|
||||
if(mode && !same(label,"SCREEN")) continue;
|
||||
if(!mode && same(label,"OTHER") && height>TABLE_MAX_HEIGHT) continue;
|
||||
if (s->nvolumes>=MAX_VOLUMES) { warn(s,"furniture limit reached");break; }
|
||||
Vec centre=vec(avg.x+co*(u0+u1)*.5f-si*(v0+v1)*.5f,top,
|
||||
avg.z+si*(u0+u1)*.5f+co*(v0+v1)*.5f);
|
||||
/* ROOM_FORMAT yaw is an active right-handed rotation about +Y. */
|
||||
s->volumes[s->nvolumes++]=(Volume){centre,-yaw*180/PI,w,d,h,label};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A hint is a fallback for missing walls, never a constraint on observed geometry. */
|
||||
static const char *hint(Scan *s);
|
||||
const char *make_model(Scan *s)
|
||||
{
|
||||
int found;
|
||||
s->floor=plane_height(s,0,&found);
|
||||
if (!found) warn(s,"floor not seen; using STAGE floor");
|
||||
s->ceiling=plane_height(s,1,&found);
|
||||
if (!found) warn(s,"ceiling not seen");
|
||||
usize bytes=(usize)s->count*sizeof(int);
|
||||
int *ids=io_alloc(bytes),*queue=io_alloc(bytes);
|
||||
if (!ids || !queue) { io_free(ids,bytes);io_free(queue,bytes);return "model allocation failed"; }
|
||||
int n=0;
|
||||
for (int i=0;i<s->count;i++) if (s->cells[i].weight) ids[n++]=i;
|
||||
if (!n) { io_free(ids,bytes);io_free(queue,bytes);return "no surfaces in reconstruction bounds"; }
|
||||
walls(s,ids,n);outline(s,ids,n);
|
||||
const char *error=hint(s);
|
||||
if (!error) furniture(s,ids,n,queue);
|
||||
float perimeter=0,observed=0;
|
||||
for (int i=0;i<s->noutline;i++) {
|
||||
float d=root(distance2(s->outline[i],s->outline[(i+1)%s->noutline]));perimeter+=d;
|
||||
if (!s->invisible[i]) observed+=d;
|
||||
}
|
||||
s->coverage=perimeter ? 100*observed/perimeter : 0;
|
||||
if (s->coverage<LOW_COVERAGE_PERCENT) warn(s,"low wall coverage; scan all walls");
|
||||
io_free(ids,bytes);io_free(queue,bytes);
|
||||
return error;
|
||||
}
|
||||
|
||||
static int number(char **cursor, float *value)
|
||||
{
|
||||
char *p=*cursor;while (*p==' ' || *p=='\t' || *p=='\r') p++;
|
||||
int sign=1,digits=0;if (*p=='-' || *p=='+') { if (*p=='-') sign=-1;p++; }
|
||||
float v=0,scale=1;
|
||||
while (*p>='0' && *p<='9') { v=v*10+*p++-'0';digits++;if(v>10000) return 0; }
|
||||
if (*p=='.') { p++;while (*p>='0' && *p<='9') { scale*=.1f;v+=(*p++-'0')*scale;digits++; } }
|
||||
if(*p=='e' || *p=='E') {
|
||||
p++;int exponent=0,negative=0,edigits=0;
|
||||
if(*p=='-' || *p=='+') { negative=*p=='-';p++; }
|
||||
while(*p>='0' && *p<='9') { exponent=exponent*10+*p++-'0';edigits++;if(exponent>38) return 0; }
|
||||
if(!edigits) return 0;
|
||||
while(exponent--) v=negative ? v*.1f : v*10;
|
||||
}
|
||||
if (!digits || !finite(v) || v>10000) return 0;
|
||||
*value=sign*v;*cursor=p;return 1;
|
||||
}
|
||||
static const char *hint(Scan *s)
|
||||
{
|
||||
if (!s->opt.hint) return 0;
|
||||
int fd=io_open(s->opt.hint,0);
|
||||
if (fd<0) return "cannot open outline hint";
|
||||
char data[8192];usize size=0;
|
||||
for (;;) {
|
||||
long r=io_read(fd,data+size,sizeof(data)-1-size);
|
||||
if (r<0 || (r>0 && size+(usize)r==sizeof(data)-1)) { io_close(fd);return "outline hint too large or unreadable"; }
|
||||
if (!r) break;
|
||||
size+=(usize)r;
|
||||
}
|
||||
io_close(fd);data[size]=0;
|
||||
Pt pts[MAX_OUTLINE];int n=0;char *p=data;
|
||||
while (*p) {
|
||||
while (*p==' ' || *p=='\t' || *p=='\r' || *p=='\n') p++;
|
||||
if (!*p) break;
|
||||
if (*p=='#') { while (*p && *p!='\n') p++;continue; }
|
||||
if (n==MAX_OUTLINE || !number(&p,&pts[n].x) || !number(&p,&pts[n].z)) return "invalid outline hint";
|
||||
n++;while (*p==' ' || *p=='\t' || *p=='\r') p++;
|
||||
if (*p && *p!='\n' && *p!='#') return "invalid outline hint";
|
||||
if (*p=='#') while (*p && *p!='\n') p++;
|
||||
}
|
||||
if (n<3) return "outline hint needs at least three points";
|
||||
Pt centre={0};for (int i=0;i<n;i++) { centre.x+=pts[i].x/n;centre.z+=pts[i].z/n; }
|
||||
float mean=0,variance=0;
|
||||
for (int i=0;i<n;i++) mean+=root(distance2(pts[i],centre))/n;
|
||||
for (int i=0;i<n;i++) { float r=root(distance2(pts[i],centre))-mean;variance+=r*r/n; }
|
||||
if (n>=DEFAULT_CIRCLE_MIN_POINTS && root(variance)<mean*DEFAULT_CIRCLE_RADIAL_CV) {
|
||||
warn(s,"default circle hint ignored");return 0;
|
||||
}
|
||||
int missing=0;for(int i=0;i<s->noutline;i++) missing+=s->invisible[i];
|
||||
if (!missing) return 0;
|
||||
Pt saved[MAX_OUTLINE];int inv[MAX_OUTLINE],old=s->noutline;
|
||||
memcpy(saved,s->outline,sizeof(saved));memcpy(inv,s->invisible,sizeof(inv));
|
||||
float area=0;for(int i=0;i<n;i++) area+=pts[i].x*pts[(i+1)%n].z-pts[(i+1)%n].x*pts[i].z;
|
||||
s->noutline=n;
|
||||
for(int i=0;i<n;i++) { s->outline[i]=pts[area>0 ? i : n-1-i];s->invisible[i]=1; }
|
||||
if (!valid_polygon(s)) {
|
||||
memcpy(s->outline,saved,sizeof(saved));memcpy(s->invisible,inv,sizeof(inv));s->noutline=old;
|
||||
return "invalid outline hint polygon";
|
||||
}
|
||||
/* Only use a hint compatible with every observed wall. Chaperone can be an inset play area. */
|
||||
for(int i=0;i<s->nplanes;i++) {
|
||||
Plane *w=&s->planes[i];int match=0;
|
||||
for(int k=0;k<n;k++) {
|
||||
Pt a=s->outline[k],b=s->outline[(k+1)%n];
|
||||
if(ab(a.x*w->n.x+a.z*w->n.z-w->d)<s->opt.gap &&
|
||||
ab(b.x*w->n.x+b.z*w->n.z-w->d)<s->opt.gap) { match=1;s->invisible[k]=0; }
|
||||
}
|
||||
if (!match) {
|
||||
memcpy(s->outline,saved,sizeof(saved));memcpy(s->invisible,inv,sizeof(inv));s->noutline=old;
|
||||
warn(s,"hint disagrees with observed walls; ignored");return 0;
|
||||
}
|
||||
}
|
||||
s->hint_used=1;warn(s,"incomplete walls closed using outline hint");return 0;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "platform.h"
|
||||
|
||||
#ifdef FP_HOSTED
|
||||
#define _DEFAULT_SOURCE 1
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/random.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
int io_open(const char *p, int w) { return open(p, w ? O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW : O_RDONLY, 0600); }
|
||||
long io_read(int fd, void *p, usize n)
|
||||
{
|
||||
long r;
|
||||
do r = read(fd, p, n); while (r < 0 && errno == EINTR);
|
||||
return r;
|
||||
}
|
||||
static long put(int fd, const void *p, usize n)
|
||||
{
|
||||
long r;
|
||||
do r = write(fd, p, n); while (r < 0 && errno == EINTR);
|
||||
return r;
|
||||
}
|
||||
int io_close(int fd) { return close(fd); }
|
||||
int io_rename(const char *a, const char *b) { return rename(a, b); }
|
||||
void io_remove(const char *p) { unlink(p); }
|
||||
int io_mkdir(const char *p) { return mkdir(p, 0700) == 0 || errno == EEXIST; }
|
||||
void *io_alloc(usize n)
|
||||
{
|
||||
void *p = mmap(0, n, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
|
||||
return p == MAP_FAILED ? 0 : p;
|
||||
}
|
||||
void io_free(void *p, usize n) { if (p) munmap(p, n); }
|
||||
int io_random(void *p, usize n)
|
||||
{
|
||||
usize done = 0;
|
||||
while (done < n) {
|
||||
long r = getrandom((u8 *)p + done, n - done, 0);
|
||||
if (r < 0 && errno == EINTR) continue;
|
||||
if (r <= 0) return 0;
|
||||
done += (usize)r;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
i64 io_time(int mono)
|
||||
{
|
||||
struct timespec t;
|
||||
if (clock_gettime(mono ? CLOCK_MONOTONIC : CLOCK_REALTIME, &t)) return 0;
|
||||
return (i64)t.tv_sec * 1000000000 + t.tv_nsec;
|
||||
}
|
||||
#else
|
||||
/* asm-generic Linux syscall numbers: this target has no libc or UAPI dependency. */
|
||||
static long call(long n, long a, long b, long c, long d, long e, long f)
|
||||
{
|
||||
register long x8 __asm__("x8") = n;
|
||||
register long x0 __asm__("x0") = a;
|
||||
register long x1 __asm__("x1") = b;
|
||||
register long x2 __asm__("x2") = c;
|
||||
register long x3 __asm__("x3") = d;
|
||||
register long x4 __asm__("x4") = e;
|
||||
register long x5 __asm__("x5") = f;
|
||||
__asm__ volatile("svc #0" : "+r"(x0) : "r"(x8), "r"(x1), "r"(x2), "r"(x3), "r"(x4), "r"(x5) : "memory", "cc");
|
||||
return x0;
|
||||
}
|
||||
int io_open(const char *p, int w) { return (int)call(56, -100, (long)p, w ? 1|64|128|131072 : 0, 0600, 0, 0); }
|
||||
long io_read(int fd, void *p, usize n)
|
||||
{
|
||||
long r;
|
||||
do r = call(63, fd, (long)p, (long)n, 0, 0, 0); while (r == -4);
|
||||
return r;
|
||||
}
|
||||
static long put(int fd, const void *p, usize n)
|
||||
{
|
||||
long r;
|
||||
do r = call(64, fd, (long)p, (long)n, 0, 0, 0); while (r == -4);
|
||||
return r;
|
||||
}
|
||||
int io_close(int fd) { return (int)call(57, fd, 0, 0, 0, 0, 0); }
|
||||
int io_rename(const char *a, const char *b) { return (int)call(38, -100, (long)a, -100, (long)b, 0, 0); }
|
||||
void io_remove(const char *p) { call(35, -100, (long)p, 0, 0, 0, 0); }
|
||||
int io_mkdir(const char *p)
|
||||
{
|
||||
long r = call(34, -100, (long)p, 0700, 0, 0, 0);
|
||||
return r == 0 || r == -17;
|
||||
}
|
||||
void *io_alloc(usize n)
|
||||
{
|
||||
long p = call(222, 0, (long)n, 3, 0x22, -1, 0);
|
||||
return (unsigned long)p > (unsigned long)-4096 ? 0 : (void *)p;
|
||||
}
|
||||
void io_free(void *p, usize n) { if (p) call(215, (long)p, (long)n, 0, 0, 0, 0); }
|
||||
int io_random(void *p, usize n)
|
||||
{
|
||||
usize done = 0;
|
||||
while (done < n) {
|
||||
long r = call(278, (long)((u8 *)p + done), (long)(n - done), 0, 0, 0, 0);
|
||||
if (r == -4) continue;
|
||||
if (r <= 0) return 0;
|
||||
done += (usize)r;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
i64 io_time(int mono)
|
||||
{
|
||||
struct { long sec, ns; } t = {0, 0};
|
||||
call(113, mono ? 1 : 0, (long)&t, 0, 0, 0, 0);
|
||||
return (i64)t.sec * 1000000000 + t.ns;
|
||||
}
|
||||
void *memcpy(void *d, const void *s, usize n)
|
||||
{
|
||||
u8 *a = d;
|
||||
const u8 *b = s;
|
||||
while (n--) *a++ = *b++;
|
||||
return d;
|
||||
}
|
||||
void *memset(void *d, int c, usize n)
|
||||
{
|
||||
u8 *a = d;
|
||||
while (n--) *a++ = (u8)c;
|
||||
return d;
|
||||
}
|
||||
extern int main(int argc, char **argv);
|
||||
__attribute__((used, noreturn)) void start_c(long *sp)
|
||||
{
|
||||
int code = main((int)sp[0], (char **)(sp + 1));
|
||||
for (;;) call(94, code, 0, 0, 0, 0, 0);
|
||||
}
|
||||
__asm__(".text\n.global _start\n.type _start,%function\n_start:\n"
|
||||
"mov x29,#0\nmov x30,#0\nmov x0,sp\nbl start_c\nbrk #0\n");
|
||||
#endif
|
||||
|
||||
int io_write(int fd, const void *p, usize n)
|
||||
{
|
||||
while (n) {
|
||||
long r = put(fd, p, n);
|
||||
if (r <= 0) return 0;
|
||||
p = (const u8 *)p + r;
|
||||
n -= (usize)r;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#ifndef FP_ROOMSCAN_PLATFORM_H
|
||||
#define FP_ROOMSCAN_PLATFORM_H
|
||||
|
||||
/* The reconstruction sources use only this boundary, on Linux and in hosted tests. */
|
||||
typedef __SIZE_TYPE__ usize;
|
||||
typedef unsigned char u8;
|
||||
typedef unsigned int u32;
|
||||
typedef unsigned long long u64;
|
||||
typedef long long i64;
|
||||
int io_open(const char *path, int write);
|
||||
long io_read(int fd, void *data, usize size);
|
||||
int io_write(int fd, const void *data, usize size);
|
||||
int io_close(int fd);
|
||||
int io_rename(const char *old, const char *new);
|
||||
void io_remove(const char *path);
|
||||
int io_mkdir(const char *path);
|
||||
void *io_alloc(usize size);
|
||||
void io_free(void *data, usize size);
|
||||
int io_random(void *data, usize size);
|
||||
i64 io_time(int monotonic);
|
||||
void *memcpy(void *dst, const void *src, usize size);
|
||||
void *memset(void *dst, int value, usize size);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,93 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#ifndef FP_ROOMSCAN_H
|
||||
#define FP_ROOMSCAN_H
|
||||
#include "platform.h"
|
||||
|
||||
/* Resource limits are independent of dump length. Two passes avoid retaining frames. */
|
||||
#define MAX_FRAME_VERTICES 1000000u
|
||||
#define MAX_FRAME_INDICES 3000000u
|
||||
#define MAX_CELLS 4200000u
|
||||
#define MAX_SAMPLES 32768
|
||||
#define MAX_PLANES 32
|
||||
#define MAX_OUTLINE 96
|
||||
#define MAX_VOLUMES 128
|
||||
#define MAX_TRIANGLES 200000
|
||||
#define MAX_MESH_VERTICES 600000
|
||||
#define PI 3.14159265358979323846f
|
||||
|
||||
typedef struct { float x, y, z; } Vec;
|
||||
typedef struct { float x, z; } Pt;
|
||||
typedef struct {
|
||||
Vec p, n;
|
||||
float weight, sdf, sw;
|
||||
int mark;
|
||||
unsigned views, last_frame;
|
||||
} Cell;
|
||||
typedef struct {
|
||||
Vec n;
|
||||
float d, lo, hi, bottom, top, area;
|
||||
int used;
|
||||
} Plane;
|
||||
typedef struct { Vec p; float yaw, w, d, h; const char *label; } Volume;
|
||||
typedef struct {
|
||||
const char *input, *out, *hint;
|
||||
u64 universe;
|
||||
float voxel, ceiling_default, truncation, max_edge, grazing, range, depth_jump, depth_ratio, min_views;
|
||||
float patch_residual;
|
||||
float plane_distance, min_plane_area, wall_height, snap_degrees;
|
||||
float floor_band, ceiling_min, min_volume, cluster_gap, gap, horizontal;
|
||||
} Options;
|
||||
typedef struct {
|
||||
Options opt;
|
||||
Cell *cells;
|
||||
int nx, ny, nz, count;
|
||||
Vec origin;
|
||||
Vec samples[MAX_SAMPLES];
|
||||
int nsamples;
|
||||
u64 seen, accepted, rejected, clipped, far_vertices, discontinuities;
|
||||
unsigned frames, skipped_frames, fuse_frame;
|
||||
Plane planes[MAX_PLANES];
|
||||
int nplanes;
|
||||
Pt outline[MAX_OUTLINE];
|
||||
int invisible[MAX_OUTLINE], noutline;
|
||||
Volume volumes[MAX_VOLUMES];
|
||||
int nvolumes;
|
||||
float floor, ceiling, coverage;
|
||||
const char *warnings[24];
|
||||
int nwarnings, hint_used;
|
||||
u32 rng;
|
||||
Vec *vertices;
|
||||
u32 *indices;
|
||||
int nv, ni;
|
||||
i64 t0, tread, tfuse, tmodel, tmesh;
|
||||
} Scan;
|
||||
|
||||
static inline float ab(float x) { return x < 0 ? -x : x; }
|
||||
static inline float mn(float a, float b) { return a < b ? a : b; }
|
||||
static inline float mx(float a, float b) { return a > b ? a : b; }
|
||||
static inline float clamp(float x, float a, float b) { return mn(mx(x, a), b); }
|
||||
static inline Vec vec(float x, float y, float z) { return (Vec){x,y,z}; }
|
||||
static inline Vec add(Vec a, Vec b) { return vec(a.x+b.x,a.y+b.y,a.z+b.z); }
|
||||
static inline Vec sub(Vec a, Vec b) { return vec(a.x-b.x,a.y-b.y,a.z-b.z); }
|
||||
static inline Vec mul(Vec a, float b) { return vec(a.x*b,a.y*b,a.z*b); }
|
||||
static inline float dot(Vec a, Vec b) { return a.x*b.x+a.y*b.y+a.z*b.z; }
|
||||
static inline Vec cross(Vec a, Vec b) { return vec(a.y*b.z-a.z*b.y,a.z*b.x-a.x*b.z,a.x*b.y-a.y*b.x); }
|
||||
static inline int finite(float x) { return x == x && ab(x) < 1e20f; }
|
||||
float root(float x);
|
||||
float sine(float x);
|
||||
float cosine(float x);
|
||||
float angle(float y, float x);
|
||||
Vec unit(Vec p);
|
||||
int flo(float x);
|
||||
u32 random_next(Scan *s);
|
||||
void warn(Scan *s, const char *message);
|
||||
int cell_index(Scan *s, Vec p);
|
||||
Vec grid_point(Scan *s, int x, int y, int z);
|
||||
const char *read_dump(Scan *s, int fuse);
|
||||
const char *init_grid(Scan *s);
|
||||
void finish_fusion(Scan *s);
|
||||
const char *make_model(Scan *s);
|
||||
const char *make_mesh(Scan *s);
|
||||
int same(const char *a, const char *b);
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
|
||||
@@ -1,69 +1,508 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score the patch heuristics against the verified catalog: for every game dump that has a catalog recipe, suggest a
|
||||
recipe with the catalog switched off and compare the recipe-relevant choices.
|
||||
"""Evaluate static patch suggestions against catalog-only ground truth (no catalog-assisted suggestions).
|
||||
|
||||
python scripts/eval_heuristics.py "<folder with game dumps>" [--verbose]
|
||||
eval_heuristics.py <dumps folder> [--verbose]
|
||||
eval_heuristics.py --snapshot analyses.json --split all --json baseline.json
|
||||
eval_heuristics.py --snapshot analyses.json --fit-report --out fit-report.md
|
||||
|
||||
Snapshots can be in progress: each read copies to a temporary file and retries incomplete JSON. The split is
|
||||
stratified by analysis engine and stable for a fixed set of packages. Converted inputs are included and labelled.
|
||||
Patch scores are micro-averaged across games, excluding enabled automatic defaults. Expected defaults are detected
|
||||
per analysis before applying explicit catalog removals; inapplicable defaults create no removal decisions. A wrong
|
||||
adapter value is both an FP and an FN. Removal decisions and alternate-build patches have their own identities
|
||||
("remove:<id>", "alt:<id>").
|
||||
Unsupported catalog entries and runtime-only choices are reported separately from the headline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from frameport.analysis.detect import analyze # noqa: E402
|
||||
from frameport.core.library import analysis_from_dict # noqa: E402
|
||||
from frameport.core.models import Recipe # noqa: E402
|
||||
from frameport.patches import base # noqa: E402
|
||||
from frameport.recommend import catalog, engine # noqa: E402
|
||||
from frameport.sources import quest_dump # noqa: E402
|
||||
|
||||
RUNTIME_ONLY = [
|
||||
"use_alt", "adapter.vk_shader_fix", "adapter.focus_hold", "adapter.focus_hold_ms", "adapter.sync_guard",
|
||||
"adapter.scale", "adapter.layer_debug", "adapter.refresh_rate", "adapter.pose_consistency",
|
||||
"lepton_env", "config_sync",
|
||||
]
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
DEVICE_FIELDS = {"device.files", "device.lepton_env", "device.config_sync", "device.foveation"}
|
||||
|
||||
def signature(recipe) -> dict:
|
||||
"""The parts of a recipe that differ between games (defaults/automatic fixes are excluded)."""
|
||||
|
||||
def expected_defaults(analysis) -> set[str]:
|
||||
"""Defaults recommended by their own detection, before recipe normalization/removals.
|
||||
|
||||
As in suggest's initial pass, default_on patches bypass the opt-in applies gate; their own detect() decides.
|
||||
This masks e.g. LTW defaults in games without LTW, without importing any heuristic opt-in/adapter choices.
|
||||
"""
|
||||
return {p.id for p in base.all_patches() if p.default_on and base.for_game(p, analysis)
|
||||
and (s := p.detect(analysis)) and s.recommended}
|
||||
|
||||
|
||||
def signature(recipe: Recipe, defaults: set[str] | None = None) -> dict:
|
||||
"""Recipe decisions, excluding enabled defaults but retaining explicit removal of defaults."""
|
||||
pats = recipe.patches
|
||||
return {
|
||||
"overport": sorted(p for p in pats if base.get(p).category == "overport" and not base.get(p).default_on),
|
||||
"overport_removed": sorted(p for p in (x.id for x in base.all_patches()
|
||||
if x.category == "overport" and x.default_on)
|
||||
if p not in pats),
|
||||
"frame": sorted(p for p in pats if base.get(p).category == "frame" and not base.get(p).default_on),
|
||||
rift = recipe.package.startswith("rift.")
|
||||
patches = [p for p in base.all_patches() if (p.category == "pcvr") == rift]
|
||||
out = {}
|
||||
for category in ("overport", "frame", "pcvr"):
|
||||
out[category] = sorted(p.id for p in patches if p.category == category and not p.default_on and p.id in pats)
|
||||
out[category + "_removed"] = sorted(p.id for p in patches
|
||||
if p.category == category and p.default_on and p.id not in pats
|
||||
and (defaults is None or p.id in defaults))
|
||||
out.update({
|
||||
"adapter": {p.split(".", 1)[1]: v.get("value") for p, v in sorted(pats.items()) if p.startswith("adapter.")},
|
||||
"device": sorted(p for p in pats if p.startswith("device.") and p not in DEVICE_FIELDS
|
||||
and not base.get(p).default_on),
|
||||
"device_files": sorted((pats.get("device.files") or {}).get("files", {})),
|
||||
"foveation": dict(pats.get("device.foveation") or {}),
|
||||
"lepton_env": dict((pats.get("device.lepton_env") or {}).get("env", {})),
|
||||
"config_sync": dict((pats.get("device.config_sync") or {}).get("files", {})),
|
||||
"alt": sorted(recipe.alt_patches),
|
||||
"use_alt": recipe.use_alt,
|
||||
"status_unsupported": recipe.status == "unsupported",
|
||||
}
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def catalog_recipe(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> Recipe:
|
||||
"""Materialize catalog choices plus expected defaults; never import heuristic opt-in choices. With the game's
|
||||
analysis, the engine's structural transforms apply as in suggest(): a 32-bit game goes through the bridge (its
|
||||
entry relies on that instead of listing what the bridge replaces), a 2D app keeps only its non-VR patches."""
|
||||
r = Recipe(entry.package, status=entry.status, as_is=entry.as_is,
|
||||
alt_patches=list(entry.alt_overport), use_alt=entry.use_alt)
|
||||
rift = entry.kind == "rift" or entry.package.startswith("rift.")
|
||||
r.patches = {p.id: {} for p in base.all_patches() if p.default_on and (p.category == "pcvr") == rift
|
||||
and (defaults is None or p.id in defaults)}
|
||||
for pid in (*entry.overport_extra, *entry.frame, *entry.device, *entry.pcvr):
|
||||
r.patches[pid] = {}
|
||||
for pid in (*entry.overport_remove, *entry.frame_remove, *entry.pcvr_remove):
|
||||
r.patches.pop(pid, None)
|
||||
for key, value in entry.adapter.items():
|
||||
r.patches[f"adapter.{key}"] = {"value": value}
|
||||
for pid, param, values in (("device.files", "files", entry.device_files),
|
||||
("device.lepton_env", "env", entry.lepton_env),
|
||||
("device.config_sync", "files", entry.config_sync)):
|
||||
if values:
|
||||
r.patches[pid] = {param: copy.deepcopy(values)}
|
||||
if entry.foveation:
|
||||
r.patches["device.foveation"] = {"mode": entry.foveation}
|
||||
if entry.foveation_radius:
|
||||
r.patches["device.foveation"]["radius"] = entry.foveation_radius
|
||||
if entry.foveation_test:
|
||||
r.patches["device.foveation"]["test"] = True
|
||||
if analysis is not None:
|
||||
if analysis.only_32bit and "frame.a32_bridge" not in entry.frame_remove:
|
||||
r.patches.setdefault("frame.a32_bridge", {})
|
||||
engine.a32_recipe(analysis, r, newly=False)
|
||||
elif analysis.vr_kind == "none":
|
||||
needed = {pid for pid in r.patches if not base.get(pid).needs_vr and pid != "frame.launcher"}
|
||||
needed |= {"frame.launcher"} if analysis.has_info_category else set()
|
||||
r.patches = {pid: v for pid, v in r.patches.items() if pid in needed}
|
||||
r.alt_patches = []
|
||||
return r
|
||||
|
||||
|
||||
def catalog_signature(entry: catalog.CatalogEntry, defaults: set[str] | None = None, analysis=None) -> dict:
|
||||
return signature(catalog_recipe(entry, defaults, analysis), defaults)
|
||||
|
||||
|
||||
def engine_group(name: str) -> str:
|
||||
return name if name in ("Unity", "Unreal") else "Other"
|
||||
|
||||
|
||||
def split_packages(engines: dict[str, str]) -> dict[str, str]:
|
||||
"""package -> dev/holdout; nearest 20% per engine (half rounded up), lowest sha256 first.
|
||||
|
||||
Only pass the intersection of catalog and analyses. This is stable under input reordering; adding packages can
|
||||
change membership at a stratum's cutoff. Tiny strata may have no holdout. Unsupported entries participate too.
|
||||
"""
|
||||
groups = defaultdict(list)
|
||||
for pkg, name in engines.items():
|
||||
groups[engine_group(name)].append(pkg)
|
||||
result = {}
|
||||
for packages in groups.values():
|
||||
ordered = sorted(packages, key=lambda p: (hashlib.sha256(p.encode()).digest(), p))
|
||||
n = (len(ordered) + 2) // 5
|
||||
result.update((p, "holdout" if i < n else "dev") for i, p in enumerate(ordered))
|
||||
return dict(sorted(result.items()))
|
||||
|
||||
|
||||
def _parts(sig: dict) -> tuple[dict, dict]:
|
||||
headline, runtime = {}, {}
|
||||
for key, value in sig.items():
|
||||
if key == "adapter":
|
||||
headline[key] = {k: v for k, v in value.items() if f"adapter.{k}" not in RUNTIME_ONLY}
|
||||
runtime[key] = {k: v for k, v in value.items() if f"adapter.{k}" in RUNTIME_ONLY}
|
||||
else:
|
||||
(runtime if key in RUNTIME_ONLY else headline)[key] = value
|
||||
return headline, runtime
|
||||
|
||||
|
||||
def _decisions(sig: dict) -> dict:
|
||||
"""Patch/action identities -> values. Status is a field metric, not a patch."""
|
||||
out = {}
|
||||
for field, value in sig.items():
|
||||
if field == "status_unsupported":
|
||||
continue
|
||||
if field in ("adapter", "lepton_env", "config_sync"):
|
||||
prefix = "adapter" if field == "adapter" else f"device.{field}"
|
||||
out.update((f"{prefix}.{k}", v) for k, v in value.items())
|
||||
elif field == "foveation":
|
||||
if value:
|
||||
out["device.foveation"] = value
|
||||
elif field == "use_alt":
|
||||
if value:
|
||||
out["use_alt"] = value
|
||||
else:
|
||||
prefix = ("remove:" if field.endswith("_removed") else "alt:" if field == "alt"
|
||||
else "device.files:" if field == "device_files" else "")
|
||||
out.update((prefix + p, True) for p in value)
|
||||
return out
|
||||
|
||||
|
||||
def _rates(tp: int, fp: int, fn: int) -> dict:
|
||||
# No positive decisions yields 0, rather than inflating scores for empty recipes/strata.
|
||||
return {"tp": tp, "fp": fp, "fn": fn, "precision": tp / (tp + fp) if tp + fp else 0.0,
|
||||
"recall": tp / (tp + fn) if tp + fn else 0.0,
|
||||
"f1": 2 * tp / (2 * tp + fp + fn) if 2 * tp + fp + fn else 0.0}
|
||||
|
||||
|
||||
def score_signatures(want: dict, got: dict, gaps: frozenset | set = frozenset()) -> dict:
|
||||
"""Compare one pair of signatures; value mismatches count as both missing and extra. `gaps` = decisions the
|
||||
catalog entry couldn't have made (the patch didn't exist when it was verified): left out of both sides."""
|
||||
result = {}
|
||||
for scope, w, g in zip(("headline", "runtime"), _parts(want), _parts(got), strict=True):
|
||||
wanted, actual = _decisions(w), _decisions(g)
|
||||
gap = {k: actual.pop(k) for k in list(actual) if k in gaps and k not in wanted}
|
||||
missing = {k: v for k, v in wanted.items() if k not in actual or actual[k] != v}
|
||||
extra = {k: v for k, v in actual.items() if k not in wanted or wanted[k] != v}
|
||||
per_patch = {k: _rates(int(k in wanted and k in actual and wanted[k] == actual[k]),
|
||||
int(k in extra), int(k in missing)) for k in sorted(wanted.keys() | actual.keys())}
|
||||
fields = {k: w.get(k) == g.get(k) for k in sorted(w.keys() | g.keys())}
|
||||
exact = all(fields.values()) if not gap else not missing and not extra
|
||||
result[scope] = {"exact": exact, "fields": fields, "missing": missing, "extra": extra,
|
||||
"per_patch": per_patch, "gap": gap}
|
||||
return result
|
||||
|
||||
|
||||
def _aggregate(rows: list[dict], scope: str, score_key: str = "score") -> dict:
|
||||
fields = defaultdict(lambda: {"matched": 0, "total": 0})
|
||||
counts = defaultdict(Counter)
|
||||
gaps = Counter()
|
||||
for row in rows:
|
||||
s = row[score_key][scope]
|
||||
gaps.update(s.get("gap") or {})
|
||||
for key, match in s["fields"].items():
|
||||
fields[key]["matched"] += int(match)
|
||||
fields[key]["total"] += 1
|
||||
for key, stats in s["per_patch"].items():
|
||||
counts[key].update({k: stats[k] for k in ("tp", "fp", "fn")})
|
||||
per_patch = {k: _rates(**v) for k, v in sorted(counts.items())}
|
||||
total = Counter()
|
||||
for stats in counts.values():
|
||||
total.update(stats)
|
||||
exact = sum(row[score_key][scope]["exact"] for row in rows)
|
||||
return {"games": len(rows), "exact": exact, "exact_ratio": exact / len(rows) if rows else 0.0,
|
||||
"fields": dict(sorted(fields.items())), "per_patch": per_patch, "gaps": dict(sorted(gaps.items())),
|
||||
"patches": _rates(*(total[k] for k in ("tp", "fp", "fn")))}
|
||||
|
||||
|
||||
def score_records(records: list[dict]) -> dict:
|
||||
"""Score {package, engine, status, converted, want, got, ...} records; no filesystem needed."""
|
||||
rows = [{**r, "score": score_signatures(r["want"], r["got"]),
|
||||
"score_dated": score_signatures(r["want"], r["got"], set(r.get("gaps") or ()))}
|
||||
for r in sorted(records, key=lambda r: r["package"])]
|
||||
|
||||
def metrics(group):
|
||||
supported = [r for r in group if r["status"] != "unsupported"]
|
||||
unsupported = [r for r in group if r["status"] == "unsupported"]
|
||||
return {"total_games": len(group), "converted_games": sum(bool(r.get("converted")) for r in group),
|
||||
"excluded_unsupported": len(unsupported),
|
||||
"headline": _aggregate(supported, "headline"), "runtime": _aggregate(supported, "runtime"),
|
||||
"headline_dated": _aggregate(supported, "headline", "score_dated"),
|
||||
"unsupported": {scope: _aggregate(unsupported, scope) for scope in ("headline", "runtime")}}
|
||||
|
||||
return {**metrics(rows), "by_engine": {name: metrics([r for r in rows if engine_group(r["engine"]) == name])
|
||||
for name in ("Unity", "Unreal", "Other")}, "games": rows}
|
||||
|
||||
|
||||
def read_snapshot(path: Path, attempts: int = 8) -> dict:
|
||||
"""Copy before parsing, even on retries; never write to the source or read APK/data paths in it."""
|
||||
with tempfile.TemporaryDirectory(prefix="frameport-eval-") as tmp:
|
||||
dest = Path(tmp) / "snapshot.json"
|
||||
for attempt in range(attempts):
|
||||
shutil.copyfile(path, dest)
|
||||
try:
|
||||
return json.loads(dest.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
time.sleep(0.25 * (attempt + 1))
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def read_dumps(path: Path) -> dict:
|
||||
records = {}
|
||||
for g in quest_dump.scan(path):
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
row = {"analysis": a.to_dict(), "converted": a.is_overport_output, "name": g.name}
|
||||
if a.package not in records or records[a.package]["converted"] and not row["converted"]:
|
||||
records[a.package] = row
|
||||
return records
|
||||
|
||||
|
||||
def gap_decisions(entry, intro_dates: dict | None) -> set[str]:
|
||||
"""Decision keys for patches introduced after the entry was verified: the catalog can't be wrong about them."""
|
||||
verified = str((entry.verified or {}).get("date") or "")
|
||||
if not intro_dates or not verified:
|
||||
return set()
|
||||
return {(f"alt:{pid}" if pid.startswith("patch_remove_unreal") else pid)
|
||||
for pid, date in intro_dates.items() if date and date > verified}
|
||||
|
||||
|
||||
def inherited_decisions() -> set[str]:
|
||||
"""Heuristic picks a catalog recipe keeps even when the entry doesn't name them (engine.suggest: adapter
|
||||
settings and device patches other than catalog.TOGGLED_DEVICE): the verified build had them, so an "extra" one
|
||||
isn't a detection error. A catalog value that differs still counts (the key is in both sides then)."""
|
||||
return {p.id for p in base.all_patches()
|
||||
if p.category == "adapter" or (p.category == "device" and p.id not in catalog.TOGGLED_DEVICE)}
|
||||
|
||||
|
||||
def evaluate(snapshot: dict, entries: dict, split: str = "dev", intro_dates: dict | None = None) -> dict:
|
||||
analyses = {pkg: analysis_from_dict(v["analysis"]) for pkg, v in snapshot.items() if pkg in entries}
|
||||
assignments = split_packages({pkg: a.engine for pkg, a in analyses.items()})
|
||||
records = []
|
||||
for pkg, a in sorted(analyses.items()):
|
||||
if split != "all" and assignments[pkg] != split:
|
||||
continue
|
||||
entry = entries[pkg]
|
||||
defaults = expected_defaults(a)
|
||||
records.append({"package": pkg, "title": entry.title, "engine": a.engine, "status": entry.status,
|
||||
"converted": bool(snapshot[pkg].get("converted", a.is_overport_output)),
|
||||
"analysis_version": snapshot[pkg].get("analysis_version"), "split": assignments[pkg],
|
||||
"want": catalog_signature(entry, defaults, a),
|
||||
"got": signature(engine.suggest(a, use_catalog=False), defaults),
|
||||
"gaps": sorted(gap_decisions(entry, intro_dates) | inherited_decisions())})
|
||||
return {"schema_version": 1, "split": split, "runtime_only": RUNTIME_ONLY, "snapshot_packages": len(snapshot),
|
||||
"default_policy": "per-analysis default detection, then explicit catalog removals",
|
||||
"catalog_packages": len(entries), "matched_packages": len(analyses),
|
||||
"split_assignments": assignments, **score_records(records)}
|
||||
|
||||
|
||||
def fit_candidates(analysis, entry: catalog.CatalogEntry) -> list[dict]:
|
||||
"""Use suggest's final heuristic pass, including platform filtering, non-VR and a32 normalization.
|
||||
|
||||
Requiring a recommended detect() as well excludes dependencies and synthetic recipe additions. No engine
|
||||
refactor is needed: suggest() remains untouched, and catalog data is used only for the candidate exclusion and
|
||||
the hypothetical warning recipe.
|
||||
"""
|
||||
suggested = engine.suggest(analysis, use_catalog=False)
|
||||
chosen = catalog_recipe(entry, expected_defaults(analysis), analysis)
|
||||
listed = set(chosen.patches) | set(entry.alt_overport)
|
||||
removed = set(entry.overport_remove) | set(entry.frame_remove) | set(entry.pcvr_remove)
|
||||
result = []
|
||||
for pid, params in sorted(suggested.patches.items()):
|
||||
p = base.get(pid)
|
||||
if (p.default_on or pid in listed or pid in removed or not base.for_game(p, analysis)
|
||||
or not p.applies(analysis)):
|
||||
continue
|
||||
s = p.detect(analysis)
|
||||
if not s or not s.recommended:
|
||||
continue
|
||||
trial = copy.deepcopy(chosen)
|
||||
trial.patches[pid] = dict(params)
|
||||
result.append({"patch": pid, "category": p.category, "experimental": p.experimental,
|
||||
"strict": bool(getattr(p, "strict", False)), "reason": s.reason,
|
||||
"warnings": engine.warnings(trial, analysis)})
|
||||
return result
|
||||
|
||||
|
||||
def patch_intro_dates(cache_path: Path) -> dict:
|
||||
"""One local git pickaxe per uncached patch (including missing-history results); never use the network."""
|
||||
try:
|
||||
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
cached = {}
|
||||
changed = False
|
||||
for p in base.all_patches():
|
||||
if p.id in cached:
|
||||
continue
|
||||
needle = p.id.split(".", 1)[1] if p.category == "adapter" else p.id
|
||||
paths = ["src/frameport/patches/settings.py"] if p.category == "adapter" else ["src/frameport"]
|
||||
log = subprocess.run(["git", "log", f'-S"{needle}"', "--reverse", "--format=%ad", "--date=short", "--",
|
||||
*paths], cwd=REPO, text=True, capture_output=True, check=True)
|
||||
dates = log.stdout.splitlines()
|
||||
cached[p.id] = dates[0] if dates else None
|
||||
changed = True
|
||||
if changed:
|
||||
cache_path.write_text(json.dumps(cached, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
return cached
|
||||
|
||||
|
||||
def entry_date(entry) -> str:
|
||||
"""When the recipe was verified; `updated` only when there is no verified date (it also moves for unrelated
|
||||
edits, e.g. a note, so it says nothing about which patches the verifier could pick)."""
|
||||
return str(entry.verified.get("date") or "") or str(entry.updated or "")
|
||||
|
||||
|
||||
def fit_report(snapshot: dict, entries: dict, intro_dates: dict) -> dict:
|
||||
games, metadata = [], []
|
||||
totals = Counter()
|
||||
for pkg, entry in sorted(entries.items()):
|
||||
common = {"package": pkg, "title": entry.title, "engine": entry.engine, "xr": entry.xr,
|
||||
"status": entry.status, "verified_date": str(entry.verified.get("date") or ""),
|
||||
"updated": str(entry.updated or ""), "entry_date": entry_date(entry)}
|
||||
if pkg not in snapshot:
|
||||
metadata.append(common)
|
||||
continue
|
||||
candidates = fit_candidates(analysis_from_dict(snapshot[pkg]["analysis"]), entry)
|
||||
for c in candidates:
|
||||
intro = intro_dates.get(c["patch"])
|
||||
c.update(intro_date=intro,
|
||||
newer_patch=bool(intro and common["entry_date"] and intro > common["entry_date"]))
|
||||
totals[c["patch"]] += 1
|
||||
games.append({**common, "converted": bool(snapshot[pkg].get("converted")), "candidates": candidates})
|
||||
return {"games": games, "metadata_only": metadata, "totals_by_patch": dict(sorted(totals.items())),
|
||||
"candidate_count": sum(totals.values()), "games_with_candidates": sum(bool(g["candidates"]) for g in games)}
|
||||
|
||||
|
||||
def _md(value) -> str:
|
||||
return str(value if value is not None else "unknown").replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render_fit_report(report: dict) -> str:
|
||||
lines = ["# New patch fit", "", f"{len(report['games'])} catalog games with analyses; "
|
||||
f"{report['games_with_candidates']} with candidates; {report['candidate_count']} candidate additions. "
|
||||
f"{len(report['metadata_only'])} metadata-only entries.", "",
|
||||
"Candidates are opt-in recommended patches absent from the entry, after the heuristic engine's platform "
|
||||
"and a32 rules. A listed adapter key is excluded even if its value differs. Warnings are for the full "
|
||||
"catalog recipe plus that one patch; they can include pre-existing conflicts. Unknown history/date "
|
||||
"does not imply a newer patch. Default-on patches are not candidates. All splits and statuses included.",
|
||||
"", "## Totals by patch", "", "| Patch | Games |", "| --- | ---: |"]
|
||||
lines.extend(f"| {pid} | {n} |" for pid, n in
|
||||
sorted(report["totals_by_patch"].items(), key=lambda kv: (-kv[1], kv[0])))
|
||||
for game in report["games"]:
|
||||
lines += ["", f"## {_md(game['title'])} (`{game['package']}`)", "",
|
||||
f"{game['engine']} / {game['xr']}; status: {game['status']}; "
|
||||
f"entry date: {game['entry_date'] or 'unknown'}; converted: {game['converted']}.", ""]
|
||||
if not game["candidates"]:
|
||||
lines += ["No candidates."]
|
||||
continue
|
||||
lines += ["| Patch | Category | Experimental | Strict | Reason | Introduced | Newer patch | Warnings |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for c in game["candidates"]:
|
||||
cells = [c["patch"], c["category"], c["experimental"], c["strict"], c["reason"], c["intro_date"],
|
||||
c["newer_patch"], "; ".join(c["warnings"]) or "none"]
|
||||
lines.append("| " + " | ".join(_md(v) for v in cells) + " |")
|
||||
lines += ["", "## Catalog entries without analyses (metadata only)", "",
|
||||
"| Package | Title | Engine | XR | Status | Verified | Updated | Entry date |",
|
||||
"| --- | --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for g in report["metadata_only"]:
|
||||
lines.append("| " + " | ".join(_md(g[k]) for k in
|
||||
("package", "title", "engine", "xr", "status", "verified_date", "updated", "entry_date")) + " |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def print_metrics(report: dict, verbose: bool = False) -> None:
|
||||
print(f"Split: {report['split']}; catalog/analysis intersection: {report['matched_packages']}; "
|
||||
f"converted: {report['converted_games']}; unsupported excluded: {report['excluded_unsupported']}")
|
||||
print(f"{'Engine':10} {'Games':>5} {'Exact':>9} {'Fields':>11} {'Prec':>7} {'Recall':>7} {'F1':>7} {'Runtime':>9}")
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
h, r = metrics["headline"], metrics["runtime"]
|
||||
fields = h["fields"].values()
|
||||
f = f"{sum(x['matched'] for x in fields)}/{sum(x['total'] for x in h['fields'].values())}"
|
||||
p = h["patches"]
|
||||
print(f"{name:10} {h['games']:5} {h['exact']:4}/{h['games']:<4} {f:>11} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%} {r['exact']:4}/{r['games']:<4}")
|
||||
print("\nDated (extras the entry couldn't have listed - patch newer than its verified date - or that catalog "
|
||||
"recipes inherit anyway - adapter/device picks - are not errors):")
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
d = metrics["headline_dated"]
|
||||
p = d["patches"]
|
||||
print(f"{name:10} {d['games']:5} {d['exact']:4}/{d['games']:<4} {'':>11} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}")
|
||||
gaps = report["headline_dated"]["gaps"]
|
||||
if gaps:
|
||||
print("Catalog gaps by patch: " + ", ".join(f"{k}={v}" for k, v in sorted(gaps.items(), key=lambda kv: -kv[1])))
|
||||
for name, metrics in [("All", report), *report["by_engine"].items()]:
|
||||
print(f"\n{name}: field matches (headline / runtime)")
|
||||
for scope in ("headline", "runtime"):
|
||||
matches = metrics[scope]["fields"].items()
|
||||
print(scope + ": " + ", ".join(f"{k}={v['matched']}/{v['total']}" for k, v in matches))
|
||||
print(f"{'Scope / patch':55} {'TP':>5} {'FP':>5} {'FN':>5} {'Prec':>7} {'Recall':>7} {'F1':>7}")
|
||||
for scope in ("headline", "runtime"):
|
||||
for pid, p in metrics[scope]["per_patch"].items():
|
||||
print(f"{scope + ' / ' + pid:55} {p['tp']:5} {p['fp']:5} {p['fn']:5} "
|
||||
f"{p['precision']:7.1%} {p['recall']:7.1%} {p['f1']:7.1%}")
|
||||
u = report["unsupported"]["headline"]
|
||||
print(f"\nUnsupported (excluded): {u['exact']}/{u['games']} exact; patch F1={u['patches']['f1']:.1%}")
|
||||
for game in report["games"]:
|
||||
s = game["score"]
|
||||
if verbose or not s["headline"]["exact"] or not s["runtime"]["exact"] or game["status"] == "unsupported":
|
||||
print(f"\n{game['package']} [{game['engine']}, {game['status']}, {game['split']}"
|
||||
+ (", converted" if game["converted"] else "") + "]")
|
||||
for scope in ("headline", "runtime"):
|
||||
for diff in ("missing", "extra"):
|
||||
if s[scope][diff]:
|
||||
print(f" {scope} {diff}: {json.dumps(s[scope][diff], sort_keys=True)}")
|
||||
if not s[scope]["fields"].get("status_unsupported", True):
|
||||
print(f" {scope} status_unsupported: catalog={game['want']['status_unsupported']} "
|
||||
f"heuristics={game['got']['status_unsupported']}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("dumps", type=Path)
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("dumps", type=Path, nargs="?")
|
||||
ap.add_argument("--snapshot", type=Path)
|
||||
ap.add_argument("--split", choices=("dev", "holdout", "all"), default="dev")
|
||||
ap.add_argument("--json", type=Path, dest="json_out")
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
ap.add_argument("--fit-report", action="store_true")
|
||||
ap.add_argument("--out", type=Path, help="Markdown destination for --fit-report (otherwise stdout)")
|
||||
args = ap.parse_args()
|
||||
games = quest_dump.scan(args.dumps)
|
||||
total = matched_fields = games_ok = 0
|
||||
misses = []
|
||||
for g in games:
|
||||
a = analyze(g.apk, data_bytes=g.data_bytes())
|
||||
if not catalog.lookup(a.package):
|
||||
continue
|
||||
want = signature(engine.suggest(a, use_catalog=True))
|
||||
want["status_unsupported"] = (catalog.lookup(a.package).status == "unsupported"
|
||||
and "32-bit" in catalog.lookup(a.package).notes)
|
||||
got = signature(engine.suggest(a, use_catalog=False))
|
||||
diffs = {k: (want[k], got[k]) for k in want if want[k] != got[k]}
|
||||
total += len(want)
|
||||
matched_fields += len(want) - len(diffs)
|
||||
games_ok += not diffs
|
||||
if diffs:
|
||||
misses.append((a.package, diffs))
|
||||
if args.verbose or diffs:
|
||||
print(f"{'OK ' if not diffs else 'DIFF'} {a.package}")
|
||||
for k, (w, h) in diffs.items():
|
||||
print(f" {k}: catalog={w} heuristics={h}")
|
||||
n = games_ok + len(misses)
|
||||
print(f"\n{games_ok}/{n} games reproduced exactly; {matched_fields}/{total} recipe fields match")
|
||||
return 0 if not misses else 1
|
||||
if not args.snapshot and not args.dumps:
|
||||
ap.error("provide a dumps folder or --snapshot")
|
||||
if args.out and not args.fit_report:
|
||||
ap.error("--out requires --fit-report")
|
||||
snapshot = read_snapshot(args.snapshot) if args.snapshot else read_dumps(args.dumps)
|
||||
# Explicitly bundled entries: remote caches and personal recipes must not affect this benchmark.
|
||||
entries = catalog._load_dir(REPO / "catalog/games", "bundled")
|
||||
if args.fit_report:
|
||||
cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json"
|
||||
report = fit_report(snapshot, entries, patch_intro_dates(cache))
|
||||
markdown = render_fit_report(report)
|
||||
if args.out:
|
||||
args.out.write_text(markdown, encoding="utf-8")
|
||||
print(f"{report['candidate_count']} candidates in {report['games_with_candidates']} games -> {args.out}")
|
||||
else:
|
||||
print(markdown, end="")
|
||||
else:
|
||||
cache = (args.snapshot.parent if args.snapshot else args.dumps) / "patch-intro-dates.json"
|
||||
# dated scoring reads the cache --fit-report writes; a plain run never shells out for dates
|
||||
dates = json.loads(cache.read_text(encoding="utf-8")) if cache.is_file() else None
|
||||
report = evaluate(snapshot, entries, args.split, dates)
|
||||
print_metrics(report, args.verbose)
|
||||
if args.json_out:
|
||||
args.json_out.write_text(json.dumps(report, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8")
|
||||
# Retain the legacy evaluator's nonzero exit for recipe differences; JSON is still written.
|
||||
return 0 if args.fit_report or report["headline"]["games"] == report["headline"]["exact"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Analyse every Quest dump below the given folders once and save the analyses as JSON, so heuristics evaluations
|
||||
(`eval_heuristics.py --snapshot`) don't re-read gigabytes of APKs each run.
|
||||
|
||||
python scripts/heuristics_snapshot.py <out.json> <dumps folder> [<dumps folder> ...]
|
||||
|
||||
An original APK beats an already converted one (OVRPort output, e.g. PATCHED/ copies): converted inputs change what
|
||||
the heuristics suggest, so they are only kept when no original exists and are marked `converted`. The output holds
|
||||
local paths: keep it out of git.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from frameport.analysis.detect import ANALYSIS_VERSION, analyze # noqa: E402
|
||||
from frameport.sources import quest_dump # noqa: E402
|
||||
|
||||
|
||||
def _skip(game, roots: list[Path]) -> bool:
|
||||
"""Folders starting with "_" (known-good backups, test builds, keys) are not dumps."""
|
||||
for root in roots:
|
||||
try:
|
||||
rel = game.apk.relative_to(root)
|
||||
except ValueError:
|
||||
continue
|
||||
return any(part.startswith("_") for part in rel.parts[:-1])
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("out", type=Path)
|
||||
ap.add_argument("roots", type=Path, nargs="+")
|
||||
args = ap.parse_args()
|
||||
roots = [r.resolve() for r in args.roots]
|
||||
old = json.loads(args.out.read_text()) if args.out.is_file() else {}
|
||||
snap: dict[str, dict] = {}
|
||||
for root in roots:
|
||||
for g in quest_dump.scan(root):
|
||||
if _skip(g, roots):
|
||||
continue
|
||||
key = str(g.apk)
|
||||
cached = next((v for v in old.values() if v.get("apk") == key
|
||||
and v.get("analysis_version") == ANALYSIS_VERSION
|
||||
and v.get("apk_size") == g.apk.stat().st_size), None)
|
||||
t = time.time()
|
||||
if cached:
|
||||
entry = cached
|
||||
else:
|
||||
try:
|
||||
data_bytes = g.data_bytes()
|
||||
a = analyze(g.apk, data_bytes=data_bytes)
|
||||
except Exception as e: # noqa: BLE001 - one unreadable dump mustn't stop the snapshot
|
||||
print(f"FAIL {g.apk}: {e}", file=sys.stderr, flush=True)
|
||||
continue
|
||||
entry = {"analysis": dataclasses.asdict(a), "data_bytes": data_bytes, "apk": key,
|
||||
"apk_size": g.apk.stat().st_size, "name": g.name, "root": str(root),
|
||||
"converted": bool(a.is_overport_output), "analysis_version": ANALYSIS_VERSION}
|
||||
pkg = entry["analysis"]["package"]
|
||||
have = snap.get(pkg)
|
||||
# originals beat converted copies; otherwise the first root given wins
|
||||
if have is None or (have["converted"] and not entry["converted"]):
|
||||
snap[pkg] = entry
|
||||
print(f"{'cache' if cached else f'{time.time() - t:5.1f}s'} {pkg}"
|
||||
f"{' (converted)' if entry['converted'] else ''}", flush=True)
|
||||
args.out.write_text(json.dumps(snap, indent=1, default=str)) # partial results survive a crash
|
||||
print(f"{len(snap)} packages ({sum(v['converted'] for v in snap.values())} converted only) -> {args.out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Render the in-headset room-scan prompts (SteamVR overlay images) into artifacts/roomscan/.
|
||||
|
||||
The Frame has no imaging library, so the agent shows pre-rendered PNGs through IVROverlay while it records the
|
||||
passthrough depth mesh (agent `room_capture`). One image per state; "look around" comes in 13 variants (0-12 of 12
|
||||
directions seen) so the progress ring can advance without drawing on the Frame. Colours = the Portal theme.
|
||||
|
||||
uv run python scripts/mr/render_prompts.py [--check]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUT = ROOT / "artifacts" / "roomscan"
|
||||
W, H = 1024, 400
|
||||
BG, SURFACE, TEXT, TEXT_2 = (13, 14, 18, 235), (29, 31, 39, 255), (236, 238, 243, 255), (167, 172, 186, 255)
|
||||
ACCENT, SECONDARY, OK = (255, 138, 31, 255), (58, 168, 255, 255), (74, 222, 128, 255)
|
||||
DIRECTIONS = 12
|
||||
|
||||
PROMPTS = {
|
||||
"start": ("Turn on passthrough", "The scan runs while you can see your room:\n"
|
||||
"open the quick access menu and switch passthrough on."),
|
||||
"ceiling": ("Look up at the ceiling", "Hold it for a few seconds,\n"
|
||||
"then look back down and keep turning."),
|
||||
"floor": ("Look down at the floor", "Hold it for a few seconds,\nthen keep turning."),
|
||||
"done": ("Room scanned", "You can take the headset off or start a mixed reality game."),
|
||||
"failed": ("Scan stopped", "The headset's cameras sent no room view.\nStart the scan again from FramePort."),
|
||||
}
|
||||
|
||||
|
||||
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||
for name in ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf",):
|
||||
for base in ("/usr/share/fonts/truetype/dejavu", "C:/Windows/Fonts", "/Library/Fonts"):
|
||||
p = Path(base) / name
|
||||
if p.is_file():
|
||||
return ImageFont.truetype(str(p), size)
|
||||
return ImageFont.load_default(size=size)
|
||||
|
||||
|
||||
def panel(title: str, body: str, seen: int | None = None, accent=ACCENT) -> Image.Image:
|
||||
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rounded_rectangle((4, 4, W - 4, H - 4), radius=48, fill=BG, outline=SURFACE, width=4)
|
||||
d.rounded_rectangle((4, 4, 20, H - 4), radius=8, fill=accent) # portal edge
|
||||
x = 64
|
||||
if seen is not None: # progress ring: one segment per 30 degree direction seen
|
||||
cx, cy, r = 150, H // 2, 92
|
||||
for i in range(DIRECTIONS):
|
||||
a0 = -90 + i * 360 / DIRECTIONS + 3
|
||||
d.arc((cx - r, cy - r, cx + r, cy + r), a0, a0 + 360 / DIRECTIONS - 6,
|
||||
fill=(SECONDARY if i < seen else SURFACE), width=26)
|
||||
d.text((cx, cy), f"{seen}/{DIRECTIONS}", font=font(44, True), fill=TEXT, anchor="mm")
|
||||
x = 290
|
||||
d.text((x, 108), title, font=font(54, True), fill=TEXT, anchor="lm")
|
||||
y = 180
|
||||
for line in body.split("\n"):
|
||||
d.text((x, y), line, font=font(32), fill=TEXT_2, anchor="lm")
|
||||
y += 48
|
||||
return img
|
||||
|
||||
|
||||
def images() -> dict[str, bytes]:
|
||||
out = {}
|
||||
for key, (title, body) in PROMPTS.items():
|
||||
out[key] = panel(title, body, accent=OK if key == "done" else ACCENT)
|
||||
for n in range(DIRECTIONS + 1):
|
||||
out[f"look_{n:02d}"] = panel("Look around the room", "Turn slowly until the ring is full:\n"
|
||||
"every wall and your furniture.", seen=n, accent=SECONDARY)
|
||||
data = {}
|
||||
for key, img in out.items():
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, "PNG", optimize=True)
|
||||
data[f"{key}.png"] = buf.getvalue()
|
||||
return data
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--check", action="store_true", help="fail if artifacts/roomscan is out of date")
|
||||
args = ap.parse_args()
|
||||
data = images()
|
||||
if args.check:
|
||||
stale = [n for n, b in data.items() if not (OUT / n).is_file() or (OUT / n).read_bytes() != b]
|
||||
if stale:
|
||||
print("out of date:", ", ".join(stale))
|
||||
return 1
|
||||
return 0
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for name, b in data.items():
|
||||
p = OUT / name
|
||||
if not p.is_file() or p.read_bytes() != b:
|
||||
p.write_bytes(b)
|
||||
print(f"{len(data)} prompts in {OUT.relative_to(ROOT)} "
|
||||
f"({sum(map(len, data.values())) // 1024} KiB, {hashlib.sha256(b''.join(data.values())).hexdigest()[:12]})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render deterministic, noisy head-centred depth meshes into the FPRS v1 contract (stdlib only)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import random
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
BOX_OUTLINE = [(-2, -1.5), (2, -1.5), (2, 1.5), (-2, 1.5)]
|
||||
L_OUTLINE = [(-2, -2), (2, -2), (2, 0), (0, 0), (0, 2), (-2, 2)]
|
||||
TABLE = ((-.6, 0, -.25), (1.2, .75, .7))
|
||||
COUCH = ((.6, 0, .95), (1.6, .65, .65))
|
||||
ROOMVIEW_WIDTH, ROOMVIEW_HEIGHT = 132, 128
|
||||
ROOMVIEW_NEAR, ROOMVIEW_FAR = .3, 4.0
|
||||
|
||||
|
||||
def fan(width, height, roomview):
|
||||
"""RoomView camera axes are +Z forward / -Y image up; poses convert them to STAGE."""
|
||||
sx, sy = (1.96, 1.94) if roomview else (math.tan(math.radians(55)), math.tan(math.radians(38)))
|
||||
for row in range(height):
|
||||
for col in range(width):
|
||||
x = (2 * col / (width - 1) - 1) * sx
|
||||
y = (2 * row / (height - 1) - 1) * sy
|
||||
if not roomview:
|
||||
y = -y
|
||||
length = math.sqrt(x * x + y * y + 1)
|
||||
yield x / length, y / length, (1 if roomview else -1) / length
|
||||
|
||||
|
||||
def smooth_depths(values, width, height, radius=3):
|
||||
"""Separable box smoothing models soft stereo boundaries, without a NumPy/scipy dependency."""
|
||||
result = list(values)
|
||||
for vertical in (False, True):
|
||||
source, result = result, [0.0] * len(values)
|
||||
lines, length, step = (width, height, width) if vertical else (height, width, 1)
|
||||
for line in range(lines):
|
||||
start = line if vertical else line * width
|
||||
prefix = [0.0]
|
||||
for i in range(length):
|
||||
prefix.append(prefix[-1] + source[start + i * step])
|
||||
for i in range(length):
|
||||
lo, hi = max(0, i - radius), min(length, i + radius + 1)
|
||||
result[start + i * step] = (prefix[hi] - prefix[lo]) / (hi - lo)
|
||||
return result
|
||||
|
||||
|
||||
def inside(x, z, polygon):
|
||||
odd = False
|
||||
for (ax, az), (bx, bz) in zip(polygon, polygon[1:] + polygon[:1], strict=False):
|
||||
if (az > z) != (bz > z) and x < (bx - ax) * (z - az) / (bz - az) + ax:
|
||||
odd = not odd
|
||||
return odd
|
||||
|
||||
|
||||
def rotate(v, q):
|
||||
x, y, z = v
|
||||
qx, qy, qz, qw = q
|
||||
tx, ty, tz = 2 * (qy * z - qz * y), 2 * (qz * x - qx * z), 2 * (qx * y - qy * x)
|
||||
return (x + qw * tx + qy * tz - qz * ty,
|
||||
y + qw * ty + qz * tx - qx * tz,
|
||||
z + qw * tz + qx * ty - qy * tx)
|
||||
|
||||
|
||||
def cast(eye, direction, polygon, ceiling, furniture, missing_wall):
|
||||
ex, ey, ez = eye
|
||||
dx, dy, dz = direction
|
||||
nearest = 5.0
|
||||
hit = False
|
||||
for height in (0, ceiling):
|
||||
if abs(dy) > 1e-8:
|
||||
t = (height - ey) / dy
|
||||
if .3 < t < nearest and inside(ex + t * dx, ez + t * dz, polygon):
|
||||
nearest, hit = t, True
|
||||
for index, ((ax, az), (bx, bz)) in enumerate(zip(polygon, polygon[1:] + polygon[:1], strict=False)):
|
||||
if index == missing_wall:
|
||||
continue
|
||||
sx, sz = bx - ax, bz - az
|
||||
det = dx * sz - dz * sx
|
||||
if abs(det) < 1e-8:
|
||||
continue
|
||||
t = ((ax - ex) * sz - (az - ez) * sx) / det
|
||||
u = ((ax - ex) * dz - (az - ez) * dx) / det
|
||||
if .3 < t < nearest and 0 <= u <= 1 and 0 <= ey + t * dy <= ceiling:
|
||||
nearest, hit = t, True
|
||||
for (cx, bottom, cz), (width, height, depth) in furniture:
|
||||
near, far = -1e20, 1e20
|
||||
for pos, ray, lo, hi in zip(eye, direction,
|
||||
(cx - width / 2, bottom, cz - depth / 2),
|
||||
(cx + width / 2, bottom + height, cz + depth / 2), strict=False):
|
||||
if abs(ray) < 1e-8:
|
||||
if not lo <= pos <= hi:
|
||||
far = -1e20
|
||||
break
|
||||
else:
|
||||
a, b = (lo - pos) / ray, (hi - pos) / ray
|
||||
near, far = max(near, min(a, b)), min(far, max(a, b))
|
||||
if .3 < near < nearest and near <= far:
|
||||
nearest, hit = near, True
|
||||
return nearest if hit else None
|
||||
|
||||
|
||||
def render(path: Path, *, shape="box", frames=30, width=None, height=None, noise=.004,
|
||||
holes=.025, smear=.012, floor_bias=.008, no_ceiling=False, seed=7,
|
||||
yaw=0.0, offset=(0.0, 0.0, 0.0), missing_wall=-1, objects=True, profile="roomview",
|
||||
invalid_start=None, smoothing=3):
|
||||
"""Each frame has its own moving pose; no ground-truth room data is put in the dump."""
|
||||
rng = random.Random(seed)
|
||||
roomview = profile == "roomview"
|
||||
width = width or (ROOMVIEW_WIDTH if roomview else 56)
|
||||
height = height or (ROOMVIEW_HEIGHT if roomview else 40)
|
||||
invalid_start = (2 if roomview else 0) if invalid_start is None else invalid_start
|
||||
polygon = BOX_OUTLINE if shape == "box" else L_OUTLINE
|
||||
ceiling = 2.6
|
||||
furniture = [TABLE, COUCH] if objects and shape == "box" else []
|
||||
rotation = (0, math.sin(yaw / 2), 0, math.cos(yaw / 2))
|
||||
rays = list(fan(width, height, roomview))
|
||||
with path.open("wb") as out:
|
||||
out.write(struct.pack("<4sI", b"FPRS", 1))
|
||||
for frame in range(frames):
|
||||
heading = 2 * math.pi * frame / max(frames, 1)
|
||||
pitch = -.85 if no_ceiling else (-.60, 0, .70)[frame % 3]
|
||||
if roomview and no_ceiling:
|
||||
pitch = -1.2
|
||||
# The L's two arms need viewpoints on both sides of the re-entrant corner.
|
||||
centre = (-.65, -.65) if shape == "l" else (0, 0)
|
||||
eye = (centre[0] + .35 * math.cos(heading), 1.6 + .04 * math.sin(heading * 2),
|
||||
centre[1] + .35 * math.sin(heading))
|
||||
if roomview:
|
||||
eye = eye[0], 1.3 + .4 * math.sin(heading), eye[2]
|
||||
sy, cy = math.sin(heading / 2), math.cos(heading / 2)
|
||||
sp, cp = math.sin(pitch / 2), math.cos(pitch / 2)
|
||||
q = (cy * sp, sy * cp, -sy * sp, cy * cp)
|
||||
if roomview:
|
||||
q = q[3], q[2], -q[1], -q[0] # head rotation composed with camera's 180 degree X rotation
|
||||
depths = [cast(eye, rotate(ray, q), polygon, ceiling, furniture, missing_wall) for ray in rays]
|
||||
if roomview:
|
||||
# Smooth both valid depth and inpainted far pixels. Low-frequency noise survives the filter,
|
||||
# while isolated high-frequency speckle is small, as in a stereo patch fit.
|
||||
coarse_width = (width + 11) // 12
|
||||
coarse = [rng.gauss(0, .012) for _ in range(coarse_width * ((height + 11) // 12))]
|
||||
depths = [(depth if depth is not None else 8 + rng.random() * 2)
|
||||
+ coarse[(i // width // 12) * coarse_width + (i % width // 12)]
|
||||
for i, depth in enumerate(depths)]
|
||||
depths = smooth_depths(depths, width, height, smoothing)
|
||||
vertices, valid = [], []
|
||||
for index, (ray, depth) in enumerate(zip(rays, depths, strict=False)):
|
||||
keep = depth is not None and rng.random() >= holes
|
||||
valid.append(keep or roomview)
|
||||
if roomview and not keep:
|
||||
depth = 8 + rng.random() * 2
|
||||
if depth is None:
|
||||
depth = 0
|
||||
else:
|
||||
# Stereo edges blend two depths, producing stretched grazing triangles.
|
||||
other = depths[index + 1] if (index + 1) % width and index + 1 < len(depths) else depth
|
||||
if other is not None and abs(other - depth) > .3 and rng.random() < smear:
|
||||
depth = (depth + other) / 2
|
||||
world_ray = rotate(ray, q)
|
||||
if eye[1] + depth * world_ray[1] < .15:
|
||||
depth += floor_bias * rng.random()
|
||||
depth += rng.gauss(0, noise)
|
||||
vertices.extend(c * depth for c in ray)
|
||||
indices = []
|
||||
for row in range(height - 1):
|
||||
for col in range(width - 1):
|
||||
a = row * width + col
|
||||
for triangle in ((a, a + width, a + 1), (a + 1, a + width, a + width + 1)):
|
||||
if all(valid[i] for i in triangle):
|
||||
indices.extend(triangle)
|
||||
# Compose the global room transform with each independent mesh -> room pose.
|
||||
pose_eye = tuple(a + b for a, b in zip(rotate(eye, rotation), offset, strict=False))
|
||||
ax, ay, az, aw = rotation
|
||||
bx, by, bz, bw = q
|
||||
pose_q = (aw * bx + ax * bw + ay * bz - az * by,
|
||||
aw * by - ax * bz + ay * bw + az * bx,
|
||||
aw * bz + ax * by - ay * bx + az * bw,
|
||||
aw * bw - ax * bx - ay * by - az * bz)
|
||||
if frame < invalid_start:
|
||||
pose_eye = (float("nan"),) * 3
|
||||
out.write(struct.pack("<d7fII", frame / (3 if roomview else 15),
|
||||
*pose_eye, *pose_q, len(rays), len(indices)))
|
||||
out.write(struct.pack(f"<{len(vertices)}f", *vertices))
|
||||
out.write(struct.pack(f"<{len(indices)}I", *indices))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("out", type=Path)
|
||||
ap.add_argument("--shape", choices=("box", "l"), default="box")
|
||||
ap.add_argument("--frames", type=int, default=30)
|
||||
ap.add_argument("--profile", choices=("roomview", "legacy"), default="roomview")
|
||||
ap.add_argument("--width", type=int)
|
||||
ap.add_argument("--height", type=int)
|
||||
ap.add_argument("--invalid-start", type=int, help="untracked initial frames (RoomView default: 2)")
|
||||
ap.add_argument("--smoothing", type=int, default=3, help="RoomView depth filter radius in pixels")
|
||||
ap.add_argument("--noise", type=float, default=.004)
|
||||
ap.add_argument("--holes", type=float, default=.025)
|
||||
ap.add_argument("--smear", type=float, default=.012)
|
||||
ap.add_argument("--floor-bias", type=float, default=.008)
|
||||
ap.add_argument("--no-ceiling", action="store_true")
|
||||
ap.add_argument("--no-objects", action="store_true")
|
||||
ap.add_argument("--missing-wall", type=int, default=-1)
|
||||
ap.add_argument("--yaw", type=float, default=0, help="global room yaw in degrees")
|
||||
ap.add_argument("--seed", type=int, default=7)
|
||||
args = ap.parse_args()
|
||||
if args.frames < 1 or (args.width is not None and args.width < 3) or (args.height is not None and args.height < 3):
|
||||
ap.error("positive frame count and a grid of at least 3 x 3 required")
|
||||
render(args.out, shape=args.shape, frames=args.frames, width=args.width, height=args.height,
|
||||
noise=args.noise, holes=args.holes, smear=args.smear, floor_bias=args.floor_bias,
|
||||
no_ceiling=args.no_ceiling, seed=args.seed, yaw=math.radians(args.yaw),
|
||||
missing_wall=args.missing_wall, objects=not args.no_objects, profile=args.profile,
|
||||
invalid_start=args.invalid_start, smoothing=args.smoothing)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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)")):
|
||||
|
||||
@@ -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():
|
||||
|
||||