Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8c750bd53 | ||
|
|
20ae3bebab | ||
|
|
da58afa3e3 | ||
|
|
51a866389a | ||
|
|
1f992a4970 | ||
|
|
68ef5eb6e8 | ||
|
|
6fb05e7bd5 | ||
|
|
5ceef3f8dc | ||
|
|
65296a5d1f | ||
|
|
c75b480a72 | ||
|
|
0c02f6ca15 | ||
|
|
bffa2f01d3 | ||
|
|
4f871b2033 | ||
|
|
24e9ca3cb5 | ||
|
|
8d0a225349 | ||
|
|
68771a3e1b | ||
|
|
8f0d65fd50 | ||
|
|
1763462d12 | ||
|
|
488ba70e66 | ||
|
|
6e42d99bfe | ||
|
|
3d2b5a6d63 | ||
|
|
b07bf1da11 | ||
|
|
a3362b98f7 | ||
|
|
030a170fc1 | ||
|
|
05a05d430b | ||
|
|
786c2eaa85 | ||
|
|
2b1b5cb031 | ||
|
|
868badb442 | ||
|
|
638600510a | ||
|
|
4c3056f96f | ||
|
|
b516ece20b | ||
|
|
61db8747ec | ||
|
|
44f8989d11 | ||
|
|
fe2fab7a8f | ||
|
|
0cf923dbc2 | ||
|
|
94538965d9 | ||
|
|
e2d6fe4db5 | ||
|
|
fa80ebf556 | ||
|
|
e96f6da681 | ||
|
|
f294e62925 | ||
|
|
1b987a7a8e | ||
|
|
f68ab37aa0 | ||
|
|
b100ee8710 | ||
|
|
8ffc33eef4 | ||
|
|
dd596dc9b2 | ||
|
|
1555ae1248 | ||
|
|
6e0dbd230b | ||
|
|
6b51f9b93f | ||
|
|
0c04684e88 | ||
|
|
f7b9f388de | ||
|
|
2d81727415 | ||
|
|
543c710d96 | ||
|
|
ba8ff97b00 | ||
|
|
4965e53a28 | ||
|
|
8ae6320910 | ||
|
|
174ec07231 | ||
|
|
25f4daa59c | ||
|
|
161594db42 | ||
|
|
e0293c6386 | ||
|
|
e842e1cc02 | ||
|
|
922f7f31d4 | ||
|
|
efe0ed9df8 | ||
|
|
5e087a7d6a | ||
|
|
427a2d79af | ||
|
|
7cf06e0332 | ||
|
|
3c4c705253 | ||
|
|
c89c5aefae | ||
|
|
2fd2672237 | ||
|
|
a9e7f95dfc | ||
|
|
8df7fe68a7 | ||
|
|
81beaf9806 | ||
|
|
fa0669a952 | ||
|
|
382183b594 | ||
|
|
394b25c5c8 | ||
|
|
8071ae7fac | ||
|
|
538ac66499 | ||
|
|
fdac2a451a | ||
|
|
17056473ce | ||
|
|
030c0f676b | ||
|
|
4499949781 | ||
|
|
090f7fa1af |
No files matched your search
@@ -60,20 +60,12 @@ Detailed device findings per game/issue: docs/DEVICE_NOTES.md (read before debug
|
||||
FrameBridge pacing; End game = Steam's Exit game, then cmd_stop), tiles with `C.Sparkline` (Flet canvas, no charts
|
||||
extension), details, a pooled process table (Game / Steam & SteamVR / All; right-click: end / force kill / end
|
||||
game; MON_CRITICAL needs force, MON_NEVER is refused)); room (`views/room.py`, persistent, "experimental" pill:
|
||||
scan with progress + Cancel = agent v109 `room_capture_stop`, play area, delete; cache `ui/room_state.py` shared
|
||||
scan with progress + Cancel = agent v106 `room_capture_stop`, play area, delete; cache `ui/room_state.py` shared
|
||||
with the Frame page's short card, mesh cached in `<data>/rooms-cache/<frame>/`; in-window 3D `ui/room3d.py`
|
||||
RoomCanvas over `mr/view3d.py` (pure-Python projection, mesh reduced to ≤4k/≤1.5k edges at rest/moving, ≤18 fps
|
||||
worker; each redraw sends NEW shape controls: changing a Points list made Flet diff it item by item, 0.3-1 s);
|
||||
"Open full 3D view" = `mr/room_viewer.py` token server on 127.0.0.1 + hand-written WebGL `mr/viewer_page.py`,
|
||||
stopped on disconnect/close; Test Quest MR tip = `mr.TESTER_*`; editing cards `views/room_edit.py` (agent v109):
|
||||
Clean-up = fp_roomscan's refinement settings, `install/rooms.REFINE_DEFAULTS` = the owner's defaults + slider
|
||||
metadata (agent `ROOM_REFINE_DEFAULTS` must match, a test checks), saved non-defaults in the Frame's
|
||||
`rooms/refine.json`, used by every build; Apply = job → agent `room_refine` (`--refine-only` on a copy of the room
|
||||
folder from `room-raw.txt`/`room-mesh-raw.bin`, scan.json → status `report`/`labels`/`suggestions`); Labels =
|
||||
the label rules' decisions + Add as door / Add as closet; Furniture & screens = box editor (click a box in the
|
||||
canvas: `view3d.pick`) + doors/windows on a wall (`mr.room.plane_on_wall`), Save = `rooms.put_room` → `room_put
|
||||
edited` (keeps raw files, `edits.txt` survives a new clean-up; "Back to the scan's boxes" = keep_edits false); no
|
||||
eraser in the GUI (`rooms.refine(erase=)` only)); settings; welcome; activity panel). Files, Screenshots and the Library share right-click menus
|
||||
stopped on disconnect/close; Test Quest MR tip = `mr.TESTER_*`); settings; welcome; activity panel). Files, Screenshots and the Library share right-click menus
|
||||
(one `ft.ContextMenu` per view, filled on right-click; on one of several selected items they act on the whole
|
||||
selection, `C.menu_targets`) and click-and-drag multi-select (`C.DragSelect`: pan start/end on the area + item
|
||||
hover events, which Flutter also sends with the button held; Flet can't report item positions, so no rubber band).
|
||||
|
||||
@@ -36,9 +36,6 @@ Frame, patches each game so it runs there, uploads it and adds it to your Steam
|
||||
in FramePort.
|
||||
- **Your Frame from your PC:** Type on Frame (your keyboard on the Frame), Files, Screenshots, Live view (what the
|
||||
headset shows, in your browser) and Monitor (frame rate, temperatures, battery, processes).
|
||||
- **Your room for mixed reality (experimental):** scan the room in the headset, see it in 3D, clean up the scan and
|
||||
fix furniture, screen and door labels, so mixed-reality games get your room. Try it with
|
||||
[Test Quest MR](https://github.com/spoopyghosty0/TestQuestMR).
|
||||
- **Updates itself**, and reports problems without personal data.
|
||||
|
||||
Details for each: [Install and first steps](docs/INSTALL.md).
|
||||
@@ -47,8 +44,6 @@ Details for each: [Install and first steps](docs/INSTALL.md).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Quick start
|
||||
|
||||
1. [Download](https://github.com/spoopyghosty0/frameport/releases/latest) the build for Windows, macOS (Apple
|
||||
|
||||
@@ -47,7 +47,7 @@ try:
|
||||
except ImportError: # Windows: pc_revive loads this file for its VDF code only (GitHub #131)
|
||||
fcntl = None
|
||||
|
||||
AGENT_VERSION = 109
|
||||
AGENT_VERSION = 106
|
||||
HOME = os.path.expanduser("~")
|
||||
STEAM = os.path.join(HOME, ".local/share/Steam")
|
||||
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
|
||||
@@ -3466,8 +3466,6 @@ def upgrade_launchers():
|
||||
new = set_codec_line(new, wants_hw_video(os.path.dirname(path)))
|
||||
new = set_gamepad_line(new, wants_gamepad(os.path.dirname(path)))
|
||||
pkg = os.path.basename(os.path.dirname(path))
|
||||
dep = deployment(pkg) or {}
|
||||
new = set_openxr_manifest_line(new, pkg, dep.get("static_openxr_loader", False))
|
||||
base = launcher_base(new, pkg)
|
||||
if base: # games on a drive whose path has a space reach Lepton through a link (agent v83)
|
||||
new = set_app_dir(new, base, pkg)[0]
|
||||
@@ -3929,16 +3927,8 @@ def upgrade_linux_launcher(text):
|
||||
return text
|
||||
|
||||
|
||||
def set_openxr_manifest_line(text, pkg, enabled):
|
||||
line = (f"python3 {shlex.quote(os.path.abspath(__file__))} _openxr_manifest {shlex.quote(pkg)}")
|
||||
text = "\n".join(ln for ln in text.split("\n") if " _openxr_manifest " not in ln)
|
||||
if enabled:
|
||||
text = text.replace("\nfix_perms\n", "\n" + line + "\nfix_perms\n", 1)
|
||||
return text
|
||||
|
||||
|
||||
def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False, gamepad=False,
|
||||
config_sync=False, static_openxr=False, scene_room=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,
|
||||
@@ -3949,7 +3939,6 @@ 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], save_snapshot=snapshot_line(pkg),
|
||||
restart=restart_block(pkg, lepton))
|
||||
text = set_openxr_manifest_line(text, pkg, static_openxr)
|
||||
text = set_config_sync_line(text, pkg, config_sync)
|
||||
text = set_room_sync_line(text, pkg, scene_room)
|
||||
text = set_refresh_rate_line(text, pkg)
|
||||
@@ -3960,32 +3949,6 @@ def write_launcher(anchor, base, pkg, title, appid, lepton, env, hw_video=False,
|
||||
os.replace(path + ".tmp", path)
|
||||
|
||||
|
||||
def write_openxr_runtime_manifest(base, enabled):
|
||||
"""Lepton maps this per-game external root to /sdcard. Never follow a game's symlink."""
|
||||
if not enabled:
|
||||
return
|
||||
root = os.path.join(base, "lepton-data", "external")
|
||||
os.makedirs(root, exist_ok=True)
|
||||
payload = {"file_format_version": "1.0.0",
|
||||
"runtime": {"library_path": "libfp_xrruntime.so", "name": "FramePort"}}
|
||||
for rel in ("fp_openxr_runtime.json", "fp/1/active_runtime.json"):
|
||||
parent = os.path.dirname(os.path.join(root, rel))
|
||||
if not _inside(os.path.realpath(parent), os.path.realpath(root)):
|
||||
raise AgentError("refusing linked OpenXR manifest directory outside game storage")
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
target = os.path.join(root, rel)
|
||||
# Atomic replace repairs stale manifests without following the final symlink.
|
||||
fd, tmp = tempfile.mkstemp(prefix=".fp-openxr-", dir=parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(payload, f, separators=(",", ":"))
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, target)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
|
||||
def data_files_dir(base, pkg):
|
||||
return os.path.join(base, "lepton-data/external/Android/data", pkg, "files")
|
||||
|
||||
@@ -4379,24 +4342,6 @@ 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')
|
||||
# Scan clean-up (fp_roomscan's refinement + label rules): the owner's tuned defaults are compiled into the scanner;
|
||||
# refine.json holds only the settings that differ from them (PC: install/rooms.REFINE_DEFAULTS, same keys).
|
||||
ROOM_REFINE_FILE = os.path.join(ROOMS_DIR, "refine.json")
|
||||
ROOM_REFINE_DEFAULTS = {
|
||||
"refine": 1, "walls": "panels", "band": 0.35, "facing_deg": 60, "band_in": 0.12, "floor_in": 0.08,
|
||||
"ceil_in": 0.25, "top_strip": 0.5, "near": 0.03, "clip": 0.05, "clip_open": 0.10, "island": 0.20, "smooth": 8,
|
||||
"tops": 0.10, "protect": 0, "tubes": 3.0, "tube_thick": 0.15, "walked": 0.40, "label_rules": 1,
|
||||
}
|
||||
ROOM_REFINE_LIMITS = { # key -> (lowest, highest)
|
||||
"refine": (0, 1), "band": (0, 2), "facing_deg": (1, 89), "band_in": (0, 2), "floor_in": (0, 2), "ceil_in": (0, 2),
|
||||
"top_strip": (0, 3), "near": (0, 2), "clip": (0, 10), "clip_open": (0, 10), "island": (0, 10), "smooth": (0, 50),
|
||||
"tops": (0, 1), "protect": (0, 1), "tubes": (0, 50), "tube_thick": (0, 2), "walked": (0, 3), "label_rules": (0, 1),
|
||||
}
|
||||
ROOM_REFINE_INTS = ("refine", "smooth", "protect", "label_rules")
|
||||
# besides room.txt + its mesh + scan.log: the scanner's unrefined output and its clean-up report, and the player's
|
||||
# own furniture / door edits (kept over a new clean-up)
|
||||
ROOM_EXTRA_FILES = {"room-raw.txt": 8 * 1024 * 1024, "room-mesh-raw.bin": 64 * 1024 * 1024,
|
||||
"scan.json": 8 * 1024 * 1024, "edits.txt": 1024 * 1024}
|
||||
|
||||
|
||||
def room_universe(value):
|
||||
@@ -4617,12 +4562,10 @@ def cmd_room_status(args):
|
||||
continue
|
||||
active = None
|
||||
text = None
|
||||
info = {}
|
||||
if selected:
|
||||
with open(os.path.join(selected, "room.txt")) as f:
|
||||
text = f.read()
|
||||
active = room_records(text)
|
||||
info = room_scan_info(selected)
|
||||
try:
|
||||
_, points = room_boundary(room_chaperone(), current)
|
||||
circle = room_default_circle(points)
|
||||
@@ -4631,119 +4574,11 @@ def cmd_room_status(args):
|
||||
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),
|
||||
# scan clean-up: the saved settings (non-defaults), what the last clean-up used, did and decided
|
||||
"refine": room_refine_load(), "refine_used": info.get("refine"), "report": info.get("report"),
|
||||
"labels": info.get("labels") or [], "suggestions": info.get("suggestions") or [],
|
||||
"raw": bool(selected and os.path.isfile(os.path.join(selected, "room-raw.txt"))),
|
||||
"edited": bool(selected and os.path.isfile(os.path.join(selected, "edits.txt")))}
|
||||
"default_circle": circle, "scanner_installed": os.access(ROOM_SCANNER, os.X_OK)}
|
||||
|
||||
|
||||
def room_refine_settings(settings):
|
||||
"""Validated clean-up settings, only the keys that differ from the defaults ({} = the defaults)."""
|
||||
import math
|
||||
if not isinstance(settings, dict):
|
||||
raise AgentError("clean-up settings must be an object")
|
||||
out = {}
|
||||
for key, value in settings.items():
|
||||
if key not in ROOM_REFINE_DEFAULTS:
|
||||
raise AgentError(f"unknown clean-up setting: {key}")
|
||||
if key == "walls":
|
||||
if value not in ("off", "snap", "panels"):
|
||||
raise AgentError("walls must be off, snap or panels")
|
||||
else:
|
||||
if isinstance(value, bool):
|
||||
value = int(value)
|
||||
if not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise AgentError(f"invalid clean-up setting: {key}")
|
||||
lo, hi = ROOM_REFINE_LIMITS[key]
|
||||
if not lo <= value <= hi:
|
||||
raise AgentError(f"clean-up setting out of range: {key}")
|
||||
if key in ROOM_REFINE_INTS:
|
||||
if value != int(value):
|
||||
raise AgentError(f"{key} must be a whole number")
|
||||
value = int(value)
|
||||
else:
|
||||
value = float(value)
|
||||
if value != ROOM_REFINE_DEFAULTS[key]:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def room_refine_load():
|
||||
try:
|
||||
with open(ROOM_REFINE_FILE) as f:
|
||||
return room_refine_settings(json.load(f))
|
||||
except (OSError, ValueError, AgentError):
|
||||
return {}
|
||||
|
||||
|
||||
def room_refine_args(settings, erase=None):
|
||||
"""fp_roomscan options for the stored settings (only non-defaults: with none, an older scanner without the
|
||||
clean-up still runs) and optional erase spheres [[x, y, z, r], ...] (or {x, y, z, r})."""
|
||||
import math
|
||||
args = []
|
||||
for key, value in settings.items():
|
||||
args += ["--" + key.replace("_", "-"), value if isinstance(value, str) else format(value, "g")]
|
||||
if erase:
|
||||
spots = []
|
||||
for spot in erase:
|
||||
if isinstance(spot, dict):
|
||||
spot = [spot.get(k) for k in ("x", "y", "z", "r")]
|
||||
if (not isinstance(spot, (list, tuple)) or len(spot) != 4
|
||||
or not all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
||||
for v in spot) or spot[3] <= 0):
|
||||
raise AgentError("invalid erase spot")
|
||||
spots.append(",".join(format(float(v), ".4g") for v in spot))
|
||||
if len(spots) > 500:
|
||||
raise AgentError("too many erase spots")
|
||||
args += ["--erase", ";".join(spots)]
|
||||
return args
|
||||
|
||||
|
||||
def room_extras(folder):
|
||||
"""The extra files of a room folder (ROOM_EXTRA_FILES) as name -> bytes."""
|
||||
out = {}
|
||||
for name, limit in ROOM_EXTRA_FILES.items():
|
||||
path = os.path.join(folder, name)
|
||||
if os.path.isfile(path) and not os.path.islink(path):
|
||||
with open(path, "rb") as f:
|
||||
data = f.read(limit + 1)
|
||||
if len(data) <= limit:
|
||||
out[name] = data
|
||||
return out
|
||||
|
||||
|
||||
def room_scan_info(folder):
|
||||
"""scan.json's clean-up settings, report, label decisions and door/closet suggestions ({} when there's none)."""
|
||||
try:
|
||||
with open(os.path.join(folder, "scan.json")) as f:
|
||||
info = json.load(f)
|
||||
except (OSError, ValueError, TypeError):
|
||||
return {}
|
||||
if not isinstance(info, dict):
|
||||
return {}
|
||||
out = {}
|
||||
for key, kind in (("refine", dict), ("report", dict), ("labels", list), ("suggestions", list)):
|
||||
if isinstance(info.get(key), kind):
|
||||
out[key] = info[key]
|
||||
return out
|
||||
|
||||
|
||||
def room_merge_edits(text, edits):
|
||||
"""The player's own furniture and doors/windows (volume + plane records) instead of the scanner's."""
|
||||
keep = [line for line in text.splitlines() if line.split()[:1] not in (["volume"], ["plane"])]
|
||||
mine = [line for line in edits.splitlines() if line.split()[:1] in (["volume"], ["plane"])]
|
||||
merged = "\n".join(keep + mine) + "\n"
|
||||
room_records(merged)
|
||||
return merged
|
||||
|
||||
|
||||
def room_store(text, mesh=None, scan_log=None, extras=None):
|
||||
def room_store(text, mesh=None, scan_log=None):
|
||||
record = room_records(text)
|
||||
for name in extras or {}:
|
||||
if name not in ROOM_EXTRA_FILES:
|
||||
raise AgentError(f"unexpected room file: {name}")
|
||||
if record.get("mesh"):
|
||||
if mesh is None:
|
||||
raise AgentError("room mesh sidecar is missing")
|
||||
@@ -4769,9 +4604,6 @@ def room_store(text, mesh=None, scan_log=None, extras=None):
|
||||
if scan_log:
|
||||
with open(os.path.join(stage, "scan.log"), "w") as f:
|
||||
f.write(scan_log)
|
||||
for name, data in (extras or {}).items():
|
||||
with open(os.path.join(stage, name), "wb") as f:
|
||||
f.write(data)
|
||||
shutil.rmtree(previous, ignore_errors=True)
|
||||
if os.path.isdir(dest):
|
||||
os.rename(dest, previous)
|
||||
@@ -4807,15 +4639,7 @@ def cmd_room_put(args):
|
||||
raise AgentError("linked mesh outside upload folder")
|
||||
with open(mesh_path, "rb") as f:
|
||||
mesh = f.read(64*1024*1024)
|
||||
extras = None
|
||||
if args.get("edited"):
|
||||
# the Room tab's furniture / door editor: the same room with the player's boxes. Keep the scan's raw files
|
||||
# (a new clean-up starts from them) and remember the edits, so a new clean-up keeps them.
|
||||
folder = os.path.join(ROOMS_DIR, room_universe(record["universe"]))
|
||||
extras = room_extras(folder) if os.path.isdir(folder) and not os.path.islink(folder) else {}
|
||||
extras["edits.txt"] = "".join(line + "\n" for line in text.splitlines()
|
||||
if line.split()[:1] in (["volume"], ["plane"])).encode()
|
||||
return {"room": room_store(text, mesh, extras=extras)}
|
||||
return {"room": room_store(text, mesh)}
|
||||
|
||||
|
||||
def cmd_room_delete(args):
|
||||
@@ -4864,8 +4688,7 @@ def cmd_room_build(args):
|
||||
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",
|
||||
*room_refine_args(room_refine_load())],
|
||||
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:])
|
||||
@@ -4888,68 +4711,8 @@ def cmd_room_build(args):
|
||||
scan_log += "\n" + log.read()
|
||||
except OSError:
|
||||
pass
|
||||
room_store(text, mesh, scan_log, room_extras(out))
|
||||
return {**cmd_room_status({}), "report": report} # (the scanner's summary, not scan.json's)
|
||||
|
||||
|
||||
def cmd_room_refine(args):
|
||||
"""Clean up the saved room's scan again (fp_roomscan --refine-only on a copy of the room folder, from its raw
|
||||
files), then publish it like room_store (games pick it up through room_sync).
|
||||
settings: the clean-up settings to save (null = keep the saved ones); erase: [[x, y, z, r], ...] spheres of scan
|
||||
to remove; keep_edits (default true): the player's own furniture / doors stay (false = back to the scan's)."""
|
||||
settings = args.get("settings")
|
||||
if settings is not None:
|
||||
settings = room_refine_settings(settings)
|
||||
else:
|
||||
settings = room_refine_load()
|
||||
options = room_refine_args(settings, args.get("erase"))
|
||||
if args.get("settings") is not None:
|
||||
os.makedirs(ROOMS_DIR, exist_ok=True)
|
||||
_write_json(ROOM_REFINE_FILE, settings)
|
||||
if not os.access(ROOM_SCANNER, os.X_OK):
|
||||
raise AgentError("Room scanner is not installed")
|
||||
selected = room_selected()
|
||||
if not selected:
|
||||
raise AgentError("No room saved yet")
|
||||
with open(os.path.join(ROOMS_DIR, ".scan.lock"), "a") as lock: # not while a scan builds a room
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise AgentError("A room scan is running. Try again when it has finished.") from None
|
||||
with tempfile.TemporaryDirectory(prefix=".refine-", dir=ROOMS_DIR) as stage:
|
||||
for name in os.listdir(selected):
|
||||
path = os.path.join(selected, name)
|
||||
if os.path.isfile(path) and not os.path.islink(path):
|
||||
shutil.copy2(path, os.path.join(stage, name))
|
||||
result = subprocess.run([ROOM_SCANNER, "--refine-only", stage, *options, "--json"],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
if result.returncode:
|
||||
raise AgentError("Room clean-up failed: " + (result.stderr or result.stdout or "")[-4000:])
|
||||
try:
|
||||
summary = json.loads(result.stdout or "{}")
|
||||
except ValueError:
|
||||
summary = {}
|
||||
with open(os.path.join(stage, "room.txt")) as f:
|
||||
text = f.read()
|
||||
record = room_records(text)
|
||||
if str(record["universe"]) != os.path.basename(selected):
|
||||
raise AgentError("Room clean-up returned the wrong universe")
|
||||
extras = room_extras(stage)
|
||||
edits = extras.get("edits.txt")
|
||||
if edits is not None and not args.get("keep_edits", True):
|
||||
del extras["edits.txt"]
|
||||
elif edits is not None:
|
||||
text = room_merge_edits(text, edits.decode("utf-8", "replace"))
|
||||
mesh = None
|
||||
if record.get("mesh"):
|
||||
with open(os.path.join(stage, record["mesh"]["filename"]), "rb") as f:
|
||||
mesh = f.read()
|
||||
scan_log = None
|
||||
if os.path.isfile(os.path.join(stage, "scan.log")):
|
||||
with open(os.path.join(stage, "scan.log"), errors="replace") as f:
|
||||
scan_log = f.read()
|
||||
room_store(text, mesh, scan_log, extras)
|
||||
return {"summary": summary if isinstance(summary, dict) else {}, **cmd_room_status({})}
|
||||
room_store(text, mesh, scan_log)
|
||||
return {"report": report, **cmd_room_status({})}
|
||||
|
||||
|
||||
# --- RoomView 3D capture (docs/mr/PHASE0.md) -------------------------------------------------------------------
|
||||
@@ -5211,8 +4974,7 @@ class _ScanPreview:
|
||||
U, E = ct.c_uint64, ct.c_int
|
||||
signatures = {"Create": (ct.c_char_p, ct.c_char_p, ct.POINTER(U)), "Destroy": (U,),
|
||||
"SortOrder": (U, ct.c_uint32), "Width": (U, ct.c_float),
|
||||
"Absolute": (U, E, ct.POINTER(rv.M34)),
|
||||
"Raw": (U, ct.c_void_p, ct.c_uint32, ct.c_uint32, ct.c_uint32), "Show": (U,)}
|
||||
"Absolute": (U, E, ct.POINTER(rv.M34)), "Raw": (U, ct.c_void_p, ct.c_uint32, ct.c_uint32, ct.c_uint32), "Show": (U,)}
|
||||
self.functions = {name: ct.CFUNCTYPE(E, *args)(fn[self.SLOTS[name]])
|
||||
for name, args in signatures.items()}
|
||||
h = U(0)
|
||||
@@ -5320,8 +5082,7 @@ def _scan_complete(started, now, yaw_bins, pitch):
|
||||
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, preview=True,
|
||||
preview_log=None):
|
||||
def capture_roomview(seconds, out_path, wait=0.0, max_hz=3.0, progress=None, prompts=None, preview=True, preview_log=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."""
|
||||
@@ -5494,7 +5255,7 @@ def room_capture_worker(payload):
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise AgentError("A room scan is already running") from None
|
||||
raise AgentError("A room scan is already running")
|
||||
return _room_capture_worker(payload)
|
||||
|
||||
|
||||
@@ -5572,10 +5333,8 @@ def set_scan_watch_line(text, pkg, want):
|
||||
return text
|
||||
line = SCAN_WATCH_LINE.format(agent_q=shlex.quote(os.path.abspath(__file__)), pkg_q=shlex.quote(pkg))
|
||||
text = text.replace("\nchild=$!\n", "\nchild=$!\n" + line + "\n", 1)
|
||||
if '${scanwatch:-}' in text:
|
||||
return text
|
||||
stop = ' [[ -z "${scanwatch:-}" ]] || kill "$scanwatch" 2>/dev/null || true\n'
|
||||
return text.replace(' trap - EXIT INT TERM\n', ' trap - EXIT INT TERM\n' + stop, 1)
|
||||
return text.replace(' trap - EXIT INT TERM\n',
|
||||
' trap - EXIT INT TERM\n [[ -z "${scanwatch:-}" ]] || kill "$scanwatch" 2>/dev/null || true\n', 1) if '${scanwatch:-}' not in text else text
|
||||
|
||||
|
||||
def scan_watch_write(folder, name, text):
|
||||
@@ -5862,8 +5621,6 @@ def configure_install(args, prep, moved):
|
||||
new_managed[rel] = {"sha256": sha256_file(target), "backup": had_original}
|
||||
models = install_controller_models(files_dir, str(settings.get("controller_models", 0)) not in ("0", "0.0"))
|
||||
recipe = args.get("recipe") if isinstance(args.get("recipe"), dict) else {}
|
||||
static_openxr = "frame.static_openxr_loader" in (recipe.get("patches") or [])
|
||||
write_openxr_runtime_manifest(base, static_openxr)
|
||||
hw_video = HW_VIDEO_PATCH in (recipe.get("patches") or []) # the shared codec (install_video_codec)
|
||||
gamepad = GAMEPAD_PATCH in (recipe.get("patches") or []) # Steam Input's virtual gamepad (PODMAN_WRAPPER)
|
||||
if gamepad:
|
||||
@@ -5872,8 +5629,8 @@ def configure_install(args, prep, moved):
|
||||
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),
|
||||
static_openxr, scene_room)
|
||||
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):
|
||||
@@ -5885,7 +5642,6 @@ def configure_install(args, prep, moved):
|
||||
"apk": args.get("apk_name", previous.get("apk", "game.apk")),
|
||||
"sha256": args.get("apk_sha256") or previous.get("sha256"),
|
||||
"recipe": args.get("recipe"), "installed_by": "frameport",
|
||||
"static_openxr_loader": static_openxr,
|
||||
"hw_video_decode": hw_video, "steam_gamepad": gamepad, "config_sync": config_sync,
|
||||
"scene_room": scene_room,
|
||||
"agent_version": AGENT_VERSION, "time": time.time()}
|
||||
@@ -10482,11 +10238,6 @@ COMMANDS = {n[4:]: f for n, f in globals().items() if n.startswith("cmd_")}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "_openxr_manifest":
|
||||
dep = deployment(check_pkg(sys.argv[2])) or {}
|
||||
if dep.get("static_openxr_loader"):
|
||||
write_openxr_runtime_manifest(dep["base"], True)
|
||||
return
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_snapshot_close":
|
||||
return snapshot_close(sys.argv[2])
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "_fetch_save_snapshot":
|
||||
|
||||
@@ -9,20 +9,19 @@ d6337034fb0bdcca7ef9b7063d85b7586b8508b63b435959e8fffaba0691091c ./arm64-v8a/li
|
||||
40defdaddcda53bd2649eb48076fae1622bfc2bfc88e9e03c94fc8b45af2a4f2 ./arm64-v8a/libfp_ovrtrace.so
|
||||
0167c2fde5dfde62897f9d85afc4fc8e97784e988b5b8df6cea8c407240b3424 ./arm64-v8a/libfp_tkeys.so
|
||||
e32f9b3222bfe2e58c193db809ac2cf54086df36fd2ec6651aa2e58308f6b39d ./arm64-v8a/libfp_vk.so
|
||||
3c548b567c6984c91bf3f4a2b0bae1336f569f3e9fa9c3204872fd9876f88284 ./arm64-v8a/libfp_xrruntime.so
|
||||
383054f8b3b41dde76d062c71856cd3163655e50009c08454bc1793f393ead65 ./arm64-v8a/libfpg.so
|
||||
4a13a33e112c9e4e3c5ff4337ed6e988a9e5b3602f48d654df148ef5fafbf426 ./arm64-v8a/libfpglmv.so
|
||||
36400eae4ba3807bcfcd54463daa217347032b07218792faa448d8cbbd086a0e ./arm64-v8a/libframe_xrshim.so
|
||||
19ba5ed3c4b780f5ae46251f6958f552acfc5f13c8bb39f995579593fa8990c6 ./arm64-v8a/libglshim.so
|
||||
7c33d7fb9fd176ca222efb2d87cf525e2f0604457c16896027a5881594495f76 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
001e699b64062e2cc260915c75aa3508aff10e590125a38c1a3007f61c13c681 ./arm64-v8a/libopenxr_loader_generic.so
|
||||
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi.so
|
||||
a4ed80ae45ca90758239f152ea5a2e8c127d491e1e697ea5680db6fd7f72db07 ./arm64-v8a/libvrapi_foveation.so
|
||||
19ae0a45e80d8031300eea0d54810c14e4711fa8cd383c29ca65d85a74f577c4 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
b022dc0d48c9ee46e5afa6e169386cdce36d896102fb8677f930abf15408e963 ./armeabi-v7a/libopenxr_loader_generic.so
|
||||
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
|
||||
b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe ./hevc/COPYING.FFmpeg
|
||||
82b11650e8eaab014aaab7e67ec781499df670c2519484f4ec9f2316db17c03c ./hevc/libstagefrighthw.so
|
||||
96c7c947963dce72c3c2633c005f2801de54d16896d14d7afd91304ed1399d22 ./hevc/manifest.json
|
||||
141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c ./hevc/libstagefrighthw.so
|
||||
4516971bbb2b635e19b14d2d37b9353962876f7de02043e7457327052c1fcbc0 ./hevc/manifest.json
|
||||
46c3dcad2c43cc30bea3b3680b362ed84a99c15d60714bbbbb422c80ecf90405 ./hevc/media_codecs_frameport.xml
|
||||
529ea35d5d82474afe9e09ad1216d269317f56dd24a05fb100fced52b7b4dbca ./hevc/podman.py.txt
|
||||
6cda3076a8c09b31bb1f90da614ea89e7ee194589626eeac613e522849604e47 ./licenses/LuaJIT.txt
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"revision": 10,
|
||||
"revision": 8,
|
||||
"runtime_sha256": "456e912c75cd389abcf6a63bc80e2a53bdc334371d00b200c93680388ae955e2",
|
||||
"files": {
|
||||
"libstagefrighthw.so": "82b11650e8eaab014aaab7e67ec781499df670c2519484f4ec9f2316db17c03c",
|
||||
"libstagefrighthw.so": "141de01f0d40b97d4f9a720db5ab8ea442f6aa35db5272048899292884994a1c",
|
||||
"podman.py": "529ea35d5d82474afe9e09ad1216d269317f56dd24a05fb100fced52b7b4dbca",
|
||||
"media_codecs_frameport.xml": "46c3dcad2c43cc30bea3b3680b362ed84a99c15d60714bbbbb422c80ecf90405",
|
||||
"COPYING.FFmpeg": "b634ab5640e258563c536e658cad87080553df6f34f62269a21d554844e58bfe"
|
||||
|
||||
@@ -8,21 +8,19 @@ notes: 'Native OpenXR GLES video player. 360 theatres/videos (equirect2 layers,
|
||||
details: 'Settings: equirect_emul (GLES worker draws 360 layers into a projection layer), stable_local, focus_hold,
|
||||
scale 0.75; frame.swapchain_limit for its 7680x3840 theatre swapchains. Its hardware decoding mode (Java MediaCodec,
|
||||
Vr4pMediaPlayer; its own FFmpeg is the software path) gets the Frame''s video hardware with frame.hw_video_decode
|
||||
(added 2026-10-09, not yet checked in the headset). swapchain_pool shares extra GLES panel images to avoid the
|
||||
runtime swapchain count cap (#387); host-tested only, menu interaction needs a headset check.'
|
||||
(added 2026-10-09, not yet checked in the headset).'
|
||||
tested_version: 2.0.22
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.hw_video_decode
|
||||
adapter:
|
||||
swapchain_pool: 1
|
||||
equirect_emul: 1
|
||||
stable_local: 1
|
||||
focus_hold: 1
|
||||
scale: 0.75
|
||||
verified:
|
||||
date: '2026-10-01'
|
||||
updated: '2026-10-18'
|
||||
updated: '2026-10-10'
|
||||
min_app: 0.12.1
|
||||
source_hint: 4XVR Video Player
|
||||
@@ -16,7 +16,6 @@ frame:
|
||||
- frame.hw_video_decode
|
||||
- frame.unity_text_input
|
||||
adapter:
|
||||
fixed_resolution: 1
|
||||
sync_guard: 1
|
||||
surface_native: 1
|
||||
fade_quad: 1
|
||||
@@ -27,6 +26,6 @@ verified:
|
||||
overport_cli: 1.2.3
|
||||
overport_runtime: 3.4.3-23204ea
|
||||
known_good_sha256: 796d1cab08f614d72c50d9702974c719c0908f5de91183cfe02d1fd5eb09551a
|
||||
updated: '2026-10-18'
|
||||
min_app: 1.2.2
|
||||
updated: '2026-10-15'
|
||||
min_app: 1.1.0
|
||||
source_hint: Batman- Arkham Shadow
|
||||
@@ -1,16 +1,14 @@
|
||||
package: org.primedgun.v2
|
||||
title: PrimedGun v2
|
||||
status: unknown
|
||||
notes: 'Vulkan via a statically linked Khronos OpenXR loader bypassed FrameBridge (GitHub #254).
|
||||
The embedded-loader patch routes it through FrameBridge; device validation is still pending.
|
||||
Keep the Vulkan library rename disabled: this game assembles its library name from two pieces,
|
||||
so renaming it prevented Vulkan from loading and VR could not start. Not yet confirmed in the headset.'
|
||||
notes: 'Without the Vulkan library rename: the game builds the library name from two pieces, so the renamed name didn''t
|
||||
exist ("libfp_vkn.so"), its renderer fell back to OpenGL and VR couldn''t start (GitHub #254). Not yet confirmed in
|
||||
the headset.'
|
||||
tested_version: 2.0.0-alpha.4
|
||||
engine: Other
|
||||
xr: OpenXR
|
||||
frame:
|
||||
- frame.sdl_clipboard
|
||||
- frame.static_openxr_loader
|
||||
frame_remove:
|
||||
- frame.vk_sanitize
|
||||
updated: '2026-10-18'
|
||||
|
||||
@@ -18,24 +18,6 @@ signatures:
|
||||
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: dynamic-resolution-foveation
|
||||
pattern: 'projection view [01](?: rect change)?:[^\r\n]*rect=(?:[1-9]\d*,\d+|0,[1-9]\d*) '
|
||||
unless: 'fixed_resolution: recommended'
|
||||
severity: info
|
||||
diagnosis: The game submits an offset projection viewport. With Valve foveation, dynamic resolution can move the sharp centre or expose a white edge. Keeping full resolution may help; it increases GPU load and needs the updated adapter and OVRPlugin shim in a rebuilt APK.
|
||||
question: With foveated rendering enabled, was the sharp centre misplaced or was there a white line at the picture edge?
|
||||
suggest: [adapter.fixed_resolution]
|
||||
- id: static-openxr-loader-bypass
|
||||
pattern: 'RuntimeManifestFile::FindManifestFiles - using global runtime file /[^\r\n]*etc/openxr/1/active_runtime(?:\.[A-Za-z0-9_-]+)?\.json'
|
||||
unless: 'FrameBridge[: ]|fp_xrruntime[: ]'
|
||||
severity: warning
|
||||
diagnosis: A Khronos loader loaded the system runtime without FrameBridge. A statically linked loader may bypass the bundled loader chain; analyze again, enable the embedded OpenXR loader patch when detected, then rebuild and reinstall to write its per-game manifest.
|
||||
suggest: [frame.static_openxr_loader]
|
||||
- id: swapchain-limit-reached
|
||||
pattern: 'FrameBridge:\s+xrCreateSwapchain [^\r\n]*\bresult=-10\b'
|
||||
severity: error
|
||||
diagnosis: The Frame runtime reached its swapchain count limit (about 15 live swapchains per session), not necessarily a memory limit. Extra menus or video panels may close or stay gray. Updated FramePort builds provide Share panel images (swapchain limit) for GLES apps; Vulkan apps are unaffected.
|
||||
suggest: []
|
||||
- id: quest2-headset-identity
|
||||
pattern: 'venderId[^\r\n]*systemName Oculus Quest2'
|
||||
severity: info
|
||||
|
||||
@@ -146,23 +146,11 @@ pose (images drawn only after head turns, or ahead from xrWaitFrame and sometime
|
||||
lists its own `/sdcard/4XPlayer`, not Movies: agent v25 `link_media` hard-links sent files into an app's own top-level
|
||||
folder (`app_media_dirs`), `frameport frame send --app <pkg>`, GUI game page "Add videos" (players) / "Add videos &
|
||||
files…". A 4XVR webm stereo swapchain was refused with XR_ERROR_LIMIT_REACHED (-10) → right eye grey; our projection
|
||||
swapchain was originally halved (1536/eye) while investigating memory. Issue #387 below confirms a swapchain
|
||||
count cap; reducing image sizes does not avoid it. Frame data snapshot
|
||||
swapchain was halved (1536/eye) in case memory is the limit (unverified). Frame data snapshot
|
||||
(SteamVR runtimes, Lepton scripts, logs; never commit Valve binaries): `~/frameport-research/frame-data-2026-09-30/`.
|
||||
Not yet verified in the headset.
|
||||
|
||||
|
||||
**Issue #387 (2026-10-18, host-tested only):** 4XVR's extra sub-panels hit the session cap at roughly 15–16 live
|
||||
swapchains: the same 256×128 create succeeds then fails in the same millisecond, so shrinking images is not a fix.
|
||||
Default-off GLES `swapchain_pool` (catalog enabled for 4XVR) starts at eight live app runtime swapchains, reserving runtime
|
||||
headroom for up to two 4096² SRGB atlases. Single-face/layer/sample/mip colour images use private app-context textures;
|
||||
submitted panels are blitted into packed atlas rectangles after cylinder strips and rect_clamp. Destroy frees rectangles.
|
||||
Vulkan is untouched. No adapter revision bump: this opt-in fix should not mark every installed game for update.
|
||||
The new catalog key already makes older apps reject the recipe via `unusable_reason`; triage suggests no unknown setting.
|
||||
Headset check: repeatedly open/close all 4XVR submenus while playing stereo and 360 video, verify both eyes, panel crops,
|
||||
curved strips, alpha and frame pacing; confirm no new -10 errors or menu closures and reuse after closing panels.
|
||||
|
||||
|
||||
## Companion apps / intents (Stremio + 4XVR)
|
||||
|
||||
**Companion apps / intents (2026-10-03, tested with Stremio + 4XVR):** a second APK can be installed into a running
|
||||
@@ -854,109 +842,3 @@ SteamVR's own Touch remap (`/opt/steamvr/drivers/frame_controller/resources/inpu
|
||||
layout `oculus_touch`) is the diagram's "SteamVR default": X -> d-pad down, Y -> d-pad left/right/up, A -> A,
|
||||
B -> B/X/Y, Menu -> View, plus trigger click -> bumper. Games with their own Valve binding file in that folder
|
||||
(Beat Saber, Batman, Accounting+, In Death, ...) use that file instead.
|
||||
|
||||
|
||||
## Fixed layer resolution / Batman (#406, #394)
|
||||
|
||||
Batman: Arkham Shadow (`com.camouflaj.manta`, Unity + OVRPlugin, OpenXR/Vulkan) submits
|
||||
`2768x2768`, then corner-anchored `rect=0,32 2736x2736`, `rect=0,64 2704x2704` inside the
|
||||
same swapchain. On SteamOS 0.5.6 Valve's FDM injection covers the full swapchain: shrinking
|
||||
viewports can move the sharp centre towards the top right and expose a coarse white right edge.
|
||||
The runtime recommendation driving OVRPlugin's dynamic resolution remains unverified.
|
||||
|
||||
`adapter.fixed_resolution=1` (default off, Batman recipe on) overrides
|
||||
`xrGetRecommendedLayerResolutionMETA`: tracked full swapchain dimensions, `isValid=XR_TRUE`.
|
||||
The existing rect_clamp size table already records successful xrCreateSwapchain calls and
|
||||
retires entries at destruction. Projection views must have matching sizes; quads are supported;
|
||||
unknown swapchains, mixed sizes and other layer types retain the runtime behavior. Submitted
|
||||
rectangles are never enlarged after rendering. Full resolution may increase GPU load.
|
||||
|
||||
The cached OVRPort 3.4.3-aa54c3f dispatcher contains both the extension and function name
|
||||
(and a runtime-missing diagnostic). Its fixed routing table is not a guarantee that every
|
||||
version forwards the call unchanged. Enable the existing arm64 xrshim for this setting:
|
||||
OVRPlugin's loader lookup reaches `framebridge_extension_proc` directly, while native OpenXR
|
||||
loaders use the adapter's xrGetInstanceProcAddr hook. No extension hiding: keeping the
|
||||
recommendation API available avoids relying on an unverified OVRPlugin fallback. The adapter
|
||||
revision stays unchanged because the new setting is opt-in; the catalog requires 1.2.2 so older
|
||||
remote clients keep their bundled recipe (`recommend.catalog.unusable_reason`). Rebuild and
|
||||
reinstall Batman to receive the binary and shim routing; this is not a settings-only fix for
|
||||
an old APK. Device/headset verification is pending.
|
||||
|
||||
Headless check: expect `extension shim: xrGetRecommendedLayerResolutionMETA -> FrameBridge`
|
||||
for OVRPlugin, then up to five `fixed_resolution: recommended WxH -> 2768x2768 (runtime=...)`
|
||||
lines. Compare the size to the actual eye swapchain creation log; scaling can change it.
|
||||
`projection view 0 rect change: ... rect=0,0 2768x2768` (and view 1) should remain full size
|
||||
through gameplay/loading transitions. Rect changes continue to log at most once per second
|
||||
per eye, comparing against the last logged state so a throttled final change is still shown.
|
||||
No recommendation logs means this API path was not exercised: do not claim the fix worked.
|
||||
Headset check: sharp region centred in both eyes, no white right edge, correct menus/loading
|
||||
fades, and acceptable pacing with full-resolution GPU load.
|
||||
|
||||
This is separate from #303's `foveation_storage_fix` (clearing SUBSAMPLED allocation flags;
|
||||
see PLAYBOOK and FRAME_RUNTIME), which fixes black/coarse storage corruption. It also leaves
|
||||
#394's `FOVE_LEVEL=lo/med/hi` presets intact (commits 01ad5e6b, d7f0d514): those select the
|
||||
full-swapchain density pattern, not the game's dynamic viewport.
|
||||
## Embedded Khronos OpenXR loaders (GitHub #254)
|
||||
|
||||
PrimedGun v2 (`org.primedgun.v2`, 2.0.0-alpha.4) embeds the Khronos loader in
|
||||
`libmetroid_prime_port.so` (no loader DT_NEEDED). Its diagnostics show both Android
|
||||
broker queries failing, then `/vendor/etc/openxr/1/active_runtime.json`, runtime
|
||||
`SteamVR/OpenXR`, and no FrameBridge messages. The supplied `openxr_android.cpp`
|
||||
initializes the loader with the VM and SDL activity. This is Vulkan OpenXR; retain
|
||||
the catalog's removal of `frame.vk_sanitize` (the game assembles the Vulkan soname).
|
||||
|
||||
`frame.static_openxr_loader` is general: analysis v23 detects arm64 game libraries
|
||||
with Khronos manifest strings and negotiation/manifest markers. Complete manifest
|
||||
paths (including ABI variants) are replaced in `.rodata`, NUL padded, with
|
||||
`/sdcard/fp_openxr_runtime.json`. Khronos also builds paths from fragments: the
|
||||
verified `platform_utils.hpp` layout uses five partition prefixes, `/etc/openxr/`,
|
||||
the major API version and `/active_runtime[.aarch64].json`. For this complete table,
|
||||
prefixes become `/sd` and the subdir becomes `card/fp/`; all candidates then resolve
|
||||
to `/sdcard/fp/1/active_runtime[.aarch64].json`. The undecorated manifest is written;
|
||||
a missing ABI-specific file falls back to it. No offsets or sizes change. **Pooled
|
||||
partition strings can also be used by game code**; this cannot be ruled out from
|
||||
synthetic tests. Only the complete known fragment table is accepted. Other layouts
|
||||
need analysis/rewrite support before they can be patched.
|
||||
|
||||
Both broker authorities become `org.fp_null.openxr.*`, causing provider lookup to
|
||||
fail and the loader to fall back to files even if a broker is later installed.
|
||||
Only the game's copy is changed. Agent v108 writes both manifest spellings into
|
||||
`<base>/lepton-data/external` (the game's private `/sdcard`), records
|
||||
`static_openxr_loader` in deployment.json, and repairs them before every launch.
|
||||
Launcher upgrades preserve that repair. A configuration-only finalize also writes
|
||||
them. The payload is `{"file_format_version":"1.0.0","runtime":{"library_path":"libfp_xrruntime.so","name":"FramePort"}}`.
|
||||
|
||||
The shim exports negotiation and initialization, loads FrameBridge by soname,
|
||||
forwards Android loader initialization through its GIPA, and returns that GIPA to
|
||||
the outer loader. FrameBridge then loads `libopenxr_loader_original.so`, an
|
||||
unmodified Khronos loader reused from OVRPort's downloaded generic runtime.
|
||||
This works for recipes with `overport: False`: the patch supplies the original
|
||||
loader before `frame.adapter` installs FrameBridge, its settings payload and
|
||||
foveation support. Settings still use `settings.conf` through the normal launcher.
|
||||
No Meta dispatcher is needed. The loader's unmodified system paths keep it from
|
||||
recursing through the game manifest. Android linker namespaces, symbol binding
|
||||
between the two loaders, runtime handles across the chain, and FrameBridge's
|
||||
behavior without the OVRPort dispatcher still require a device check.
|
||||
|
||||
Source verification: Khronos OpenXR-SDK-Source
|
||||
[`release-1.0.34/src/common/platform_utils.hpp`](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.34/src/common/platform_utils.hpp)
|
||||
and the same fragment layout in release-1.1.53;
|
||||
[`manifest_file.cpp`](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.34/src/loader/manifest_file.cpp)
|
||||
leaves a bare library filename unchanged for dlopen and tries brokers before files;
|
||||
[`runtime_interface.cpp`](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.34/src/loader/runtime_interface.cpp)
|
||||
forwards loader initialization to the runtime export before negotiation (or through
|
||||
GIPA afterwards). The reporter's diagnostic ELF summary contains no embedded
|
||||
loader version or raw string table; the exact game's loader revision and binary
|
||||
rewrite remain unverified. Host tests cover complete and assembled synthetic ELF
|
||||
layouts, idempotence, unchanged other bytes, APK apply/validate with no OVRPort,
|
||||
negotiation rejection, Android init forwarding and missing-chain failure. No Frame
|
||||
connection or device test was performed.
|
||||
|
||||
Headless acceptance after rebuilding and installing with this development build:
|
||||
`uv run frameport test org.primedgun.v2`. Expect `fp_xrruntime: chain resolved:
|
||||
FrameBridge`, `fp_xrruntime: xrInitializeLoaderKHR forwarded to FrameBridge: 0`,
|
||||
`fp_xrruntime: negotiated runtime interface 1: FrameBridge`, then FrameBridge's
|
||||
`scale=... loader=...libopenxr_loader_original.so`, `xrCreateInstance` and pacing /
|
||||
`new layer:` lines. The outer loader should log the private sdcard manifest, the
|
||||
inner loader the real vendor manifest. Require RUNNING and submitted frames;
|
||||
headset rendering and controls need separate confirmation.
|
||||
@@ -149,7 +149,6 @@ Generated from [catalog/games](../catalog/games) by `scripts/compat_list.py`.
|
||||
| Taskmaster | Quest | ✅ Works | |
|
||||
| Tea For God | Quest | ✅ Works | |
|
||||
| Test Quest Controllers | Quest | ✅ Works | |
|
||||
| Test Quest MR | Quest | ✅ Works | |
|
||||
| TetrisEffect | Quest | ✅ Works | |
|
||||
| The Boys VR | Quest | ✅ Works | |
|
||||
| The Climb | Quest | ✅ Works | |
|
||||
|
||||
@@ -192,20 +192,8 @@ Mixed-reality games ask for your room: walls, furniture and its surfaces. **Room
|
||||
the SteamVR play area instead (no furniture).
|
||||
- The room shows in 3D: drag to turn it, scroll to zoom, right-drag to move it, double-click to see all of it.
|
||||
**Open full 3D view** shows every detail of the scan in your browser (a page on this PC only).
|
||||
- **Clean-up** tidies every scan (flat walls, no scan behind walls or where you walked, no loose bits). The
|
||||
recommended settings suit most rooms; change them (**More settings** has all of them) and press **Apply** to clean
|
||||
up your saved scan again. Your settings are also used for your next scans.
|
||||
- **Labels** shows what the scanner's boxes became and why. A tall, thin box by a wall can be **Add as door** or
|
||||
**Add as closet**.
|
||||
- **Furniture & screens**: pick a box in the list or click it in the 3D view to change its type, place or size, add
|
||||
or delete boxes, and add doors and windows on a wall. **Save** sends them to the Frame; they stay when you clean up
|
||||
the scan again (**Back to the scan's boxes** under Labels drops them).
|
||||
- The free **Test Quest MR** app (Get Test Quest MR on the tab) shows what a game receives from your scan.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Updating
|
||||
|
||||
FramePort checks for a new version at start and every 6 hours. When there is one, the Library shows **Update now**:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Patches
|
||||
|
||||
Every patch and game setting FramePort knows: 194 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
Every patch and game setting FramePort knows: 191 entries in 5 groups. A game's recipe is its chosen patches and settings; tested games get theirs from the catalog ([GAMES.md](GAMES.md)), and other games get the patches FramePort suggests from its analysis. You change them on the game page under Customize.
|
||||
|
||||
Each entry says what the patch does, whether it changes the APK or only the install, when FramePort suggests it, its options, the catalog games whose recipe names it and the launch-test signatures that suggest it. Patches that are on by default are on for every game, so the catalog rarely names them.
|
||||
|
||||
@@ -11,8 +11,8 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
## Groups
|
||||
|
||||
- [Patches from OVRPort](#group-overport) (25)
|
||||
- [Steam Frame patches](#group-frame) (65)
|
||||
- [Game settings](#group-adapter) (81)
|
||||
- [Steam Frame patches](#group-frame) (64)
|
||||
- [Game settings](#group-adapter) (79)
|
||||
- [Files and settings on the Frame](#group-device) (10)
|
||||
- [PC VR (Proton and Revive)](#group-pcvr) (13)
|
||||
|
||||
@@ -79,7 +79,6 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [SDL apps: start without a clipboard service](#frame-sdl_clipboard) | `frame.sdl_clipboard` | Steam Frame patches | 6 |
|
||||
| [Stress Level Zero: let Unity start Vulkan itself](#frame-slz_vulkan_hooks) | `frame.slz_vulkan_hooks` | Steam Frame patches | 1 |
|
||||
| [Start straight in VR (skip the 2D launcher)](#frame-start_activity) | `frame.start_activity` | Steam Frame patches | |
|
||||
| [Embedded OpenXR loader: use FrameBridge](#frame-static_openxr_loader) | `frame.static_openxr_loader` | Steam Frame patches | 1 |
|
||||
| [Allow swapchains larger than 4096 px](#frame-swapchain_limit) | `frame.swapchain_limit` | Steam Frame patches | default |
|
||||
| [Team Beef ports: treat the Frame as a Meta headset](#frame-tbxr_vendor) | `frame.tbxr_vendor` | Steam Frame patches | 1 |
|
||||
| [Unity App UI: no Android 12 vibrator](#frame-unity_appui_vibrator) | `frame.unity_appui_vibrator` | Steam Frame patches | |
|
||||
@@ -129,7 +128,6 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Per-eye diagnostics (log)](#adapter-eye_debug) | `adapter.eye_debug` | Game settings | |
|
||||
| [Draw fades over the picture](#adapter-fade_quad) | `adapter.fade_quad` | Game settings | 2 |
|
||||
| [Colour space support](#adapter-fb_color_space) | `adapter.fb_color_space` | Game settings | |
|
||||
| [Keep full resolution (no dynamic resolution)](#adapter-fixed_resolution) | `adapter.fixed_resolution` | Game settings | 1 |
|
||||
| [Upside-down panel images (diagnostics)](#adapter-flip_dump) | `adapter.flip_dump` | Game settings | |
|
||||
| [Fix upside-down menus](#adapter-flip_emul) | `adapter.flip_emul` | Game settings | |
|
||||
| [Rotate panels 180°](#adapter-flip_quads) | `adapter.flip_quads` | Game settings | |
|
||||
@@ -178,7 +176,6 @@ Link to an entry with its id, dots replaced by dashes: `PATCHES.md#frame-unity_t
|
||||
| [Native video surfaces (Batman renderer)](#adapter-surface_native) | `adapter.surface_native` | Game settings | 1 |
|
||||
| [Swap eyes](#adapter-swap_eyes) | `adapter.swap_eyes` | Game settings | |
|
||||
| [Retry refused picture formats](#adapter-swapchain_fix) | `adapter.swapchain_fix` | Game settings | |
|
||||
| [Share panel images (swapchain limit)](#adapter-swapchain_pool) | `adapter.swapchain_pool` | Game settings | 1 |
|
||||
| [Steady controller input after focus](#adapter-sync_guard) | `adapter.sync_guard` | Game settings | 3 |
|
||||
| [Hide Vulkan foveation](#adapter-vk_hide_fdm) | `adapter.vk_hide_fdm` | Game settings | 1 |
|
||||
| [Without Steam's shader cache layer](#adapter-vk_no_fossilize) | `adapter.vk_no_fossilize` | Game settings | 1 |
|
||||
@@ -967,21 +964,6 @@ Opens the app straight in VR, skipping its flat launcher screen. Turn off to use
|
||||
|
||||
> Some apps open a flat Android launcher that then starts a separate VR activity (for example WiiCompiled). Shown with Lepton's flat window, that window stays in view after the VR part starts. This makes the VR activity the one Lepton starts (its LAUNCHER category), so the app opens in VR with no flat window. Everything only the launcher does (choosing or importing content) is skipped: turn it off for a run when you need the launcher, and turn off "Show the app's Android window".
|
||||
|
||||
### <a id="frame-static_openxr_loader"></a>Embedded OpenXR loader: use FrameBridge
|
||||
|
||||
Makes an embedded OpenXR loader use FrameBridge and your game settings.
|
||||
|
||||
- `frame.static_openxr_loader`
|
||||
- **Changes:** Changes the APK (a rebuild and reinstall apply it).
|
||||
- **Suggested (on):** The embedded Khronos OpenXR loader bypasses FrameBridge.
|
||||
- **Requires:** [`frame.adapter`](#frame-adapter)
|
||||
- **Catalog games (1):** PrimedGun v2.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `static-openxr-loader-bypass` (warning): A Khronos loader loaded the system runtime without FrameBridge.
|
||||
- **Source:** [patches/frame/static_openxr_loader.py](../src/frameport/patches/frame/static_openxr_loader.py)
|
||||
|
||||
> Routes a game's statically linked Khronos OpenXR loader through FrameBridge by rewriting its runtime manifest paths in place into its own /sdcard (including assembled paths). Disables its Android runtime broker queries so they fall back to that file. Ships a runtime shim and an unmodified inner Khronos loader, which reads the system runtime manifest. Reinstall to write the per-game manifest.
|
||||
|
||||
### <a id="frame-swapchain_limit"></a>Allow swapchains larger than 4096 px
|
||||
|
||||
Allows very large pictures (8K video, theaters) instead of quitting.
|
||||
@@ -1666,23 +1648,6 @@ For games that stop at start because the headset reports no colour space (for ex
|
||||
|
||||
> Offers Meta's colour-space extension when the Frame's runtime lacks it: the game is told the headset uses Rec.709 and may choose any colour space (no conversion is done). For games that quit before their VR session with "Rec.709 color space unavailable" (for example OutRun Quest VR, GitHub #336). Only reaches games that use their own OpenXR loader; OVRPort's dispatcher can't forward the new functions.
|
||||
|
||||
### <a id="adapter-fixed_resolution"></a>Keep full resolution (no dynamic resolution)
|
||||
|
||||
Prevents dynamic resolution from moving Valve foveation off-centre or leaving a white edge line. May increase GPU load. Used at the next launch.
|
||||
|
||||
- `adapter.fixed_resolution`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Picture → "Keep full resolution (no dynamic resolution)" (advanced).
|
||||
- **Catalog games (1):** Batman: Arkham Shadow.
|
||||
- **Suggested by launch-test signatures:**
|
||||
- `dynamic-resolution-foveation` (info): The game submits an offset projection viewport.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Keeps OpenXR layer recommendations at the full swapchain size. Prevents dynamic resolution from moving Valve foveation off-centre or leaving a coarse white edge line. May increase GPU load.
|
||||
|
||||
### <a id="adapter-flip_dump"></a>Upside-down panel images (diagnostics)
|
||||
|
||||
Saves a few UI panels before and after FrameBridge turns them upright, for testing without the headset.
|
||||
@@ -2389,21 +2354,6 @@ Lets OpenGL ES games start when the Frame refuses their picture format. Leave it
|
||||
|
||||
> Retry rejected GLES formats/MSAA with sRGB, samples=1.
|
||||
|
||||
### <a id="adapter-swapchain_pool"></a>Share panel images (swapchain limit)
|
||||
|
||||
Keeps extra menus and panels working when the Frame reaches its image count limit. GLES apps only; Vulkan apps are unaffected.
|
||||
|
||||
- `adapter.swapchain_pool`
|
||||
- **Changes:** Game setting: stored in the build and on the Frame (settings.conf); can be changed without a rebuild.
|
||||
- **Suggested:** not automatically; set on the game page (Customize), by a recipe or after a launch test.
|
||||
- **Options:**
|
||||
- Value: int, default `0`.
|
||||
- Game settings → Troubleshooting → "Share panel images (swapchain limit)" (advanced).
|
||||
- **Catalog games (1):** 4XVR Video Player.
|
||||
- **Source:** [patches/settings.py](../src/frameport/patches/settings.py)
|
||||
|
||||
> Share GLES panel images in runtime atlases to avoid the Frame's swapchain count limit (about 15 per session). Useful when opening extra menus closes them or leaves panels gray. Vulkan apps are unaffected.
|
||||
|
||||
### <a id="adapter-sync_guard"></a>Steady controller input after focus
|
||||
|
||||
For games that crash right after you return to them (for example after the Steam menu).
|
||||
|
||||
@@ -12,9 +12,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
|
||||
## Startup failures (visible in launch.log)
|
||||
| Symptom | Cause | Fix |
|
||||
| `RuntimeManifestFile::FindManifestFiles - using global runtime file /vendor/etc/openxr/1/active_runtime.json`, no FrameBridge lines (#254) | Embedded Khronos loader bypasses the APK loader chain | `frame.static_openxr_loader`: redirect its manifest paths and broker authorities, bundle runtime shim + inner loader + FrameBridge; reinstall to write `/sdcard/fp_openxr_runtime.json`. Device check pending |
|
||||
|---|---|---|
|
||||
| Valve foveation sharp centre moves to the top right or a white line appears at the right edge as Batman shrinks its projection rect (#406 / #394) | Dynamic resolution draws a corner-anchored subrect inside the full swapchain used by Valve FDM | Enable `adapter.fixed_resolution` (Game settings → Picture), rebuild/reinstall for the new adapter and OVRPlugin shim; verify `fixed_resolution: recommended ... -> ...` and full-size `projection view ... rect change` logs. Headset verification pending; more GPU load |
|
||||
| Quest 3-only options missing; OVRPlugin logs `venderId 10291, systemName Oculus Quest2` (#373, Asgard's Wrath 2) | OVRPort's dispatcher overwrites FrameBridge's name and supplies a Quest 2 `XR_META_headset_id`; OVRPlugin prioritizes that UUID | `device.headset_model`: Quest 3 by default, per-game Quest 3S / Quest Pro / Quest 2; rebuild the APK. Byte-checked dispatcher name + UUID edit; Quest 2 leaves the dispatcher unchanged. See DEVICE_NOTES 'Per-game headset identity' |
|
||||
| Startup exits through `libxlua.so (lj_err_throw)`, then Unity aborts in mutex cleanup; captured Lua error is `bad light userdata pointer` (GitHub #257, BattleTalent 4.7) | This older LuaJIT encodes lightuserdata in 47 bits; Frame supplies valid wider native pointers. Controlled tests with the unchanged library pass a real low-address allocation and fail its own high-address static pointer | `frame.luajit_pointers` automatically selects a full-pointer Lua engine only for the verified library SHA-256, retaining xLua bindings and custom helpers. The scoped patch reaches OpenXR startup for 60 s headless; rendering and gameplay remain unverified. A `lj_err_throw` cleanup stack without the original error message is insufficient to infer this cause |
|
||||
| A GL4ES Team Beef game draws stretched triangles or spikes across characters and the scene (GitHub #191, Lambda1VR) | The newer GL shim exports GLES draw functions that intercept GL4ES's desktop OpenGL calls, bypassing its client-array and immediate-mode translation | `frame.tbxr_vendor` keeps the shim's five draw symbols private when the APK bundles `libgl4es.so`. GL queries and single-sampled framebuffer fixes remain active; other games retain the draw hooks. A/B eye captures on SteamOS 0.4.5: old shim normal, current shim spikes, current shim with private draw symbols normal at ~72 fps. Worn-headset gameplay still needs confirmation |
|
||||
@@ -119,7 +117,7 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
|
||||
| Vermillion: the hand and brush disappear over the canvas while painting, but still paint (GitHub #200) | the game shows a canvas being painted as its own compositor (quad) layer and turns the canvas's scene renderer off; the Frame draws quad layers above every projection layer whatever the order, so the canvas covers the hand (Vulkan game: no GL layer emulation) | `frame.vermillion_scene_canvas` (catalog): `PaintableMesh.HasCompositorLayerAvailable` / `ShouldUseCompositorOverlay` -> false in libil2cpp.so (Cpp2IL), so the game's own fallback draws the canvas in the scene (Klownicle's finding) |
|
||||
| Unity game hangs right after its first frames, no `pacing:` lines; SIGQUIT dump shows UnityMain in a game library's `JNI_OnLoad` → `usleep` (for example SKYBOX: `libskybox.so`, `SignatureChecker`) | the game checks its APK signing certificate; every port is re-signed | can't be fixed without defeating anti-tamper (FramePort doesn't): mark "Can't run". Diagnose hangs: `podman exec <container> kill -3 <pid>` writes all thread stacks to launch.log |
|
||||
| Video player doesn't list sent videos (for example 4XVR "Internal Storage" only shows some) | the player lists its own /sdcard folder (4XVR: /sdcard/4XPlayer), not /sdcard/Movies; Android's media index doesn't work in Lepton | game page "Add videos" / game menu "Add videos and files" (Videos + the app's own folder, hard links) or `frameport frame send <files> --app <pkg>` |
|
||||
| One eye gray in a stereo video, log `xrCreateSwapchain … result=-10` | the runtime refused a swapchain (XR_ERROR_LIMIT_REACHED) | GLES: `adapter.swapchain_pool=1` shares extra panel images in atlases (#387; headset validation pending). This is a count cap, so lowering resolution does not fix it; Vulkan unaffected |
|
||||
| One eye gray in a stereo video, log `xrCreateSwapchain … result=-10` | the runtime refused a swapchain (XR_ERROR_LIMIT_REACHED) | fewer/smaller swapchains: lower `equirect_res`; unresolved |
|
||||
| VrApi game (32-bit bridge or `frame.vrapi_bridge`): holding the menu button to recenter turns the view ~90-120 degrees to the side | the bridge's app space is LOCAL + the head's yaw at the start; the Frame's recenter moves LOCAL to the head, so the old yaw was added again | fixed in the VrApi bridge (2026-10-13): on XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING (LOCAL) and on `vrapi_RecenterPose` it takes the origin from the head again (log `recentered: head yaw …`); rebuild the game |
|
||||
| Menu/screen jumps to where you look (for example 4XVR) | the app re-creates LOCAL spaces (4XVR: every 2–4 s) and the Frame places them at the current head yaw, or the app recenters after the Frame's brief focus dips | turn on `adapter.layer_debug`, check `that LOCAL space sits at … deg` vs `focus_hold: hid a … ms focus dip`; patches: `adapter.stable_local` (new LOCAL spaces line up with the first) / `adapter.focus_hold` (on by default: hides dips up to `focus_hold_ms` once the game has been focused for a second; never at start-up: a global debounce broke AC Nexus) |
|
||||
| Pointer misses menu items | the Frame's aim pose differs from Touch's | `adapter.layer_debug` logs `aim in grip … pitch/yaw`; correct with `aim_pitch`/`aim_yaw`/`aim_forward` (`frameport settings`, no rebuild) |
|
||||
|
||||
@@ -117,10 +117,6 @@ Hooks (`steps.HOOKS`) cover what a real Frame would do:
|
||||
- `pairing_done`: the Frame ran the setup command, so FramePort connects.
|
||||
- `live_stream`: a test picture through the real relay.
|
||||
- `monitor_details`, `type_tab`, `select_files` and `settings_section`.
|
||||
- `open_room` (the Room tab with the pretend Frame's room drawn) and `room_editing` (its editing cards: `cleanup`,
|
||||
`more`, `box`, `doors`, `suggestion`). The room is a made-up bedroom built in code (`fakes.fake_room`, never a real
|
||||
scan): a bed under a wall-mounted TV, a desk with two monitors under a window, a closet, a door, a lamp and a plant,
|
||||
with a noisy scanned-surface mesh and a clean-up report and labels that match it.
|
||||
|
||||
In a video, clicking Install runs the scripted install, and the game is then installed on the pretend Frame.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 506 KiB After Width: | Height: | Size: 505 KiB |
|
Before Width: | Height: | Size: 665 KiB After Width: | Height: | Size: 663 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 596 KiB After Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 89 KiB |
@@ -108,21 +108,3 @@ shots:
|
||||
steps:
|
||||
- go: settings
|
||||
- wait_for: "Portal (OLED)"
|
||||
|
||||
- name: room # experimental: the pretend Frame's made-up bedroom scan (fakes.fake_room), no real scan
|
||||
docs: [README.md, docs/INSTALL.md]
|
||||
viewport: [1280, 1690]
|
||||
steps:
|
||||
- hook: open_room
|
||||
- wait_for: "Furniture & screens"
|
||||
- park: true
|
||||
|
||||
- name: room-editing # a box picked in the list (its type, place and size) next to the clean-up's labels
|
||||
docs: [docs/INSTALL.md]
|
||||
viewport: [1280, 2000] # (tall enough that nothing scrolls)
|
||||
steps:
|
||||
- hook: open_room
|
||||
- wait_for: "Furniture & screens"
|
||||
- hook: {name: room_editing, args: [box]}
|
||||
- park: true
|
||||
crop: {anchor: "Furniture & screens", left: -26, top: -28, width: 670, height: 1262}
|
||||
@@ -4,14 +4,6 @@ FrameBridge includes automatic metadata retirement for the verified Meta XR Audi
|
||||
Wwise build; see [AUDIO_METADATA.md](../docs/AUDIO_METADATA.md) for its lifetime
|
||||
invariant and regression coverage. It uses the existing adapter installation.
|
||||
|
||||
`adapter/swapchain_pool.c` implements default-off GLES `swapchain_pool` (#387): at eight live app runtime
|
||||
swapchains, eligible single-layer colour images use private GL textures packed into up to two lazy 4096² sRGB
|
||||
runtime atlases. EndFrame copies each referenced source once and rewrites the final quads (including cylinder
|
||||
strips), cylinders and stereo projection views after existing layer fixes. OpenXR image ownership, GL bindings,
|
||||
scissor state, source fences, rectangle reuse and session cleanup are covered by `tests/test_swapchain_pool.py`
|
||||
(`FRAMEPORT_NATIVE_TESTS=1`, host compiler + cached OpenXR and GLES/EGL headers). Vulkan is unaffected. Host-tested;
|
||||
4XVR submenu interaction, visual correctness and copy cost still need a headset check.
|
||||
|
||||
Sources of the prebuilt binaries in `../artifacts/` (committed, with `SHA256SUMS`). End users never compile anything;
|
||||
developers rebuild with `python native/build.py` (downloads NDK r27c, OpenXR headers at pinned commits, d8 into
|
||||
`native/.cache/`, git-ignored). The adapter, GL shim and platform compat rebuild byte-identically; the VrApi bridge
|
||||
@@ -30,7 +22,6 @@ embeds debug paths (`-g`) and the dex depends on the d8 version, so those differ
|
||||
| `zinkfix/` | `arm64-v8a/libVkLayer_fp_shaderfix.so` | Shader-fix Vulkan layer `VK_LAYER_FP_shader_fix` for OpenGL ES games (patch `frame.zink_shader_fix`): sits between Zink (Mesa's GL on Vulkan) and the driver and inserts the words of `zink_shader_fix` (same format as the Vulkan shim's `vk_shader_fix`: size + SHA-256 match) into SPIR-V modules; `zink_shader_dump=1` writes every distinct module to `files/fp_spirv/`. The engine library loads it first (DT_NEEDED); its constructor puts the layer in front of Android's GraphicsEnv debug layer list (private Android 11 `getDebugLayers`/`setDebugLayers`, found through libvulkan.so's dependencies) before EGL starts, so the loader picks it up from the app's lib dir. Exports only the enumeration functions and `VK_LAYER_FP_shader_fixGet*ProcAddr` (never plain `vk*` entry points). Vader Immortal's lightspeed shaders (Klownicle, GitHub #49). |
|
||||
| `xrlayer/` | `linux-arm64/libxr_frameport_timefix.so` + `XR_APILAYER_FRAMEPORT_timefix.json` | OpenXR API layer for Windows PC VR games under Proton on the Frame (glibc aarch64, built freestanding with the NDK's clang), on by default (patch `pcvr.xr_timefix`). Retries `xrCreateInstance` as an OpenXR 1.0 app when the runtime rejects 1.1 (the Frame's SteamVR runtime does, and Proton 11's VR helper asks for 1.1). Also emulates `xrConvertTimespecTimeToTimeKHR`/`xrConvertTimeToTimespecTimeKHR` if the runtime refuses them (the Frame's Android runtime does; its Linux runtime, used by Proton, supports them as of 2026-09-29 — so this part is only a fallback; Proton's wineopenxr needs them for `XR_KHR_win32_convert_performance_counter_time`, which Revive requires) with the adapter's xrWaitFrame-calibrated offset, and drops the extension from xrCreateInstance if the runtime rejects it. Enabled per game by the Proton launch.sh (`XR_API_LAYER_PATH`, `XR_ENABLE_API_LAYERS`). `tests/test_xrlayer.py` builds it for x86_64 and drives it via ctypes. |
|
||||
| `oculushmd/` | `win-x64/fp_oculushmd.exe` | PC VR counterpart of overport's `patch_oculus_unreal` (patch `pcvr.oculus_unreal`, default for Unreal Rift games on the Frame). Unreal's OculusHMD and LibOVR's `ovr_Detect` only start when the Windows named event `OculusHMDConnected` exists and is signalled (the Oculus service creates it). `fp_oculushmd.exe <command line>` creates it (manual reset, signalled), runs the command (Revive's injector) in a job object and keeps the event until every process in the job — the game and its children — has exited (fallback without job support: 3 minutes); returns the command's exit code and logs `FramePort oculushmd: …` to stderr (launch.log). Freestanding Windows x64 console exe (no CRT, no Windows SDK): the NDK's clang + `lld-link /Brepro` with a kernel32 import library generated by `llvm-dlltool`, so it's byte-reproducible. Tested on Windows from WSL with a build whose event name is `-DEVENT_NAME=L"…"` (the Oculus service owns the real event on a PC with the Oculus app). |
|
||||
| `xrruntime/` | `arm64-v8a/libfp_xrruntime.so` | Runtime negotiation endpoint for embedded Khronos loaders (`frame.static_openxr_loader`). Loads FrameBridge by soname, forwards Android loader initialization through its GIPA, and returns FrameBridge GIPA. The unmodified inner Khronos loader is reused from OVRPort’s generic loader (also when `overport: False`); its paths remain system paths. Built with `--only xrruntime`. |
|
||||
| `xrshim/` | `arm64-v8a/libframe_xrshim.so` | FrameBridge extension shim, only in builds with `controller_models=1`. overport's dispatcher (`libopenxr_loader.so`) answers `xrGetInstanceProcAddr` from a fixed table and refuses everything else (`overportOXR: Unknown proc addr: xrLoadRenderModelFB`), so the adapter never sees XR_FB_render_model lookups. Meta's OVRPlugin gets the function with `dlopen("libopenxr_loader.so")` + `dlsym`, so FramePort rewrites that `.rodata` string in `libOVRPlugin.so` (same length, in place) to `libframe_xrshim.so`. The shim returns the adapter's emulated functions (`framebridge_extension_proc`) and forwards every other name to overport. A DT_NEEDED interposer does not work here (dlsym on a handle searches that library first); tried and dropped. |
|
||||
| `venc/` | `linux-arm64-bin/fp_venc` | Hardware H.264 encoder for the live view, run on the Frame (spec: `venc/SPEC.md`). Reads SteamVR's "headset view" webcam (v4l2loopback `/dev/video99`, RGB24), box-scales and converts it to NV12 (BT.709 limited range; `convert.c`, NEON with a bit-identical scalar reference, also built for x86_64 by the host test), and feeds the SoC's V4L2 stateful encoder (`dev-encoder.rst`, Qualcomm iris `/dev/video-enc0`): CBR, no B-frames, SPS/PPS on every IDR, one frame per slot of a fixed grid (fps = a divisor of the panel refresh read through DRM KMS, default cap 45), Annex B on stdout. stdin `k` = keyframe, `q`/EOF = drain and stop; `--probe` prints the setup as JSON, `--selftest` compares NEON and scalar output. Fully static and freestanding (raw syscalls, own `_start`; only the NDK's kernel UAPI headers, staged in `.cache/uapi-arm64`), byte-reproducible. Lives in `linux-arm64-bin/` because `linux-arm64/` is uploaded whole as the OpenXR layer. |
|
||||
| `java-stubs/` | `dex/oculusos-stubs.dex` | No-op `com.oculus.os.AnalyticsEvent` / `UnifiedTelemetryLogger` for native code that looks them up. |
|
||||
@@ -40,9 +31,3 @@ missing symbols (no compiler needed).
|
||||
|
||||
After changing anything here: `python native/build.py`, commit artifacts + SHA256SUMS, then run `frameport parity`
|
||||
(the report marks updated binaries as "expected") and a device launch test.
|
||||
|
||||
`adapter.fixed_resolution` (off by default, #406/#394) keeps META recommended layer
|
||||
resolution at tracked full swapchain size. Both adapter GIPA and xrshim's existing
|
||||
extension entry point serve it; the setting adds OVRPlugin shim routing. The extension
|
||||
stays visible, avoiding dependence on a plugin fallback. No adapter revision bump for
|
||||
this opt-in fix. See DEVICE_NOTES "Fixed layer resolution / Batman" for verification.
|
||||
@@ -42,8 +42,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL eye_hook_xrReleaseSwapchainImage(XrSwapcha
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
if (release_wait_active()) eye_wait_for_gpu();
|
||||
int pooled = pool_find(swapchain);
|
||||
XrResult result = pooled >= 0 ? pool_cycle(pooled, 2, NULL) : fn(swapchain, info);
|
||||
XrResult result = fn(swapchain, info);
|
||||
if (eye_debug) {
|
||||
int64_t now = monotonic_ns();
|
||||
pthread_mutex_lock(&eye_lock);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// swapchain_size is the adapter's mutex-protected table, shared with rect_clamp.
|
||||
static int fixed_layer_size(const XrCompositionLayerBaseHeader *layer, XrExtent2Di *full) {
|
||||
if (!layer) return 0;
|
||||
uint32_t w = 0, h = 0;
|
||||
if (layer->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) {
|
||||
const XrCompositionLayerProjection *p = (const XrCompositionLayerProjection *)layer;
|
||||
if (!p->viewCount || !p->views) return 0;
|
||||
for (uint32_t v = 0; v < p->viewCount; ++v) {
|
||||
uint32_t vw, vh;
|
||||
if (!swapchain_size(p->views[v].subImage.swapchain, &vw, &vh)) return 0;
|
||||
if (v && (vw != w || vh != h)) return 0; // no single full size for this layer
|
||||
w = vw; h = vh;
|
||||
}
|
||||
} else if (layer->type == XR_TYPE_COMPOSITION_LAYER_QUAD) {
|
||||
const XrCompositionLayerQuad *q = (const XrCompositionLayerQuad *)layer;
|
||||
if (!swapchain_size(q->subImage.swapchain, &w, &h)) return 0;
|
||||
} else return 0; // preserve runtime behavior for other layer types
|
||||
if (!w || !h || w > INT32_MAX || h > INT32_MAX) return 0;
|
||||
full->width = (int32_t)w; full->height = (int32_t)h;
|
||||
return 1;
|
||||
}
|
||||
@@ -131,8 +131,6 @@ static int swapchain_fix = 1;
|
||||
static int cube_quads = 1;
|
||||
static int cube_standin = 1; // cube swapchains the runtime refuses: served by the adapter (cube_standin.c)
|
||||
static int rect_clamp = 1;
|
||||
static int fixed_resolution;
|
||||
static int swapchain_pool = 0;
|
||||
static int fb_color_space; // fb_color_space: offer XR_FB_color_space when the runtime lacks it (#336)
|
||||
static int action_dedup = 1; // rename duplicate action names in a set (session_fixes.c; GitHub #323)
|
||||
static int pose_consistency = 0; // repeat xrLocateViews queries for one display time get the same poses (GitHub #8) // clamp submitted image rects to their swapchain (SteamVR rejects a 1 px overrun)
|
||||
@@ -212,9 +210,7 @@ static void read_settings(const char *path) {
|
||||
if (sscanf(line, "swapchain_fix=%f", &value) == 1) swapchain_fix = value != 0;
|
||||
if (sscanf(line, "cube_quads=%f", &value) == 1) cube_quads = value != 0;
|
||||
if (sscanf(line, "cube_standin=%f", &value) == 1) cube_standin = value != 0;
|
||||
if (sscanf(line, "fixed_resolution=%f", &value) == 1) fixed_resolution = value != 0;
|
||||
if (sscanf(line, "rect_clamp=%f", &value) == 1) rect_clamp = value != 0;
|
||||
if (sscanf(line, "swapchain_pool=%f", &value) == 1) swapchain_pool = value != 0;
|
||||
if (sscanf(line, "fb_color_space=%f", &value) == 1) fb_color_space = value != 0;
|
||||
if (sscanf(line, "action_dedup=%f", &value) == 1) action_dedup = value != 0;
|
||||
if (sscanf(line, "pose_consistency=%f", &value) == 1) pose_consistency = value != 0;
|
||||
@@ -307,7 +303,6 @@ static void initialize(void) {
|
||||
if (input_diag) LOG("per-game: input_diag=1 (controller-input diagnostics)");
|
||||
if (proximity_emul) LOG("per-game: proximity_emul=%d (finger proximity from capacitive touch: %s)", proximity_emul,
|
||||
proximity_emul > 1 ? "thumb + index" : "thumb");
|
||||
if (fixed_resolution) LOG("per-game: fixed_resolution=1 (full swapchain recommendations)");
|
||||
if (hide_fb_foveation) LOG("per-game: hide_fb_foveation=1 (Quest Vulkan foveation suppressed)");
|
||||
if (hide_space_warp) LOG("per-game: hide_space_warp=1 (XR_FB_space_warp hidden, space warp info removed)");
|
||||
if (strip_color_bias) LOG("per-game: strip_color_bias=%d (layer color scale/bias removed)", strip_color_bias);
|
||||
@@ -565,7 +560,7 @@ static int64_t pick_format(XrSession session, int64_t wanted) {
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// Known app swapchains, including pooled images rewritten into runtime atlases before submission.
|
||||
// Swapchains the runtime actually created; layers referencing anything else are dropped.
|
||||
static pthread_mutex_t swapchains_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static XrSwapchain *swapchains;
|
||||
static size_t swapchain_count, swapchain_capacity;
|
||||
@@ -606,29 +601,6 @@ static int swapchain_size(XrSwapchain handle, uint32_t *w, uint32_t *h) {
|
||||
return found;
|
||||
}
|
||||
|
||||
#include "fixed_resolution.h"
|
||||
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL fixed_recommended_resolution(XrSession session,
|
||||
const XrRecommendedLayerResolutionGetInfoMETA *info, XrRecommendedLayerResolutionMETA *out) {
|
||||
PFN_xrGetRecommendedLayerResolutionMETA fn = (PFN_xrGetRecommendedLayerResolutionMETA)
|
||||
lookup(active_instance, "xrGetRecommendedLayerResolutionMETA");
|
||||
if (!info || !out) return XR_ERROR_VALIDATION_FAILURE;
|
||||
XrExtent2Di full;
|
||||
if (!fixed_layer_size(info->layer, &full))
|
||||
return fn ? fn(session, info, out) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
// Read the runtime recommendation only for diagnostics. The override also works
|
||||
// when the runtime lacks this function (OVRPort advertises it itself).
|
||||
XrRecommendedLayerResolutionMETA old = {XR_TYPE_RECOMMENDED_LAYER_RESOLUTION_META};
|
||||
XrResult result = fn ? fn(session, info, &old) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
static unsigned logged;
|
||||
if (logged++ < 5) LOG("fixed_resolution: recommended %dx%d -> %dx%d (runtime=%d)",
|
||||
old.recommendedImageDimensions.width, old.recommendedImageDimensions.height,
|
||||
full.width, full.height, result);
|
||||
out->recommendedImageDimensions = full;
|
||||
out->isValid = XR_TRUE;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
|
||||
// Clamp `r` to a w x h image; returns whether it changed (2 = it was empty and now covers the whole image).
|
||||
static int clamp_rect(XrRect2Di *r, uint32_t w, uint32_t h) {
|
||||
XrRect2Di c = *r;
|
||||
@@ -653,10 +625,6 @@ static int clamp_rect(XrRect2Di *r, uint32_t w, uint32_t h) {
|
||||
}
|
||||
|
||||
static void forget_swapchain(XrSwapchain handle) {
|
||||
pthread_mutex_lock(&sizes_lock);
|
||||
for (int i = 0; i < MAX_SIZES; ++i)
|
||||
if (sizes[i].handle == handle) memset(&sizes[i], 0, sizeof sizes[i]);
|
||||
pthread_mutex_unlock(&sizes_lock);
|
||||
pthread_mutex_lock(&swapchains_lock);
|
||||
for (size_t i = 0; i < swapchain_count; ++i)
|
||||
if (swapchains[i] == handle) { swapchains[i] = swapchains[--swapchain_count]; break; }
|
||||
@@ -682,16 +650,6 @@ static void cube_outputs_destroy(void);
|
||||
static void cube_session_destroy(XrSession session);
|
||||
static XrResult standin_enumerate(XrSwapchain handle, uint32_t capacity, uint32_t *count, XrSwapchainImageBaseHeader *images);
|
||||
|
||||
static int pool_find(XrSwapchain h);
|
||||
static int pool_should_create(XrSession session);
|
||||
static void pool_note_runtime(XrSession session, XrSwapchain handle);
|
||||
static void pool_forget_runtime(XrSwapchain handle);
|
||||
static int pool_create(XrSession session, const XrSwapchainCreateInfo *ci, XrSwapchain *out, int limited);
|
||||
static void pool_destroy(int i);
|
||||
static void pool_cleanup(XrSession session);
|
||||
static XrResult pool_cycle(int i, int op, uint32_t *index);
|
||||
static XrResult pool_enumerate(int i, uint32_t cap, uint32_t *count, XrSwapchainImageBaseHeader *images);
|
||||
|
||||
static int known_swapchain(XrSwapchain handle) {
|
||||
int found = 0;
|
||||
pthread_mutex_lock(&swapchains_lock);
|
||||
@@ -779,8 +737,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
snap_on_create(*swapchain, &fixed);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
// Start before the session's ~15-slot cap; leave headroom for 360/video outputs and a second atlas.
|
||||
if (pool_should_create(session) && pool_create(session, &fixed, swapchain, 0)) return XR_SUCCESS;
|
||||
clear_gl_errors("xrCreateSwapchain");
|
||||
XrResult result = fn(session, &fixed, swapchain);
|
||||
LOG("xrCreateSwapchain %ux%u format=%lld samples=%u array=%u faces=%u usage=0x%llx flags=0x%llx result=%d handle=%p",
|
||||
@@ -788,7 +744,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
(unsigned long long)fixed.usageFlags, (unsigned long long)fixed.createFlags, result,
|
||||
(void *)(uintptr_t)(XR_SUCCEEDED(result) && swapchain ? *swapchain : 0));
|
||||
if (XR_SUCCEEDED(result)) {
|
||||
pool_note_runtime(session, *swapchain);
|
||||
remember_swapchain(*swapchain);
|
||||
note_swapchain_size(*swapchain, fixed.width, fixed.height);
|
||||
flip_on_create_swapchain(*swapchain, &fixed);
|
||||
@@ -796,7 +751,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
snap_on_create(*swapchain, &fixed);
|
||||
return result;
|
||||
}
|
||||
if (result == XR_ERROR_LIMIT_REACHED && pool_create(session, &fixed, swapchain, 1)) return XR_SUCCESS;
|
||||
if (!swapchain_fix) return standin_after_failure(session, &fixed, swapchain, result);
|
||||
|
||||
// Frame's runtime rejects some GLES formats (GL_RGBA8) and MSAA swapchains (overport #71).
|
||||
@@ -814,14 +768,12 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(XrSession session, const XrSwap
|
||||
}
|
||||
}
|
||||
if (XR_SUCCEEDED(result)) {
|
||||
pool_note_runtime(session, *swapchain);
|
||||
remember_swapchain(*swapchain);
|
||||
note_swapchain_size(*swapchain, fixed.width, fixed.height);
|
||||
flip_on_create_swapchain(*swapchain, &fixed);
|
||||
emul_on_create_swapchain(*swapchain, &fixed);
|
||||
snap_on_create(*swapchain, &fixed);
|
||||
}
|
||||
if (result == XR_ERROR_LIMIT_REACHED && pool_create(session, &fixed, swapchain, 1)) return XR_SUCCESS;
|
||||
return standin_after_failure(session, &fixed, swapchain, result);
|
||||
}
|
||||
|
||||
@@ -832,9 +784,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrDestroySwapchain(XrSwapchain swapchain) {
|
||||
static int logged;
|
||||
if (logged++ < 200) LOG("layer_debug: xrDestroySwapchain %p", (void *)swapchain);
|
||||
}
|
||||
pool_forget_runtime(swapchain);
|
||||
int pooled = pool_find(swapchain);
|
||||
if (pooled >= 0) { pool_destroy(pooled); return XR_SUCCESS; }
|
||||
if (is_standin(swapchain)) { standin_destroy(swapchain); return XR_SUCCESS; }
|
||||
surf_scene_destroy(swapchain);
|
||||
forget_swapchain(swapchain);
|
||||
@@ -891,7 +840,7 @@ static int cylinder_strips(const XrCompositionLayerCylinderKHR *c, XrComposition
|
||||
}
|
||||
|
||||
// A layer is usable if every swapchain it references was created successfully.
|
||||
// Runtime images and pooled images (rewritten after layer fixes). Adapter-served 360 pictures only feed its drawing.
|
||||
// Swapchains the runtime can show: created by it (adapter-served 360 pictures only feed the adapter's own drawing).
|
||||
static int runtime_swapchain(XrSwapchain handle) {
|
||||
return known_swapchain(handle) && !(equirect_emul && emul_is_virtual(handle));
|
||||
}
|
||||
@@ -1175,7 +1124,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(XrSpace space) {
|
||||
#include "input_diag.c"
|
||||
#include "session_fixes.c"
|
||||
#include "layer_emul_gl.c"
|
||||
#include "swapchain_pool.c"
|
||||
#include "cube_standin.c"
|
||||
#include "snapshot_gl.c"
|
||||
#include "surface_swapchain.c"
|
||||
@@ -1362,10 +1310,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateSession(XrInstance instance, const XrSess
|
||||
scene_on_create_session(*session);
|
||||
session_fixes_on_create_session(*session);
|
||||
emul_on_create_session(*session, info);
|
||||
if (swapchain_pool) {
|
||||
for (const XrBaseInStructure *p = info->next; p; p = p->next)
|
||||
if (p->type == XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR) pool_session = *session;
|
||||
}
|
||||
fade_on_create_session(*session, info);
|
||||
}
|
||||
return result;
|
||||
@@ -1434,12 +1378,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain swapchain,
|
||||
XrSwapchainImageBaseHeader *images) {
|
||||
PFN_xrEnumerateSwapchainImages fn = (PFN_xrEnumerateSwapchainImages)lookup(active_instance, "xrEnumerateSwapchainImages");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
int pooled = pool_find(swapchain);
|
||||
if (pooled >= 0) {
|
||||
XrResult r = pool_enumerate(pooled, capacity, count, images);
|
||||
if (XR_SUCCEEDED(r) && capacity && images) emul_on_enumerate(swapchain, 1, images);
|
||||
return r;
|
||||
}
|
||||
if (is_standin(swapchain)) return standin_enumerate(swapchain, capacity, count, images);
|
||||
XrResult result = equirect_emul && emul_is_virtual(swapchain) ? emul_virtual_enumerate(swapchain, capacity, count, images)
|
||||
: hide_fb_foveation_vulkan ? fb_hide_foveation_images(fn, swapchain, capacity, count, images)
|
||||
@@ -1456,8 +1394,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSwapchainImages(XrSwapchain swapchain,
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrWaitSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageWaitInfo *info) {
|
||||
PFN_xrWaitSwapchainImage fn = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
int pooled = pool_find(swapchain);
|
||||
if (pooled >= 0) return pool_cycle(pooled, 1, NULL);
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
XrResult result = fn(swapchain, info);
|
||||
__atomic_add_fetch(&sc_waits, 1, __ATOMIC_RELAXED);
|
||||
@@ -1468,8 +1404,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrWaitSwapchainImage(XrSwapchain swapchain, const
|
||||
XRAPI_ATTR XrResult XRAPI_CALL xrReleaseSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageReleaseInfo *info) {
|
||||
PFN_xrReleaseSwapchainImage fn = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
int pooled = pool_find(swapchain);
|
||||
if (pooled >= 0) return pool_cycle(pooled, 2, NULL);
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
__atomic_add_fetch(&sc_releases, 1, __ATOMIC_RELAXED);
|
||||
return fn(swapchain, info);
|
||||
@@ -1479,12 +1413,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrAcquireSwapchainImage(XrSwapchain swapchain, co
|
||||
uint32_t *index) {
|
||||
PFN_xrAcquireSwapchainImage fn = (PFN_xrAcquireSwapchainImage)lookup(active_instance, "xrAcquireSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
int pooled = pool_find(swapchain);
|
||||
if (pooled >= 0) {
|
||||
XrResult r = pool_cycle(pooled, 0, index);
|
||||
if (XR_SUCCEEDED(r)) emul_on_acquire(swapchain, *index);
|
||||
return r;
|
||||
}
|
||||
if (is_standin(swapchain)) { // adapter-served cube stand-in: one image
|
||||
if (!index) return XR_ERROR_VALIDATION_FAILURE;
|
||||
*index = 0;
|
||||
@@ -1513,8 +1441,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL surf_composite_release(XrSwapchain handle,
|
||||
tracked_swapchain *t=find_tracked(handle,0);
|
||||
if(t && t->acquired_count && t->waited_count)surf_scene_capture(t,t->acquired[0]);
|
||||
pthread_mutex_unlock(&flip_lock);
|
||||
XrResult result=pool_find(handle)>=0 && !equirect_emul ? xrReleaseSwapchainImage(handle,info) :
|
||||
equirect_emul?hook_xrReleaseSwapchainImage(handle,info):
|
||||
XrResult result=equirect_emul?hook_xrReleaseSwapchainImage(handle,info):
|
||||
((PFN_xrReleaseSwapchainImage)lookup(active_instance,"xrReleaseSwapchainImage"))(handle,info);
|
||||
if(XR_SUCCEEDED(result)) {
|
||||
surf_scene_release_commit(handle);
|
||||
@@ -1528,8 +1455,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL surf_composite_release(XrSwapchain handle,
|
||||
return result;
|
||||
}
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL surf_composite_wait(XrSwapchain handle,const XrSwapchainImageWaitInfo *info) {
|
||||
XrResult result=pool_find(handle)>=0 && !equirect_emul ? xrWaitSwapchainImage(handle,info) :
|
||||
equirect_emul?hook_xrWaitSwapchainImage(handle,info):
|
||||
XrResult result=equirect_emul?hook_xrWaitSwapchainImage(handle,info):
|
||||
((PFN_xrWaitSwapchainImage)lookup(active_instance,"xrWaitSwapchainImage"))(handle,info);
|
||||
if(result==XR_SUCCESS) {
|
||||
pthread_mutex_lock(&flip_lock);tracked_swapchain *t=find_tracked(handle,0);
|
||||
@@ -1843,8 +1769,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
frames = 0; drift_sum = drift_max = 0;
|
||||
}
|
||||
}
|
||||
if (swapchain_pool && info && info->layerCount > 64) return XR_ERROR_LAYER_LIMIT_EXCEEDED;
|
||||
if ((!swapchain_pool && !cube_standin && !layer_fix && !swap_eyes && !emulate_passthrough && !flip_quads && !flip_emul && !strip_depth && !fade_quad) || !info || !info->layerCount ||
|
||||
if ((!cube_standin && !layer_fix && !swap_eyes && !emulate_passthrough && !flip_quads && !flip_emul && !strip_depth && !fade_quad) || !info || !info->layerCount ||
|
||||
info->layerCount > 64)
|
||||
return fn(session, info);
|
||||
if (layer_debug) { // the submitted layer list (order, flags, eyes, chained structs) whenever its shape changes
|
||||
@@ -2224,11 +2149,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
for (uint32_t i = 0; i < tcount; ++i) kept[i] = trimmed[i];
|
||||
count = tcount;
|
||||
}
|
||||
PoolFrame pool_frame;
|
||||
if (swapchain_pool && session == pool_session) {
|
||||
count = pool_rewrite_layers(kept, count, &pool_frame);
|
||||
++swapped;
|
||||
}
|
||||
XrFrameEndInfo fixed = *info;
|
||||
fixed.layers = kept;
|
||||
fixed.layerCount = count;
|
||||
@@ -2236,30 +2156,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(XrSession session, const XrFrameEndInf
|
||||
static int warned, failures, passthrough_logged;
|
||||
if (dropped && warned++ < 5) LOG("xrEndFrame: dropped %u unusable layer(s)", dropped);
|
||||
if (passthrough && !passthrough_logged++) LOG("xrEndFrame: passthrough layer -> ALPHA_BLEND environment");
|
||||
static struct { XrSwapchain swapchain; XrRect2Di rect; uint32_t array; } last_rect[2];
|
||||
static double last_rect_log[2];
|
||||
struct timespec rect_now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &rect_now);
|
||||
double rect_seconds = rect_now.tv_sec + rect_now.tv_nsec / 1e9;
|
||||
for (uint32_t i = 0; i < info->layerCount; ++i) {
|
||||
const XrCompositionLayerBaseHeader *l = info->layers[i];
|
||||
if (!l || l->type != XR_TYPE_COMPOSITION_LAYER_PROJECTION) continue;
|
||||
const XrCompositionLayerProjection *p = (const XrCompositionLayerProjection *)l;
|
||||
for (uint32_t v = 0; v < p->viewCount && v < 2; ++v) {
|
||||
const XrSwapchainSubImage *sub = &p->views[v].subImage;
|
||||
if ((last_rect[v].swapchain != sub->swapchain || last_rect[v].array != sub->imageArrayIndex ||
|
||||
memcmp(&last_rect[v].rect, &sub->imageRect, sizeof(sub->imageRect))) &&
|
||||
rect_seconds - last_rect_log[v] >= 1.0) {
|
||||
LOG("projection view %u rect change: swapchain=%p rect=%d,%d %dx%d array=%u", v,
|
||||
(void *)(uintptr_t)sub->swapchain, sub->imageRect.offset.x, sub->imageRect.offset.y,
|
||||
sub->imageRect.extent.width, sub->imageRect.extent.height, sub->imageArrayIndex);
|
||||
last_rect[v].swapchain = sub->swapchain;
|
||||
last_rect[v].rect = sub->imageRect;
|
||||
last_rect[v].array = sub->imageArrayIndex;
|
||||
last_rect_log[v] = rect_seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
static int views_logged, quads_logged;
|
||||
if (views_logged < 3 || quads_logged < 10) { // describe the stereo layer layout and quads
|
||||
for (uint32_t i = 0; i < fixed.layerCount; ++i) {
|
||||
@@ -2499,8 +2395,6 @@ static XRAPI_ATTR XrResult XRAPI_CALL shim_poll_event(XrInstance instance, XrEve
|
||||
__attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_proc(const char *name) {
|
||||
pthread_once(&init_once, initialize);
|
||||
if (!name) return NULL;
|
||||
if (fixed_resolution && !strcmp(name, "xrGetRecommendedLayerResolutionMETA"))
|
||||
return (PFN_xrVoidFunction)fixed_recommended_resolution;
|
||||
if (!strcmp(name, "framebridge_hide_fb_foveation"))
|
||||
return (PFN_xrVoidFunction)framebridge_hide_fb_foveation;
|
||||
if (hide_fb_foveation_vulkan) {
|
||||
@@ -2566,10 +2460,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
*function = (PFN_xrVoidFunction)fb_hide_update_swapchain;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
if (fixed_resolution && !strcmp(name, "xrGetRecommendedLayerResolutionMETA")) {
|
||||
*function = (PFN_xrVoidFunction)fixed_recommended_resolution;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
HOOK(xrGetSystemProperties)
|
||||
HOOK(xrApplyHapticFeedback)
|
||||
HOOK(xrCreateReferenceSpace)
|
||||
@@ -2603,7 +2493,6 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
HOOK_AS(xrReleaseSwapchainImage, surf_composite_release)
|
||||
HOOK_AS(xrWaitSwapchainImage, surf_composite_wait)
|
||||
}
|
||||
if (swapchain_pool) { HOOK_AS(xrDestroySession, hook_xrDestroySession) }
|
||||
if (cube_standin) {
|
||||
HOOK_AS(xrEndSession, cube_end_session)
|
||||
HOOK_AS(xrDestroySession, hook_xrDestroySession)
|
||||
@@ -2622,7 +2511,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
|
||||
if (instance != XR_NULL_HANDLE) HOOK_AS(xrGetDisplayRefreshRateFB, hook_get_refresh_rate)
|
||||
if (surface_emul) HOOK_AS(xrCreateSwapchainAndroidSurfaceKHR, hook_xrCreateSwapchainAndroidSurfaceKHR)
|
||||
if (eye_debug || release_wait) HOOK_AS(xrReleaseSwapchainImage, eye_hook_xrReleaseSwapchainImage)
|
||||
if (swapchain_pool || layer_debug || cube_standin) { // pooled images, counters + cube stand-ins; every other wait/release hook above takes precedence
|
||||
if (layer_debug || cube_standin) { // counters + cube stand-ins; every other wait/release hook above takes precedence
|
||||
HOOK(xrWaitSwapchainImage)
|
||||
HOOK(xrReleaseSwapchainImage)
|
||||
}
|
||||
|
||||
@@ -304,7 +304,6 @@ static void emul_on_destroy_swapchain(XrSwapchain handle) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrReleaseSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageReleaseInfo *info) {
|
||||
PFN_xrReleaseSwapchainImage fn = (PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
int pooled = pool_find(swapchain);
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
pthread_mutex_lock(&emul_lock);
|
||||
emul_source *s = emul_active() ? emul_find_source(swapchain, 0) : NULL;
|
||||
@@ -315,8 +314,7 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrReleaseSwapchainImage(XrSwapchain s
|
||||
if (fence) p_glFlush();
|
||||
}
|
||||
pthread_mutex_unlock(&emul_lock);
|
||||
XrResult result = pooled >= 0 ? pool_cycle(pooled, 2, NULL) :
|
||||
emul_is_virtual(swapchain) ? XR_SUCCESS : fn(swapchain, info);
|
||||
XrResult result = emul_is_virtual(swapchain) ? XR_SUCCESS : fn(swapchain, info);
|
||||
pthread_mutex_lock(&emul_lock);
|
||||
s = emul_active() ? emul_find_source(swapchain, 0) : NULL;
|
||||
if (s && XR_SUCCEEDED(result) && s->acquired_count) {
|
||||
@@ -338,10 +336,8 @@ static XRAPI_ATTR XrResult XRAPI_CALL hook_xrReleaseSwapchainImage(XrSwapchain s
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrWaitSwapchainImage(XrSwapchain swapchain, const XrSwapchainImageWaitInfo *info) {
|
||||
PFN_xrWaitSwapchainImage fn = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
|
||||
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
int pooled = pool_find(swapchain);
|
||||
if (is_standin(swapchain)) return XR_SUCCESS;
|
||||
XrResult result = pooled >= 0 ? pool_cycle(pooled, 1, NULL) :
|
||||
emul_is_virtual(swapchain) ? XR_SUCCESS : fn(swapchain, info);
|
||||
XrResult result = emul_is_virtual(swapchain) ? XR_SUCCESS : fn(swapchain, info);
|
||||
pthread_mutex_lock(&emul_lock);
|
||||
emul_source *s = emul_active() ? emul_find_source(swapchain, 0) : NULL;
|
||||
if (s && s->reading >= 0 && s->reading == s->last_acquired) {
|
||||
@@ -1009,7 +1005,6 @@ static void emul_on_destroy_session(XrSession session) {
|
||||
static XRAPI_ATTR XrResult XRAPI_CALL hook_xrDestroySession(XrSession session) {
|
||||
cube_session_destroy(session);
|
||||
emul_on_destroy_session(session);
|
||||
pool_cleanup(session);
|
||||
PFN_xrDestroySession fn = (PFN_xrDestroySession)lookup(active_instance, "xrDestroySession");
|
||||
return fn ? fn(session) : XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// First-fit edge packer. Removing a live rectangle immediately frees its space.
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
typedef struct {
|
||||
int x, y, w, h;
|
||||
} PoolRect;
|
||||
static int pool_place(const PoolRect *used, int n, int side, int w, int h, PoolRect *out) {
|
||||
if (w <= 0 || h <= 0 || w > side || h > side) return 0;
|
||||
for (int yi = -1; yi < n; ++yi)
|
||||
for (int xi = -1; xi < n; ++xi) {
|
||||
int x = xi < 0 ? 0 : used[xi].x + used[xi].w;
|
||||
int y = yi < 0 ? 0 : used[yi].y + used[yi].h;
|
||||
if (x > side - w || y > side - h) continue;
|
||||
int fits = 1;
|
||||
for (int i = 0; i < n; ++i)
|
||||
if (x < used[i].x + used[i].w && x + w > used[i].x && y < used[i].y + used[i].h && y + h > used[i].y) {
|
||||
fits = 0;
|
||||
break;
|
||||
}
|
||||
if (fits) {
|
||||
*out = (PoolRect){x, y, w, h};
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
// Included after layer_emul_gl.c: reuses its dynamically loaded GLES entry points.
|
||||
// Proactive at eight app swapchains: an atlas cannot be allocated after the runtime cap.
|
||||
#include "pool_rect.h"
|
||||
#define POOL_MAX 128
|
||||
#define POOL_SIDE 4096
|
||||
static XrSession pool_session;
|
||||
static struct {
|
||||
XrSwapchain handle;
|
||||
GLuint images[16];
|
||||
uint32_t count, index;
|
||||
int acquired, waited;
|
||||
} pool_atlas[2];
|
||||
static struct {
|
||||
XrSwapchain handle;
|
||||
GLuint tex;
|
||||
PoolRect rect;
|
||||
int atlas, state, released, copied;
|
||||
GLsync fence, read_fence;
|
||||
} pool_images[POOL_MAX];
|
||||
static unsigned pool_errors;
|
||||
static XrSwapchain pool_runtime[256];
|
||||
static unsigned pool_runtime_count;
|
||||
static void pool_note_runtime_locked(XrSession session, XrSwapchain handle) {
|
||||
if (!swapchain_pool || session != pool_session) return;
|
||||
for (int i = 0; i < 256; ++i)
|
||||
if (!pool_runtime[i]) {
|
||||
pool_runtime[i] = handle;
|
||||
++pool_runtime_count;
|
||||
return;
|
||||
}
|
||||
}
|
||||
static void pool_forget_runtime_locked(XrSwapchain handle) {
|
||||
for (int i = 0; i < 256; ++i)
|
||||
if (pool_runtime[i] == handle && handle) {
|
||||
pool_runtime[i] = XR_NULL_HANDLE;
|
||||
--pool_runtime_count;
|
||||
return;
|
||||
}
|
||||
}
|
||||
static int pool_should_create_locked(XrSession session) {
|
||||
return swapchain_pool && session == pool_session && pool_runtime_count >= 8;
|
||||
}
|
||||
static int pool_find_locked(XrSwapchain h) {
|
||||
for (int i = 0; h && i < POOL_MAX; ++i)
|
||||
if (pool_images[i].handle == h) return i;
|
||||
return -1;
|
||||
}
|
||||
static void pool_error(void) {
|
||||
if (pool_errors++ % 240 == 0) LOG("swapchain_pool: atlas update failed; omitting affected layers");
|
||||
}
|
||||
static int pool_create_locked(XrSession session, const XrSwapchainCreateInfo *ci, XrSwapchain *out, int limited) {
|
||||
if (!swapchain_pool || session != pool_session || !out || ci->next || ci->faceCount != 1 || ci->arraySize != 1 ||
|
||||
ci->sampleCount != 1 || ci->mipCount != 1 || !(ci->usageFlags & XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT) ||
|
||||
(ci->createFlags & ~XR_SWAPCHAIN_CREATE_STATIC_IMAGE_BIT) ||
|
||||
(ci->format != GL_RGBA8 && ci->format != GL_SRGB8_ALPHA8) || !ci->width || !ci->height ||
|
||||
ci->width > POOL_SIDE || ci->height > POOL_SIDE)
|
||||
return 0;
|
||||
if (!p_eglCreateContext && !emul_load_gl()) return 0;
|
||||
if (p_eglGetCurrentContext() == EGL_NO_CONTEXT) return 0;
|
||||
// Like emul_virtual_create: stale application GL errors must not look like allocation failures.
|
||||
for (int i = 0; i < 8 && p_glGetError() != GL_NO_ERROR; ++i) {
|
||||
}
|
||||
int slot = -1;
|
||||
for (int i = 0; i < POOL_MAX; ++i)
|
||||
if (!pool_images[i].handle) {
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
if (slot < 0) return 0;
|
||||
for (int a = 0; a < 2; ++a) {
|
||||
PoolRect used[POOL_MAX], rect;
|
||||
int n = 0;
|
||||
for (int i = 0; i < POOL_MAX; ++i)
|
||||
if (pool_images[i].handle && pool_images[i].atlas == a) used[n++] = pool_images[i].rect;
|
||||
if (!pool_place(used, n, POOL_SIDE, ci->width, ci->height, &rect)) continue;
|
||||
if (!pool_atlas[a].handle) {
|
||||
XrSwapchainCreateInfo ai = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
||||
ai.width = ai.height = POOL_SIDE;
|
||||
ai.arraySize = ai.faceCount = ai.sampleCount = ai.mipCount = 1;
|
||||
ai.format = GL_SRGB8_ALPHA8;
|
||||
ai.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT | XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT;
|
||||
PFN_xrCreateSwapchain create = (PFN_xrCreateSwapchain)lookup(active_instance, "xrCreateSwapchain");
|
||||
PFN_xrEnumerateSwapchainImages enumerate =
|
||||
(PFN_xrEnumerateSwapchainImages)lookup(active_instance, "xrEnumerateSwapchainImages");
|
||||
if (XR_FAILED(create(session, &ai, &pool_atlas[a].handle))) return 0;
|
||||
emul_gles_image images[16];
|
||||
for (int j = 0; j < 16; ++j) images[j] = (emul_gles_image){XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR};
|
||||
if (XR_FAILED(enumerate(pool_atlas[a].handle, 16, &pool_atlas[a].count, (void *)images)) ||
|
||||
!pool_atlas[a].count || pool_atlas[a].count > 16) {
|
||||
((PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain"))(pool_atlas[a].handle);
|
||||
memset(&pool_atlas[a], 0, sizeof pool_atlas[a]);
|
||||
return 0;
|
||||
}
|
||||
for (uint32_t j = 0; j < pool_atlas[a].count; ++j) pool_atlas[a].images[j] = images[j].image;
|
||||
}
|
||||
GLint old;
|
||||
GLuint tex = 0;
|
||||
p_glGetIntegerv(GL_TEXTURE_BINDING_2D, &old);
|
||||
p_glGenTextures(1, &tex);
|
||||
p_glBindTexture(GL_TEXTURE_2D, tex);
|
||||
p_glTexStorage2D(GL_TEXTURE_2D, 1, ci->format, ci->width, ci->height);
|
||||
GLenum err = p_glGetError();
|
||||
p_glBindTexture(GL_TEXTURE_2D, old);
|
||||
if (err != GL_NO_ERROR) {
|
||||
p_glDeleteTextures(1, &tex);
|
||||
return 0;
|
||||
}
|
||||
pool_images[slot].handle = FAKE_HANDLE(XrSwapchain);
|
||||
pool_images[slot].tex = tex;
|
||||
pool_images[slot].rect = rect;
|
||||
pool_images[slot].atlas = a;
|
||||
*out = pool_images[slot].handle;
|
||||
note_swapchain_size(*out, ci->width, ci->height);
|
||||
remember_swapchain(*out);
|
||||
emul_on_create_swapchain(*out, ci);
|
||||
LOG("swapchain_pool: virtual %ux%u (%s)", ci->width, ci->height,
|
||||
limited ? "runtime limit reached" : "runtime limit proactively avoided");
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
static void pool_destroy_locked(int i) {
|
||||
emul_on_destroy_swapchain(pool_images[i].handle);
|
||||
if (pool_images[i].fence && p_eglGetCurrentContext && p_eglGetCurrentContext() != EGL_NO_CONTEXT)
|
||||
p_glDeleteSync(pool_images[i].fence);
|
||||
if (p_eglGetCurrentContext && p_eglGetCurrentContext() != EGL_NO_CONTEXT)
|
||||
p_glDeleteTextures(1, &pool_images[i].tex);
|
||||
if (pool_images[i].read_fence && p_eglGetCurrentContext && p_eglGetCurrentContext() != EGL_NO_CONTEXT)
|
||||
p_glDeleteSync(pool_images[i].read_fence);
|
||||
forget_swapchain(pool_images[i].handle);
|
||||
memset(&pool_images[i], 0, sizeof pool_images[i]);
|
||||
}
|
||||
static XrResult pool_enumerate_locked(int i, uint32_t cap, uint32_t *count, XrSwapchainImageBaseHeader *images) {
|
||||
if (!count) return XR_ERROR_VALIDATION_FAILURE;
|
||||
*count = 1;
|
||||
if (!cap) return XR_SUCCESS;
|
||||
if (!images || images->type != XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR) return XR_ERROR_VALIDATION_FAILURE;
|
||||
((emul_gles_image *)images)->image = pool_images[i].tex;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XrResult pool_cycle_locked(int i, int op, uint32_t *index) {
|
||||
int *state = &pool_images[i].state;
|
||||
if (op == 0) {
|
||||
if (!index) return XR_ERROR_VALIDATION_FAILURE;
|
||||
if (*state) return XR_ERROR_CALL_ORDER_INVALID;
|
||||
if (pool_images[i].read_fence) {
|
||||
if (p_eglGetCurrentContext() == EGL_NO_CONTEXT) return XR_ERROR_RUNTIME_FAILURE;
|
||||
p_glWaitSync(pool_images[i].read_fence, 0, GL_TIMEOUT_IGNORED);
|
||||
}
|
||||
*index = 0;
|
||||
*state = 1;
|
||||
}
|
||||
if (op == 1) {
|
||||
if (*state != 1) return XR_ERROR_CALL_ORDER_INVALID;
|
||||
*state = 2;
|
||||
}
|
||||
if (op == 2) {
|
||||
if (*state != 2) return XR_ERROR_CALL_ORDER_INVALID;
|
||||
if (p_eglGetCurrentContext() == EGL_NO_CONTEXT) return XR_ERROR_RUNTIME_FAILURE;
|
||||
if (pool_images[i].fence) p_glDeleteSync(pool_images[i].fence);
|
||||
pool_images[i].fence = p_glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
if (!pool_images[i].fence) return XR_ERROR_RUNTIME_FAILURE;
|
||||
p_glFlush();
|
||||
*state = 0;
|
||||
pool_images[i].released = 1;
|
||||
}
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static void pool_cleanup_locked(XrSession session) {
|
||||
if (session != pool_session) return;
|
||||
for (int i = 0; i < POOL_MAX; ++i)
|
||||
if (pool_images[i].handle) pool_destroy_locked(i);
|
||||
for (int a = 0; a < 2; ++a)
|
||||
if (pool_atlas[a].handle) {
|
||||
((PFN_xrDestroySwapchain)lookup(active_instance, "xrDestroySwapchain"))(pool_atlas[a].handle);
|
||||
memset(&pool_atlas[a], 0, sizeof pool_atlas[a]);
|
||||
}
|
||||
memset(pool_runtime, 0, sizeof pool_runtime);
|
||||
pool_runtime_count = 0;
|
||||
pool_session = XR_NULL_HANDLE;
|
||||
}
|
||||
// Called on final layers, after strips, flips and rect_clamp. Copy every referenced image
|
||||
// each frame: runtime atlas images rotate, and untouched areas are not preserved.
|
||||
static void pool_begin_frame_locked(void) {
|
||||
for (int i = 0; i < POOL_MAX; ++i) pool_images[i].copied = 0;
|
||||
}
|
||||
static int pool_subimage_locked(XrSwapchainSubImage *sub) {
|
||||
int i = pool_find_locked(sub->swapchain);
|
||||
if (i < 0) return 1;
|
||||
int a = pool_images[i].atlas;
|
||||
if (!pool_images[i].released || pool_images[i].state || sub->imageArrayIndex) return 0;
|
||||
PFN_xrAcquireSwapchainImage acquire =
|
||||
(PFN_xrAcquireSwapchainImage)lookup(active_instance, "xrAcquireSwapchainImage");
|
||||
PFN_xrWaitSwapchainImage wait = (PFN_xrWaitSwapchainImage)lookup(active_instance, "xrWaitSwapchainImage");
|
||||
if (!pool_atlas[a].acquired) {
|
||||
XrSwapchainImageAcquireInfo ai = {XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
|
||||
if (XR_FAILED(acquire(pool_atlas[a].handle, &ai, &pool_atlas[a].index))) return 0;
|
||||
pool_atlas[a].acquired = 1;
|
||||
}
|
||||
if (!pool_atlas[a].waited) {
|
||||
XrSwapchainImageWaitInfo wi = {XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO, NULL, XR_INFINITE_DURATION};
|
||||
if (wait(pool_atlas[a].handle, &wi) != XR_SUCCESS) return 0;
|
||||
pool_atlas[a].waited = 1;
|
||||
}
|
||||
if (p_eglGetCurrentContext() == EGL_NO_CONTEXT) return 0;
|
||||
PoolRect r = pool_images[i].rect;
|
||||
if (!pool_images[i].copied) {
|
||||
for (int e = 0; e < 8 && p_glGetError() != GL_NO_ERROR; ++e) {
|
||||
}
|
||||
if (pool_images[i].fence) p_glWaitSync(pool_images[i].fence, 0, GL_TIMEOUT_IGNORED);
|
||||
GLint read, draw;
|
||||
GLuint fb[2];
|
||||
GLboolean scissor = p_glIsEnabled(GL_SCISSOR_TEST);
|
||||
p_glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read);
|
||||
p_glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw);
|
||||
p_glGenFramebuffers(2, fb);
|
||||
p_glDisable(GL_SCISSOR_TEST);
|
||||
p_glBindFramebuffer(GL_READ_FRAMEBUFFER, fb[0]);
|
||||
p_glFramebufferTexture2D(GL_READ_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, pool_images[i].tex, 0);
|
||||
p_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fb[1]);
|
||||
p_glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
|
||||
pool_atlas[a].images[pool_atlas[a].index], 0);
|
||||
int ok = p_glCheckFramebufferStatus(GL_READ_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE &&
|
||||
p_glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE;
|
||||
if (ok) p_glBlitFramebuffer(0, 0, r.w, r.h, r.x, r.y, r.x + r.w, r.y + r.h, GL_COLOR_BUFFER_BIT, GL_NEAREST);
|
||||
ok &= p_glGetError() == GL_NO_ERROR;
|
||||
p_glBindFramebuffer(GL_READ_FRAMEBUFFER, read);
|
||||
p_glBindFramebuffer(GL_DRAW_FRAMEBUFFER, draw);
|
||||
if (scissor) p_glEnable(GL_SCISSOR_TEST);
|
||||
p_glDeleteFramebuffers(2, fb);
|
||||
if (!ok) return 0;
|
||||
if (pool_images[i].read_fence) p_glDeleteSync(pool_images[i].read_fence);
|
||||
pool_images[i].read_fence = p_glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
|
||||
if (!pool_images[i].read_fence) return 0;
|
||||
pool_images[i].copied = 1;
|
||||
}
|
||||
clamp_rect(&sub->imageRect, r.w, r.h);
|
||||
if (sub->imageRect.offset.x < 0 || sub->imageRect.offset.y < 0 ||
|
||||
(int64_t)sub->imageRect.offset.x + sub->imageRect.extent.width > r.w ||
|
||||
(int64_t)sub->imageRect.offset.y + sub->imageRect.extent.height > r.h)
|
||||
return 0;
|
||||
sub->swapchain = pool_atlas[a].handle;
|
||||
sub->imageRect.offset.x += r.x;
|
||||
sub->imageRect.offset.y += r.y;
|
||||
return 1;
|
||||
}
|
||||
static int pool_finish_locked(void) {
|
||||
int ok = 1;
|
||||
for (int a = 0; a < 2; ++a)
|
||||
if (pool_atlas[a].acquired && pool_atlas[a].waited) {
|
||||
p_glFlush();
|
||||
XrSwapchainImageReleaseInfo ri = {XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
|
||||
XrResult r = ((PFN_xrReleaseSwapchainImage)lookup(active_instance, "xrReleaseSwapchainImage"))(
|
||||
pool_atlas[a].handle, &ri);
|
||||
if (XR_FAILED(r))
|
||||
ok = 0;
|
||||
else
|
||||
pool_atlas[a].acquired = pool_atlas[a].waited = 0;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Each OpenXR call may run on a different thread, including creates of different panels.
|
||||
static pthread_mutex_t pool_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static int pool_find(XrSwapchain h) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
int result = pool_find_locked(h);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static int pool_create(XrSession session, const XrSwapchainCreateInfo *ci, XrSwapchain *out, int limited) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
int result = pool_create_locked(session, ci, out, limited);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void pool_destroy(int i) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
pool_destroy_locked(i);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
}
|
||||
|
||||
static void pool_cleanup(XrSession session) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
pool_cleanup_locked(session);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
}
|
||||
|
||||
static XrResult pool_cycle(int i, int op, uint32_t *index) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
XrResult result = pool_cycle_locked(i, op, index);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static XrResult pool_enumerate(int i, uint32_t cap, uint32_t *count, XrSwapchainImageBaseHeader *images) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
XrResult result = pool_enumerate_locked(i, cap, count, images);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void pool_begin_frame(void) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
pool_begin_frame_locked();
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
}
|
||||
|
||||
static int pool_subimage(XrSwapchainSubImage *sub) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
int result = pool_subimage_locked(sub);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static int pool_finish(void) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
int result = pool_finish_locked();
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void pool_note_runtime(XrSession session, XrSwapchain handle) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
pool_note_runtime_locked(session, handle);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
}
|
||||
|
||||
static void pool_forget_runtime(XrSwapchain handle) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
pool_forget_runtime_locked(handle);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
}
|
||||
|
||||
static int pool_should_create(XrSession session) {
|
||||
pthread_mutex_lock(&pool_lock);
|
||||
int result = pool_should_create_locked(session);
|
||||
pthread_mutex_unlock(&pool_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Caller-owned storage stays live until the runtime's xrEndFrame returns. The input layer
|
||||
// structs are const: never mutate them, including repeated references from strips or stereo.
|
||||
typedef struct {
|
||||
XrCompositionLayerQuad quads[64];
|
||||
XrCompositionLayerCylinderKHR cylinders[64];
|
||||
XrCompositionLayerProjection projections[64];
|
||||
XrCompositionLayerProjectionView views[64][2];
|
||||
} PoolFrame;
|
||||
static uint32_t pool_rewrite_layers(const XrCompositionLayerBaseHeader **layers, uint32_t count, PoolFrame *frame) {
|
||||
pool_begin_frame();
|
||||
uint32_t n = 0;
|
||||
for (uint32_t j = 0; j < count; ++j) {
|
||||
const XrCompositionLayerBaseHeader *l = layers[j];
|
||||
int ok = 1;
|
||||
if (l->type == XR_TYPE_COMPOSITION_LAYER_QUAD) {
|
||||
frame->quads[j] = *(const XrCompositionLayerQuad *)l;
|
||||
ok = pool_subimage(&frame->quads[j].subImage);
|
||||
l = (const void *)&frame->quads[j];
|
||||
} else if (l->type == XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR) {
|
||||
frame->cylinders[j] = *(const XrCompositionLayerCylinderKHR *)l;
|
||||
ok = pool_subimage(&frame->cylinders[j].subImage);
|
||||
l = (const void *)&frame->cylinders[j];
|
||||
} else if (l->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION) {
|
||||
const XrCompositionLayerProjection *p = (const void *)l;
|
||||
if (p->viewCount <= 2) {
|
||||
frame->projections[j] = *p;
|
||||
for (uint32_t v = 0; v < p->viewCount; ++v) {
|
||||
frame->views[j][v] = p->views[v];
|
||||
ok &= pool_subimage(&frame->views[j][v].subImage);
|
||||
}
|
||||
frame->projections[j].views = frame->views[j];
|
||||
l = (const void *)&frame->projections[j];
|
||||
}
|
||||
}
|
||||
if (ok)
|
||||
layers[n++] = l;
|
||||
else
|
||||
pool_error();
|
||||
}
|
||||
if (!pool_finish()) {
|
||||
pool_error();
|
||||
return 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ timefix OpenXR layer for Proton games (linux-arm64, glibc; the NDK's clang build
|
||||
OculusHMDConnected helper for Rift games under Proton (win-x64 PE; the NDK's clang + lld-link, no Windows SDK), the
|
||||
live view's hardware H.264 encoder fp_venc (linux-arm64-bin, static freestanding executable), and rewrites artifacts/SHA256SUMS. Run `frameport parity` afterwards to see which games change.
|
||||
|
||||
python native/build.py [--only adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrruntime,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,vrsettings,venc,vroverlay_probe] [--ndk PATH]
|
||||
python native/build.py [--only adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,vrsettings,venc,vroverlay_probe] [--ndk PATH]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -343,15 +343,6 @@ def build_ovrtrace(tc: Path):
|
||||
"-o", ART / "arm64-v8a/libfp_ovrtrace.so"], cwd=src)
|
||||
|
||||
|
||||
def build_xrruntime(tc: Path):
|
||||
src = HERE / "xrruntime"
|
||||
run([exe(tc, "aarch64-linux-android29-clang"), "-shared", "-O2", "-fPIC", "-Wall", "-Wextra", "-Werror",
|
||||
f"-ffile-prefix-map={ROOT}=.", "-fvisibility=hidden", "-Wl,-Bsymbolic",
|
||||
"-I", openxr_include("adapter"), "-Wl,-soname,libfp_xrruntime.so",
|
||||
"-Wl,-z,max-page-size=16384", "xrruntime.c", "-ldl", "-llog",
|
||||
"-o", ART / "arm64-v8a/libfp_xrruntime.so"], cwd=src)
|
||||
|
||||
|
||||
def build_xrshim(tc: Path):
|
||||
"""FrameBridge extension shim (see xrshim/xrshim.c): DT_NEEDED-injected in front of overport's libopenxr_loader.so."""
|
||||
src = HERE / "xrshim"
|
||||
@@ -461,7 +452,7 @@ def write_sums():
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrruntime,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,roomscan,a32")
|
||||
ap.add_argument("--only", default="adapter,bridge,compat,langpack,glshim,eglfmt,glmv,ovrtrace,dex,xrlayer,oculushmd,xrshim,vkshim,zinkfix,foveation,ovrpshim,tlskeys,luajit,vrsettings,venc,roomscan,a32")
|
||||
ap.add_argument("--ndk")
|
||||
args = ap.parse_args()
|
||||
parts = set(args.only.split(","))
|
||||
@@ -469,7 +460,7 @@ def main():
|
||||
(ART / d).mkdir(parents=True, exist_ok=True)
|
||||
tc = clang_dir(ndk(args.ndk)) if parts - {"dex"} else None
|
||||
steps = {"adapter": lambda: build_adapter(tc), "bridge": lambda: build_bridge(tc), "compat": lambda: build_compat(tc),
|
||||
"xrruntime": lambda: build_xrruntime(tc), "langpack": lambda: build_langpack(tc),
|
||||
"langpack": lambda: build_langpack(tc),
|
||||
"glshim": lambda: build_glshim(tc), "eglfmt": lambda: build_eglfmt(tc), "glmv": lambda: build_glmv(tc), "ovrtrace": lambda: build_ovrtrace(tc), "xrshim": lambda: build_xrshim(tc), "dex": build_dex, "xrlayer": lambda: build_xrlayer(tc),
|
||||
"oculushmd": lambda: build_oculushmd(tc), "vkshim": lambda: build_vkshim(tc),
|
||||
"zinkfix": lambda: build_zinkfix(tc),
|
||||
@@ -479,7 +470,7 @@ def main():
|
||||
"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", "xrruntime", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "luajit", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
|
||||
for name in ("adapter", "bridge", "compat", "langpack", "glshim", "eglfmt", "glmv", "ovrtrace", "xrshim", "vkshim", "zinkfix", "foveation", "ovrpshim", "tlskeys", "luajit", "dex", "xrlayer", "oculushmd", "vrsettings", "venc",
|
||||
"roomscan", "vroverlay_probe", "a32"):
|
||||
if name in parts:
|
||||
log(f"build {name}")
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
# Shared hardware video decoding for Lepton
|
||||
|
||||
`frameport_hevc.cpp` implements Android OMX decoders for HEVC Main and Main 10,
|
||||
H.264 (Constrained) Baseline/Main/(Constrained) High and VP9 Profile 0 using Iris
|
||||
`frameport_hevc.cpp` implements Android OMX decoders for HEVC Main, H.264
|
||||
(Constrained) Baseline/Main/(Constrained) High and VP9 Profile 0 using Iris
|
||||
stateful V4L2 through FFmpeg. Android reports constrained H.264 streams under
|
||||
separate profile values, so they are listed as stock decoders list them.
|
||||
Output is 8-bit 4:2:0. HEVC Main 10 decodes in FFmpeg's software decoder inside
|
||||
the component and is narrowed to 8 bits: Iris on the Frame's kernel (6.18)
|
||||
switches a Main 10 session to its compressed `Q10C` capture format by itself
|
||||
(the capture width becomes a multiple of 192), so an NV12 session returned no
|
||||
usable pictures, hung and blocked later decoder starts in the app. Mainline
|
||||
Iris gained linear P010 in Linux 7.2; `Q10C`'s compression isn't public.
|
||||
`hevc_profile.h` reads the profile from the parameter sets (CODECCONFIG or the
|
||||
first access unit); streams beyond Main never open Iris. Measured on the Frame
|
||||
(software, 4 threads): a 1080p Main 10 episode at 232 fps, a 4K Main 10 test
|
||||
clip at 72 fps.
|
||||
|
||||
HDR pictures (PQ/HDR10 or HLG, from the bitstream or the container) are tone
|
||||
mapped to SDR BT.709 while they are narrowed (`hdr_tonemap.h`): BT.2390's
|
||||
EETF puts HDR reference white (203 cd/m²) at SDR white and rolls highlights
|
||||
off up to the content's MaxCLL (else the mastering display's peak, else
|
||||
1000 cd/m²); colour is converted per chroma sample through linear light into
|
||||
BT.709, and colours outside it move toward grey of the same luminance. The
|
||||
component then reports BT.709 (bitstream aspects preferred) so surfaces and
|
||||
its own RGBA conversion treat the picture as SDR. The work runs in the colour
|
||||
helper threads; one big core takes about 10 ms per 1080p picture and 40-50 ms
|
||||
per 4K picture, so 4K HDR is split over four threads. Other 10-bit pictures
|
||||
are rounded to 8 bits.
|
||||
Protected content keeps Android's existing codecs; clients selecting by MIME alone
|
||||
These are 8-bit 4:2:0 decoders. Format-aware clients keep Android's existing
|
||||
codecs for Main10/HDR and protected content; clients selecting by MIME alone
|
||||
must still respect the component's advertised profiles. Other codec types
|
||||
retain Android's existing codecs. Applications that explicitly select their
|
||||
own software decoder bypass this integration. Byte-buffer clients receive full-size
|
||||
|
||||
@@ -252,7 +252,7 @@ def build(parser, repo: Path, ndk_root: Path, runtime: Path):
|
||||
path = artifacts / (name + ".txt" if name == "podman.py" else name)
|
||||
files[name] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
# bump the revision with every change: a Frame keeps an installed codec of the same or a newer revision
|
||||
manifest = {"revision": 10, "runtime_sha256": RUNTIME_SHA, "files": files,
|
||||
manifest = {"revision": 8, "runtime_sha256": RUNTIME_SHA, "files": files,
|
||||
"codecs": ["video/hevc", "video/avc", "video/x-vnd.on2.vp9"],
|
||||
"build": {"ndk_revision": NDK_REVISION, "ffmpeg_source_sha256": FFMPEG_SHA}}
|
||||
(artifacts / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
|
||||
@@ -21,12 +21,9 @@
|
||||
#include <poll.h>
|
||||
#include "yuv_copy.h"
|
||||
#include "yuv_color.h"
|
||||
#include "hevc_profile.h"
|
||||
#include "hdr_tonemap.h"
|
||||
extern "C" {
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/opt.h>
|
||||
#include <libavutil/mastering_display_metadata.h>
|
||||
extern const YuvConstants kYvuH709Constants, kYvuI601Constants;
|
||||
extern const YuvConstants kYvuJPEGConstants;
|
||||
void NV21ToARGBRow_Any_NEON(const uint8_t*,const uint8_t*,uint8_t*,const YuvConstants*,int);
|
||||
@@ -38,10 +35,7 @@ int frameport_v4l2_export_frame(const AVFrame*);
|
||||
#include "dma_copy.h"
|
||||
|
||||
namespace android {
|
||||
// Main 10 decodes in software (Iris on the Frame's kernel has no 10-bit
|
||||
// capture format) and is narrowed to the 8-bit outputs.
|
||||
static const CodecProfileLevel hevc_profiles[]={{OMX_VIDEO_HEVCProfileMain,OMX_VIDEO_HEVCMainTierLevel62},
|
||||
{OMX_VIDEO_HEVCProfileMain10,OMX_VIDEO_HEVCMainTierLevel51}};
|
||||
static const CodecProfileLevel hevc_profiles[]={{OMX_VIDEO_HEVCProfileMain,OMX_VIDEO_HEVCMainTierLevel62}};
|
||||
// Android reports constrained AVC streams under their own profile values;
|
||||
// stock decoders list them so format-aware clients can match them.
|
||||
static const CodecProfileLevel avc_profiles[]={
|
||||
@@ -63,7 +57,7 @@ struct DecoderKind {
|
||||
};
|
||||
static const DecoderKind kinds[]={
|
||||
{"OMX.frameport.hevc.decoder","video_decoder.hevc","video/hevc","hevc_v4l2m2m","hevc",
|
||||
OMX_VIDEO_CodingHEVC,V4L2_PIX_FMT_HEVC,hevc_profiles,2,8192,8192},
|
||||
OMX_VIDEO_CodingHEVC,V4L2_PIX_FMT_HEVC,hevc_profiles,1,8192,8192},
|
||||
{"OMX.frameport.avc.decoder","video_decoder.avc","video/avc","h264_v4l2m2m","h264",
|
||||
OMX_VIDEO_CodingAVC,V4L2_PIX_FMT_H264,avc_profiles,5,8192,8192},
|
||||
{"OMX.frameport.vp9.decoder","video_decoder.vp9","video/x-vnd.on2.vp9","vp9_v4l2m2m","vp9",
|
||||
@@ -80,10 +74,8 @@ class ColorWorkers {
|
||||
AVFrame *frame=nullptr;uint8_t *output=nullptr;size_t stride=0;
|
||||
const YuvConstants *matrix=nullptr;
|
||||
FramePortYuvPlane planes[3]={};bool native_yuv=false;
|
||||
void (*job)(void*,unsigned,unsigned)=nullptr;void *job_context=nullptr;
|
||||
void rows(unsigned band) {
|
||||
unsigned bands=count+1;
|
||||
if(job){job(job_context,band,bands);return;}
|
||||
if(frame->format==AV_PIX_FMT_YUV420P || frame->format==AV_PIX_FMT_YUVJ420P){
|
||||
unsigned begin=(frame->height/2)*band/bands,end=(frame->height/2)*(band+1)/bands;
|
||||
if(native_yuv){
|
||||
@@ -124,35 +116,21 @@ class ColorWorkers {
|
||||
}
|
||||
pthread_mutex_unlock(&pool.mutex);return nullptr;
|
||||
}
|
||||
void start() {
|
||||
if(started)return;
|
||||
started=true;
|
||||
for(unsigned i=0;i<3;i++) {
|
||||
// Only this thread advances the generation, so a helper
|
||||
// created now waits for the next picture, never a stale one.
|
||||
workers[i]={this,i+1,generation,{}};
|
||||
if(pthread_create(&workers[i].thread,nullptr,thread,&workers[i]))break;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
public:
|
||||
/** Run fn(context, band, bands) over every band, this thread taking band 0. */
|
||||
void run(void (*fn)(void*,unsigned,unsigned),void *context) {
|
||||
start();
|
||||
pthread_mutex_lock(&mutex);
|
||||
job=fn;job_context=context;complete=0;generation++;
|
||||
pthread_cond_broadcast(&work);pthread_mutex_unlock(&mutex);
|
||||
rows(0);
|
||||
pthread_mutex_lock(&mutex);
|
||||
while(complete<count)pthread_cond_wait(&done,&mutex);
|
||||
job=nullptr;
|
||||
pthread_mutex_unlock(&mutex);
|
||||
}
|
||||
void convert(AVFrame *source,uint8_t *destination,size_t pitch,const YuvConstants *constants,
|
||||
const AHardwareBuffer_Planes *native=nullptr) {
|
||||
// Start helpers when a large picture first appears, including after
|
||||
// an adaptive stream begins at a lower resolution.
|
||||
if(source->width*source->height>=4096*2048)start();
|
||||
if(!started && source->width*source->height>=4096*2048) {
|
||||
started=true;
|
||||
for(unsigned i=0;i<3;i++) {
|
||||
// Only this thread advances the generation, so a helper
|
||||
// created now waits for the next picture, never a stale one.
|
||||
workers[i]={this,i+1,generation,{}};
|
||||
if(pthread_create(&workers[i].thread,nullptr,thread,&workers[i]))break;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
pthread_mutex_lock(&mutex);
|
||||
frame=source;output=destination;stride=pitch;matrix=constants;complete=0;generation++;
|
||||
native_yuv=native!=nullptr;
|
||||
@@ -177,10 +155,7 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
|
||||
bool gpu_allowed=true;
|
||||
AVCodecContext *codec=nullptr;
|
||||
AVCodecParserContext *parser=nullptr;
|
||||
AVFrame *frame=av_frame_alloc(),*narrow=av_frame_alloc();
|
||||
// HDR pictures are tone mapped to SDR BT.709 while narrowed (hdr_tonemap.h); the output then reports BT.709
|
||||
fp_tonemap *tonemap=nullptr;
|
||||
bool tone_mapped=false;
|
||||
AVFrame *frame=av_frame_alloc();
|
||||
uint8_t *config=nullptr;
|
||||
size_t config_size=0;
|
||||
bool failed=false,held=false,drained=false,replace_config=false,surface=false;
|
||||
@@ -274,7 +249,7 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
|
||||
}
|
||||
int openCodec(const char *name,bool hardware) {
|
||||
const AVCodec *implementation=avcodec_find_decoder_by_name(name);
|
||||
if(!implementation || !frame || !narrow)return AVERROR(ENOMEM);
|
||||
if(!implementation || !frame)return AVERROR(ENOMEM);
|
||||
codec=avcodec_alloc_context3(implementation);
|
||||
if(!codec)return AVERROR(ENOMEM);
|
||||
const auto &input=editPortInfo(kInputPortIndex)->mDef.format.video;
|
||||
@@ -298,21 +273,10 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
|
||||
int result=avcodec_open2(codec,implementation,&options);av_dict_free(&options);
|
||||
return result;
|
||||
}
|
||||
bool openDecoder(const uint8_t *first,size_t first_size) {
|
||||
bool openDecoder() {
|
||||
if(codec)return true;
|
||||
const auto &size=editPortInfo(kInputPortIndex)->mDef.format.video;
|
||||
int result=AVERROR(ENODEV);
|
||||
// Iris accepts a Main 10 session but returns no pictures: decode
|
||||
// anything beyond 8-bit Main in software. Parameter sets come as
|
||||
// CODECCONFIG or in-band in the first access unit.
|
||||
if(kind.coding==OMX_VIDEO_CodingHEVC && !prefer_software) {
|
||||
int profile=config_size?hevc_profile(config,config_size):-1;
|
||||
if(profile<0)profile=hevc_profile(first,first_size);
|
||||
if(hevc_needs_software(profile)) {
|
||||
ALOGW("HEVC profile %d is beyond Iris's 8-bit Main; using software decoding",profile);
|
||||
prefer_software=true;
|
||||
}
|
||||
}
|
||||
if(size.nFrameWidth>kind.max_width || size.nFrameHeight>kind.max_height)
|
||||
ALOGW("%s %ux%u is above what Iris decodes (%ux%u); using software decoding",kind.mime,
|
||||
(unsigned)size.nFrameWidth,(unsigned)size.nFrameHeight,kind.max_width,kind.max_height);
|
||||
@@ -389,78 +353,10 @@ class FramePortVideo final : public SoftVideoDecoderOMXComponent {
|
||||
if(aspects.mRange==ColorAspects::RangeFull)return bt709?&frameport_full709_rgba:&kYvuJPEGConstants;
|
||||
return bt709?&kYvuH709Constants:&kYvuI601Constants;
|
||||
}
|
||||
struct ToneMapJob {const fp_tonemap *tm;AVFrame *src,*dst;};
|
||||
static void toneMapBand(void *context,unsigned band,unsigned bands) {
|
||||
auto &job=*static_cast<ToneMapJob*>(context);
|
||||
int rows=(job.src->height+1)/2,begin=rows*band/bands,end=rows*(band+1)/bands;
|
||||
const uint8_t *src[3]={job.src->data[0],job.src->data[1],job.src->data[2]};
|
||||
uint8_t *dst[3]={job.dst->data[0],job.dst->data[1],job.dst->data[2]};
|
||||
fp_tonemap_rows(job.tm,job.src->width,job.src->height,begin,end,src,job.src->linesize,dst,job.dst->linesize);
|
||||
}
|
||||
int hdrTransfer() {
|
||||
// the bitstream's transfer, else the container's
|
||||
if(frame->color_trc==AVCOL_TRC_SMPTE2084)return FP_TM_PQ;
|
||||
if(frame->color_trc==AVCOL_TRC_ARIB_STD_B67)return FP_TM_HLG;
|
||||
if(frame->color_trc!=AVCOL_TRC_UNSPECIFIED)return FP_TM_NONE;
|
||||
Mutex::Autolock lock(mColorAspectsLock);
|
||||
if(mDefaultColorAspects.mTransfer==ColorAspects::TransferST2084)return FP_TM_PQ;
|
||||
if(mDefaultColorAspects.mTransfer==ColorAspects::TransferHLG)return FP_TM_HLG;
|
||||
return FP_TM_NONE;
|
||||
}
|
||||
float contentPeak() {
|
||||
// MaxCLL, else the mastering display's peak (0: unknown, 1000 cd/m² is assumed)
|
||||
if(auto *side=av_frame_get_side_data(frame,AV_FRAME_DATA_CONTENT_LIGHT_LEVEL)){
|
||||
auto *light=reinterpret_cast<const AVContentLightMetadata*>(side->data);
|
||||
if(light->MaxCLL)return (float)light->MaxCLL;
|
||||
}
|
||||
if(auto *side=av_frame_get_side_data(frame,AV_FRAME_DATA_MASTERING_DISPLAY_METADATA)){
|
||||
auto *display=reinterpret_cast<const AVMasteringDisplayMetadata*>(side->data);
|
||||
if(display->has_luminance)return (float)av_q2d(display->max_luminance);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
bool narrowTo8Bit() {
|
||||
// Software Main 10 pictures are 10-bit planar; every output here is
|
||||
// 8-bit SDR, so HDR is tone mapped to BT.709 and other pictures are rounded.
|
||||
av_frame_unref(narrow);
|
||||
narrow->format=AV_PIX_FMT_YUV420P;narrow->width=frame->width;narrow->height=frame->height;
|
||||
int result=av_frame_get_buffer(narrow,64);
|
||||
if(result>=0)result=av_frame_copy_props(narrow,frame);
|
||||
if(result<0){fail(result,"10-bit picture conversion");return false;}
|
||||
int transfer=hdrTransfer();
|
||||
if(transfer!=FP_TM_NONE) {
|
||||
bool full=frame->color_range==AVCOL_RANGE_JPEG;
|
||||
bool matrix2020=frame->colorspace==AVCOL_SPC_BT2020_NCL || frame->colorspace==AVCOL_SPC_UNSPECIFIED;
|
||||
bool primaries2020=frame->color_primaries==AVCOL_PRI_BT2020 || frame->color_primaries==AVCOL_PRI_UNSPECIFIED;
|
||||
float peak=transfer==FP_TM_PQ?contentPeak():0;
|
||||
if(!tonemap)tonemap=new fp_tonemap;
|
||||
if(!tone_mapped || tonemap->transfer!=transfer || tonemap->full_range!=full || tonemap->matrix2020!=matrix2020 ||
|
||||
tonemap->primaries2020!=primaries2020 || tonemap->peak!=fp_tonemap_peak(transfer,peak)) {
|
||||
fp_tonemap_init(tonemap,transfer,full,matrix2020,primaries2020,peak);
|
||||
ALOGI("%s HDR (%s, peak %.0f cd/m²): tone mapping to SDR BT.709",kind.mime,
|
||||
transfer==FP_TM_PQ?"PQ":"HLG",tonemap->peak);
|
||||
}
|
||||
tone_mapped=true;
|
||||
ToneMapJob job={tonemap,frame,narrow};
|
||||
color.run(toneMapBand,&job);
|
||||
narrow->color_primaries=AVCOL_PRI_BT709;narrow->color_trc=AVCOL_TRC_BT709;
|
||||
narrow->colorspace=AVCOL_SPC_BT709;narrow->color_range=AVCOL_RANGE_MPEG;
|
||||
} else for(int plane=0;plane<3;plane++) {
|
||||
int width=plane?(frame->width+1)/2:frame->width,height=plane?(frame->height+1)/2:frame->height;
|
||||
for(int row=0;row<height;row++) {
|
||||
const uint16_t *src=(const uint16_t*)(frame->data[plane]+row*frame->linesize[plane]);
|
||||
uint8_t *dst=narrow->data[plane]+row*narrow->linesize[plane];
|
||||
for(int x=0;x<width;x++){unsigned value=(src[x]+2u)>>2;dst[x]=value>255?255:value;}
|
||||
}
|
||||
}
|
||||
av_frame_unref(frame);av_frame_move_ref(frame,narrow);
|
||||
return true;
|
||||
}
|
||||
bool outputFrame() {
|
||||
// Hardware errors are recovered in software. Software decoders
|
||||
// conceal damage themselves; output their picture as stock decoders do.
|
||||
if(frame->decode_error_flags && hardware_active)return recoverHardware("decoded picture");
|
||||
if(frame->format==AV_PIX_FMT_YUV420P10LE && !narrowTo8Bit())return false;
|
||||
reportBitstreamColor();
|
||||
bool reset=false;
|
||||
handlePortSettingsChange(&reset,frame->width,frame->height,
|
||||
@@ -635,8 +531,7 @@ protected:
|
||||
OMX_ERRORTYPE result=SoftVideoDecoderOMXComponent::internalSetParameter(index,params);
|
||||
rgbaPort();return result;
|
||||
}
|
||||
// a tone mapped picture is BT.709 whatever the container says
|
||||
int getColorAspectPreference() override {return tone_mapped?kPreferBitstream:kPreferContainer;}
|
||||
int getColorAspectPreference() override {return kPreferContainer;}
|
||||
void onQueueFilled(OMX_U32) override {
|
||||
if(failed || mOutputPortSettingsChange!=NONE || drained)return;
|
||||
auto &inputs=getPortQueue(kInputPortIndex);auto &outputs=getPortQueue(kOutputPortIndex);
|
||||
@@ -696,7 +591,7 @@ protected:
|
||||
if(codec)prepend_config=annexB();
|
||||
consumeInput(info);continue;
|
||||
}
|
||||
if(!openDecoder(header->pBuffer+header->nOffset,header->nFilledLen))return;
|
||||
if(!openDecoder())return;
|
||||
if(header->nFilledLen) {
|
||||
AVPacket *packet=av_packet_alloc();
|
||||
if(!packet){fail(AVERROR(ENOMEM),"input packet");return;}
|
||||
@@ -781,14 +676,14 @@ protected:
|
||||
void onReset() override {
|
||||
gpu_allowed=true;
|
||||
av_frame_unref(frame);av_parser_close(parser);parser=nullptr;gpu.clearBuffers();avcodec_free_context(&codec);av_freep(&config);config_size=0;clearHistory();
|
||||
hardware_active=false;prefer_software=false;eos_submitted=false;replay_eos=false;tone_mapped=false;
|
||||
hardware_active=false;prefer_software=false;eos_submitted=false;replay_eos=false;
|
||||
last_output_pts=discard_until=AV_NOPTS_VALUE;
|
||||
held=false;drained=false;failed=false;replace_config=false;
|
||||
SoftVideoDecoderOMXComponent::onReset();
|
||||
surface=false;native_enabled=false;metadata=false;mOutputFormat=OMX_COLOR_FormatYUV420Planar;
|
||||
updatePortDefinitions(true,false);
|
||||
}
|
||||
~FramePortVideo() override {delete tonemap;av_frame_free(&frame);av_frame_free(&narrow);av_parser_close(parser);gpu.clearBuffers();avcodec_free_context(&codec);av_freep(&config);clearHistory();}
|
||||
~FramePortVideo() override {av_frame_free(&frame);av_parser_close(parser);gpu.clearBuffers();avcodec_free_context(&codec);av_freep(&config);clearHistory();}
|
||||
public:
|
||||
FramePortVideo(const DecoderKind &type,const OMX_CALLBACKTYPE *callbacks,OMX_PTR appData,OMX_COMPONENTTYPE **component)
|
||||
:SoftVideoDecoderOMXComponent(type.name,type.role,type.coding,
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// HDR to SDR for the software Main 10 path (frameport_hevc.cpp): every output of the component is 8-bit BT.709
|
||||
// SDR, so PQ (HDR10) and HLG pictures are tone mapped and BT.2020 colours are mapped into BT.709 while the 10-bit
|
||||
// planes are narrowed. Pure C, so the host test (tests/fixtures/src/hdr_tonemap_test.c) can check it.
|
||||
//
|
||||
// Brightness follows ITU-R BT.2390's EETF in the PQ domain: HDR reference white (203 cd/m², BT.2408) becomes SDR
|
||||
// white, darker tones keep their level and highlights up to the content's peak roll off instead of clipping.
|
||||
// Colour is converted per 2x2 block (one chroma sample): BT.2020 R'G'B' to linear light, scaled by the tone curve
|
||||
// at the block's luminance (hue kept), BT.2020 to BT.709 primaries, pulled toward the grey of the same luminance
|
||||
// where it falls outside BT.709 or above white, encoded for a BT.1886 display.
|
||||
// Each pixel's luma keeps its own detail and is moved by its block's change, so luma and chroma agree.
|
||||
#pragma once
|
||||
#include <math.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
enum { FP_TM_NONE = 0, FP_TM_PQ = 1, FP_TM_HLG = 2 };
|
||||
|
||||
#define FP_TM_WHITE 203.0f // cd/m²: HDR reference white, shown as SDR white
|
||||
#define FP_TM_STEPS 4095 // the tone curve table, over sqrt(L / peak)
|
||||
#define FP_TM_ENC 65535 // the display encoding table, over SDR linear light
|
||||
|
||||
struct fp_tonemap {
|
||||
int transfer, full_range, matrix2020, primaries2020;
|
||||
float peak; // cd/m² the content reaches (PQ), 1000 for HLG
|
||||
float luma[1024]; // 10-bit Y' -> SDR Y' (0..1), the per-pixel curve
|
||||
int16_t luma8[1024]; // the same as 8-bit limited-range code values
|
||||
float chan[1024]; // E' (0..1 in 1023 steps) -> linear cd/m² / peak
|
||||
float ratio[FP_TM_STEPS + 1]; // sqrt(L / peak) -> SDR linear (white = 1) per unit of L / peak
|
||||
uint8_t enc[FP_TM_ENC + 1]; // SDR linear (0..1) -> E' * 219 for a BT.1886 display (gamma 2.4)
|
||||
};
|
||||
|
||||
static inline float fp_tm_pq_eotf(float e) { // PQ signal -> cd/m²
|
||||
const float m1 = 2610.0f / 16384, m2 = 2523.0f / 4096 * 128, c1 = 3424.0f / 4096, c2 = 2413.0f / 4096 * 32,
|
||||
c3 = 2392.0f / 4096 * 32;
|
||||
if (e <= 0) return 0;
|
||||
float p = powf(e, 1 / m2), n = p - c1;
|
||||
return n <= 0 ? 0 : 10000 * powf(n / (c2 - c3 * p), 1 / m1);
|
||||
}
|
||||
|
||||
static inline float fp_tm_pq_oetf(float l) { // cd/m² -> PQ signal
|
||||
const float m1 = 2610.0f / 16384, m2 = 2523.0f / 4096 * 128, c1 = 3424.0f / 4096, c2 = 2413.0f / 4096 * 32,
|
||||
c3 = 2392.0f / 4096 * 32;
|
||||
if (l <= 0) return 0;
|
||||
float y = powf(l / 10000, m1);
|
||||
return powf((c1 + c2 * y) / (1 + c3 * y), m2);
|
||||
}
|
||||
|
||||
static inline float fp_tm_hlg_linear(float e) { // HLG signal -> display light / peak (OOTF with system gamma 1.2)
|
||||
const float a = 0.17883277f, b = 0.28466892f, c = 0.55991073f;
|
||||
if (e <= 0) return 0;
|
||||
float s = e <= 0.5f ? e * e / 3 : (expf((e - c) / a) + b) / 12;
|
||||
return powf(s, 1.2f);
|
||||
}
|
||||
|
||||
// BT.2390 EETF: content luminance (cd/m², up to `peak`) -> SDR luminance relative to white (0..1)
|
||||
static inline float fp_tm_eetf(float l, float peak) {
|
||||
if (peak <= FP_TM_WHITE) return fminf(l / FP_TM_WHITE, 1);
|
||||
float w = fp_tm_pq_oetf(peak), e1 = fp_tm_pq_oetf(l) / w, max_lum = fp_tm_pq_oetf(FP_TM_WHITE) / w;
|
||||
float ks = fmaxf(1.5f * max_lum - 0.5f, 0), e2 = e1;
|
||||
if (e1 >= 1) e2 = max_lum;
|
||||
else if (e1 > ks) {
|
||||
float t = (e1 - ks) / (1 - ks), t2 = t * t, t3 = t2 * t;
|
||||
e2 = (2 * t3 - 3 * t2 + 1) * ks + (t3 - 2 * t2 + t) * (1 - ks) + (-2 * t3 + 3 * t2) * max_lum;
|
||||
}
|
||||
return fminf(fp_tm_pq_eotf(e2 * w) / FP_TM_WHITE, 1);
|
||||
}
|
||||
|
||||
static inline float fp_tm_signal_nits(const struct fp_tonemap *tm, float e) {
|
||||
return tm->transfer == FP_TM_PQ ? fp_tm_pq_eotf(e) : tm->peak * fp_tm_hlg_linear(e);
|
||||
}
|
||||
|
||||
/** The peak the tables use: PQ content's MaxCLL or mastering peak (0 = unknown -> 1000, kept within 400..10000),
|
||||
* 1000 cd/m² for HLG. */
|
||||
static inline float fp_tonemap_peak(int transfer, float peak) {
|
||||
return transfer == FP_TM_HLG || !(peak > 0) ? 1000 : fminf(fmaxf(peak, 400), 10000);
|
||||
}
|
||||
|
||||
/** Build the tables. transfer FP_TM_PQ/FP_TM_HLG; peak in cd/m² (PQ: MaxCLL or the mastering display's maximum,
|
||||
* 0 = unknown -> 1000; HLG always uses 1000). */
|
||||
static inline void fp_tonemap_init(struct fp_tonemap *tm, int transfer, int full_range, int matrix2020,
|
||||
int primaries2020, float peak) {
|
||||
tm->transfer = transfer;
|
||||
tm->full_range = full_range;
|
||||
tm->matrix2020 = matrix2020;
|
||||
tm->primaries2020 = primaries2020;
|
||||
tm->peak = fp_tonemap_peak(transfer, peak);
|
||||
for (int i = 0; i < 1024; i++) tm->chan[i] = fp_tm_signal_nits(tm, i / 1023.0f) / tm->peak;
|
||||
for (int code = 0; code < 1024; code++) {
|
||||
float e = full_range ? code / 1023.0f : (code - 64) / 876.0f;
|
||||
e = fminf(fmaxf(e, 0), 1);
|
||||
tm->luma[code] = powf(fp_tm_eetf(fp_tm_signal_nits(tm, e), tm->peak), 1 / 2.4f);
|
||||
tm->luma8[code] = (int16_t)(16 + 219 * tm->luma[code] + 0.5f);
|
||||
}
|
||||
for (int i = 0; i <= FP_TM_STEPS; i++) {
|
||||
float s = (float)i / FP_TM_STEPS, rel = s * s; // L / peak
|
||||
tm->ratio[i] = i ? fp_tm_eetf(rel * tm->peak, tm->peak) / rel : tm->peak / FP_TM_WHITE;
|
||||
}
|
||||
for (int i = 0; i <= FP_TM_ENC; i++) tm->enc[i] = (uint8_t)(219 * powf((float)i / FP_TM_ENC, 1 / 2.4f) + 0.5f);
|
||||
}
|
||||
|
||||
static inline float fp_tm_clamp01(float x) { return x < 0 ? 0 : x > 1 ? 1 : x; }
|
||||
static inline int fp_tm_index(float x01, int steps) { return (int)(fp_tm_clamp01(x01) * steps + 0.5f); }
|
||||
static inline uint8_t fp_tm_u8(int v) { return v < 0 ? 0 : v > 255 ? 255 : (uint8_t)v; }
|
||||
|
||||
#define FP_TM_CHUNK 128 // blocks per pass: the passes alternate plain arithmetic (NEON) and table lookups
|
||||
|
||||
/** Tone map chroma rows [crow0, crow1) of a 10-bit 4:2:0 picture (and the luma rows they cover) into 8-bit 4:2:0
|
||||
* limited range BT.709. Strides are in bytes. Rows are independent, so callers may split a picture into bands. */
|
||||
static inline void fp_tonemap_rows(const struct fp_tonemap *tm, int width, int height, int crow0, int crow1,
|
||||
const uint8_t *const src[3], const int src_stride[3],
|
||||
uint8_t *const dst[3], const int dst_stride[3]) {
|
||||
const float kr = tm->matrix2020 ? 0.2627f : 0.2126f, kb = tm->matrix2020 ? 0.0593f : 0.0722f;
|
||||
const float kg = 1 - kr - kb, inv_kg = 1 / kg, cr_r = 2 - 2 * kr, cb_b = 2 - 2 * kb;
|
||||
const float yscale = tm->full_range ? 1023.0f : 876.0f, yoff = tm->full_range ? 0 : 64;
|
||||
const float cscale = tm->full_range ? 1023.0f : 896.0f;
|
||||
// BT.2020 -> BT.709 primaries in linear light (ITU-R BT.2087)
|
||||
static const float m2020[9] = {1.6605f, -0.5876f, -0.0728f, -0.1246f, 1.1329f, -0.0083f,
|
||||
-0.0182f, -0.1006f, 1.1187f};
|
||||
static const float ident[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
|
||||
const float *m = tm->primaries2020 ? m2020 : ident;
|
||||
const int cw = (width + 1) / 2, pairs = width / 2;
|
||||
int c0[FP_TM_CHUNK], c1[FP_TM_CHUNK], c2[FP_TM_CHUNK], c3[FP_TM_CHUNK], ir[FP_TM_CHUNK], ig[FP_TM_CHUNK],
|
||||
ib[FP_TM_CHUNK], shift[FP_TM_CHUNK];
|
||||
float fr[FP_TM_CHUNK], fg[FP_TM_CHUNK], fb[FP_TM_CHUNK], fk[FP_TM_CHUNK];
|
||||
for (int cy = crow0; cy < crow1; cy++) {
|
||||
const int y0 = cy * 2, y1 = y0 + 1 < height ? y0 + 1 : y0;
|
||||
const uint16_t *l0 = (const uint16_t *)(src[0] + (size_t)y0 * src_stride[0]);
|
||||
const uint16_t *l1 = (const uint16_t *)(src[0] + (size_t)y1 * src_stride[0]);
|
||||
const uint16_t *us = (const uint16_t *)(src[1] + (size_t)cy * src_stride[1]);
|
||||
const uint16_t *vs = (const uint16_t *)(src[2] + (size_t)cy * src_stride[2]);
|
||||
uint8_t *d0 = dst[0] + (size_t)y0 * dst_stride[0], *d1 = dst[0] + (size_t)y1 * dst_stride[0];
|
||||
uint8_t *du = dst[1] + (size_t)cy * dst_stride[1], *dv = dst[2] + (size_t)cy * dst_stride[2];
|
||||
for (int start = 0; start < cw; start += FP_TM_CHUNK) {
|
||||
const int n = cw - start < FP_TM_CHUNK ? cw - start : FP_TM_CHUNK;
|
||||
const int full = pairs - start < n ? (pairs - start > 0 ? pairs - start : 0) : n;
|
||||
// the block's four luma codes (an odd width's last block repeats its column)
|
||||
for (int i = 0; i < full; i++) {
|
||||
const int x = (start + i) * 2;
|
||||
c0[i] = l0[x] & 1023; c1[i] = l0[x + 1] & 1023; c2[i] = l1[x] & 1023; c3[i] = l1[x + 1] & 1023;
|
||||
}
|
||||
for (int i = full; i < n; i++) {
|
||||
const int x = (start + i) * 2;
|
||||
c0[i] = c1[i] = l0[x] & 1023; c2[i] = c3[i] = l1[x] & 1023;
|
||||
}
|
||||
// block R'G'B' (0..1) -> table indices
|
||||
for (int i = 0; i < n; i++) {
|
||||
float ye = ((c0[i] + c1[i] + c2[i] + c3[i]) * 0.25f - yoff) / yscale;
|
||||
float cb = ((us[start + i] & 1023) - 512) / cscale, cr = ((vs[start + i] & 1023) - 512) / cscale;
|
||||
float r = ye + cr_r * cr, b = ye + cb_b * cb, g = (ye - kr * r - kb * b) * inv_kg;
|
||||
ir[i] = fp_tm_index(r, 1023); ig[i] = fp_tm_index(g, 1023); ib[i] = fp_tm_index(b, 1023);
|
||||
}
|
||||
for (int i = 0; i < n; i++) { fr[i] = tm->chan[ir[i]]; fg[i] = tm->chan[ig[i]]; fb[i] = tm->chan[ib[i]]; }
|
||||
// linear light / peak -> the tone curve's index at the block's luminance
|
||||
for (int i = 0; i < n; i++) ir[i] = fp_tm_index(sqrtf(kr * fr[i] + kg * fg[i] + kb * fb[i]), FP_TM_STEPS);
|
||||
for (int i = 0; i < n; i++) fk[i] = tm->ratio[ir[i]];
|
||||
// scaled by the tone curve (hue kept), BT.709 primaries; colours outside BT.709 or above white move
|
||||
// toward the grey of the same luminance until they fit (clipping a channel would darken them)
|
||||
for (int i = 0; i < n; i++) {
|
||||
float r = fr[i], g = fg[i], b = fb[i], k = fk[i];
|
||||
float R = (m[0] * r + m[1] * g + m[2] * b) * k, G = (m[3] * r + m[4] * g + m[5] * b) * k,
|
||||
B = (m[6] * r + m[7] * g + m[8] * b) * k;
|
||||
float y = fp_tm_clamp01(0.2126f * R + 0.7152f * G + 0.0722f * B);
|
||||
float lo = R < G ? (R < B ? R : B) : (G < B ? G : B), hi = R > G ? (R > B ? R : B) : (G > B ? G : B);
|
||||
float t = 1;
|
||||
if (lo < 0) t = y / (y - lo);
|
||||
if (hi > 1 && (1 - y) < t * (hi - y)) t = (1 - y) / (hi - y);
|
||||
ir[i] = fp_tm_index(y + t * (R - y), FP_TM_ENC);
|
||||
ig[i] = fp_tm_index(y + t * (G - y), FP_TM_ENC);
|
||||
ib[i] = fp_tm_index(y + t * (B - y), FP_TM_ENC);
|
||||
}
|
||||
for (int i = 0; i < n; i++) { ir[i] = tm->enc[ir[i]]; ig[i] = tm->enc[ig[i]]; ib[i] = tm->enc[ib[i]]; }
|
||||
// chroma, and how much the colour work moved the block's luma from the per-pixel curve
|
||||
for (int i = 0; i < n; i++) {
|
||||
float er = (float)ir[i], eg = (float)ig[i], eb = (float)ib[i]; // E' * 219
|
||||
float ey = 0.2126f * er + 0.7152f * eg + 0.0722f * eb;
|
||||
du[start + i] = fp_tm_u8((int)(128.5f + (224.0f / 219 / 1.8556f) * (eb - ey)));
|
||||
dv[start + i] = fp_tm_u8((int)(128.5f + (224.0f / 219 / 1.5748f) * (er - ey)));
|
||||
shift[i] = (int)(16.5f + ey);
|
||||
}
|
||||
for (int i = 0; i < n; i++) {
|
||||
c0[i] = tm->luma8[c0[i]]; c1[i] = tm->luma8[c1[i]]; c2[i] = tm->luma8[c2[i]]; c3[i] = tm->luma8[c3[i]];
|
||||
}
|
||||
// each pixel keeps its own curve value, moved with its block
|
||||
for (int i = 0; i < full; i++) {
|
||||
const int x = (start + i) * 2, sh = shift[i] - ((c0[i] + c1[i] + c2[i] + c3[i] + 2) >> 2);
|
||||
d0[x] = fp_tm_u8(c0[i] + sh); d0[x + 1] = fp_tm_u8(c1[i] + sh);
|
||||
d1[x] = fp_tm_u8(c2[i] + sh); d1[x + 1] = fp_tm_u8(c3[i] + sh);
|
||||
}
|
||||
for (int i = full; i < n; i++) {
|
||||
const int x = (start + i) * 2, sh = shift[i] - ((c0[i] + c2[i] + 1) >> 1);
|
||||
d0[x] = fp_tm_u8(c0[i] + sh); d1[x] = fp_tm_u8(c2[i] + sh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// HEVC profile of a stream's parameter sets, to keep streams Iris can't decode
|
||||
// into NV12 on the software path (frameport_hevc.cpp). Pure, so the host test
|
||||
// (tests/fixtures/src/hevc_profile_test.c) can check it.
|
||||
#pragma once
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// general_profile_idc of the first SPS (1 Main, 2 Main 10, 3 Main Still
|
||||
// Picture, 4+ range extensions) in Annex B data or an hvcC record, or -1.
|
||||
// The field precedes any possible emulation-prevention byte.
|
||||
static inline int hevc_profile(const uint8_t *data, size_t size) {
|
||||
if (size >= 2 && data[0] == 1) return data[1] & 0x1f; // hvcC: configurationVersion, then profile space/tier/idc
|
||||
for (size_t i = 0; i + 7 <= size; i++)
|
||||
if (!data[i] && !data[i + 1] && data[i + 2] == 1 && ((data[i + 3] >> 1) & 0x3f) == 33)
|
||||
return data[i + 6] & 0x1f;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Iris decodes 8-bit Main (and Main Still Picture) into NV12; a stream with no
|
||||
// profile found keeps trying it.
|
||||
static inline int hevc_needs_software(int profile) {
|
||||
return profile > 1 && profile != 3;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/* SPDX-License-Identifier: MIT */
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_loader_negotiation.h>
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
extern XrResult xrNegotiateLoaderRuntimeInterface(const XrNegotiateLoaderInfo *, XrNegotiateRuntimeRequest *);
|
||||
extern XrResult xrInitializeLoaderKHR(const XrLoaderInitInfoBaseHeaderKHR *);
|
||||
int main(int argc, char **argv) {
|
||||
XrNegotiateLoaderInfo li = {XR_LOADER_INTERFACE_STRUCT_LOADER_INFO, XR_LOADER_INFO_STRUCT_VERSION,
|
||||
sizeof(li), 1, 1, XR_MAKE_VERSION(1,0,0), XR_CURRENT_API_VERSION};
|
||||
XrNegotiateRuntimeRequest r = {XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST, XR_RUNTIME_INFO_STRUCT_VERSION, sizeof(r)};
|
||||
assert(xrNegotiateLoaderRuntimeInterface(NULL, &r) < 0);
|
||||
assert(xrNegotiateLoaderRuntimeInterface(&li, NULL) < 0);
|
||||
li.minInterfaceVersion = 2;
|
||||
assert(xrNegotiateLoaderRuntimeInterface(&li, &r) < 0);
|
||||
li.minInterfaceVersion = 1;
|
||||
r.structSize--;
|
||||
assert(xrNegotiateLoaderRuntimeInterface(&li, &r) < 0);
|
||||
r.structSize++;
|
||||
li.minApiVersion = XR_MAKE_VERSION(2,0,0);
|
||||
assert(xrNegotiateLoaderRuntimeInterface(&li, &r) < 0);
|
||||
li.minApiVersion = XR_MAKE_VERSION(1,0,0);
|
||||
li.minApiVersion = XR_MAKE_VERSION(1,0,1);
|
||||
li.maxApiVersion = XR_MAKE_VERSION(1,0,0);
|
||||
assert(xrNegotiateLoaderRuntimeInterface(&li, &r) < 0);
|
||||
li.minApiVersion = XR_MAKE_VERSION(1,0,0);
|
||||
li.maxApiVersion = XR_CURRENT_API_VERSION;
|
||||
XrResult result = xrNegotiateLoaderRuntimeInterface(&li, &r);
|
||||
if (argc > 1 && !strcmp(argv[1], "missing")) {
|
||||
assert(result == XR_ERROR_INITIALIZATION_FAILED);
|
||||
return 0;
|
||||
}
|
||||
assert(result == XR_SUCCESS && r.getInstanceProcAddr && r.runtimeInterfaceVersion == 1);
|
||||
assert(r.runtimeApiVersion <= li.maxApiVersion && r.runtimeApiVersion >= li.minApiVersion);
|
||||
XrLoaderInitInfoBaseHeaderKHR info = {XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR, (void *)0x1234};
|
||||
assert(xrInitializeLoaderKHR(&info) == XR_SUCCESS);
|
||||
info.next = NULL;
|
||||
assert(xrInitializeLoaderKHR(&info) == XR_ERROR_RUNTIME_FAILURE);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* SPDX-License-Identifier: MIT */
|
||||
#include <openxr/openxr.h>
|
||||
#include <string.h>
|
||||
static XrResult init(const XrLoaderInitInfoBaseHeaderKHR *info) {
|
||||
return info->type == XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR && info->next == (void *)0x1234 ?
|
||||
XR_SUCCESS : XR_ERROR_RUNTIME_FAILURE;
|
||||
}
|
||||
XrResult xrGetInstanceProcAddr(XrInstance instance, const char *name, PFN_xrVoidFunction *fn) {
|
||||
(void)instance;
|
||||
if (!strcmp(name, "xrInitializeLoaderKHR")) {
|
||||
*fn = (PFN_xrVoidFunction)init;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
*fn = NULL;
|
||||
return XR_ERROR_FUNCTION_UNSUPPORTED;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
* Runtime endpoint for an application's embedded Khronos loader.
|
||||
* Only the application's loader paths change: the inner loader still finds SteamVR.
|
||||
*/
|
||||
#include <openxr/openxr.h>
|
||||
#include <openxr/openxr_loader_negotiation.h>
|
||||
#include <dlfcn.h>
|
||||
#include <pthread.h>
|
||||
#include <string.h>
|
||||
#ifdef __ANDROID__
|
||||
#include <android/log.h>
|
||||
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "fp_xrruntime", __VA_ARGS__)
|
||||
#else
|
||||
#include <stdio.h>
|
||||
#define LOG(...) do { fprintf(stderr, "fp_xrruntime: "); fprintf(stderr, __VA_ARGS__); fputc('\n', stderr); } while (0)
|
||||
#endif
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
static pthread_once_t once = PTHREAD_ONCE_INIT;
|
||||
static PFN_xrGetInstanceProcAddr chain;
|
||||
|
||||
static void resolve(void) {
|
||||
/* RTLD_LOCAL and handle-specific dlsym prevent binding to the game's own xr* exports. */
|
||||
void *bridge = dlopen("libopenxr_loader_generic.so", RTLD_NOW | RTLD_LOCAL);
|
||||
if (bridge) chain = (PFN_xrGetInstanceProcAddr)dlsym(bridge, "xrGetInstanceProcAddr");
|
||||
if (chain) LOG("chain resolved: FrameBridge");
|
||||
else LOG("chain resolution failed: %s", dlerror());
|
||||
}
|
||||
|
||||
EXPORT XrResult XRAPI_CALL xrInitializeLoaderKHR(const XrLoaderInitInfoBaseHeaderKHR *info) {
|
||||
pthread_once(&once, resolve);
|
||||
if (!chain || !info) return XR_ERROR_INITIALIZATION_FAILED;
|
||||
PFN_xrVoidFunction proc = NULL;
|
||||
XrResult result = chain(XR_NULL_HANDLE, "xrInitializeLoaderKHR", &proc);
|
||||
if (XR_SUCCEEDED(result) && proc)
|
||||
result = ((PFN_xrInitializeLoaderKHR)proc)(info);
|
||||
else if (XR_SUCCEEDED(result)) result = XR_ERROR_INITIALIZATION_FAILED;
|
||||
LOG("xrInitializeLoaderKHR forwarded to FrameBridge: %d", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
EXPORT XrResult XRAPI_CALL xrNegotiateLoaderRuntimeInterface(
|
||||
const XrNegotiateLoaderInfo *loader, XrNegotiateRuntimeRequest *runtime) {
|
||||
if (!loader || !runtime ||
|
||||
loader->structType != XR_LOADER_INTERFACE_STRUCT_LOADER_INFO ||
|
||||
loader->structVersion != XR_LOADER_INFO_STRUCT_VERSION || loader->structSize != sizeof(*loader) ||
|
||||
runtime->structType != XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST ||
|
||||
runtime->structVersion != XR_RUNTIME_INFO_STRUCT_VERSION || runtime->structSize != sizeof(*runtime) ||
|
||||
loader->minInterfaceVersion > 1 || loader->maxInterfaceVersion < 1 ||
|
||||
loader->minApiVersion > loader->maxApiVersion ||
|
||||
loader->minApiVersion > XR_CURRENT_API_VERSION || loader->maxApiVersion < XR_MAKE_VERSION(1, 0, 0))
|
||||
return XR_ERROR_INITIALIZATION_FAILED;
|
||||
pthread_once(&once, resolve);
|
||||
if (!chain) return XR_ERROR_INITIALIZATION_FAILED;
|
||||
runtime->runtimeInterfaceVersion = 1;
|
||||
runtime->runtimeApiVersion = loader->maxApiVersion < XR_CURRENT_API_VERSION ?
|
||||
loader->maxApiVersion : XR_CURRENT_API_VERSION;
|
||||
runtime->getInstanceProcAddr = chain;
|
||||
LOG("negotiated runtime interface 1: FrameBridge");
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
@@ -61,14 +61,6 @@ def from_data() -> dict:
|
||||
texts[label] = ""
|
||||
for _, title in GROUPS: # the Game settings dialog
|
||||
texts[title] = ""
|
||||
from frameport.install.rooms import REFINE_DEFAULTS, REFINE_GROUPS
|
||||
|
||||
for meta in REFINE_DEFAULTS.values(): # the Room tab's scan clean-up settings
|
||||
texts[meta["label"]] = ""
|
||||
for _, label in meta.get("choices", ()):
|
||||
texts[label] = ""
|
||||
for _, title in REFINE_GROUPS:
|
||||
texts[title] = ""
|
||||
for ui in [*UI.values(), *FOVEATION_UI.values()]:
|
||||
for t in (ui.get("label"), ui.get("help")):
|
||||
if t:
|
||||
|
||||
@@ -76,8 +76,8 @@ def images() -> dict[str, bytes]:
|
||||
"every wall and your furniture.", seen=n, accent=SECONDARY)
|
||||
for n in range(DIRECTIONS + 1): # everything seen before the minimum scan time: the ring is the time left
|
||||
out[f"finish_{n:02d}"] = panel("Almost done", "Walls, ceiling and floor are covered.\n"
|
||||
"Look closely at your furniture\nuntil the ring fills.", seen=n, accent=OK,
|
||||
ring=OK, label=f"{(DIRECTIONS - n) * 5}s")
|
||||
"Look closely at your furniture\nuntil the ring fills.", seen=n, accent=OK, ring=OK,
|
||||
label=f"{(DIRECTIONS - n) * 5}s")
|
||||
data = {}
|
||||
for key, img in out.items():
|
||||
buf = io.BytesIO()
|
||||
|
||||
@@ -8,7 +8,6 @@ Quest builds are "installed" (the 8 largest).
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import threading
|
||||
@@ -31,98 +30,61 @@ def frame_state() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def fake_room_mesh(room, cell: float = 0.2, seed: int = 7):
|
||||
def fake_room_mesh(room, cell: float = 0.25, seed: int = 7):
|
||||
"""A made-up scan of `room`: its floor, walls and furniture as noisy triangle grids, wound like a real scan's
|
||||
(counter-clockwise seen from inside the room). Like a real scan it has holes where the headset sees nothing:
|
||||
the floor under furniture that stands on it, and window glass."""
|
||||
from frameport.mr.room import Mesh, plane_corners
|
||||
(counter-clockwise seen from inside the room)."""
|
||||
from frameport.mr.room import Mesh
|
||||
from frameport.mr.view3d import box_corners
|
||||
|
||||
rnd = random.Random(seed)
|
||||
verts, idx = [], []
|
||||
|
||||
def surface(o, u, v, noise=0.012, keep=None): # o + s*u + t*v, normal = u x v; keep(point) False = a hole
|
||||
def surface(o, u, v): # o + s*u + t*v, normal = u x v
|
||||
lu = max(1, round((sum(c * c for c in u) ** 0.5) / cell))
|
||||
lv = max(1, round((sum(c * c for c in v) ** 0.5) / cell))
|
||||
base = len(verts)
|
||||
for i in range(lv + 1):
|
||||
for j in range(lu + 1):
|
||||
n = noise if 0 < i < lv and 0 < j < lu else 0.0
|
||||
n = 0.015 if 0 < i < lv and 0 < j < lu else 0.0
|
||||
verts.append(tuple(o[k] + u[k] * j / lu + v[k] * i / lv + rnd.uniform(-n, n) for k in range(3)))
|
||||
for i in range(lv):
|
||||
for j in range(lu):
|
||||
mid = tuple(o[k] + u[k] * (j + 0.5) / lu + v[k] * (i + 0.5) / lv for k in range(3))
|
||||
if keep is not None and not keep(mid):
|
||||
continue
|
||||
a = base + i * (lu + 1) + j
|
||||
b, c, d = a + 1, a + lu + 1, a + lu + 2
|
||||
idx.extend((a, b, d, a, d, c))
|
||||
|
||||
def inside(vol, x, z): # (x, z) on the box's footprint
|
||||
a = math.radians(vol.yaw_deg)
|
||||
dx, dz = x - vol.cx, z - vol.cz
|
||||
lx, lz = math.cos(a) * dx - math.sin(a) * dz, math.sin(a) * dx + math.cos(a) * dz
|
||||
return abs(lx) < vol.w / 2 and abs(lz) < vol.d / 2
|
||||
|
||||
y0 = room.floor
|
||||
standing = [v for v in room.volumes if v.top_y - v.h - y0 < 0.05 and v.h > 0.3] # on the floor, no gap below
|
||||
windows = [plane_corners(p) for p in room.planes if p.label == "WINDOW_FRAME"]
|
||||
|
||||
def floor_seen(p):
|
||||
return not any(inside(v, p[0], p[2]) for v in standing)
|
||||
|
||||
def wall_seen(p): # not through a window's glass
|
||||
for c in windows:
|
||||
xs, ys, zs = [q[0] for q in c], [q[1] for q in c], [q[2] for q in c]
|
||||
if (min(ys) + 0.05 < p[1] < max(ys) - 0.05 and min(xs) - 0.05 <= p[0] <= max(xs) + 0.05
|
||||
and min(zs) - 0.05 <= p[2] <= max(zs) + 0.05):
|
||||
return False
|
||||
return True
|
||||
|
||||
xs, zs = [x for x, _ in room.outline], [z for _, z in room.outline]
|
||||
surface((min(xs), y0, max(zs)), (max(xs) - min(xs), 0, 0), (0, 0, min(zs) - max(zs)), 0.008,
|
||||
floor_seen) # floor, facing up
|
||||
pts = room.outline
|
||||
xs, zs = [x for x, _ in pts], [z for _, z in pts]
|
||||
surface((min(xs), y0, max(zs)), (max(xs) - min(xs), 0, 0), (0, 0, min(zs) - max(zs))) # floor, facing up
|
||||
for w in room.walls:
|
||||
if w.label == "INVISIBLE_WALL_FACE":
|
||||
continue
|
||||
surface((w.x0, w.y0, w.z0), (w.x1 - w.x0, 0, w.z1 - w.z0), (0, w.y1 - w.y0, 0), 0.012,
|
||||
wall_seen) # facing into the room
|
||||
surface((w.x0, w.y0, w.z0), (w.x1 - w.x0, 0, w.z1 - w.z0), (0, w.y1 - w.y0, 0)) # facing into the room
|
||||
for vol in room.volumes:
|
||||
c = box_corners(vol.cx, vol.cz, vol.yaw_deg, vol.w, vol.d)
|
||||
top, bottom = vol.top_y, vol.top_y - vol.h
|
||||
soft = 0.03 if vol.label in ("BED", "COUCH") else 0.015 # (a duvet isn't flat)
|
||||
surface((c[0][0], top, c[0][1]), (c[3][0] - c[0][0], 0, c[3][1] - c[0][1]),
|
||||
(c[1][0] - c[0][0], 0, c[1][1] - c[0][1]), soft) # top, facing up
|
||||
(c[1][0] - c[0][0], 0, c[1][1] - c[0][1])) # top, facing up
|
||||
for i in range(4):
|
||||
a, b = c[i], c[(i + 1) % 4]
|
||||
surface((a[0], bottom, a[1]), (0, top - bottom, 0), (b[0] - a[0], 0, b[1] - a[1]), 0.012) # facing out
|
||||
surface((a[0], bottom, a[1]), (0, top - bottom, 0), (b[0] - a[0], 0, b[1] - a[1])) # facing out
|
||||
return Mesh(vertices=verts, indices=idx)
|
||||
|
||||
|
||||
def fake_room():
|
||||
"""The pretend Frame's scanned room, made up from scratch (no real scan): a 3.6 x 3.2 m bedroom with a bed and a
|
||||
nightstand under a wall-mounted TV, a closet, a desk with two monitors under the window, a door, a lamp and a
|
||||
plant. Walls: 0 = the bed's (-Z, forward), 1 = right (+X), 2 = behind (+Z), 3 = the desk's and the door's (-X);
|
||||
the 3D view looks at walls 0 and 3."""
|
||||
from frameport.mr.room import Volume, box_room, plane_on_wall
|
||||
"""The pretend Frame's scanned room: a 5 x 4 m living room with a couch, a table, a TV and shelves."""
|
||||
from frameport.mr.room import Volume, box_room
|
||||
|
||||
room = box_room(3.6, 3.2, 2.5)
|
||||
room = box_room(5, 4, 2.6)
|
||||
room.source, room.universe, room.time = "scan", 7, int(SHOTS_AT)
|
||||
room.walls = [type(w)(f"{0xa0 + i:032x}", w.x0, w.z0, w.x1, w.z1, w.y0, w.y1, w.label)
|
||||
for i, w in enumerate(room.walls)] # (fixed ids: the same room in every render)
|
||||
room.uuid, room.floor_uuid, room.ceiling_uuid = (f"{0xf0 + i:032x}" for i in range(3))
|
||||
room.walls[1].label = "INVISIBLE_WALL_FACE" # an open side (a doorway the scan closed)
|
||||
vid = iter(f"{i:032x}" for i in range(1, 99))
|
||||
room.volumes = [Volume(next(vid), "BED", 0.6, -0.58, 0.55, 0, 1.45, 2.05, 0.55),
|
||||
Volume(next(vid), "SCREEN", 0.6, -1.55, 1.78, 0, 1.12, 0.07, 0.66), # TV above the bed
|
||||
Volume(next(vid), "TABLE", -1.44, -0.25, 0.75, 90, 1.4, 0.7, 0.75), # desk
|
||||
Volume(next(vid), "SCREEN", -1.6, -0.56, 1.2, 82, 0.6, 0.06, 0.36), # monitors on it
|
||||
Volume(next(vid), "SCREEN", -1.6, 0.06, 1.2, 98, 0.6, 0.06, 0.36),
|
||||
Volume(next(vid), "STORAGE", -1.25, -1.3, 2.1, 0, 1.0, 0.6, 2.1), # closet
|
||||
Volume(next(vid), "TABLE", -0.42, -1.38, 0.52, 0, 0.45, 0.4, 0.52), # nightstand
|
||||
Volume(next(vid), "LAMP", -0.42, -1.42, 0.9, 0, 0.2, 0.2, 0.38),
|
||||
Volume(next(vid), "PLANT", 1.56, -1.37, 0.95, 0, 0.36, 0.36, 0.95)] # in the corner
|
||||
room.planes = [plane_on_wall(room, 3, 0.17, 0.0, 0.85, 2.05, "DOOR_FRAME", f"{0xd0:032x}"),
|
||||
plane_on_wall(room, 3, 0.58, 1.22, 1.05, 1.05, "WINDOW_FRAME", f"{0xd1:032x}")]
|
||||
room.volumes = [Volume(next(vid), "COUCH", -1.2, 1.3, 0.85, 0, 2.2, 0.9, 0.85),
|
||||
Volume(next(vid), "TABLE", -0.9, 0.1, 0.45, 0, 1.1, 0.6, 0.45),
|
||||
Volume(next(vid), "SCREEN", -1.0, -1.85, 1.5, 0, 1.4, 0.12, 0.8),
|
||||
Volume(next(vid), "STORAGE", 1.9, -1.4, 1.8, 90, 1.2, 0.4, 1.8),
|
||||
Volume(next(vid), "TABLE", 1.6, 1.2, 0.75, 20, 1.2, 0.7, 0.75)]
|
||||
return room
|
||||
|
||||
|
||||
@@ -137,7 +99,6 @@ class FakeRoom:
|
||||
self.home = home
|
||||
self.scan: dict = {"state": "none"}
|
||||
self.polls = 0
|
||||
self.mesh_triangles = 0
|
||||
if ROOM_DIR:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -145,21 +106,14 @@ class FakeRoom:
|
||||
src = Path(ROOM_DIR)
|
||||
self.room = parse((src / "room.txt").read_text())
|
||||
self.mesh = (src / self.room.mesh.filename).read_bytes() if self.room.mesh else None
|
||||
if self.room.mesh:
|
||||
self.mesh_triangles = self.room.mesh.index_count // 3
|
||||
else:
|
||||
from frameport.mr.room import Mesh
|
||||
|
||||
self.room = fake_room()
|
||||
m = fake_room_mesh(self.room)
|
||||
self.room.mesh = Mesh(uuid=f"{0xe0:032x}", filename="room-mesh.bin", vertex_count=len(m.vertices),
|
||||
index_count=len(m.indices))
|
||||
self.room.mesh = Mesh(filename="room-mesh.bin", vertex_count=len(m.vertices), index_count=len(m.indices))
|
||||
self.mesh = write_mesh(m)
|
||||
self.mesh_triangles = len(m.indices) // 3
|
||||
self.text = dumps(self.room)
|
||||
self.refine: dict = {} # the saved clean-up settings (non-defaults)
|
||||
self.edited = False
|
||||
self.last_report = self.report()
|
||||
self._write()
|
||||
|
||||
def _folder(self):
|
||||
@@ -173,72 +127,15 @@ class FakeRoom:
|
||||
with open(os.path.join(self._folder(), self.room.mesh.filename), "wb") as f:
|
||||
f.write(self.mesh)
|
||||
|
||||
# the scan clean-up (scan.json, docs/mr/ROOM_FORMAT.md): what the label rules decided for each of the scanner's
|
||||
# boxes (in its order; to = None: removed), and the door / closet suggestions (never applied by the scanner)
|
||||
LABELS = [
|
||||
{"from": "BED", "to": "BED", "reason": "flat top at 0.55 m, 2.05 x 1.45 m against a wall: a bed",
|
||||
"box": {"w": 1.45, "d": 2.05, "h": 0.55}},
|
||||
{"from": "TABLE", "to": "SCREEN", "reason": "7 cm thin, flat on the wall, 1.1 m above the floor: a screen",
|
||||
"box": {"w": 1.12, "d": 0.07, "h": 0.66}},
|
||||
{"from": "TABLE", "to": "TABLE", "reason": "top at 0.75 m by a wall: a desk",
|
||||
"box": {"w": 1.4, "d": 0.7, "h": 0.75}},
|
||||
{"from": "OTHER", "to": "SCREEN", "reason": "6 cm thin and upright, standing on a table: a screen",
|
||||
"box": {"w": 0.6, "d": 0.06, "h": 0.36}},
|
||||
{"from": "SCREEN", "to": "SCREEN", "reason": "upright on a table, facing the room: a screen",
|
||||
"box": {"w": 0.6, "d": 0.06, "h": 0.36}},
|
||||
{"from": "STORAGE", "to": "STORAGE", "reason": "2.1 m high and 0.6 m deep in a corner: storage",
|
||||
"box": {"w": 1.0, "d": 0.6, "h": 2.1}},
|
||||
{"from": "STORAGE", "to": "TABLE", "reason": "top at 0.52 m, 0.45 m wide by the bed: a nightstand",
|
||||
"box": {"w": 0.45, "d": 0.4, "h": 0.52}},
|
||||
{"from": "LAMP", "to": "LAMP", "reason": "0.38 m high, standing on a table: a lamp",
|
||||
"box": {"w": 0.2, "d": 0.2, "h": 0.38}},
|
||||
{"from": "PLANT", "to": "PLANT", "reason": "0.95 m high and round from above: a plant",
|
||||
"box": {"w": 0.36, "d": 0.36, "h": 0.95}},
|
||||
{"from": "OTHER", "to": None, "reason": "0.5 m behind the wall: outside the room",
|
||||
"box": {"w": 0.6, "d": 0.4, "h": 0.8}},
|
||||
{"from": "TABLE", "to": None, "reason": "only 3 cm above the floor: a rug, not furniture",
|
||||
"box": {"w": 1.6, "d": 1.1, "h": 0.03}},
|
||||
{"from": "STORAGE", "to": None, "suggest": "door", "reason": "2.0 m high, 10 cm thin, flat on the wall",
|
||||
"box": {"w": 0.8, "d": 0.1, "h": 2.0}}]
|
||||
SUGGESTIONS = [{"kind": "door", "box": {"cx": 1.75, "cz": 1.0, "top_y": 2.0, "yaw_deg": 90, "w": 0.8, "d": 0.1,
|
||||
"h": 2.0}, "wall": 1, "along": 0.81,
|
||||
"reason": "A tall, thin slab on the wall, 0.8 m wide: a door, or a closet's front"}]
|
||||
|
||||
def report(self, settings: dict | None = None) -> dict:
|
||||
"""scan.json's report (its keys: docs/mr/ROOM_FORMAT.md), consistent with the room's own mesh: `kept` is
|
||||
the mesh's triangle count with the recommended settings, other settings remove more or less."""
|
||||
s = {**{"walked": 0.4, "smooth": 8, "island": 0.2, "walls": "panels"}, **(settings or {})}
|
||||
|
||||
def removed(s):
|
||||
return {"walked": int(1900 * s["walked"]), "small_pieces": int(3600 * s["island"]),
|
||||
"near_walls": 140 if s["walls"] == "panels" else 40}
|
||||
|
||||
now, default = removed(s), removed({"walked": 0.4, "island": 0.2, "walls": "panels"})
|
||||
fixed = {"outside": 1260, "thin_pieces": 184, "erased": 0}
|
||||
kept = self.mesh_triangles - (sum(now.values()) - sum(default.values()))
|
||||
return {"triangles": self.mesh_triangles + sum(default.values()) + sum(fixed.values()), "kept": kept,
|
||||
"structural": int(self.mesh_triangles * 0.62) if s["walls"] != "off" else 0, **now, **fixed,
|
||||
"tops": 410, "panel_triangles": 64 if s["walls"] == "panels" else 0, "truncated": 0,
|
||||
"walked_skipped": False, "floor_y": 0.0, "ceiling_y": 2.5,
|
||||
"boxes": sum(1 for lb in self.LABELS if lb["to"]),
|
||||
"relabelled": sum(1 for lb in self.LABELS if lb["to"] and lb["to"] != lb["from"]),
|
||||
"removed_boxes": sum(1 for lb in self.LABELS if not lb["to"]),
|
||||
"suggestions": len(self.SUGGESTIONS), "plane_shifts": [{"kind": "wall", "wall": 3, "shift": 0.02}]}
|
||||
|
||||
def status(self) -> dict:
|
||||
from frameport.mr.room import summary
|
||||
|
||||
if self.text is None:
|
||||
return {"rooms": [], "universe": self.room.universe, "room": None, "room_text": None, "aligned": False,
|
||||
"default_circle": False, "scanner_installed": True, "refine": self.refine, "labels": [],
|
||||
"suggestions": [], "report": None}
|
||||
scanned = self.room.source == "scan"
|
||||
"default_circle": False, "scanner_installed": True}
|
||||
return {"rooms": [], "universe": self.room.universe, "aligned": True, "default_circle": False,
|
||||
"scanner_installed": True, "room_text": self.text,
|
||||
"room": {"universe": self.room.universe, "source": self.room.source, "summary": summary(self.room)},
|
||||
"refine": dict(self.refine), "report": self.last_report if scanned else None,
|
||||
"labels": self.LABELS if scanned else [], "suggestions": self.SUGGESTIONS if scanned else [],
|
||||
"raw": scanned, "edited": self.edited}
|
||||
"room": {"universe": self.room.universe, "source": self.room.source, "summary": summary(self.room)}}
|
||||
|
||||
def agent(self, command, **args):
|
||||
if command == "room_status":
|
||||
@@ -263,31 +160,6 @@ class FakeRoom:
|
||||
{"state": "recording", "directions": 7, "looked_up": 6, "looked_down": 2}]
|
||||
self.scan.update(steps[min(self.polls - 1, len(steps) - 1)])
|
||||
return dict(self.scan)
|
||||
if command == "room_refine": # a clean-up takes a moment; the room's mesh stays the made-up one
|
||||
from frameport.mr.room import dumps
|
||||
|
||||
time.sleep(5)
|
||||
if args.get("settings") is not None:
|
||||
self.refine = dict(args["settings"])
|
||||
if not args.get("keep_edits", True):
|
||||
self.edited = False
|
||||
self.last_report = self.report(self.refine)
|
||||
self.room.time += 1
|
||||
self.text = dumps(self.room)
|
||||
self._write()
|
||||
return {"summary": {"refine": self.last_report}, **self.status()}
|
||||
if command == "room_put": # the box editor's Save
|
||||
from pathlib import Path
|
||||
|
||||
from frameport.mr.room import dumps, parse
|
||||
path = Path(args["path"])
|
||||
self.room = parse(path.read_text())
|
||||
if self.room.mesh:
|
||||
self.mesh = (path.parent / self.room.mesh.filename).read_bytes()
|
||||
self.text = dumps(self.room)
|
||||
self.edited = bool(args.get("edited"))
|
||||
self._write()
|
||||
return {"room": {"universe": self.room.universe}}
|
||||
if command == "room_capture_stop":
|
||||
if self.scan.get("state") in ("waiting", "recording", "building"):
|
||||
self.scan.update(state="failed", error="cancelled", cancelled=True)
|
||||
@@ -322,29 +194,6 @@ class FakeFS:
|
||||
self.shots = self._screenshots()
|
||||
self.room = FakeRoom(self.home)
|
||||
|
||||
def run(self, cmd: str):
|
||||
"""Only the room uploads' folder (rooms._upload_dir: mkdir -p / rm -rf below the room store)."""
|
||||
import shlex
|
||||
import shutil
|
||||
|
||||
rooms_dir = os.path.join(self.home, ".local/share/frameport/rooms")
|
||||
words = shlex.split(cmd)
|
||||
path = words[-1] if words else ""
|
||||
if words[:2] == ["mkdir", "-p"] and path.startswith(rooms_dir):
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return 0, "", ""
|
||||
if words[:3] == ["rm", "-rf", "--"] and path.startswith(rooms_dir + "/.incoming-"):
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
return 0, "", ""
|
||||
raise AttributeError(f"fake Frame: no shell for {cmd!r}")
|
||||
|
||||
def put(self, local, remote, **kwargs):
|
||||
import shutil
|
||||
|
||||
if not str(remote).startswith(os.path.join(self.home, ".local/share/frameport/rooms/.incoming-")):
|
||||
raise AttributeError("fake Frame: uploads only for rooms")
|
||||
shutil.copyfile(local, remote)
|
||||
|
||||
def _screenshots(self) -> list[dict]:
|
||||
"""Sample Steam screenshots for the Screenshots tab, two days, a few games + SteamVR. They are the games' own
|
||||
in-game store screenshots (shot_*.jpg from the store details), never cover art: games installed on the
|
||||
@@ -749,8 +598,6 @@ def start_fake_scan(app: FramePortApp) -> None:
|
||||
from frameport.ui import room_state
|
||||
|
||||
app.go("room")
|
||||
if app.room_view is not None and app.room_view.root is not None: # (back to the top after the editing cards)
|
||||
app.page.run_task(app.room_view.root.scroll_to, offset=0, duration=0)
|
||||
room_state.operate(app, "capture")
|
||||
time.sleep(5) # rooms.scan polls every 2 s
|
||||
|
||||
@@ -762,44 +609,6 @@ def cancel_fake_scan(app: FramePortApp) -> None:
|
||||
time.sleep(3)
|
||||
|
||||
|
||||
def show_room_editing(app: FramePortApp, which: str) -> None:
|
||||
"""The Room tab's editing cards (scrolled into view): cleanup (Clean-up with the key settings), more (+ More
|
||||
settings), box (a box selected in the editor), doors (a door added on a wall), suggestion (the Labels card's door
|
||||
suggestion taken), applied (other settings applied: the job runs, then the new counts)."""
|
||||
view = app.room_view
|
||||
if view is None or view.root is None:
|
||||
open_room(app)
|
||||
view = app.room_view
|
||||
if which == "cleanup":
|
||||
if view.cleanup.more.visible:
|
||||
view.cleanup.toggle_more()
|
||||
elif which == "more":
|
||||
if not view.cleanup.more.visible:
|
||||
view.cleanup.toggle_more()
|
||||
elif which == "box":
|
||||
if view.cleanup.more.visible:
|
||||
view.cleanup.toggle_more()
|
||||
view.editor.select(("box", 1))
|
||||
elif which == "doors":
|
||||
view.editor._add_plane("DOOR_FRAME")
|
||||
view.editor._plane_value("along", 0.3)
|
||||
elif which == "suggestion":
|
||||
view.editor.accept(app.target.frame.room.SUGGESTIONS[0], "door")
|
||||
elif which == "applied":
|
||||
view.editor.discard()
|
||||
view.cleanup.set("walls", "snap")
|
||||
view.cleanup._slid("smooth", 4)
|
||||
view.cleanup.apply()
|
||||
time.sleep(0.6)
|
||||
app.page.run_task(view.show_editing)
|
||||
time.sleep(0.6)
|
||||
return # (the running clean-up: the next step shows its result)
|
||||
elif which == "result":
|
||||
time.sleep(6)
|
||||
app.page.run_task(view.show_editing)
|
||||
time.sleep(1.5)
|
||||
|
||||
|
||||
def show_room_state(app: FramePortApp, which: str) -> None:
|
||||
"""none: the Room tab without a saved room; disconnected: its not-connected page; frame: the Frame page's room card
|
||||
(scrolled into view) with the room back."""
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Write the site demo's room (site/public/demo/room.json) for the Room tab of the clickable app on the project page:
|
||||
the showcase's made-up bedroom (fakes.fake_room, no real scan), its scanned surfaces, the label rules' decisions and
|
||||
the door suggestion, plus what a raw scan has before the clean-up (pieces floating in the air, the sweep of an arm
|
||||
held up while looking at the ceiling, surfaces seen through the walls, and the boxes the label rules remove).
|
||||
|
||||
uv run python scripts/showcase/site_room.py
|
||||
|
||||
Coordinates are centimetres (integers), metres in the JSON's box fields like room.txt; y is up, the play area's
|
||||
middle is the origin, -z is forward.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from fakes import FakeRoom, fake_room, fake_room_mesh # noqa: E402
|
||||
|
||||
from frameport.mr.room import plane_corners # noqa: E402
|
||||
|
||||
OUT = ROOT / "site/public/demo/room.json"
|
||||
CELL = 0.25 # coarser than the app's renders: the page draws every edge
|
||||
|
||||
|
||||
def cm(p):
|
||||
return [round(c * 100) for c in p]
|
||||
|
||||
|
||||
def junk(seed: int = 11):
|
||||
"""Triangles a raw scan has and the clean-up removes, as (kind, [vertex], [index])."""
|
||||
rnd = random.Random(seed)
|
||||
pieces = []
|
||||
|
||||
def blob(cx, cy, cz, r, n=7): # a small crumpled patch in the air
|
||||
verts = [(cx + rnd.uniform(-r, r), cy + rnd.uniform(-r, r) * 0.6, cz + rnd.uniform(-r, r)) for _ in range(n)]
|
||||
idx = []
|
||||
for i in range(1, n - 1):
|
||||
idx += [0, i, i + 1]
|
||||
return verts, idx
|
||||
|
||||
for c in [(0.2, 1.7, 0.3), (-0.5, 2.1, 0.8), (1.0, 1.1, 1.1), (-0.9, 1.6, -0.4), (0.6, 2.3, -0.6), (1.3, 0.4, 0.9)]:
|
||||
pieces.append(("speck", *blob(*c, r=0.09)))
|
||||
# an arm and controller held up while looking at the ceiling: a thin tube swept from 1.5 m to the ceiling
|
||||
a, b, r, seg, ring = (0.85, 1.5, 0.35), (1.2, 2.48, 0.5), 0.035, 10, 8
|
||||
verts, idx = [], []
|
||||
for i in range(seg + 1):
|
||||
t = i / seg
|
||||
c = [a[k] + (b[k] - a[k]) * t for k in range(3)]
|
||||
for j in range(ring):
|
||||
th = 2 * math.pi * j / ring
|
||||
verts.append((c[0] + r * math.cos(th), c[1] + rnd.uniform(-0.01, 0.01), c[2] + r * math.sin(th)))
|
||||
for i in range(seg):
|
||||
for j in range(ring):
|
||||
p, q = i * ring + j, i * ring + (j + 1) % ring
|
||||
idx += [p, q, q + ring, p, q + ring, p + ring]
|
||||
pieces.append(("arm", verts, idx))
|
||||
# seen through the glass and a door gap: surfaces outside the room
|
||||
for cx, cz, w in [(2.35, 0.3, 0.7), (-2.3, 0.9, 0.5)]:
|
||||
verts = [(cx + rnd.uniform(-0.03, 0.03), 0.3 + y * 0.35, cz + (x - 1) * w / 2)
|
||||
for y in range(4) for x in range(3)]
|
||||
idx = []
|
||||
for y in range(3):
|
||||
for x in range(2):
|
||||
p = y * 3 + x
|
||||
idx += [p, p + 1, p + 4, p, p + 4, p + 3]
|
||||
pieces.append(("outside", verts, idx))
|
||||
return pieces
|
||||
|
||||
|
||||
def main():
|
||||
room = fake_room()
|
||||
mesh = fake_room_mesh(room, cell=CELL)
|
||||
verts = [cm(v) for v in mesh.vertices]
|
||||
tris = list(mesh.indices)
|
||||
kinds = {}
|
||||
raw_from = len(tris) // 3
|
||||
for kind, jv, ji in junk():
|
||||
base = len(verts)
|
||||
verts += [cm(v) for v in jv]
|
||||
pieces, count = kinds.get(kind, (0, 0))
|
||||
kinds[kind] = (pieces + 1, count + len(ji) // 3) # (pieces, triangles)
|
||||
tris += [base + i for i in ji]
|
||||
|
||||
labels = FakeRoom.LABELS
|
||||
kept = [lb for lb in labels if lb["to"]]
|
||||
boxes = []
|
||||
for vol, lb in zip(room.volumes, kept, strict=True):
|
||||
boxes.append({"label": lb["to"], "from": lb["from"], "reason": lb["reason"], "cx": vol.cx, "cz": vol.cz,
|
||||
"top": vol.top_y, "yaw": vol.yaw_deg, "w": vol.w, "d": vol.d, "h": vol.h})
|
||||
removed_at = [(2.15, 0.2, 0.8, 0), (0.3, 0.75, 0.03, 0)] # behind the right wall; a rug on the floor
|
||||
removed = []
|
||||
for lb, (cx, cz, top, yaw) in zip([lb for lb in labels if not lb["to"] and not lb.get("suggest")], removed_at,
|
||||
strict=True):
|
||||
removed.append({"label": lb["from"], "reason": lb["reason"], "cx": cx, "cz": cz, "top": top, "yaw": yaw,
|
||||
**{k: lb["box"][k] for k in ("w", "d", "h")}})
|
||||
sug = FakeRoom.SUGGESTIONS[0]
|
||||
slab = next(lb for lb in labels if lb.get("suggest"))
|
||||
suggestion = {"kind": sug["kind"], "reason": sug["reason"], "from": slab["from"], "detail": slab["reason"],
|
||||
"cx": sug["box"]["cx"], "cz": sug["box"]["cz"], "top": sug["box"]["top_y"],
|
||||
"yaw": sug["box"]["yaw_deg"], **{k: sug["box"][k] for k in ("w", "d", "h")}}
|
||||
|
||||
data = {
|
||||
"outline": [[round(x, 2), round(z, 2)] for x, z in room.outline],
|
||||
"floor": room.floor, "ceiling": room.ceiling,
|
||||
"walls": [[w.x0, w.z0, w.x1, w.z1, w.y0, w.y1] for w in room.walls],
|
||||
"planes": [{"label": p.label, "corners": cm(sum(plane_corners(p), ()))} for p in room.planes],
|
||||
"boxes": boxes, "removed": removed, "suggestion": suggestion,
|
||||
"mesh": {"v": sum(verts, []), "t": tris, "raw_from": raw_from, "junk": kinds},
|
||||
}
|
||||
OUT.write_text(json.dumps(data, separators=(",", ":")) + "\n")
|
||||
print(f"{OUT.relative_to(ROOT)}: {len(verts)} vertices, {len(tris) // 3} triangles "
|
||||
f"({raw_from} kept), {OUT.stat().st_size // 1024} KiB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -399,17 +399,6 @@ def _long_upload(session, package: str, seconds: float = 30.0, after: float = 2.
|
||||
app.jobs.submit(Job(f"Install {app._title(package)} on Frame", run, package, "install"))
|
||||
|
||||
|
||||
def _open_room(session) -> None:
|
||||
"""The Room tab with the pretend Frame's scanned room loaded and drawn (3D view, cards)."""
|
||||
fakes.open_room(session.app)
|
||||
|
||||
|
||||
def _room_editing(session, which: str = "box") -> None:
|
||||
"""The Room tab's editing cards scrolled into view (fakes.show_room_editing: cleanup, more, box = a box selected,
|
||||
doors, suggestion = the door suggestion taken)."""
|
||||
fakes.show_room_editing(session.app, which)
|
||||
|
||||
|
||||
def _press_keys(session, text: str, gap: float = 0.28) -> None:
|
||||
"""Keys typed on the Type on Frame tab (the pretend keyboard), one by one."""
|
||||
view = session.app.keyboard_view
|
||||
@@ -425,4 +414,4 @@ HOOKS = {"fake_install": _fake_install, "wait_jobs": _wait_jobs, "held_install":
|
||||
"frame_asks": _frame_asks,
|
||||
"easter_eggs": _easter_eggs, "fps_dip": _fps_dip, "perfect_pacing": _perfect_pacing,
|
||||
"battery": _battery, "long_upload": _long_upload, "press_keys": _press_keys,
|
||||
"monitor_settled": _monitor_settled, "open_room": _open_room, "room_editing": _room_editing}
|
||||
"monitor_settled": _monitor_settled}
|
||||
@@ -33,7 +33,6 @@ from showcase.fakes import ( # noqa: E402,F401
|
||||
open_monitor_details,
|
||||
open_room,
|
||||
open_type_tab,
|
||||
show_room_editing,
|
||||
show_room_state,
|
||||
start_fake_install,
|
||||
start_fake_scan,
|
||||
@@ -144,16 +143,7 @@ def main() -> int:
|
||||
("keyboard", lambda a: (stop_fake_live(a), a.go("keyboard"))),
|
||||
("monitor", lambda a: a.go("monitor")), ("monitor-details", open_monitor_details),
|
||||
# the Room tab (--fake-frame: a made-up scanned room; a scan steps forward and waits, then is cancelled)
|
||||
("room", open_room),
|
||||
# its editing cards: the scan clean-up, the box editor, doors and windows, the label rules' suggestions
|
||||
("room-cleanup", lambda a: show_room_editing(a, "cleanup")),
|
||||
("room-cleanup-more", lambda a: show_room_editing(a, "more")),
|
||||
("room-box-selected", lambda a: show_room_editing(a, "box")),
|
||||
("room-doors", lambda a: show_room_editing(a, "doors")),
|
||||
("room-suggestion-door", lambda a: show_room_editing(a, "suggestion")),
|
||||
("room-cleanup-applying", lambda a: show_room_editing(a, "applied")),
|
||||
("room-cleanup-applied", lambda a: show_room_editing(a, "result")),
|
||||
("room-scanning", start_fake_scan), ("room-cancelled", cancel_fake_scan),
|
||||
("room", open_room), ("room-scanning", start_fake_scan), ("room-cancelled", cancel_fake_scan),
|
||||
("room-none", lambda a: show_room_state(a, "none")),
|
||||
("room-disconnected", lambda a: show_room_state(a, "disconnected")),
|
||||
("room-frame-card", lambda a: show_room_state(a, "frame")),
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"outline":[[-1.8,-1.6],[1.8,-1.6],[1.8,1.6],[-1.8,1.6]],"floor":0,"ceiling":2.5,"walls":[[-1.8,-1.6,1.8,-1.6,0,2.5],[1.8,-1.6,1.8,1.6,0,2.5],[1.8,1.6,-1.8,1.6,0,2.5],[-1.8,1.6,-1.8,-1.6,0,2.5]],"planes":[{"label":"DOOR_FRAME","corners":[-179,0,148,-179,0,63,-179,205,63,-179,205,148]},{"label":"WINDOW_FRAME","corners":[-179,122,27,-179,122,-78,-179,227,-78,-179,227,27]}],"boxes":[{"label":"BED","from":"BED","reason":"flat top at 0.55 m, 2.05 x 1.45 m against a wall: a bed","cx":0.6,"cz":-0.58,"top":0.55,"yaw":0,"w":1.45,"d":2.05,"h":0.55},{"label":"SCREEN","from":"TABLE","reason":"7 cm thin, flat on the wall, 1.1 m above the floor: a screen","cx":0.6,"cz":-1.55,"top":1.78,"yaw":0,"w":1.12,"d":0.07,"h":0.66},{"label":"TABLE","from":"TABLE","reason":"top at 0.75 m by a wall: a desk","cx":-1.44,"cz":-0.25,"top":0.75,"yaw":90,"w":1.4,"d":0.7,"h":0.75},{"label":"SCREEN","from":"OTHER","reason":"6 cm thin and upright, standing on a table: a screen","cx":-1.6,"cz":-0.56,"top":1.2,"yaw":82,"w":0.6,"d":0.06,"h":0.36},{"label":"SCREEN","from":"SCREEN","reason":"upright on a table, facing the room: a screen","cx":-1.6,"cz":0.06,"top":1.2,"yaw":98,"w":0.6,"d":0.06,"h":0.36},{"label":"STORAGE","from":"STORAGE","reason":"2.1 m high and 0.6 m deep in a corner: storage","cx":-1.25,"cz":-1.3,"top":2.1,"yaw":0,"w":1.0,"d":0.6,"h":2.1},{"label":"TABLE","from":"STORAGE","reason":"top at 0.52 m, 0.45 m wide by the bed: a nightstand","cx":-0.42,"cz":-1.38,"top":0.52,"yaw":0,"w":0.45,"d":0.4,"h":0.52},{"label":"LAMP","from":"LAMP","reason":"0.38 m high, standing on a table: a lamp","cx":-0.42,"cz":-1.42,"top":0.9,"yaw":0,"w":0.2,"d":0.2,"h":0.38},{"label":"PLANT","from":"PLANT","reason":"0.95 m high and round from above: a plant","cx":1.56,"cz":-1.37,"top":0.95,"yaw":0,"w":0.36,"d":0.36,"h":0.95}],"removed":[{"label":"OTHER","reason":"0.5 m behind the wall: outside the room","cx":2.15,"cz":0.2,"top":0.8,"yaw":0,"w":0.6,"d":0.4,"h":0.8},{"label":"TABLE","reason":"only 3 cm above the floor: a rug, not furniture","cx":0.3,"cz":0.75,"top":0.03,"yaw":0,"w":1.6,"d":1.1,"h":0.03}],"suggestion":{"kind":"door","reason":"A tall, thin slab on the wall, 0.8 m wide: a door, or a closet's front","from":"STORAGE","detail":"2.0 m high, 10 cm thin, flat on the wall","cx":1.75,"cz":1.0,"top":2.0,"yaw":90,"w":0.8,"d":0.1,"h":2.0},"mesh":{"v":[-180,0,160,-154,0,160,-129,0,160,-103,0,160,-77,0,160,-51,0,160,-26,0,160,0,0,160,26,0,160,51,0,160,77,0,160,103,0,160,129,0,160,154,0,160,180,0,160,-180,0,135,-154,0,136,-129,0,136,-103,0,135,-77,0,136,-51,0,136,-26,0,135,1,1,135,26,-1,136,52,1,136,77,0,136,102,0,135,128,-1,136,154,0,135,180,0,135,-180,0,111,-154,0,111,-129,1,112,-103,-1,110,-78,0,111,-52,-1,111,-26,0,111,0,0,111,26,-1,111,52,1,111,77,0,110,103,-1,110,128,-1,111,154,-1,110,180,0,111,-180,0,86,-155,0,86,-129,1,87,-103,0,85,-78,0,86,-51,-1,85,-25,0,86,0,-1,86,26,1,86,51,0,86,78,0,87,103,0,87,129,1,87,155,0,86,180,0,86,-180,0,62,-154,1,61,-128,1,62,-103,0,61,-78,0,62,-51,1,62,-25,0,61,0,1,62,26,0,61,52,0,62,78,0,61,104,0,61,128,-1,62,155,-1,62,180,0,62,-180,0,37,-154,0,37,-128,0,38,-102,0,37,-77,0,37,-52,0,36,-25,0,37,0,1,37,26,0,37,51,-1,37,77,-1,37,102,0,37,129,0,37,154,0,36,180,0,37,-180,0,12,-154,1,12,-128,0,12,-103,0,12,-77,1,13,-51,1,12,-26,1,13,-1,-1,12,25,0,12,52,0,13,77,0,13,102,1,13,128,1,12,154,1,13,180,0,12,-180,0,-12,-154,-1,-12,-129,-1,-13,-103,0,-13,-76,0,-12,-52,0,-13,-25,0,-13,0,1,-12,25,-1,-12,52,0,-13,76,0,-12,102,1,-12,129,-1,-12,154,1,-12,180,0,-12,-180,0,-37,-155,-1,-37,-129,0,-37,-103,0,-37,-77,-1,-37,-52,0,-38,-25,0,-37,0,1,-38,26,0,-37,52,0,-37,77,1,-37,103,0,-37,128,0,-38,154,-1,-37,180,0,-37,-180,0,-62,-155,0,-62,-129,-1,-62,-103,1,-61,-77,0,-61,-52,0,-62,-26,0,-62,0,0,-62,25,-1,-62,51,-1,-62,77,0,-61,103,0,-61,129,0,-62,155,-1,-61,180,0,-62,-180,0,-86,-154,-1,-86,-129,1,-86,-102,0,-86,-77,0,-87,-52,-1,-86,-26,1,-86,0,0,-86,26,-1,-86,52,0,-86,77,-1,-86,102,-1,-87,129,0,-86,155,0,-86,180,0,-86,-180,0,-111,-155,0,-110,-129,0,-112,-104,0,-110,-77,0,-111,-51,0,-111,-26,1,-111,1,1,-112,26,1,-110,51,0,-111,78,0,-111,102,0,-110,128,1,-111,155,0,-111,180,0,-111,-180,0,-135,-155,-1,-136,-129,1,-136,-102,1,-136,-76,0,-135,-52,0,-136,-25,0,-136,0,0,-136,25,1,-136,52,0,-136,77,0,-136,104,1,-135,129,1,-135,154,0,-136,180,0,-135,-180,0,-160,-154,0,-160,-129,0,-160,-103,0,-160,-77,0,-160,-51,0,-160,-26,0,-160,0,0,-160,26,0,-160,51,0,-160,77,0,-160,103,0,-160,129,0,-160,154,0,-160,180,0,-160,-180,0,-160,-154,0,-160,-129,0,-160,-103,0,-160,-77,0,-160,-51,0,-160,-26,0,-160,0,0,-160,26,0,-160,51,0,-160,77,0,-160,103,0,-160,129,0,-160,154,0,-160,180,0,-160,-180,25,-160,-154,24,-160,-129,26,-160,-102,25,-161,-78,26,-160,-52,24,-160,-25,25,-161,0,26,-161,25,25,-160,52,24,-159,78,25,-161,104,25,-159,128,24,-159,154,26,-160,180,25,-160,-180,50,-160,-155,49,-159,-129,49,-161,-103,51,-159,-77,51,-159,-52,49,-159,-25,49,-160,0,50,-160,25,49,-161,51,51,-161,78,49,-160,104,51,-160,127,50,-160,155,49,-160,180,50,-160,-180,75,-160,-155,74,-159,-129Line truncated
|
||||
@@ -10,7 +10,7 @@ const site = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repo = resolve(site, '..');
|
||||
|
||||
const SHOTS = ['library.png', 'game.png', 'monitor.png', 'live-view.png', 'files.png', 'type-on-frame.png',
|
||||
'screenshots.png', 'install-progress.png', 'patches.png', 'room.png'];
|
||||
'screenshots.png', 'install-progress.png', 'patches.png'];
|
||||
const PUBLIC = {
|
||||
'docs/images': ['tour-teaser.webp'],
|
||||
'docs/media': ['frameport-tour.mp4', 'frameport-tour.jpg', 'frameport-install.mp4', 'frameport-install.jpg'],
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props { games: MockGame[]; compact?: boolean; route?: Route; live?: bo
|
||||
const { games, compact = false, route, live = false } = Astro.props;
|
||||
const nav = [
|
||||
['library', 'Library'], ['frame', 'Steam Frame'], ['files', 'Files'], ['shots', 'Screenshots'], ['live', 'Live view'],
|
||||
['keys', 'Type on Frame'], ['monitor', 'Monitor'], ['room', 'Room'], ['settings', 'Settings'],
|
||||
['keys', 'Type on Frame'], ['monitor', 'Monitor'], ['settings', 'Settings'],
|
||||
] as const;
|
||||
const ICON: Record<string, string> = {
|
||||
library: '<path d="M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z"/>',
|
||||
@@ -19,7 +19,6 @@ const ICON: Record<string, string> = {
|
||||
live: '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
|
||||
keys: '<rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/>',
|
||||
monitor: '<path d="M3 12h4l2-6 4 12 2-6h6"/>',
|
||||
room: '<path d="M3 8l9-5 9 5v8l-9 5-9-5zM3 8l9 5 9-5M12 13v8"/>',
|
||||
shots: '<path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/>',
|
||||
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
|
||||
};
|
||||
@@ -548,71 +547,6 @@ const ICON: Record<string, string> = {
|
||||
.framecard :global(.pwr button:hover) { color: var(--text); background: rgba(237, 238, 242, .05); }
|
||||
.side :global(.act.idle) { display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
|
||||
/* Room (the 3D view: scripts/room3d.ts) */
|
||||
.view :global(.xp) { display: inline-block; vertical-align: middle; margin-left: 6px; padding: 2px 8px; border-radius: 999px;
|
||||
font: 600 11px/1.4 var(--font-sans); color: var(--warn); background: rgba(242, 201, 76, .14); }
|
||||
.view :global(.room) { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, 300px); gap: 12px; margin-bottom: 12px; }
|
||||
.view :global(.r3d) { position: relative; min-height: 340px; border-radius: 12px; border: 1px solid var(--border); overflow: hidden;
|
||||
background: radial-gradient(120% 90% at 50% 40%, #151a24, #0a0b0f); cursor: grab; touch-action: pan-y; user-select: none; }
|
||||
.view :global(.r3d:active) { cursor: grabbing; }
|
||||
.view :global(.r3d-canvas) { position: absolute; inset: 0; display: block; }
|
||||
.view :global(.r-load) { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-2); font-size: 13px; }
|
||||
.view :global(.r-legend) { position: absolute; left: 10px; right: 10px; bottom: 8px; display: flex; flex-wrap: wrap; gap: 4px 12px;
|
||||
font-size: 11px; color: var(--text-2); pointer-events: none; }
|
||||
.view :global(.r-legend span) { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.view :global(.r-legend i) { width: 10px; height: 10px; border-radius: 2px; }
|
||||
.view :global(.lg-wall) { background: rgba(58, 168, 255, .35); border: 1px solid #3aa8ff; }
|
||||
.view :global(.lg-mesh) { height: 2px !important; background: #3aa8ff; }
|
||||
.view :global(.lg-box) { background: rgba(255, 138, 31, .3); border: 1px solid #ff8a1f; }
|
||||
.view :global(.lg-door) { background: rgba(61, 214, 140, .35); border: 1px solid #3dd68c; }
|
||||
.view :global(.lg-junk) { background: rgba(255, 95, 95, .25); border: 1px dashed #ff5f5f; }
|
||||
.view :global(.room:not(.raw) .lg-raw) { display: none !important; }
|
||||
.view :global(.r-hint) { position: absolute; top: 9px; right: 11px; font: 500 11px/1 var(--font-mono); color: var(--text-3);
|
||||
transition: opacity 400ms; pointer-events: none; }
|
||||
.view :global(.r3d.turned .r-hint) { opacity: 0; }
|
||||
.view :global(.rcol) { display: grid; gap: 10px; align-content: start; }
|
||||
.view :global(.rcard) { padding: 12px 14px; border-radius: 12px; background: var(--surface); border: 1px solid var(--border); display: grid;
|
||||
gap: 8px; align-content: start; }
|
||||
.view :global(.rcard > *), .view :global(.rcard :is(h4, p, ul)) { margin: 0; }
|
||||
.view :global(.rcard h4) { font: 700 15px/1.2 var(--font-sans); }
|
||||
.view :global(.r-k) { font-size: 12px; color: var(--text-2); }
|
||||
.view :global(.r-big) { font-size: 14px; font-weight: 600; }
|
||||
.view :global(.r-sub) { font-size: 12px; color: var(--text-2); }
|
||||
.view :global(.r-stage) { font-size: 13px; color: var(--secondary); }
|
||||
.view :global(.rcard .upbar) { display: block; width: 100%; height: 4px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
|
||||
.view :global(.rcard .upbar i) { display: block; height: 100%; background: var(--portal); transition: width 140ms linear; }
|
||||
.view :global(.r-chips) { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.view :global(.r-chips span) { padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600; color: var(--accent);
|
||||
background: var(--accent-soft); }
|
||||
.view :global(.r-tip) { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 12px; color: var(--text-2); }
|
||||
.view :global(.lnk) { all: unset; cursor: pointer; color: var(--accent); font-weight: 600; font-size: 12px; }
|
||||
.view :global(.lnk:hover) { text-decoration: underline; }
|
||||
.view :global(.lnk:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.view :global(.r-seg) { justify-self: start; }
|
||||
.view :global(.r-res) { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; font-size: 12px; }
|
||||
.view :global(.r-res li) { display: flex; justify-content: space-between; gap: 10px; color: var(--text-2); }
|
||||
.view :global(.r-res b) { color: var(--text); font-weight: 600; }
|
||||
.view :global(.room.raw .r-res b) { color: #ff7a7a; }
|
||||
.view :global(.r-sug) { display: grid; gap: 6px; padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(61, 214, 140, .35);
|
||||
background: rgba(61, 214, 140, .07); }
|
||||
.view :global(.r-sug-h) { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; }
|
||||
.view :global(.r-sug p) { font-size: 12px; color: var(--text-2); }
|
||||
.view :global(.r-list) { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 4px 14px; }
|
||||
.view :global(.rl) { all: unset; box-sizing: border-box; width: 100%; display: grid; grid-template-columns: auto 1fr auto; gap: 1px 10px; padding: 6px 8px;
|
||||
border-radius: 8px; font-size: 12.5px; cursor: pointer; }
|
||||
.view :global(button.rl:hover) { background: rgba(237, 238, 242, .05); }
|
||||
.view :global(.rl[aria-pressed="true"]) { background: linear-gradient(90deg, rgba(58, 168, 255, .18), rgba(255, 138, 31, .18)); }
|
||||
.view :global(.rl:focus-visible) { outline: 2px solid var(--accent); }
|
||||
.view :global(.rl small) { grid-column: 1 / -1; font-size: 11.5px; color: var(--text-2); }
|
||||
.view :global(.rl .kept) { color: var(--text-2); }
|
||||
.view :global(.rl .to) { color: var(--accent); }
|
||||
.view :global(.dim) { color: var(--text-2); font-size: 11.5px; white-space: nowrap; }
|
||||
.view :global(.rl.gone) { cursor: default; opacity: .6; }
|
||||
.view :global(.rl.gone span:nth-child(2)) { color: var(--text-2); }
|
||||
.view :global(.r-type) { display: flex; align-items: center; gap: 8px; padding: 2px 8px 8px; font-size: 12px; color: var(--text-2); }
|
||||
.view :global(.r-type select) { background: var(--surface-3); color: var(--text); border: 1px solid var(--border-strong); border-radius: 6px;
|
||||
padding: 4px 8px; font: inherit; }
|
||||
|
||||
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes ring { from { opacity: .9; transform: scale(1); } to { opacity: 0; transform: scale(1.6); } }
|
||||
@@ -646,8 +580,5 @@ const ICON: Record<string, string> = {
|
||||
.view :global(.lib-h .search-w .search) { flex: 1; min-width: 0; width: auto; }
|
||||
.view :global(.add-w) { grid-column: 1; justify-self: start; }
|
||||
.view :global(.add-w .menu) { right: auto; left: 0; }
|
||||
.view :global(.room) { grid-template-columns: minmax(0, 1fr); }
|
||||
.view :global(.r3d) { min-height: 0; aspect-ratio: 1; }
|
||||
.view :global(.r-legend) { font-size: 10px; gap: 2px 9px; }
|
||||
}
|
||||
</style>
|
||||
@@ -13,7 +13,6 @@ import keys from '../assets/media/type-on-frame.png';
|
||||
import screenshots from '../assets/media/screenshots.png';
|
||||
import progress from '../assets/media/install-progress.png';
|
||||
import patches from '../assets/media/patches.png';
|
||||
import room from '../assets/media/room.png';
|
||||
import { url } from '../data/site';
|
||||
|
||||
interface Props { games: MockGame[] }
|
||||
@@ -22,7 +21,6 @@ const shots = [
|
||||
{ img: library, title: 'Library', text: 'Your games with their artwork. Search, filter, install several at once.' },
|
||||
{ img: game, title: 'Game page', text: 'One click to install or play. Patches under Customize.' },
|
||||
{ img: progress, title: 'Installing', text: 'Each stage, with speed and time left.' },
|
||||
{ img: room, title: 'Room (experimental)', text: 'Your scanned room in 3D for mixed-reality games. Clean up the scan, fix the labels.' },
|
||||
{ img: monitor, title: 'Monitor', text: "Frame rate, load, temperature and battery, live." },
|
||||
{ img: live, title: 'Live view', text: 'What the headset shows, with sound, in your browser.' },
|
||||
{ img: screenshots, title: 'Screenshots', text: 'Your headset screenshots, by game and day.' },
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
{
|
||||
"updated": "2026-10-19",
|
||||
"updated": "2026-10-17",
|
||||
"people": [
|
||||
{
|
||||
"login": "spoopyghosty0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/336754034?v=4",
|
||||
"url": "https://github.com/spoopyghosty0",
|
||||
"commits": 783,
|
||||
"commits": 707,
|
||||
"issues": 6,
|
||||
"prs": 5,
|
||||
"total": 826
|
||||
},
|
||||
{
|
||||
"login": "Izimzizi",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/77581918?v=4",
|
||||
"url": "https://github.com/Izimzizi",
|
||||
"commits": 0,
|
||||
"issues": 21,
|
||||
"prs": 0,
|
||||
"total": 63
|
||||
},
|
||||
{
|
||||
"login": "tkp206093",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/29872453?v=4",
|
||||
"url": "https://github.com/tkp206093",
|
||||
"commits": 0,
|
||||
"issues": 21,
|
||||
"prs": 0,
|
||||
"total": 63
|
||||
"prs": 4,
|
||||
"total": 745
|
||||
},
|
||||
{
|
||||
"login": "Klownicle",
|
||||
@@ -37,6 +19,15 @@
|
||||
"prs": 0,
|
||||
"total": 60
|
||||
},
|
||||
{
|
||||
"login": "tkp206093",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/29872453?v=4",
|
||||
"url": "https://github.com/tkp206093",
|
||||
"commits": 0,
|
||||
"issues": 18,
|
||||
"prs": 0,
|
||||
"total": 54
|
||||
},
|
||||
{
|
||||
"login": "UnicronNL",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/1267745?v=4",
|
||||
@@ -55,24 +46,6 @@
|
||||
"prs": 0,
|
||||
"total": 45
|
||||
},
|
||||
{
|
||||
"login": "Genosystem",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/40860703?v=4",
|
||||
"url": "https://github.com/Genosystem",
|
||||
"commits": 0,
|
||||
"issues": 13,
|
||||
"prs": 0,
|
||||
"total": 39
|
||||
},
|
||||
{
|
||||
"login": "Lucas-Mathieu",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/184943223?v=4",
|
||||
"url": "https://github.com/Lucas-Mathieu",
|
||||
"commits": 12,
|
||||
"issues": 0,
|
||||
"prs": 5,
|
||||
"total": 37
|
||||
},
|
||||
{
|
||||
"login": "Sheebalove",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/176067652?v=4",
|
||||
@@ -118,6 +91,24 @@
|
||||
"prs": 0,
|
||||
"total": 30
|
||||
},
|
||||
{
|
||||
"login": "Lucas-Mathieu",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/184943223?v=4",
|
||||
"url": "https://github.com/Lucas-Mathieu",
|
||||
"commits": 9,
|
||||
"issues": 0,
|
||||
"prs": 4,
|
||||
"total": 29
|
||||
},
|
||||
{
|
||||
"login": "Genosystem",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/40860703?v=4",
|
||||
"url": "https://github.com/Genosystem",
|
||||
"commits": 0,
|
||||
"issues": 9,
|
||||
"prs": 0,
|
||||
"total": 27
|
||||
},
|
||||
{
|
||||
"login": "inseend",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/11666993?v=4",
|
||||
@@ -127,6 +118,15 @@
|
||||
"prs": 0,
|
||||
"total": 27
|
||||
},
|
||||
{
|
||||
"login": "Izimzizi",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/77581918?v=4",
|
||||
"url": "https://github.com/Izimzizi",
|
||||
"commits": 0,
|
||||
"issues": 9,
|
||||
"prs": 0,
|
||||
"total": 27
|
||||
},
|
||||
{
|
||||
"login": "jim2011",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/15848374?v=4",
|
||||
@@ -136,15 +136,6 @@
|
||||
"prs": 0,
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "Jmckenzie84",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/71196491?v=4",
|
||||
"url": "https://github.com/Jmckenzie84",
|
||||
"commits": 0,
|
||||
"issues": 8,
|
||||
"prs": 0,
|
||||
"total": 24
|
||||
},
|
||||
{
|
||||
"login": "coah80",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/100182500?v=4",
|
||||
@@ -155,14 +146,23 @@
|
||||
"total": 20
|
||||
},
|
||||
{
|
||||
"login": "broli95",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22918304?v=4",
|
||||
"url": "https://github.com/broli95",
|
||||
"login": "Jmckenzie84",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/71196491?v=4",
|
||||
"url": "https://github.com/Jmckenzie84",
|
||||
"commits": 0,
|
||||
"issues": 6,
|
||||
"prs": 0,
|
||||
"total": 18
|
||||
},
|
||||
{
|
||||
"login": "broli95",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/22918304?v=4",
|
||||
"url": "https://github.com/broli95",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "buildmine10",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/33100363?v=4",
|
||||
@@ -199,15 +199,6 @@
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "jeliastam",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/2113009?v=4",
|
||||
"url": "https://github.com/jeliastam",
|
||||
"commits": 0,
|
||||
"issues": 5,
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "Scoota23",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/27732436?v=4",
|
||||
@@ -235,6 +226,15 @@
|
||||
"prs": 0,
|
||||
"total": 15
|
||||
},
|
||||
{
|
||||
"login": "jeliastam",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/2113009?v=4",
|
||||
"url": "https://github.com/jeliastam",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "Loafmeister",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60628625?v=4",
|
||||
@@ -253,15 +253,6 @@
|
||||
"prs": 1,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "Natouille04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60348431?v=4",
|
||||
"url": "https://github.com/Natouille04",
|
||||
"commits": 0,
|
||||
"issues": 4,
|
||||
"prs": 0,
|
||||
"total": 12
|
||||
},
|
||||
{
|
||||
"login": "nickwilde77555",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/180547421?v=4",
|
||||
@@ -361,15 +352,6 @@
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "wanwanwanwano",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/341975109?v=4",
|
||||
"url": "https://github.com/wanwanwanwano",
|
||||
"commits": 0,
|
||||
"issues": 3,
|
||||
"prs": 0,
|
||||
"total": 9
|
||||
},
|
||||
{
|
||||
"login": "Akimya49",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/46827872?v=4",
|
||||
@@ -379,33 +361,6 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "crointer",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/165038970?v=4",
|
||||
"url": "https://github.com/crointer",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "DerMueller",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/82468245?v=4",
|
||||
"url": "https://github.com/DerMueller",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "DexgamingX",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/85261590?v=4",
|
||||
"url": "https://github.com/DexgamingX",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "DJ3thenew23",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/3744521?v=4",
|
||||
@@ -433,15 +388,6 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Gabiga71",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/20774946?v=4",
|
||||
"url": "https://github.com/Gabiga71",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "ganona",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/112239200?v=4",
|
||||
@@ -478,15 +424,6 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "mamasboi2000",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/344123932?v=4",
|
||||
"url": "https://github.com/mamasboi2000",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "marcoszaballos",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/44778619?v=4",
|
||||
@@ -523,6 +460,15 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "Natouille04",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/60348431?v=4",
|
||||
"url": "https://github.com/Natouille04",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "peetzweg",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/839848?v=4",
|
||||
@@ -550,6 +496,15 @@
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "wanwanwanwano",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/341975109?v=4",
|
||||
"url": "https://github.com/wanwanwanwano",
|
||||
"commits": 0,
|
||||
"issues": 2,
|
||||
"prs": 0,
|
||||
"total": 6
|
||||
},
|
||||
{
|
||||
"login": "yzfsoldier",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/35410319?v=4",
|
||||
@@ -685,6 +640,24 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DerMueller",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/82468245?v=4",
|
||||
"url": "https://github.com/DerMueller",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DexgamingX",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/85261590?v=4",
|
||||
"url": "https://github.com/DexgamingX",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "DolphinOfficial",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/92276540?v=4",
|
||||
@@ -712,15 +685,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "ehnobigdeal0",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/344167176?v=4",
|
||||
"url": "https://github.com/ehnobigdeal0",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "felis-lynx",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/106830854?v=4",
|
||||
@@ -748,15 +712,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Haseo-sudo",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/344139069?v=4",
|
||||
"url": "https://github.com/Haseo-sudo",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "hokey1981",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/42741984?v=4",
|
||||
@@ -856,24 +811,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "MThomason122",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/25598648?v=4",
|
||||
"url": "https://github.com/MThomason122",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Nikoroq",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/135438574?v=4",
|
||||
"url": "https://github.com/Nikoroq",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "njorbie",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/340724129?v=4",
|
||||
@@ -892,15 +829,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "persuaded9041",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/464398?v=4",
|
||||
"url": "https://github.com/persuaded9041",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "pinbear-creator",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/194687704?v=4",
|
||||
@@ -1018,15 +946,6 @@
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "Zomby2D",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/5513720?v=4",
|
||||
"url": "https://github.com/Zomby2D",
|
||||
"commits": 0,
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "zoomiti",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/34012653?v=4",
|
||||
@@ -1035,15 +954,6 @@
|
||||
"issues": 1,
|
||||
"prs": 0,
|
||||
"total": 3
|
||||
},
|
||||
{
|
||||
"login": "codex",
|
||||
"avatar": "https://avatars.githubusercontent.com/u/267193182?v=4",
|
||||
"url": "https://github.com/codex",
|
||||
"commits": 2,
|
||||
"issues": 0,
|
||||
"prs": 0,
|
||||
"total": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -94,11 +94,6 @@
|
||||
"title": "Colour space support",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.fixed_resolution": {
|
||||
"anchor": "adapter-fixed_resolution",
|
||||
"title": "Keep full resolution (no dynamic resolution)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.flip_dump": {
|
||||
"anchor": "adapter-flip_dump",
|
||||
"title": "Upside-down panel images (diagnostics)",
|
||||
@@ -339,11 +334,6 @@
|
||||
"title": "Retry refused picture formats",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.swapchain_pool": {
|
||||
"anchor": "adapter-swapchain_pool",
|
||||
"title": "Share panel images (swapchain limit)",
|
||||
"group": "Game settings"
|
||||
},
|
||||
"adapter.sync_guard": {
|
||||
"anchor": "adapter-sync_guard",
|
||||
"title": "Steady controller input after focus",
|
||||
@@ -624,11 +614,6 @@
|
||||
"title": "Start straight in VR (skip the 2D launcher)",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.static_openxr_loader": {
|
||||
"anchor": "frame-static_openxr_loader",
|
||||
"title": "Embedded OpenXR loader: use FrameBridge",
|
||||
"group": "Steam Frame patches"
|
||||
},
|
||||
"frame.swapchain_limit": {
|
||||
"anchor": "frame-swapchain_limit",
|
||||
"title": "Allow swapchains larger than 4096 px",
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
{
|
||||
"updated": "2026-10-19",
|
||||
"updated": "2026-10-17",
|
||||
"releases": [
|
||||
{
|
||||
"tag": "v1.3.0",
|
||||
"name": "FramePort v1.3.0",
|
||||
"date": "2026-10-19",
|
||||
"url": "https://github.com/spoopyghosty0/frameport/releases/tag/v1.3.0",
|
||||
"dev": false,
|
||||
"notes": "FramePort v1.3.0: Windows (x64), macOS (Apple Silicon) and Linux (x64, ARM64).\n\n## What's new\n\n- **Mixed reality (experimental):** a new Room tab scans your room in the headset, shows it in 3D, cleans up the scan and lets you fix furniture, screen and door labels; mixed-reality games get the room through full Meta scene emulation (thanks @alicecatpaw).\n- **Quest 3 by default:** games see a Quest 3; the headset model, game language, foveation and render resolution can be set per game.\n- **Save backups:** choose the backup folder and keep automatic snapshots made on the Frame when a game closes.\n- **Steam library:** add new games to Steam now, at Steam's next start, or not at all.\n- **Files:** SD cards and other drives, plus sections for PC VR games and Linux apps.\n- **Controllers:** an experimental Quest button mode, and own button layouts for the gamepad in 2D apps.\n- **Game fixes:** Batman: Arkham Shadow's edge line and audio hooks (thanks @Lucas-Mathieu), PrimedGun, 4XVR's menus, Halo CE VR closing during the intro, OutRun, Transformers Beyond Reality, cube loading screens, and Unreal games whose install stopped at a memory patch check.\n- **Fixes:** USB setup without a flashing console, store details after a rename.\n- **22 new recipes.**\n\nAlready have FramePort? It offers this update itself (Library → **Update now**).\n\n## Install\n\nDownload the file for your PC, unpack it and start FramePort. Guide:\n[Install and first steps](https://frameport.app/docs/install/).\n\n"
|
||||
},
|
||||
{
|
||||
"tag": "v1.2.1",
|
||||
"name": "FramePort v1.2.1",
|
||||
|
||||
@@ -5,22 +5,10 @@
|
||||
import { coverVars, initials } from './cover';
|
||||
import { checkUrl, fileName as urlFileName } from './install-link';
|
||||
import type { VrScene } from './vrscene';
|
||||
import type { RoomData, RoomView } from './room3d';
|
||||
import { url } from '../data/site';
|
||||
|
||||
export interface MockGame { title: string; engine: string; xr: string; status: 'works' | 'issues'; platform: string }
|
||||
|
||||
export type Route = 'library' | 'game' | 'frame' | 'files' | 'shots' | 'live' | 'keys' | 'monitor' | 'room' | 'settings';
|
||||
interface RoomS {
|
||||
scanned: boolean;
|
||||
/** a scan running in the headset: 0..1 */
|
||||
scanning: number | null;
|
||||
raw: boolean;
|
||||
selected: number | null;
|
||||
door: 'pending' | 'door' | 'closet' | 'dismissed';
|
||||
/** types changed by the reader, by box */
|
||||
labels: Record<number, string>;
|
||||
}
|
||||
export type Route = 'library' | 'game' | 'frame' | 'files' | 'shots' | 'live' | 'keys' | 'monitor' | 'settings';
|
||||
interface FileRow { name: string; size: string; progress?: number }
|
||||
interface State {
|
||||
connected: boolean;
|
||||
@@ -50,7 +38,6 @@ interface State {
|
||||
details: boolean;
|
||||
theme: string;
|
||||
processes: [string, string, string][];
|
||||
room: RoomS;
|
||||
}
|
||||
|
||||
const STAGES = ['scan', 'analyze', 'recipe', 'convert', 'patch', 'sign', 'check', 'upload', 'library', 'test'];
|
||||
@@ -80,11 +67,6 @@ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? o
|
||||
const human = (b: number) => b >= 1 << 30 ? `${(b / (1 << 30)).toFixed(1)} GiB` : b >= 1 << 20 ? `${(b / (1 << 20)).toFixed(1)} MiB`
|
||||
: `${Math.max(1, Math.round(b / 1024))} KiB`;
|
||||
|
||||
const ROOM_TYPES = ['BED', 'COUCH', 'TABLE', 'SCREEN', 'STORAGE', 'LAMP', 'PLANT', 'OTHER'];
|
||||
const roomName = (t: string) => t === 'STORAGE' ? 'Storage' : t.charAt(0) + t.slice(1).toLowerCase();
|
||||
const scanStage = (p: number) => p < 0.4 ? 'Look around the room slowly' : p < 0.7 ? 'Now up at the ceiling and down at the floor'
|
||||
: p < 0.92 ? 'Walk around the furniture' : 'Building the room…';
|
||||
|
||||
const PLAY = '<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M8 5.5v13l11-6.5z" fill="currentColor"/></svg>';
|
||||
|
||||
const svg = (d: string, size = 15) => `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
|
||||
@@ -130,10 +112,8 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
patches: { framebridge: true, haptics: true, focus: true, textinput: true, spacewarp: false },
|
||||
processes: [['Game', 'the game', '38%'], ['Steam', 'steamwebhelper', '6%'], ['SteamVR', 'vrcompositor', '11%'],
|
||||
['Lepton', 'lepton-container', '4%'], ['System', 'kwin_wayland', '2%']],
|
||||
room: { scanned: true, scanning: null, raw: false, selected: null, door: 'pending', labels: {} },
|
||||
});
|
||||
const fresh = (): State => ({ ...demo(), connected: false, games: [], onFrame: new Set(), tested: new Set(), playing: null,
|
||||
room: { scanned: false, scanning: null, raw: false, selected: null, door: 'pending', labels: {} } });
|
||||
const fresh = (): State => ({ ...demo(), connected: false, games: [], onFrame: new Set(), tested: new Set(), playing: null });
|
||||
let s = demo();
|
||||
let timers: number[] = [];
|
||||
const later = (fn: () => void, ms: number) => { timers.push(window.setTimeout(fn, reduce ? Math.min(ms, 60) : ms)); };
|
||||
@@ -160,11 +140,8 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
renderChrome();
|
||||
const keep = vr && s.route === 'live' && s.live && vr.title === liveTitle() ? vr : null;
|
||||
if (keep) keep.host.remove(); else dropVr();
|
||||
const room3d = s.route === 'room' ? rv : null;
|
||||
if (room3d) room3d.host.remove(); else dropRoom();
|
||||
view.innerHTML = VIEWS[s.route]();
|
||||
if (s.route === 'live' && s.live) mountLive(keep);
|
||||
if (s.route === 'room') mountRoom3d(room3d);
|
||||
afterRender();
|
||||
}
|
||||
|
||||
@@ -201,37 +178,6 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
// The Room tab's 3D view (room3d.ts) and its data load once and live across re-renders of the tab, none elsewhere.
|
||||
let roomData: RoomData | null = null;
|
||||
let roomFailed = false;
|
||||
let rv: RoomView | null = null;
|
||||
function dropRoom() { rv?.dispose(); rv?.host.remove(); rv = null; }
|
||||
function syncRoom3d() {
|
||||
if (!rv) return;
|
||||
Object.assign(rv.state, { raw: s.room.raw, selected: s.room.selected, door: s.room.door,
|
||||
reveal: s.room.scanning ?? (s.room.scanned ? 1 : 0) });
|
||||
rv.redraw();
|
||||
}
|
||||
function mountRoom3d(keep: RoomView | null) {
|
||||
const slot = view.querySelector<HTMLElement>('[data-room3d]');
|
||||
if (!slot) return;
|
||||
if (keep) { slot.replaceWith(keep.host); syncRoom3d(); return; }
|
||||
import('./room3d').then(async ({ loadRoom, mountRoom }) => {
|
||||
const first = !roomData;
|
||||
roomData = await loadRoom(url('demo/room.json'));
|
||||
if (s.route !== 'room') return;
|
||||
if (first) { render(); return; } // the lists need the data: render again, which mounts the view
|
||||
const at = view.querySelector<HTMLElement>('[data-room3d]');
|
||||
if (!at || rv) return;
|
||||
const host = document.createElement('div');
|
||||
host.className = 'r3d';
|
||||
host.innerHTML = at.innerHTML;
|
||||
at.replaceWith(host);
|
||||
rv = mountRoom(host, roomData);
|
||||
rv.onPick = (i) => { s.room.selected = i; render(); };
|
||||
syncRoom3d();
|
||||
}).catch(() => { roomFailed = true; if (s.route === 'room') render(); });
|
||||
}
|
||||
/** The sidebar only (the Frame card, the activity card): progress ticks must not rebuild the page you're on. */
|
||||
function renderChrome() {
|
||||
side.querySelectorAll<HTMLElement>('[data-route]').forEach((b) => b.setAttribute('aria-current', String(b.dataset.route === s.route
|
||||
@@ -394,78 +340,6 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
+ `<td>${grp === 'System' ? '<span class="lock" title="FramePort never ends this one">protected</span>' : `<button class="btn-s sm" data-end="${i}">End</button>`}</td></tr>`).join('')
|
||||
+ '</tbody></table>'
|
||||
: `<div class="empty"><div class="portal-ico"></div><h4>Nothing playing</h4><p>Start a game to see its frame rate, load and temperature.</p></div>`,
|
||||
room: () => {
|
||||
const r = s.room, d = roomData;
|
||||
const head = `<header class="vh"><div><h3>Room <span class="xp">experimental</span></h3>`
|
||||
+ `<p>The room mixed-reality games see: walls, furniture and the scanned surfaces.</p></div></header>`;
|
||||
if (!s.connected && !r.scanned) {
|
||||
return head + `<div class="empty"><div class="portal-ico"></div><h4>No room yet</h4>`
|
||||
+ `<p>Connect your Frame, then scan your room in the headset. Mixed-reality games get its walls and furniture.</p></div>`;
|
||||
}
|
||||
const label = (i: number) => r.labels[i] ?? d!.boxes[i].label;
|
||||
const legend = `<div class="r-legend"><span><i class="lg-wall"></i>Walls</span><span><i class="lg-mesh"></i>Scanned surfaces</span>`
|
||||
+ `<span><i class="lg-box"></i>Furniture</span><span><i class="lg-door"></i>Doors and windows</span>`
|
||||
+ `<span class="lg-raw"><i class="lg-junk"></i>Removed by the clean-up</span></div>`;
|
||||
const scanCard = `<div class="rcard"><h4>Scan</h4>`
|
||||
+ (r.scanning !== null
|
||||
? `<p class="r-stage" data-rstage>${scanStage(r.scanning)}</p><span class="upbar"><i data-rbar style="width:${Math.round(r.scanning * 100)}%"></i></span>`
|
||||
+ `<p class="r-sub">The headset shows the same steps.</p><div class="row"><button class="btn-s" data-act="room-cancel">Cancel</button></div>`
|
||||
: `<div class="row"><button class="btn-p" data-act="room-scan" ${s.connected ? '' : 'disabled title="Connect a Frame first"'}>`
|
||||
+ `${svg('<path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/>')}`
|
||||
+ `${r.scanned ? 'Scan again' : 'Scan room in the headset'}</button>`
|
||||
+ `<button class="btn-s" data-act="toast" data-msg="Games get a plain box room from your play area instead">Use my play area</button></div>`)
|
||||
+ '</div>';
|
||||
if (!d) {
|
||||
return head + `<div class="room"><div class="r3d" data-room3d><p class="r-load">${roomFailed ? "The demo room didn't load." : 'Loading the room…'}</p></div>`
|
||||
+ `<div class="rcol">${scanCard}</div></div>`;
|
||||
}
|
||||
const counts = new Map<string, number>();
|
||||
d.boxes.forEach((_, i) => counts.set(label(i), (counts.get(label(i)) ?? 0) + 1));
|
||||
if (r.door === 'closet') counts.set('STORAGE', (counts.get('STORAGE') ?? 0) + 1);
|
||||
const area = d.outline.reduce((a, [x, z], i) => { const [x2, z2] = d.outline[(i + 1) % d.outline.length]; return a + x * z2 - x2 * z; }, 0) / 2;
|
||||
const kept = d.mesh.raw_from, all = d.mesh.t.length / 3;
|
||||
const junk = d.mesh.junk;
|
||||
const info = `<div class="rcard"><p class="r-k">In this room</p>`
|
||||
+ (r.scanned
|
||||
? `<p class="r-big">${plural(d.walls.length, 'wall')} · ${Math.abs(area).toFixed(1)} m² · ${(d.ceiling - d.floor).toFixed(1)} m high</p>`
|
||||
+ `<p class="r-sub">Scanned in the headset · ${(r.raw ? all : kept).toLocaleString('en')} mesh triangles</p>`
|
||||
+ `<p class="r-chips">${[...counts].map(([k, n]) => `<span>${roomName(k)}${n > 1 ? ` × ${n}` : ''}</span>`).join('')}</p>`
|
||||
+ `<p class="r-tip">${svg('<path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2.1h5c0-.9.4-1.6 1-2.1A6 6 0 0 0 12 3z"/>', 13)}See what a game gets: `
|
||||
+ `<button class="lnk" data-act="toast" data-msg="Installs Test Quest MR, a small app that shows what games see">Get Test Quest MR</button></p>`
|
||||
: '<p class="r-sub">Nothing scanned yet.</p>')
|
||||
+ '</div>';
|
||||
const clean = `<div class="rcard"><h4>Clean-up</h4><span class="seg r-seg">`
|
||||
+ `<button data-rmode="raw" aria-pressed="${r.raw}">Raw scan</button><button data-rmode="clean" aria-pressed="${!r.raw}">Cleaned up</button></span>`
|
||||
+ `<ul class="r-res">`
|
||||
+ `<li><span>Floating pieces</span><b>${junk.speck?.[0] ?? 0}</b></li>`
|
||||
+ `<li><span>An arm swept through the view</span><b>${junk.arm?.[0] ?? 0}</b></li>`
|
||||
+ `<li><span>Seen through the walls</span><b>${junk.outside?.[0] ?? 0}</b></li>`
|
||||
+ `<li><span>Boxes removed</span><b>${d.removed.length}</b></li>`
|
||||
+ `<li><span>Boxes renamed</span><b>${d.boxes.filter((b) => b.from && b.from !== b.label).length}</b></li></ul>`
|
||||
+ `<p class="r-sub">${r.raw ? 'Red: what the clean-up takes out.' : 'Switch to the raw scan to see what it took out.'}</p></div>`;
|
||||
const sz = (b: { w: number; d: number; h: number }) => [b.w, b.d, b.h].map((v) => v < 0.1 ? v.toFixed(2) : v.toFixed(1)).join('×') + ' m';
|
||||
const sug = d.suggestion;
|
||||
const sel = r.selected;
|
||||
const rows = d.boxes.map((b, i) => {
|
||||
const now = label(i), changed = now !== (b.from ?? now);
|
||||
return `<li><button class="rl" data-rbox="${i}" aria-pressed="${sel === i}"><b>${roomName(b.from ?? now)}</b>`
|
||||
+ `<span class="${changed ? 'to' : 'kept'}">${changed ? `→ ${roomName(now)}` : 'kept'}</span><span class="dim">${sz(b)}</span>`
|
||||
+ `<small>${esc(r.labels[i] ? 'changed by you' : b.reason)}</small></button>`
|
||||
+ (sel === i ? `<label class="r-type">Type <select data-rlabel="${i}">${ROOM_TYPES.map((t) =>
|
||||
`<option value="${t}" ${t === now ? 'selected' : ''}>${roomName(t)}</option>`).join('')}</select></label>` : '')
|
||||
+ '</li>';
|
||||
}).join('') + d.removed.map((b) => `<li><div class="rl gone"><b>${roomName(b.label)}</b><span>removed</span><span class="dim">${sz(b)}</span>`
|
||||
+ `<small>${esc(b.reason)}</small></div></li>`).join('');
|
||||
const labels = `<div class="rcard r-labels"><h4>Labels</h4>`
|
||||
+ `<p class="r-sub">What the label rules decided for each box. Click one here or in the view to change its type.</p>`
|
||||
+ (r.door === 'pending' ? `<div class="r-sug"><div class="r-sug-h"><b>A door?</b><span class="dim">${sz(sug)}</span></div><p>${esc(sug.reason)}</p>`
|
||||
+ `<div class="row"><button class="btn-s" data-act="room-door" data-as="door">Add as door</button>`
|
||||
+ `<button class="btn-s" data-act="room-door" data-as="closet">Add as closet</button>`
|
||||
+ `<button class="lnk" data-act="room-door" data-as="dismissed">Not a door</button></div></div>` : '')
|
||||
+ `<ul class="r-list">${rows}</ul></div>`;
|
||||
return head + `<div class="room ${r.raw ? 'raw' : ''}"><div class="r3d" data-room3d>${legend}<span class="r-hint">Drag to turn · click a box</span></div>`
|
||||
+ `<div class="rcol">${scanCard}${info}${clean}</div></div>${labels}`;
|
||||
},
|
||||
settings: () => `<header class="vh"><div><h3>Settings</h3><p>Appearance, updates, this PC.</p></div></header>`
|
||||
+ `<h4 class="sh">Appearance</h4><div class="themes">${Object.entries(THEMES).map(([k, t]) =>
|
||||
`<button class="theme" data-theme="${k}" aria-pressed="${s.theme === k}" style="--a:${t.c.accent};--b:${t.c.secondary};--g:${t.c.bg};--s:${t.c.surface}">`
|
||||
@@ -500,34 +374,6 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
|
||||
function afterRender() { coachUpdate(); }
|
||||
|
||||
// ------------------------------------------------------------------ the Room tab's pretend scan
|
||||
let roomCancel = () => {};
|
||||
function roomScan() {
|
||||
const before = s.room.scanned;
|
||||
s.room.scanning = 0;
|
||||
s.room.selected = null;
|
||||
render();
|
||||
const step = () => {
|
||||
if (s.room.scanning === null) return;
|
||||
s.room.scanning = Math.min(1, s.room.scanning + 0.035);
|
||||
if (s.room.scanning >= 1) {
|
||||
s.room = { ...s.room, scanning: null, scanned: true, door: 'pending', labels: {} };
|
||||
render();
|
||||
toast(roomData ? `Room scanned: ${plural(roomData.walls.length, 'wall')}, ${plural(roomData.boxes.length, 'box', 'boxes')}` : 'Room scanned');
|
||||
return;
|
||||
}
|
||||
if (s.route === 'room') {
|
||||
const st = view.querySelector('[data-rstage]'), bar = view.querySelector<HTMLElement>('[data-rbar]');
|
||||
if (st) st.textContent = scanStage(s.room.scanning);
|
||||
if (bar) bar.style.width = `${Math.round(s.room.scanning * 100)}%`;
|
||||
syncRoom3d();
|
||||
}
|
||||
later(step, 140);
|
||||
};
|
||||
roomCancel = () => { s.room.scanning = null; s.room.scanned = before; render(); toast('Scan cancelled. The room is as it was.', 'info'); };
|
||||
later(step, 300);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ actions
|
||||
function go(route: Route, current: string | null = s.current) {
|
||||
s.route = route;
|
||||
@@ -715,7 +561,7 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
case 'uninstall': s.onFrame.delete(title); toast(`${title} removed from the Frame (saves kept)`, 'info'); return render();
|
||||
}
|
||||
}
|
||||
const el = t.closest<HTMLElement>('[data-route],[data-game],[data-act],[data-filter],[data-loc],[data-link],[data-theme],[data-end],[data-del],[data-shot],[data-rmode],[data-rbox]');
|
||||
const el = t.closest<HTMLElement>('[data-route],[data-game],[data-act],[data-filter],[data-loc],[data-link],[data-theme],[data-end],[data-del],[data-shot]');
|
||||
if (!t.closest('.menu, [data-ctx]')) { if (!el || !['add', 'more'].includes(el.dataset.act ?? '')) closeMenus(); }
|
||||
if (!el || !root.contains(el)) return;
|
||||
if (el.dataset.route) return go(el.dataset.route as Route, el.dataset.route === 'game' ? s.current : null);
|
||||
@@ -732,8 +578,6 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
}
|
||||
if (el.dataset.del) { const [f] = s.files[s.location].splice(Number(el.dataset.del), 1); toast(`Deleted ${f.name}`, 'info'); return render(); }
|
||||
if (el.dataset.shot) return viewShot(el);
|
||||
if (el.dataset.rmode) { s.room.raw = el.dataset.rmode === 'raw'; return render(); }
|
||||
if (el.dataset.rbox) { const i = Number(el.dataset.rbox); s.room.selected = s.room.selected === i ? null : i; return render(); }
|
||||
const act = el.dataset.act;
|
||||
if (act === 'quickplay') { ev.stopPropagation(); s.current = el.dataset.title!; return play(el.dataset.title!); }
|
||||
if (el.dataset.game) {
|
||||
@@ -773,13 +617,6 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
case 'toast': toast(el.dataset.msg!, 'info'); closeMenus(); break;
|
||||
case 'tour': startTour(); break;
|
||||
case 'explore': exploreMode(); break;
|
||||
case 'room-scan': roomScan(); break;
|
||||
case 'room-cancel': roomCancel(); break;
|
||||
case 'room-door':
|
||||
s.room.door = el.dataset.as as RoomS['door'];
|
||||
render();
|
||||
toast(s.room.door === 'door' ? 'Saved: games that ask for a door get this one' : s.room.door === 'closet' ? 'Saved as a closet (storage)' : 'Left out', 'info');
|
||||
break;
|
||||
case 'tour-next': advance(); break;
|
||||
case 'tour-skip': endTour(); break;
|
||||
}
|
||||
@@ -821,11 +658,6 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
if (t instanceof HTMLInputElement && t.matches('[data-details]')) { s.details = t.checked; render(); }
|
||||
if (t instanceof HTMLInputElement && t.matches('[data-pick]') && t.files) addFiles(t.files);
|
||||
if (t instanceof HTMLSelectElement && t.matches('[data-quality]')) { s.quality = t.value; render(); }
|
||||
if (t instanceof HTMLSelectElement && t.dataset.rlabel) {
|
||||
s.room.labels[Number(t.dataset.rlabel)] = t.value;
|
||||
toast(`Saved: games see ${t.value === 'OTHER' ? 'an unnamed box' : `a ${roomName(t.value).toLowerCase()}`} here`, 'info');
|
||||
render();
|
||||
}
|
||||
});
|
||||
root.addEventListener('input', (ev) => {
|
||||
const t = ev.target as HTMLInputElement;
|
||||
@@ -889,7 +721,7 @@ export function initMock(root: HTMLElement, games: MockGame[], opts: MockOptions
|
||||
target: '[data-act="install"]', wait: 'installed' },
|
||||
{ title: 'Play', text: "It's in the Frame's Steam library now. Play it from here or in the headset.",
|
||||
target: '[data-act="play"]', wait: 'played' },
|
||||
{ title: "That's it", text: 'Now try Monitor, Room, Live view, Screenshots, Files, Type on Frame and Settings.',
|
||||
{ title: "That's it", text: 'Now try Monitor, Live view, Screenshots, Files, Type on Frame and Settings.',
|
||||
place: 'center', next: 'Explore' },
|
||||
];
|
||||
let step = -1;
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
// The Room tab's 3D view in the clickable app (mock.ts): the demo room (public/demo/room.json, written by
|
||||
// scripts/showcase/site_room.py from the showcase's made-up bedroom, no real scan) drawn on a 2D canvas like the app's
|
||||
// in-window view: the far walls and the scanned surfaces in blue, furniture in orange, doors and windows in green, the
|
||||
// play area's middle as an orange arrow. Surfaces facing away are left out, so the near walls open the room up. Drag
|
||||
// to turn it, click a box to pick it; it sways slowly on its own when left alone (not with reduced motion).
|
||||
|
||||
export interface Box { label: string; from?: string; reason: string; cx: number; cz: number; top: number; yaw: number; w: number; d: number; h: number }
|
||||
export interface RoomData {
|
||||
outline: [number, number][];
|
||||
floor: number;
|
||||
ceiling: number;
|
||||
walls: [number, number, number, number, number, number][];
|
||||
planes: { label: string; corners: number[] }[];
|
||||
boxes: Box[];
|
||||
removed: Box[];
|
||||
suggestion: Box & { kind: string; detail: string };
|
||||
mesh: { v: number[]; t: number[]; raw_from: number; junk: Record<string, [number, number]> };
|
||||
}
|
||||
export interface RoomState {
|
||||
raw: boolean; // the scan before the clean-up: what it removes is shown in red
|
||||
selected: number | null;
|
||||
reveal: number; // 0..1 while a scan runs (the surfaces appear in order), else 1
|
||||
door: 'pending' | 'door' | 'closet' | 'dismissed';
|
||||
}
|
||||
export interface RoomView { state: RoomState; host: HTMLElement; redraw(): void; dispose(): void; onPick: (i: number | null) => void }
|
||||
|
||||
let cached: Promise<RoomData> | null = null;
|
||||
export function loadRoom(src: string): Promise<RoomData> {
|
||||
cached ??= fetch(src).then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json() as Promise<RoomData>; });
|
||||
cached.catch(() => { cached = null; });
|
||||
return cached;
|
||||
}
|
||||
|
||||
type V3 = [number, number, number];
|
||||
const BLUE = '58, 168, 255', ORANGE = '255, 138, 31', GREEN = '61, 214, 140', RED = '255, 95, 95';
|
||||
|
||||
export function boxCorners(b: Box): V3[] {
|
||||
const a = (b.yaw * Math.PI) / 180, c = Math.cos(a), s = Math.sin(a);
|
||||
const foot = [[-b.w / 2, -b.d / 2], [b.w / 2, -b.d / 2], [b.w / 2, b.d / 2], [-b.w / 2, b.d / 2]]
|
||||
.map(([x, z]) => [b.cx + c * x + s * z, b.cz - s * x + c * z]);
|
||||
return [...foot.map(([x, z]) => [x, b.top - b.h, z] as V3), ...foot.map(([x, z]) => [x, b.top, z] as V3)];
|
||||
}
|
||||
// faces of boxCorners' points with their outward order (counter-clockwise seen from outside)
|
||||
const BOX_FACES = [[7, 6, 5, 4], [0, 1, 2, 3], [4, 5, 1, 0], [5, 6, 2, 1], [6, 7, 3, 2], [7, 4, 0, 3]];
|
||||
const BOX_EDGES = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]];
|
||||
|
||||
export function mountRoom(host: HTMLElement, data: RoomData, init: Partial<RoomState> = {}): RoomView {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'r3d-canvas';
|
||||
host.prepend(canvas);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const state: RoomState = { raw: false, selected: null, reveal: 1, door: 'pending', ...init };
|
||||
const V = data.mesh.v.map((c) => c / 100);
|
||||
const T = data.mesh.t;
|
||||
const target: V3 = [0, (data.ceiling - data.floor) * 0.4, 0];
|
||||
const BASE_YAW = 0.62, BASE_PITCH = 0.5;
|
||||
let yaw = BASE_YAW, pitch = BASE_PITCH, w = 0, h = 0, f = 1, ox = 0, oy = 0, dist = 7.2;
|
||||
let lastInput = -1e9, raf = 0, visible = true, dead = false;
|
||||
const view: RoomView = { state, host, redraw: () => draw(), dispose, onPick: () => {} };
|
||||
|
||||
// camera: orbit about `target`, looking at it from `dist`
|
||||
let cy = 1, sy = 0, cp = 1, sp = 0, cam: V3 = [0, 0, 0];
|
||||
function setCamera() {
|
||||
cy = Math.cos(yaw); sy = Math.sin(yaw); cp = Math.cos(pitch); sp = Math.sin(pitch);
|
||||
cam = [target[0] + dist * cp * sy, target[1] + dist * sp, target[2] + dist * cp * cy];
|
||||
}
|
||||
function project(p: V3): [number, number, number] {
|
||||
const x = p[0] - target[0], y = p[1] - target[1], z = p[2] - target[2];
|
||||
const x1 = cy * x - sy * z, z1 = sy * x + cy * z; // turn about +y
|
||||
const y2 = cp * y - sp * z1, z2 = sp * y + cp * z1; // tilt about the camera's x
|
||||
const depth = dist - z2;
|
||||
return [ox + (f * x1) / depth, oy - (f * y2) / depth, depth];
|
||||
}
|
||||
const facing = (p: V3, n: V3) => (cam[0] - p[0]) * n[0] + (cam[1] - p[1]) * n[1] + (cam[2] - p[2]) * n[2] > 0;
|
||||
const vtx = (i: number): V3 => [V[i * 3], V[i * 3 + 1], V[i * 3 + 2]];
|
||||
function normal(a: V3, b: V3, c: V3): V3 {
|
||||
const u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]], v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||||
return [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
|
||||
}
|
||||
|
||||
function poly(pts: [number, number, number][], fill: string | null, stroke: string | null, width = 1, dash: number[] = []) {
|
||||
ctx.beginPath();
|
||||
pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
|
||||
ctx.closePath();
|
||||
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
|
||||
if (stroke) { ctx.setLineDash(dash); ctx.strokeStyle = stroke; ctx.lineWidth = width; ctx.stroke(); ctx.setLineDash([]); }
|
||||
}
|
||||
function drawBox(b: Box, fill: string, stroke: string, width: number, dash: number[] = []) {
|
||||
const c = boxCorners(b), p = c.map(project);
|
||||
const faces = BOX_FACES.map((q) => ({ q, depth: q.reduce((s, i) => s + p[i][2], 0) / 4 }))
|
||||
.filter(({ q }) => facing(c[q[0]], normal(c[q[0]], c[q[1]], c[q[2]])));
|
||||
faces.sort((a, b2) => b2.depth - a.depth).forEach(({ q }) => poly(q.map((i) => p[i]), fill, null));
|
||||
ctx.setLineDash(dash);
|
||||
ctx.strokeStyle = stroke; ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
for (const [a, b2] of BOX_EDGES) { ctx.moveTo(p[a][0], p[a][1]); ctx.lineTo(p[b2][0], p[b2][1]); }
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (dead || !w) return;
|
||||
setCamera();
|
||||
const dpr = canvas.width / w;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.lineJoin = 'round';
|
||||
const shown = state.reveal;
|
||||
const fade = Math.min(1, shown * 1.5);
|
||||
|
||||
// the far walls (their inside faces the camera)
|
||||
for (const [x0, z0, x1, z1, y0, y1] of data.walls) {
|
||||
const c: V3[] = [[x0, y0, z0], [x1, y0, z1], [x1, y1, z1], [x0, y1, z0]];
|
||||
if (!facing(c[0], normal(c[0], c[1], c[2]))) continue;
|
||||
poly(c.map(project), `rgba(${BLUE}, ${0.06 * fade})`, `rgba(${BLUE}, ${0.55 * fade})`, 1.2);
|
||||
}
|
||||
// the floor's outline
|
||||
poly(data.outline.map(([x, z]) => project([x, data.floor, z])), `rgba(${BLUE}, ${0.04 * fade})`, `rgba(${BLUE}, ${0.4 * fade})`, 1);
|
||||
|
||||
// scanned surfaces: the edges of every triangle facing the camera (the clean-up's leftovers from both sides)
|
||||
const count = T.length / 3;
|
||||
const upto = Math.floor(count * shown);
|
||||
const keep = new Set<number>();
|
||||
const kept = new Path2D(), junk = new Path2D();
|
||||
for (let t = 0; t < upto; t++) {
|
||||
const isJunk = t >= data.mesh.raw_from;
|
||||
if (isJunk && !state.raw) continue;
|
||||
const i0 = T[t * 3], i1 = T[t * 3 + 1], i2 = T[t * 3 + 2];
|
||||
const a = vtx(i0);
|
||||
if (!isJunk && !facing(a, normal(a, vtx(i1), vtx(i2)))) continue;
|
||||
const path = isJunk ? junk : kept;
|
||||
for (const [p, q] of [[i0, i1], [i1, i2], [i2, i0]]) {
|
||||
const key = p < q ? p * 65536 + q : q * 65536 + p;
|
||||
if (keep.has(key)) continue;
|
||||
keep.add(key);
|
||||
const A = project(vtx(p)), B = project(vtx(q));
|
||||
path.moveTo(A[0], A[1]); path.lineTo(B[0], B[1]);
|
||||
}
|
||||
}
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.strokeStyle = `rgba(${BLUE}, 0.42)`;
|
||||
ctx.stroke(kept);
|
||||
if (state.raw) { ctx.lineWidth = 1.4; ctx.strokeStyle = `rgba(${RED}, 0.95)`; ctx.stroke(junk); }
|
||||
|
||||
if (shown >= 1) {
|
||||
// doors and windows
|
||||
for (const pl of data.planes) {
|
||||
const c = [0, 1, 2, 3].map((k) => pl.corners.slice(k * 3, k * 3 + 3).map((n) => n / 100) as V3);
|
||||
poly(c.map(project), `rgba(${GREEN}, 0.32)`, `rgba(${GREEN}, 0.9)`, 1.4);
|
||||
}
|
||||
const sug = data.suggestion;
|
||||
if (state.door === 'door') drawBox({ ...sug, d: 0.02 }, `rgba(${GREEN}, 0.32)`, `rgba(${GREEN}, 0.9)`, 1.4);
|
||||
if (state.door === 'closet') drawBox(sug, `rgba(${ORANGE}, 0.10)`, `rgba(${ORANGE}, 0.95)`, 1.4);
|
||||
if (state.door === 'pending') drawBox(sug, `rgba(${GREEN}, 0.06)`, `rgba(${GREEN}, 0.6)`, 1.1, [5, 4]);
|
||||
// furniture, far ones first
|
||||
const order = data.boxes.map((b, i) => ({ i, d: project([b.cx, b.top - b.h / 2, b.cz])[2] })).sort((a, b) => b.d - a.d);
|
||||
for (const { i } of order) {
|
||||
const sel = state.selected === i;
|
||||
drawBox(data.boxes[i], `rgba(${ORANGE}, ${sel ? 0.34 : 0.09})`, sel ? '#ffc58a' : `rgba(${ORANGE}, 0.95)`, sel ? 2.4 : 1.4);
|
||||
}
|
||||
if (state.raw) for (const b of data.removed) drawBox(b, `rgba(${RED}, 0.08)`, `rgba(${RED}, 0.9)`, 1.3, [5, 4]);
|
||||
// the play area's middle, facing forward (-z)
|
||||
const arrow: V3[] = [[0, data.floor + 0.01, -0.32], [0.17, data.floor + 0.01, 0.12], [-0.17, data.floor + 0.01, 0.12]];
|
||||
poly(arrow.map(project), `rgb(${ORANGE})`, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ size, motion, input
|
||||
function resize() {
|
||||
const r = host.getBoundingClientRect();
|
||||
w = Math.max(1, r.width); h = Math.max(1, r.height);
|
||||
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
|
||||
canvas.style.width = `${w}px`; canvas.style.height = `${h}px`;
|
||||
fit();
|
||||
draw();
|
||||
}
|
||||
/** Scale and place the view so the room fills the space above the legend, with room to sway. */
|
||||
function fit() {
|
||||
const top = 26, bottom = (host.querySelector<HTMLElement>('.r-legend')?.offsetHeight ?? 0) + 16;
|
||||
const keep = [yaw, pitch];
|
||||
let lo = [Infinity, Infinity], hi = [-Infinity, -Infinity];
|
||||
f = 1; ox = 0; oy = 0;
|
||||
for (const y0 of [BASE_YAW - 0.4, BASE_YAW, BASE_YAW + 0.4]) {
|
||||
yaw = y0; pitch = BASE_PITCH; setCamera();
|
||||
for (const [x, z] of data.outline) for (const y of [data.floor, data.ceiling]) {
|
||||
const [px, py] = project([x, y, z]);
|
||||
lo = [Math.min(lo[0], px), Math.min(lo[1], py)]; hi = [Math.max(hi[0], px), Math.max(hi[1], py)];
|
||||
}
|
||||
}
|
||||
[yaw, pitch] = keep;
|
||||
f = Math.min((w - 24) / (hi[0] - lo[0]), (h - top - bottom) / (hi[1] - lo[1]));
|
||||
ox = 12 + ((w - 24) - (hi[0] - lo[0]) * f) / 2 - lo[0] * f;
|
||||
oy = top + ((h - top - bottom) - (hi[1] - lo[1]) * f) / 2 - lo[1] * f;
|
||||
}
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(host);
|
||||
const io = new IntersectionObserver((es) => { visible = es.some((e) => e.isIntersecting); if (visible) loop(); });
|
||||
io.observe(host);
|
||||
|
||||
function loop() {
|
||||
cancelAnimationFrame(raf);
|
||||
if (dead || !visible || document.hidden) return;
|
||||
raf = requestAnimationFrame((now) => {
|
||||
if (!reduce && now - lastInput > 2500) {
|
||||
const t = now / 1000;
|
||||
yaw += (BASE_YAW + Math.sin(t * 0.25) * 0.38 - yaw) * 0.02;
|
||||
pitch += (BASE_PITCH - pitch) * 0.02;
|
||||
}
|
||||
draw();
|
||||
loop();
|
||||
});
|
||||
}
|
||||
const onVis = () => loop();
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
|
||||
let drag: { x: number; y: number; moved: number; id: number } | null = null;
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
drag = { x: e.clientX, y: e.clientY, moved: 0, id: e.pointerId };
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
lastInput = performance.now();
|
||||
});
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
if (!drag || drag.id !== e.pointerId) return;
|
||||
const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
||||
drag.moved += Math.abs(dx) + Math.abs(dy);
|
||||
drag.x = e.clientX; drag.y = e.clientY;
|
||||
yaw -= dx * 0.009;
|
||||
pitch = Math.max(0.12, Math.min(1.3, pitch + dy * 0.006));
|
||||
lastInput = performance.now();
|
||||
host.classList.add('turned');
|
||||
if (reduce) draw();
|
||||
});
|
||||
const end = (e: PointerEvent) => {
|
||||
if (!drag || drag.id !== e.pointerId) return;
|
||||
const click = drag.moved < 6;
|
||||
drag = null;
|
||||
lastInput = performance.now();
|
||||
if (click && state.reveal >= 1) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
view.onPick(pick(e.clientX - r.left, e.clientY - r.top));
|
||||
}
|
||||
};
|
||||
canvas.addEventListener('pointerup', end);
|
||||
canvas.addEventListener('pointercancel', (e) => { if (drag?.id === e.pointerId) drag = null; });
|
||||
|
||||
/** The nearest box under the point (its projected outline), else null. */
|
||||
function pick(x: number, y: number): number | null {
|
||||
setCamera();
|
||||
let best: number | null = null, bestDepth = Infinity;
|
||||
data.boxes.forEach((b, i) => {
|
||||
const p = boxCorners(b).map(project);
|
||||
if (!inHull(p.map(([px, py]) => [px, py] as [number, number]), x, y)) return;
|
||||
const depth = p.reduce((s, q) => s + q[2], 0) / 8 - Math.min(0.6, b.w * b.d * b.h); // small ones win ties
|
||||
if (depth < bestDepth) { bestDepth = depth; best = i; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
dead = true;
|
||||
cancelAnimationFrame(raf);
|
||||
ro.disconnect(); io.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVis);
|
||||
canvas.remove();
|
||||
}
|
||||
|
||||
resize();
|
||||
loop();
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Whether (x, y) is inside the convex hull of the points (monotone chain). */
|
||||
function inHull(pts: [number, number][], x: number, y: number): boolean {
|
||||
const s = pts.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const cross = (o: number[], a: number[], b: number[]) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
const lower: [number, number][] = [], upper: [number, number][] = [];
|
||||
for (const p of s) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop(); lower.push(p); }
|
||||
for (const p of s.reverse()) { while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop(); upper.push(p); }
|
||||
const hull = lower.slice(0, -1).concat(upper.slice(0, -1));
|
||||
if (hull.length < 3) return false;
|
||||
return hull.every((a, i) => cross(a, hull[(i + 1) % hull.length], [x, y]) >= 0);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
# The FramePort version: the single source (pyproject reads it via hatch; CI checks a release tag matches it).
|
||||
__version__ = "1.3.0"
|
||||
__version__ = "1.2.1"
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
from ..apk import axml
|
||||
from ..core.models import Analysis
|
||||
from . import elf, static_openxr
|
||||
from . import elf
|
||||
from .luajit import xlua_47bit, xlua_sha256
|
||||
from .unity_split import split_build as unity_split_build
|
||||
|
||||
@@ -56,10 +56,9 @@ IL2CPP_METADATA = "assets/bin/Data/Managed/Metadata/global-metadata.dat"
|
||||
# GitHub #349) (2026-10)
|
||||
# 23: separate_process (MAIN activities in another process than the main one, which Lepton doesn't watch:
|
||||
# frame.main_process, GitHub #178) (2026-10)
|
||||
# 24: statically linked Khronos OpenXR loaders (frame.static_openxr_loader, GitHub #254) (2026-10)
|
||||
# 25: meta_depth_occlusion (Unity games with Meta's depth-occlusion keywords: frame.meta_depth_occlusion, PR #246)
|
||||
# 24: meta_depth_occlusion (Unity games with Meta's depth-occlusion keywords: frame.meta_depth_occlusion, PR #246)
|
||||
# (2026-10)
|
||||
ANALYSIS_VERSION = 25
|
||||
ANALYSIS_VERSION = 24
|
||||
|
||||
|
||||
# Android versions by API level (for messages); the Frame's Lepton container runs Android 11 (API 30)
|
||||
@@ -344,8 +343,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
libset = set(libs)
|
||||
engine = ("Unreal" if libset & {"libUE4.so", "libUnreal.so"} else "Unity" if "libunity.so" in libset
|
||||
else "CryEngine" if "libCrySystem.so" in libset or cry_project else "Other")
|
||||
static_loaders = [n for n, d in lib_bytes.items() if static_openxr.detected(n, d)] if abi == "arm64-v8a" else []
|
||||
has_openxr, has_vrapi = bool(static_loaders) or "libopenxr_loader.so" in libset, "libvrapi.so" in libset
|
||||
has_openxr, has_vrapi = "libopenxr_loader.so" in libset, "libvrapi.so" in libset
|
||||
xr = "OpenXR+VrApi" if has_openxr and has_vrapi else "OpenXR" if has_openxr else "VrApi" if has_vrapi else "?"
|
||||
is_overport = "libopenxr_loader_generic.so" in libset or "liboverport.config.so" in libset
|
||||
# VrApi called directly by the engine (no OVRPlugin): overport cannot translate it.
|
||||
@@ -410,7 +408,6 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
is_overport_output=is_overport,
|
||||
debuggable=bool(axml.Axml(manifest).get_bool("application", "debuggable")),
|
||||
extra={
|
||||
"static_openxr_loader": static_loaders,
|
||||
"analysis_version": ANALYSIS_VERSION,
|
||||
"xlua_47bit": xlua_47bit(lib_bytes.get("libxlua.so", b"")),
|
||||
"xlua_sha256": xlua_sha256(lib_bytes.get("libxlua.so", b"")),
|
||||
@@ -438,8 +435,7 @@ def analyze(path: Path, deep: bool = True, data_bytes: int | None = None) -> Ana
|
||||
"unreal_version": unreal_version,
|
||||
"oculus_os_refs": sorted(oculus_os_refs),
|
||||
"xr_layer_exts": sorted(layer_exts), # composition-layer extensions the game's own libraries request
|
||||
"vr_kind": ("openxr" if static_loaders
|
||||
and vr_kind(libset, manifest_strings) == "none" else vr_kind(libset, manifest_strings)),
|
||||
"vr_kind": vr_kind(libset, manifest_strings),
|
||||
"headset": headset(meta, abis, vr_kind(libset, manifest_strings), requires_6dof(manifest)),
|
||||
# a Google Daydream / Cardboard build (GVR / Cardboard SDK): no path to the Frame's headset (GitHub #335)
|
||||
"google_vr": google_vr(libset, cats, manifest_strings, gvr_java, boot),
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Khronos loaders embedded in game libraries (never the separately shipped loader)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
|
||||
from elftools.common.exceptions import ELFError
|
||||
from elftools.elf.elffile import ELFFile
|
||||
|
||||
from . import elf
|
||||
|
||||
MANIFEST = "/sdcard/fp_openxr_runtime.json"
|
||||
# Some builds embed complete paths. Match every partition and ABI spelling, rather than a fixed list.
|
||||
PATH = re.compile(rb"/(?:[A-Za-z0-9_-]+/)*etc/openxr/1/active_runtime(?:\.[A-Za-z0-9_-]+)?\.json\x00")
|
||||
COMPOSED_MANIFEST = "/sdcard/fp/1/active_runtime.json"
|
||||
PARTITIONS = ("/product", "/odm", "/oem", "/vendor", "/system")
|
||||
SUBDIR = "/etc/openxr/"
|
||||
BROKERS = ("org.khronos.openxr.runtime_broker", "org.khronos.openxr.system_runtime_broker")
|
||||
|
||||
|
||||
def paths(data: bytes) -> list[str]:
|
||||
if not elf.is_elf(data):
|
||||
return []
|
||||
sec = ELFFile(io.BytesIO(data)).get_section_by_name(".rodata")
|
||||
if sec is None:
|
||||
return []
|
||||
raw = sec.data()
|
||||
return sorted({m.group()[:-1].decode() for m in PATH.finditer(raw)
|
||||
if m.start() == 0 or raw[m.start() - 1] == 0})
|
||||
|
||||
|
||||
def detected(name: str, data: bytes) -> bool:
|
||||
if (name.startswith("libopenxr_loader") or not elf.is_elf(data)
|
||||
or b"RuntimeManifestFile" not in data or b"xrNegotiateLoaderRuntimeInterface" not in data):
|
||||
return False
|
||||
try:
|
||||
e = ELFFile(io.BytesIO(data))
|
||||
return e["e_machine"] == "EM_AARCH64" and (bool(paths(data)) or composed(data))
|
||||
except (ELFError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def composed(data: bytes) -> bool:
|
||||
"""Khronos platform_utils.hpp builds prefix + subdir + major + suffix.
|
||||
|
||||
Require the complete known table before touching these potentially shared literals.
|
||||
"""
|
||||
sec = ELFFile(io.BytesIO(data)).get_section_by_name(".rodata")
|
||||
strings = set(sec.data().split(b"\0")) if sec else set()
|
||||
return (all(s.encode() in strings for s in (*PARTITIONS, SUBDIR, "/active_runtime.json"))
|
||||
and any(s.startswith(b"/active_runtime.") and s.endswith(b".json") for s in strings))
|
||||
|
||||
|
||||
def redirected(data: bytes) -> bool:
|
||||
if paths(data) or composed(data) or any(b.encode() + b"\0" in data for b in BROKERS):
|
||||
return False
|
||||
if MANIFEST.encode() + b"\0" in data:
|
||||
return True
|
||||
sec = ELFFile(io.BytesIO(data)).get_section_by_name(".rodata")
|
||||
strings = set(sec.data().split(b"\0")) if sec else set()
|
||||
return (b"card/fp/" in strings and b"/sd" in strings
|
||||
and not any(p.encode() in strings for p in PARTITIONS))
|
||||
|
||||
|
||||
def rewrite(data: bytes) -> tuple[bytes, int]:
|
||||
count = 0
|
||||
if composed(data):
|
||||
# Keep every candidate identical: /sd + card/fp/ + 1 + /active_runtime[.ABI].json.
|
||||
# These partition literals may be pooled with app strings; see DEVICE_NOTES.
|
||||
for old, new in [(p, "/sd") for p in PARTITIONS] + [(SUBDIR, "card/fp/")]:
|
||||
data, n = elf.replace_rodata_string(data, old, new)
|
||||
count += n
|
||||
for old in paths(data):
|
||||
data, n = elf.replace_rodata_string(data, old, MANIFEST)
|
||||
count += n
|
||||
for old in BROKERS:
|
||||
# Keep URI length unchanged as well as the string's storage; both authorities must miss.
|
||||
data, n = elf.replace_rodata_string(data, old, old.replace("org.khronos", "org.fp_null"))
|
||||
count += n
|
||||
return data, count
|
||||
@@ -883,44 +883,7 @@ def room_scan(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
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)
|
||||
|
||||
result = rooms.scan(_target(frame).frame, seconds, wait, progress=progress, preview=preview)
|
||||
typer.echo(json.dumps(result, indent=2))
|
||||
|
||||
|
||||
@room_app.command("refine")
|
||||
def room_refine(frame: Optional[str] = typer.Option(None, help=FRAME_HELP),
|
||||
set_: list[str] = typer.Option([], "--set", metavar="KEY=VALUE",
|
||||
help="a clean-up setting (repeatable), e.g. walls=snap; "
|
||||
"the others keep their saved value"),
|
||||
reset: bool = typer.Option(False, "--reset", help="back to the recommended settings first"),
|
||||
keep_edits: bool = typer.Option(True, "--keep-edits/--scan-boxes",
|
||||
help="keep your own furniture and doors, or use the scan's boxes"),
|
||||
list_: bool = typer.Option(False, "--list", help="print the settings and their ranges, then stop")):
|
||||
"""Clean up the saved room's scan again on the Frame (and keep the settings for the next scans)."""
|
||||
from .install import rooms
|
||||
|
||||
if list_:
|
||||
for key, meta in rooms.REFINE_DEFAULTS.items():
|
||||
span = ("/".join(c for c, _ in meta["choices"]) if meta["kind"] == "choice" else "0/1"
|
||||
if meta["kind"] == "bool" else f"{meta['min']:g}..{meta['max']:g} step {meta['step']:g}")
|
||||
typer.echo(f"{key:<12} default {meta['default']!s:<7} {span:<24} {meta['label']}")
|
||||
return
|
||||
target = _target(frame)
|
||||
settings = None
|
||||
if reset or set_:
|
||||
saved = {} if reset else rooms.normalize_refine(rooms.status(target.frame).get("refine") or {})
|
||||
for item in set_ or []:
|
||||
key, sep, value = item.partition("=")
|
||||
if not sep:
|
||||
raise typer.BadParameter(f"expected KEY=VALUE, got {item!r}", param_hint="--set")
|
||||
saved[key.strip()] = value.strip()
|
||||
try:
|
||||
settings = rooms.normalize_refine(saved)
|
||||
except ValueError as exc:
|
||||
raise typer.BadParameter(str(exc), param_hint="--set") from exc
|
||||
result = rooms.refine(target.frame, settings, keep_edits=keep_edits)
|
||||
typer.echo(json.dumps({k: result.get(k) for k in ("refine", "summary", "report", "labels", "suggestions",
|
||||
"room")}, indent=2))
|
||||
typer.echo(json.dumps(rooms.scan(_target(frame).frame, seconds, wait, progress=progress, preview=preview), indent=2))
|
||||
|
||||
|
||||
@room_app.command("show")
|
||||
|
||||
@@ -342,8 +342,7 @@ def _migrate(data: dict) -> bool:
|
||||
if (not isinstance(a, dict) or not isinstance(r, dict) or r.get("source") == "user"
|
||||
or g.get("kind") in ("linux", "rift")):
|
||||
continue
|
||||
scene_on = (r.get("patches") or {}).get("adapter.scene_emul", {}).get("value")
|
||||
if scene_on and uses_scene(analysis_from_dict(a)):
|
||||
if (r.get("patches") or {}).get("adapter.scene_emul", {}).get("value") and uses_scene(analysis_from_dict(a)):
|
||||
r["patches"].setdefault("frame.meta_permissions", {})
|
||||
r.setdefault("reasons", {}).setdefault(
|
||||
"frame.meta_permissions", "Meta's room and anchor permissions must be granted for the saved room.")
|
||||
|
||||
@@ -167,138 +167,6 @@ def build(frame, dump: Path, universe: int | None = None) -> dict:
|
||||
**({"universe": universe} if universe is not None else {}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- scan clean-up (fp_roomscan's refinement)
|
||||
# The one list of clean-up settings: the owner's tuned defaults (compiled into the scanner; the agent's
|
||||
# ROOM_REFINE_DEFAULTS repeats them, a test keeps both equal) and what the Room tab shows for each one.
|
||||
# kind: choice / bool / number; unit: how the value reads (cm = metres shown in cm, m2, deg, times, count);
|
||||
# off: the value that turns the step off (shown as "off"); all: a value that keeps everything ("keep all");
|
||||
# group: walls / outside / cleanup / labels; key: shown without "More settings"; help: a key of ui/help.py HELP;
|
||||
# flag: fp_roomscan's option. Labels are English here and translated where they're shown
|
||||
# (scripts/i18n_extract.py collects them).
|
||||
REFINE_DEFAULTS: dict[str, dict] = {
|
||||
"refine": dict(default=1, kind="bool", label="Clean up the scan", group="cleanup", help="refine_refine"),
|
||||
"walls": dict(default="panels", kind="choice", label="Walls", group="walls", key=True, help="refine_walls",
|
||||
choices=(("off", "Keep scan"), ("snap", "Snap"), ("panels", "Flat panels"))),
|
||||
"walked": dict(default=0.40, min=0, max=0.8, step=0.05, unit="cm", off=0, label="Where you walked",
|
||||
group="cleanup", key=True, help="refine_walked"),
|
||||
"smooth": dict(default=8, min=0, max=15, step=1, unit="times", off=0, label="Smoothing", group="cleanup",
|
||||
key=True, help="refine_smooth"),
|
||||
"island": dict(default=0.20, min=0, max=1, step=0.01, unit="m2", off=0, label="Remove pieces smaller than",
|
||||
group="cleanup", key=True, help="refine_island"),
|
||||
"clip": dict(default=0.05, min=0, max=1, step=0.01, unit="cm", all=1, label="Keep past walls",
|
||||
group="outside", key=True, help="refine_clip"),
|
||||
"clip_open": dict(default=0.10, min=0, max=2, step=0.05, unit="cm", all=2, label="Extra past unseen edges",
|
||||
group="outside", help="refine_clip_open"),
|
||||
"band": dict(default=0.35, min=0.05, max=0.6, step=0.01, unit="cm", label="Behind a surface", group="walls",
|
||||
help="refine_band"),
|
||||
"band_in": dict(default=0.12, min=0.02, max=0.6, step=0.01, unit="cm", label="In front of a wall",
|
||||
group="walls", help="refine_band_in"),
|
||||
"floor_in": dict(default=0.08, min=0.02, max=0.4, step=0.01, unit="cm", label="Above the floor", group="walls",
|
||||
help="refine_floor_in"),
|
||||
"ceil_in": dict(default=0.25, min=0.02, max=0.6, step=0.01, unit="cm", label="Below the ceiling",
|
||||
group="walls", help="refine_ceil_in"),
|
||||
"top_strip": dict(default=0.5, min=0, max=1, step=0.05, unit="cm", off=0, label="Top of walls", group="walls",
|
||||
help="refine_top_strip"),
|
||||
"facing_deg": dict(default=60, min=10, max=80, step=1, unit="deg", label="Facing within", group="walls",
|
||||
help="refine_facing_deg"),
|
||||
"near": dict(default=0.03, min=0, max=0.6, step=0.01, unit="cm", off=0, label="Also clear near walls",
|
||||
group="walls", help="refine_near"),
|
||||
"tubes": dict(default=3.0, min=0, max=6, step=0.1, unit="times", off=0, label="Remove thin floating pieces",
|
||||
group="cleanup", help="refine_tubes"),
|
||||
"tube_thick": dict(default=0.15, min=0.04, max=0.4, step=0.01, unit="cm", label="…no thicker than",
|
||||
group="cleanup", help="refine_tube_thick"),
|
||||
"tops": dict(default=0.10, min=0, max=0.2, step=0.01, unit="cm", off=0, label="Flatten furniture tops",
|
||||
group="cleanup", help="refine_tops"),
|
||||
"protect": dict(default=0, kind="bool", label="Never remove scan inside furniture", group="cleanup",
|
||||
help="refine_protect"),
|
||||
"label_rules": dict(default=1, kind="bool", label="Check furniture labels", group="labels",
|
||||
help="refine_label_rules"),
|
||||
}
|
||||
for _key, _meta in REFINE_DEFAULTS.items():
|
||||
_meta.setdefault("kind", "number")
|
||||
_meta.setdefault("key", False)
|
||||
_meta["flag"] = "--" + _key.replace("_", "-")
|
||||
REFINE_GROUPS = (("walls", "Walls, floor and ceiling"), ("outside", "Outside the room"),
|
||||
("cleanup", "Floating pieces and furniture"),
|
||||
("labels", "Label rules"))
|
||||
INT_SETTINGS = ("refine", "smooth", "protect", "label_rules")
|
||||
|
||||
|
||||
def refine_defaults() -> dict:
|
||||
return {k: m["default"] for k, m in REFINE_DEFAULTS.items()}
|
||||
|
||||
|
||||
def normalize_refine(settings: dict | None) -> dict:
|
||||
"""Only the settings that differ from the defaults, each checked, snapped to its step and clamped to its slider
|
||||
(unknown keys raise ValueError)."""
|
||||
out = {}
|
||||
for key, value in (settings or {}).items():
|
||||
meta = REFINE_DEFAULTS.get(key)
|
||||
if meta is None:
|
||||
raise ValueError(f"unknown clean-up setting: {key}")
|
||||
if meta["kind"] == "choice":
|
||||
if value not in [c for c, _ in meta["choices"]]:
|
||||
raise ValueError(f"{key}: expected one of " + ", ".join(c for c, _ in meta["choices"]))
|
||||
elif meta["kind"] == "bool":
|
||||
if isinstance(value, str):
|
||||
value = value.strip().lower() in ("1", "true", "on", "yes")
|
||||
value = 1 if value else 0
|
||||
else:
|
||||
value = float(value)
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
raise ValueError(f"{key}: not a number")
|
||||
value = min(meta["max"], max(meta["min"], value))
|
||||
value = round(round((value - meta["min"]) / meta["step"]) * meta["step"] + meta["min"], 4)
|
||||
value = int(round(value)) if key in INT_SETTINGS else value
|
||||
if value != meta["default"]:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def current_refine(status: dict | None) -> dict:
|
||||
"""Every clean-up setting as the Frame has it saved (room_status' refine = the non-defaults)."""
|
||||
return {**refine_defaults(), **normalize_refine((status or {}).get("refine") or {})}
|
||||
|
||||
|
||||
def refine(frame, settings: dict | None = None, erase: list | None = None, keep_edits: bool = True) -> dict:
|
||||
"""Clean up the saved room's scan again on the Frame (fp_roomscan --refine-only from its raw files) and save
|
||||
`settings` there for the next scans (None = keep the saved ones; {} = the defaults). erase: [[x, y, z, r], ...]
|
||||
spheres of scan to remove. keep_edits=False drops the player's own furniture / doors (back to the scan's).
|
||||
Returns room_status with the clean-up's report, label decisions and suggestions (+ summary: its JSON output)."""
|
||||
ensure_scanner(frame)
|
||||
args: dict = {"settings": None if settings is None else normalize_refine(settings), "keep_edits": keep_edits}
|
||||
if erase:
|
||||
args["erase"] = [[float(v) for v in spot] for spot in erase]
|
||||
result = frame.agent("room_refine", **args)
|
||||
if result.get("error"):
|
||||
raise RuntimeError(result["error"])
|
||||
return result
|
||||
|
||||
|
||||
def put_room(frame, room, mesh: bytes | None = None) -> dict:
|
||||
"""Save an edited copy of the stored room (furniture boxes, doors and windows from the Room tab): the Frame keeps
|
||||
the scan's raw files and remembers the edits, so a new clean-up keeps them. `mesh` = the room's mesh sidecar
|
||||
(fetched from the Frame when not given)."""
|
||||
if room.mesh and mesh is None:
|
||||
remote = f"{frame.home}/.local/share/frameport/rooms/{room.universe}/{room.mesh.filename}"
|
||||
with frame.sftp.open(remote, "rb") as f:
|
||||
mesh = f.read(64 * 1024 * 1024)
|
||||
if room.mesh:
|
||||
m = read_mesh(mesh)
|
||||
if (m.vertex_count, m.index_count) != (room.mesh.vertex_count, room.mesh.index_count):
|
||||
raise ValueError("The room changed on the Frame; refresh and edit again")
|
||||
staging = user_data_dir() / "rooms"
|
||||
staging.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="edit-", dir=staging) as local, _upload_dir(frame) as remote:
|
||||
path = Path(local) / "room.txt"
|
||||
path.write_text(dumps(room), encoding="utf-8")
|
||||
frame.put(path, f"{remote}/room.txt", resume=False)
|
||||
if room.mesh:
|
||||
(Path(local) / room.mesh.filename).write_bytes(mesh)
|
||||
frame.put(Path(local) / room.mesh.filename, f"{remote}/{room.mesh.filename}", resume=False)
|
||||
return frame.agent("room_put", path=f"{remote}/room.txt", edited=True)
|
||||
|
||||
|
||||
def show(frame, path: Path) -> dict:
|
||||
from ..mr.room import summary
|
||||
|
||||
|
||||
@@ -61,8 +61,6 @@
|
||||
"90 Hz": "",
|
||||
"920 × 430": "",
|
||||
"96 Hz": "",
|
||||
"A closet?": "",
|
||||
"A door?": "",
|
||||
"A folder with Quest, Android or PC VR games.": "",
|
||||
"A game is running": "",
|
||||
"A game is running on the Frame": "",
|
||||
@@ -88,7 +86,6 @@
|
||||
"A small helper FramePort runs on the Frame for installs, launch tests and Steam entries. FramePort updates it by itself.": "",
|
||||
"a source checkout (git pull + uv sync)": "",
|
||||
"A SteamVR overlay app: SteamVR on the Frame draws it over whatever you play, Quest and PC VR games alike. With 'Start with SteamVR' on, SteamVR starts it by itself; otherwise start it from the library before the game.": "",
|
||||
"A tall, thin box by a wall: the scanner can't tell a door from a closet.": "",
|
||||
"A VR app made for another Android headset. OpenXR apps work like Quest games; apps built for Pico or HTC Wave can't run on the Frame.": "",
|
||||
"A web page asked FramePort to install {name}.": "",
|
||||
"A Windows game without VR. On the Frame, Proton runs it in a window from the Steam library.": "",
|
||||
@@ -97,7 +94,6 @@
|
||||
"A zip with FramePort's logs, the game's recipe and the Frame's details, without game files. Addresses, names and Steam IDs are replaced. Attach it to a GitHub issue.": "",
|
||||
"About": "",
|
||||
"About this game": "",
|
||||
"Above the floor": "",
|
||||
"AC Nexus: no AppSW at 72 Hz": "",
|
||||
"AC Nexus: no AppSW at 90 Hz": "",
|
||||
"Activity": "",
|
||||
@@ -108,19 +104,13 @@
|
||||
"Add a tag": "",
|
||||
"Add a Windows program…": "",
|
||||
"Add an APK…": "",
|
||||
"Add as closet": "",
|
||||
"Add as door": "",
|
||||
"Add box": "",
|
||||
"Add door": "",
|
||||
"Add folder…": "",
|
||||
"Add from a link…": "",
|
||||
"Add games": "",
|
||||
"Add them on a wall: games that ask for a door get it.": "",
|
||||
"Add this tag": "",
|
||||
"Add to Steam library: {title}": "",
|
||||
"Add videos": "",
|
||||
"Add videos and files": "",
|
||||
"Add window": "",
|
||||
"Add your games": "",
|
||||
"Add {name}": "",
|
||||
"Added automatically from the engine, VR API or store genre.": "",
|
||||
@@ -134,7 +124,6 @@
|
||||
],
|
||||
"Added {title}": "",
|
||||
"Added {title} — it starts {exe}. Wrong program? Change it on its page.": "",
|
||||
"Added. Check it in the view, then Save.": "",
|
||||
"Adding to the library": "",
|
||||
"Adding {title} to the Frame's Steam library — Steam restarts once": "",
|
||||
"Adds a launcher entry point (Lepton additionally needs category LAUNCHER, see the Frame 'launcher' fix).": "",
|
||||
@@ -160,12 +149,10 @@
|
||||
"Allows reading the game's logs, which helps find problems.": "",
|
||||
"Allows the game's data to be backed up.": "",
|
||||
"Allows very large pictures (8K video, theaters) instead of quitting.": "",
|
||||
"Along the wall": "",
|
||||
"Already patched: don't change the game's files": "",
|
||||
"Already patched: install unchanged": "",
|
||||
"Already set up?": "",
|
||||
"Already up to date": "",
|
||||
"Also clear near walls": "",
|
||||
"Also delete its saves and everything it stored on the Frame (mods, downloaded content)": "",
|
||||
"Also delete this game's files on this PC ({size})": "",
|
||||
"Also in your library: {value} version": "",
|
||||
@@ -199,7 +186,6 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"Apply": "",
|
||||
"Apply controller buttons to installed games": "",
|
||||
"Apply to installed games now": "",
|
||||
"Apps that suggest controller bindings only for Meta's newer profiles (Touch Plus, Touch Pro), which the Frame's runtime rejects (XR_ERROR_PATH_UNSUPPORTED), get the same bindings as oculus/touch_controller instead (components Touch lacks are dropped), and the runtime's answer for each hand is reported to them as their own profile (Unity's OpenXR input only connects controllers of a profile it knows). Meta's detached-controller paths (XR_META_detached_controllers, which the Frame lacks) are left out of the app's actions and bindings: one such path made Unity attach no controller input at all. Without it such apps get no controller input.": "",
|
||||
@@ -222,8 +208,6 @@
|
||||
"Automatic ({auto:.0%})": "",
|
||||
"Automatic SteamVR performance settings (this PC)": "",
|
||||
"Back to library": "",
|
||||
"Back to the scan's boxes": "",
|
||||
"Back to the scan's boxes?": "",
|
||||
"Back up all saves": "",
|
||||
"Back up its saves to this PC before deleting them": "",
|
||||
"Back up saves": "",
|
||||
@@ -241,17 +225,12 @@
|
||||
"Bed": "",
|
||||
"Before a swapchain image is handed to the Frame, wait until the game's GPU work on it has finished, for games that hand over images early (an eye flickers or jitters). Costs some frame time. 2 = only after the first 60 s (compare both in one session).": "",
|
||||
"Before each Play on this PC, FramePort reads SteamVR's record of the game's last session (frames dropped; frame times from fpsVR if installed). If it dropped frames, it lowers the game's refresh rate to one whose frame budget fits (for example 96 → 90 Hz) and turns motion smoothing on, through SteamVR's per-application settings; each later session can step down again. Set a refresh rate or smoothing mode here to choose yourself. Ignored on the Frame.": "",
|
||||
"Behind a surface": "",
|
||||
"Below the ceiling": "",
|
||||
"Best guess": "",
|
||||
"bHaptics' vest SDK asks for an Android 12 Bluetooth permission at start; on Lepton's Android 11 the permission screen takes the foreground for a moment and Meta's OVRPlugin stops drawing (for example Yupitergrad freezing on its loading screen). The request returns at once instead (an in-place edit of classes.dex).": "",
|
||||
"bHaptics: skip the Bluetooth permission request": "",
|
||||
"BlazeRush 1.0.349 only (Klownicle's guide, GitHub #57). Its menus wait for Meta's avatar, which never comes on the Frame, before they create the game's own controllers: the second menu ignored all input. Two instructions in libtargemapp.so skip that wait and the avatar mesh drawing; the game's shader cache gets a new folder name (cache/br_shader_v1), so programs compiled before the GL shim's shader fixes aren't reused. Only this exact build is changed (code fingerprint and every instruction checked); anything else is left as it is.": "",
|
||||
"BlazeRush: controllers without Meta avatars, own shader cache": "",
|
||||
"Blit quads flagged XrCompositionLayerImageLayoutFB VERTICAL_FLIP upside down (Vulkan). Fixes UI panels and text that show upside down (for example Assassin's Creed Nexus).": "",
|
||||
"Bottom edge": "",
|
||||
"Boxes removed": "",
|
||||
"Boxes renamed": "",
|
||||
"Browse this game's files.": "",
|
||||
"Build page": "",
|
||||
"Building the room…": "",
|
||||
@@ -285,7 +264,6 @@
|
||||
"Change…": "",
|
||||
"Check for new versions automatically": "",
|
||||
"Check for updates": "",
|
||||
"Check furniture labels": "",
|
||||
"Check game files": "",
|
||||
"Check games from install links for new versions": "",
|
||||
"Check install links for updates": "",
|
||||
@@ -300,7 +278,6 @@
|
||||
"Checking this PC…": "",
|
||||
"Checking tools…": "",
|
||||
"Checking which app opens these links…": "",
|
||||
"Checks every box the scanner found against size and place rules (see Labels). Off keeps the scanner's own names.": "",
|
||||
"checksum checked": "",
|
||||
"Check…": "",
|
||||
"Choose a backup to put back on the Frame. Close the game first.": "",
|
||||
@@ -319,13 +296,7 @@
|
||||
"Choose which Quest Touch buttons the Frame's face buttons send. Takes effect at the next launch.": "",
|
||||
"Choose…": "",
|
||||
"Chosen by you.": "",
|
||||
"Clean up a scan to see what the rules decided.": "",
|
||||
"Clean up Frida leftovers in smali": "",
|
||||
"Clean up the room scan": "",
|
||||
"Clean up the scan": "",
|
||||
"Clean up your saved scan again with these settings. They are also used for your next scans.": "",
|
||||
"Clean-up": "",
|
||||
"Cleaning up the scan on the Frame…": "",
|
||||
"Cleans up graphics data that crashes some Unreal games on the Frame.": "",
|
||||
"Clear": "",
|
||||
"Clear android:debuggable": "",
|
||||
@@ -334,7 +305,6 @@
|
||||
"Clear selection": "",
|
||||
"Clear tags": "",
|
||||
"Clear the game's GL errors (GLES)": "",
|
||||
"Cleared near walls": "",
|
||||
"Clears OpenGL errors the game left pending before FrameBridge calls the Frame's runtime. The runtime's GL code fails on any pending error; after one such failure at the start it allocated GPU memory every frame without freeing it (e.g. Vader Immortal, ~20 MB a second).": "",
|
||||
"Click here, then type. Every key goes to the Frame.": "",
|
||||
"Click to open · right-click for quick actions": "",
|
||||
@@ -468,7 +438,6 @@
|
||||
"Demo": "",
|
||||
"demo: nothing is downloaded": "",
|
||||
"Deny": "",
|
||||
"Depth": "",
|
||||
"Deselect": "",
|
||||
"Desktop": "",
|
||||
"Desktop Mode": "",
|
||||
@@ -485,7 +454,6 @@
|
||||
"Disable application space warp if used": "",
|
||||
"Disable controller tracking offset": "",
|
||||
"Disable Meta XR Audio telemetry": "",
|
||||
"Discard changes": "",
|
||||
"Disconnect": "",
|
||||
"Dismiss": "",
|
||||
"Display refresh rate for this game (72, 80, 90, 96, 108, 120 or 144; 0 = the game's choice). Applied through SteamVR before the game starts. A video's frame rate that divides the refresh rate plays smoothest (for example 30 fps at 90 Hz, 24 fps at 72 Hz).": "",
|
||||
@@ -496,9 +464,6 @@
|
||||
"Done — start the game again to see whether it helped.": "",
|
||||
"Done. It's used the next time you start {title}.": "",
|
||||
"Door": "",
|
||||
"Door or closet suggestions": "",
|
||||
"Doors & windows": "",
|
||||
"Doors and windows": "",
|
||||
"Download all ({n})…": "",
|
||||
"Download all…": "",
|
||||
"Download and install": "",
|
||||
@@ -528,7 +493,7 @@
|
||||
"Downloading…": "",
|
||||
"Downloads": "",
|
||||
"Download…": "",
|
||||
"Drag to turn the room, scroll or pinch to zoom, right-drag to move it, double-click to see all of it again. The walls nearest to you are left out so you can look inside. The full 3D view in your browser shows every detail of the scan. Click a box or a door to edit it.": "",
|
||||
"Drag to turn the room, scroll or pinch to zoom, right-drag to move it, double-click to see all of it again. The walls nearest to you are left out so you can look inside. The full 3D view in your browser shows every detail of the scan.": "",
|
||||
"Drag {name} into the GitHub issue that opened, then submit it.": "",
|
||||
"Draw each eye separately (OpenGL ES)": "",
|
||||
"Draw fades over the picture": "",
|
||||
@@ -539,10 +504,8 @@
|
||||
"Drop layer color fades": "",
|
||||
"Drop layers whose swapchain failed or whose extension isn't enabled.": "",
|
||||
"Drop to upload": "",
|
||||
"Drop your own boxes, doors and windows and use the boxes the scan found.": "",
|
||||
"Drops Quest-only shared library requirements.": "",
|
||||
"Each game start used to use up one kernel key; after about 200 starts no game started. FramePort stops that, but used keys only come back after a restart of the Frame.": "",
|
||||
"Embedded OpenXR loader: use FrameBridge": "",
|
||||
"Enabled by you.": "",
|
||||
"End all": "",
|
||||
"End all {n} {name}?": "",
|
||||
@@ -561,10 +524,8 @@
|
||||
"Environment variables for the Proton launcher on the Frame (for example DXVK_HUD=fps), one KEY=value per line.": "",
|
||||
"Epic Online Services (EOS) keeps its login keychain in the app's settings storage. When it can't write there, it resets the keychain and tries again without end (for example Beyond Sandbox: a start that never finishes, 100 % CPU, a log of gigabytes). The reset method returns at once instead (an in-place edit of classes.dex), so EOS reports the failure to the game once.": "",
|
||||
"Epic Online Services: stop the keychain retry loop": "",
|
||||
"Erased": "",
|
||||
"Español": "",
|
||||
"Español (Latinoamérica)": "",
|
||||
"Evens out bumps in the scan without shrinking shapes (Taubin smoothing). More passes, smoother.": "",
|
||||
"Every 'local' play space the game creates lines up with the one at the start. For games whose menus or screens jump to where you look on the Frame.": "",
|
||||
"Every 5 s, logs per located space how far the requested times are from the frame's predicted display time, and the runtime clock's offset from the monotonic clock. No effect on the game.": "",
|
||||
"Every detail of the scan, in your browser.": "",
|
||||
@@ -586,18 +547,14 @@
|
||||
"Experimental: shows HUDs, menus and other flat panels that stay black on the Frame (for example Doom3Quest's HUD and PDA).": "",
|
||||
"Extra diagnostics (log)": "",
|
||||
"Extra Lepton settings": "",
|
||||
"Extra past unseen edges": "",
|
||||
"Extra Proton settings (Frame)": "",
|
||||
"Extra room past edges the scanner closed without seeing a wall: they may be open space.": "",
|
||||
"Extra settings for Proton (for testing).": "",
|
||||
"Extra settings for the Android container (for testing).": "",
|
||||
"Eye-image snapshots (diagnostics)": "",
|
||||
"Eye-tracked": "",
|
||||
"Facing within": "",
|
||||
"failed": "",
|
||||
"Failed": "",
|
||||
"Fan": "",
|
||||
"Fewer settings": "",
|
||||
"File": "",
|
||||
"file": "",
|
||||
"Files": "",
|
||||
@@ -624,13 +581,7 @@
|
||||
"Fixed (doesn't follow the eyes)": "",
|
||||
"Flat": "",
|
||||
"Flat panel diagnostics (log)": "",
|
||||
"Flat panel triangles": "",
|
||||
"Flat panels": "",
|
||||
"Flat panels only: other scan this close to a wall, floor or ceiling is removed. Furniture boxes are kept.": "",
|
||||
"Flat window": "",
|
||||
"Flatten furniture tops": "",
|
||||
"Flattened onto furniture tops": "",
|
||||
"Floating pieces and furniture": "",
|
||||
"focus_hold hides focus dips up to this long (100-5000). Longer for a headset whose wear sensor flickers (\"HMD off\" for 0.5-2 s while worn, for example Blade & Sorcery pausing); taking the headset off or the system menu pauses the game only after this long.": "",
|
||||
"Folder": "",
|
||||
"folder": "",
|
||||
@@ -740,16 +691,10 @@
|
||||
"Freed {value:.1f} GiB on the Frame": "",
|
||||
"From the tested recipe for this game": "",
|
||||
"From the {src}": "",
|
||||
"Front / back": "",
|
||||
"Full (headset view size)": "",
|
||||
"Full launch log": "",
|
||||
"Full-resolution area": "",
|
||||
"Furniture": "",
|
||||
"Furniture & screens": "",
|
||||
"Furniture boxes": "",
|
||||
"Furniture boxes in the room": "",
|
||||
"Furniture games see. Pick a box here or click it in the view, then fix its type, place or size. Your boxes stay when you clean up the scan again.": "",
|
||||
"Furniture rarely reaches this close to the ceiling: here the whole band counts as wall.": "",
|
||||
"Game": "",
|
||||
"Game data (.obb) is missing, so these games hang at start. Put the .obb files in a folder named after the game next to the APK, then add it again.": "",
|
||||
"Game data (.obb) is missing. Put it in a folder named {package} next to the APK and add the folder again.": "",
|
||||
@@ -824,7 +769,6 @@
|
||||
"Heads-up: {name} looks like a different app than {title}.": "",
|
||||
"headset found": "",
|
||||
"Headset model": "",
|
||||
"Height": "",
|
||||
"Hi there!": "",
|
||||
"Hide Android's navigation bar": "",
|
||||
"Hide details": "",
|
||||
@@ -863,7 +807,6 @@
|
||||
"Image for the {label}": "",
|
||||
"Imported room": "",
|
||||
"in an mpv window": "",
|
||||
"In front of a wall": "",
|
||||
"In System, turn on Enable Developer Mode. Nothing else changes.": "",
|
||||
"In the app menu": "",
|
||||
"In the headset, select a text field, then type here.": "",
|
||||
@@ -942,39 +885,28 @@
|
||||
"Java runtime": "",
|
||||
"Java, OVRPort and apksigner are ready.": "",
|
||||
"just now": "",
|
||||
"keep all": "",
|
||||
"Keep converted copies on this PC (only needed to inspect a build)": "",
|
||||
"Keep full resolution (no dynamic resolution)": "",
|
||||
"Keep game saves on the Frame": "",
|
||||
"Keep menus in place": "",
|
||||
"Keep past walls": "",
|
||||
"Keep pictures in bounds": "",
|
||||
"Keep scan": "",
|
||||
"Keep scan leaves walls as scanned. Snap moves scan near a wall onto it. Flat panels replace the scanned walls, floor and ceiling with flat surfaces.": "",
|
||||
"Keep the game's saved resolution in step": "",
|
||||
"Keep the same Meta user between starts": "",
|
||||
"Keep the signing keys: without them, updating a game loses its saves.": "",
|
||||
"Keeps a smoothing setting off that the game turns on itself and that can freeze the Frame.": "",
|
||||
"Keeps every submitted image rect inside its swapchain. Unity can size the eye area a few pixels past the image on some Frames; SteamVR then rejects every frame (xrEndFrame -25, XR_ERROR_SWAPCHAIN_RECT_INVALID) and the game stops drawing (for example PowerWash Simulator stuck at \"Waiting\"). An empty rect (0x0, e.g. BattleTalent's first frames, GitHub #293) is rejected the same way and becomes the whole swapchain image.": "",
|
||||
"Keeps extra menus and panels working when the Frame reaches its image count limit. GLES apps only; Vulkan apps are unaffected.": "",
|
||||
"Keeps games running that use a 360° cube layer the Frame lacks (for example Budget Cuts Ultimate). Leave it on.": "",
|
||||
"Keeps objects visible up close in mixed-reality games that would hide them behind your room using Meta's depth sensor, which the Frame lacks (for example Laser Dance).": "",
|
||||
"Keeps one label so the title is stable.": "",
|
||||
"Keeps one name for the game, so it shows the same everywhere.": "",
|
||||
"Keeps OpenXR layer recommendations at the full swapchain size. Prevents dynamic resolution from moving Valve foveation off-centre or leaving a coarse white edge line. May increase GPU load.": "",
|
||||
"Keeps the game from being closed about a minute after it starts (it runs in a separate Android process the Frame doesn't watch).": "",
|
||||
"Keeps the hand and brush visible over Vermillion's painting canvas (the canvas is drawn in the scene instead of as a layer in front).": "",
|
||||
"Keeps the resolution the game saves in step with the headset's picture size (else the picture is shifted, with black borders, after a render resolution change).": "",
|
||||
"Keeps three snapshots per game on the Frame and copies new ones here when connected.": "",
|
||||
"Keeps your progress in games that file their saves under your Meta user.": "",
|
||||
"Kept": "",
|
||||
"kept": "",
|
||||
"Keyboard connected to {label}.": "",
|
||||
"Keyboard disconnected: {error}": "",
|
||||
"KiB": "",
|
||||
"kshoji's USB MIDI driver (Unity MIDI plugins, for example PianoVision) polls Android's USB service for MIDI devices; the Frame's Android has no USB service, so the app crashes a few seconds after start (NullPointerException on MidiDeviceConnectionWatchThread). The poll returns at once instead, so no USB MIDI device is ever found (an in-place edit of classes.dex).": "",
|
||||
"Label rules": "",
|
||||
"Labels": "",
|
||||
"Lamp": "",
|
||||
"Language": "",
|
||||
"Language packs from the game's files": "",
|
||||
@@ -982,7 +914,6 @@
|
||||
"Large files upload together using OpenSSH when available. Default: 3. Lower this on a busy network.": "",
|
||||
"Larger areas retain more detail but save less GPU work.": "",
|
||||
"Last build": "",
|
||||
"Last clean-up": "",
|
||||
"Last launch test's log": "",
|
||||
"Last launch test: passed": "",
|
||||
"Last launch test: {get} · got as far as: {value}": "",
|
||||
@@ -1000,7 +931,6 @@
|
||||
"Launch the game through Revive's injector, which translates the Oculus PC SDK (LibOVR) to OpenXR. Needed by every Oculus Rift game that isn't OpenXR-native. FramePort downloads Revive itself.": "",
|
||||
"Launcher category (INFO → LAUNCHER)": "",
|
||||
"launcher started": "",
|
||||
"Left / right": "",
|
||||
"Left out of the last build, OVRPort now does it itself: {names}": "",
|
||||
"Left out of the last build: OVRPort now does this itself.": "",
|
||||
"Lepton is installed": "",
|
||||
@@ -1054,7 +984,6 @@
|
||||
"Live": "",
|
||||
"Live view": "",
|
||||
"Loading the room in 3D…": "",
|
||||
"Loading the room…": "",
|
||||
"Loading…": "",
|
||||
"Loads FramePort's OpenXR layer under Proton on the Frame. The Frame's SteamVR runtime only accepts OpenXR 1.0 apps, but Proton's VR helper asks for 1.1, so without the layer VR never starts (the game shows as a flat window, or Revive fails with 'Unable to load LibOVRRT DLL'); the layer retries as 1.0. It also emulates xrConvertTimespecTimeToTimeKHR if a runtime refuses it. Ignored on this PC.": "",
|
||||
"Loads FramePort's shader-fix Vulkan layer into an OpenGL ES game. On the Frame, OpenGL ES runs on Zink (Mesa's GL on Vulkan); the layer sits between Zink and the driver and inserts the words of the adapter setting zink_shader_fix into the SPIR-V modules whose size and SHA-256 match (every other module is untouched), for example stores that set loop counters and accumulators a shader reads before writing, which hang the GPU (Vader Immortal's lightspeed jump; found by Klownicle, GitHub #49). Does nothing without zink_shader_fix (or zink_shader_dump) in the recipe, and either setting brings it in by itself. The layer adds itself to Android's Vulkan debug layer list for the game's process only.": "",
|
||||
@@ -1072,7 +1001,6 @@
|
||||
"Logs graphics pipelines the Frame's driver refuses. No effect on the game.": "",
|
||||
"Logs how game poses line up with the Frame's clock. No effect on the game.": "",
|
||||
"Logs Vulkan mistakes the game makes. Needs a special test build.": "",
|
||||
"Long, thin pieces hanging in the air (depth streaks), at least this many times longer than wide, are removed. Never furniture.": "",
|
||||
"Longer for headsets that briefly think they're off your head.": "",
|
||||
"Look for the cable": "",
|
||||
"Looking at the app": "",
|
||||
@@ -1083,11 +1011,9 @@
|
||||
"Looking for the Frame on the USB cable…": "",
|
||||
"Looking for your Frame…": "",
|
||||
"Looking up the store description and screenshots…": "",
|
||||
"Loose bits of scan smaller than this area are removed.": "",
|
||||
"Low": "",
|
||||
"Made in another play area: scan again or use your play area.": "",
|
||||
"Make Unity text fields work": "",
|
||||
"Makes an embedded OpenXR loader use FrameBridge and your game settings.": "",
|
||||
"Makes BlazeRush's menus and controllers work without Meta's avatars, and lets its shader fixes take effect.": "",
|
||||
"Makes grip, trigger and touch controls reach the game's hands (it picked the empty Gear VR controls on the Frame, for example Vader Immortal).": "",
|
||||
"Makes Meta's 3D audio library work outside Quest headsets.": "",
|
||||
@@ -1126,7 +1052,6 @@
|
||||
"Monitor is using {pct}% of the Frame's CPU": "",
|
||||
"More actions": "",
|
||||
"More actions…": "",
|
||||
"More settings": "",
|
||||
"Move": "",
|
||||
"Move to…": "",
|
||||
"Move {title} to {label}": "",
|
||||
@@ -1146,7 +1071,6 @@
|
||||
"Network": "",
|
||||
"Neutralises Meta XR Audio's Quest-only calls (Unity/Wwise).": "",
|
||||
"never": "",
|
||||
"Never remove scan inside furniture": "",
|
||||
"New folder": "",
|
||||
"New folder…": "",
|
||||
"New games go to {label}": "",
|
||||
@@ -1163,7 +1087,6 @@
|
||||
"No activity": "",
|
||||
"no answer from the Frame": "",
|
||||
"No backups of {title} on this PC yet. Back up its saves first (game menu → Back up saves).": "",
|
||||
"No changes to save": "",
|
||||
"no checksum": "",
|
||||
"No dev build is published right now": "",
|
||||
"No file at that path": "",
|
||||
@@ -1204,7 +1127,6 @@
|
||||
"Not now": "",
|
||||
"Not offered by Steam on this Frame yet": "",
|
||||
"Not recommended: breaks games that load VrApi through OVRPlugin.": "",
|
||||
"Not saved yet": "",
|
||||
"Not set up": "",
|
||||
"Not set up yet. FramePort sets it up when it starts.": "",
|
||||
"Not streaming": "",
|
||||
@@ -1234,8 +1156,6 @@
|
||||
"Oculus OS telemetry class stubs": "",
|
||||
"Oculus XR Plugin games render both eyes in one pass (multiview) on Android. If one eye shows only effects or gray (for example I Am Cat), rendering each eye in its own pass can fix it, at some GPU cost. Rewrites OculusSettings.GetStereoRenderingMode and its inlined read in OculusLoader.Initialize -> MultiPass in libil2cpp.so (found with Cpp2IL). Try it when one eye is wrong.": "",
|
||||
"Off": "",
|
||||
"off": "",
|
||||
"Off keeps the scanner's mesh exactly as recorded; the room's walls and boxes stay.": "",
|
||||
"Off — launch from FramePort": "",
|
||||
"Off: these links open whatever app is set up for them.": "",
|
||||
"off: {names}": "",
|
||||
@@ -1245,7 +1165,6 @@
|
||||
"Older way to turn upside-down panels; prefer Fix upside-down menus.": "",
|
||||
"Older workaround for upside-down quads: rotates them 180° (quads are single-sided; prefer flip_emul).": "",
|
||||
"Older xLua/LuaJIT engines reject native pointers outside their 47-bit range, causing a startup error and sometimes a second crash during Unity shutdown (e.g. BattleTalent). Uses a Lua engine that preserves full pointer values while keeping the game's original xLua bindings and native helpers. Applied only to the verified compatible library build.": "",
|
||||
"on": "",
|
||||
"ON AIR": "",
|
||||
"On Frame": "",
|
||||
"On the desktop": "",
|
||||
@@ -1261,14 +1180,11 @@
|
||||
"On your Frame": "",
|
||||
"on: {names}": "",
|
||||
"One PC game folder": "",
|
||||
"Only a scanned room can be cleaned up.": "",
|
||||
"Only FramePort's own key, and your SSH agent's keys.": "",
|
||||
"Only if 360° pictures show wrongly.": "",
|
||||
"Only if 360° pictures show wrongly: 1 = upside down, 2 = mirrored, 4 = turned around (add values to combine).": "",
|
||||
"Only install software from sites you trust. FramePort downloads it from {host} and installs it on your Frame.": "",
|
||||
"Only internal storage": "",
|
||||
"Only thin floating pieces up to this thickness are removed.": "",
|
||||
"Only this thin layer on the room side counts as wall, so wardrobes and shelves against it stay.": "",
|
||||
"Open": "",
|
||||
"Open \"Install with FrameDrop\" buttons (framedrop:// links) in FramePort": "",
|
||||
"Open backups folder": "",
|
||||
@@ -1302,7 +1218,6 @@
|
||||
"Orange for actions, blue for the PC side: the logo's two portals": "",
|
||||
"Other": "",
|
||||
"other file": "",
|
||||
"Outside the room": "",
|
||||
"Override the guardian depth (0 = use guardian, min 1.5 m).": "",
|
||||
"Override the guardian width (0 = use guardian, min 1.5 m).": "",
|
||||
"OVRPort converts Quest games to standard OpenXR. These are its optional patches.": "",
|
||||
@@ -1349,7 +1264,6 @@
|
||||
"Perfect pacing": "",
|
||||
"Photon Voice's microphone plugin (for example Table Troopers) picks the audio device with Android 12 methods (AudioManager communication devices) without checking the Android version; Lepton runs Android 11, so the game crashes with a NoSuchMethodError when voice chat starts. That method returns at once instead (an in-place edit of classes.dex); the microphone keeps Android's default audio route.": "",
|
||||
"Photon Voice: skip Android 12 audio routing": "",
|
||||
"Pick a box here or click it in the view, then fix its type, place or size.": "",
|
||||
"Pick several games and install them in one go. FramePort asks every question first, then works through them in the background.": "",
|
||||
"Pick the field in the headset": "",
|
||||
"Pick the program you'd double-click to play. Oculus builds usually work better than Steam builds.": "",
|
||||
@@ -1390,7 +1304,6 @@
|
||||
"Prepare {title}": "",
|
||||
"Pretty smooth": "",
|
||||
"Prevents a start-up crash in some Unreal games.": "",
|
||||
"Prevents dynamic resolution from moving Valve foveation off-centre or leaving a white edge line. May increase GPU load. Used at the next launch.": "",
|
||||
"Previous": "",
|
||||
"Private SSH key file (not the .pub file)": "",
|
||||
"Problems and feedback": "",
|
||||
@@ -1451,7 +1364,6 @@
|
||||
"Recipe reset": "",
|
||||
"Recipe reset — update the game on the Frame to use it": "",
|
||||
"Recipes tested on a real Frame: built in, fetched online and the ones you saved as known-good.": "",
|
||||
"Recommended": "",
|
||||
"Reconnect": "",
|
||||
"Recreate spaces after the first frames (diagnostic).": "",
|
||||
"Reduce motion": "",
|
||||
@@ -1474,16 +1386,13 @@
|
||||
"Remove folder": "",
|
||||
"Remove from library…": "",
|
||||
"Remove localized app names": "",
|
||||
"Remove pieces smaller than": "",
|
||||
"Remove tag": "",
|
||||
"Remove thin floating pieces": "",
|
||||
"Remove this theme": "",
|
||||
"Remove Unreal's ForceQuit": "",
|
||||
"Remove uses-library": "",
|
||||
"Remove VrApi library": "",
|
||||
"Remove XR_KHR_composition_layer_depth chains (diagnostic).": "",
|
||||
"Remove {title} from the library?": "",
|
||||
"removed": "",
|
||||
"Removed from Desktop Mode": "",
|
||||
"Removed from the desktop": "",
|
||||
"Removed the converted copies ({size})": "",
|
||||
@@ -1569,14 +1478,11 @@
|
||||
"Room floor plan": "",
|
||||
"Room height": "",
|
||||
"Room positions belong to the play area where they were recorded. If SteamVR changes it, scan again or use the new boundary to line up virtual walls with your room.": "",
|
||||
"Room saved": "",
|
||||
"Room scan cleaned up": "",
|
||||
"Room scan saved": "",
|
||||
"Room scanned": "",
|
||||
"Room setup": "",
|
||||
"Room width": "",
|
||||
"Rotate panels 180°": "",
|
||||
"Routes a game's statically linked Khronos OpenXR loader through FrameBridge by rewriting its runtime manifest paths in place into its own /sdcard (including assembled paths). Disables its Android runtime broker queries so they fall back to that file. Ships a runtime shim and an unmodified inner Khronos loader, which reads the system runtime manifest. Reinstall to write the per-game manifest.": "",
|
||||
"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.": "",
|
||||
"Run a launch test after installing on the Frame": "",
|
||||
"Run one line on the Frame, then click Allow here. You do this once. No password, and nothing changes on the Frame until you allow it.": "",
|
||||
@@ -1596,7 +1502,6 @@
|
||||
"Save": "",
|
||||
"Save as known-good recipe": "",
|
||||
"Save backups to keep per game": "",
|
||||
"Save room edits": "",
|
||||
"Save the screenshots to which folder on this PC?": "",
|
||||
"Saved as a known-good recipe": "",
|
||||
"Saved as known-good. Check the GitHub issue that opened and submit it.": "",
|
||||
@@ -1611,21 +1516,11 @@
|
||||
"Saves the game's shaders to capture one for a fix. No effect on the game.": "",
|
||||
"Saves the game's shaders to find one that hangs the GPU. No effect on the game.": "",
|
||||
"Saves what the game draws every few seconds, for testing without the headset.": "",
|
||||
"Saving your room on the Frame…": "",
|
||||
"Saving…": "",
|
||||
"Scales every controller vibration the game asks for (0-1): plain vibrations, Meta's amplitude envelopes and PCM buffers. For games whose vibrations feel much stronger than on a Quest.": "",
|
||||
"Scan": "",
|
||||
"Scan a folder…": "",
|
||||
"Scan canceled. Your saved room hasn't changed.": "",
|
||||
"Scan further than this behind a seen wall is removed. At the maximum nothing is removed.": "",
|
||||
"Scan inside a furniture box is never removed by the other steps.": "",
|
||||
"Scan near the top of a table, bed or other box, facing up, goes onto the box's top.": "",
|
||||
"Scan only counts as a wall when it faces the room within this angle of the wall's direction.": "",
|
||||
"Scan room in the headset": "",
|
||||
"Scan this close above the floor counts as floor.": "",
|
||||
"Scan this close below the ceiling counts as ceiling.": "",
|
||||
"Scan this far behind a wall, under the floor or above the ceiling counts as that surface.": "",
|
||||
"Scan triangles": "",
|
||||
"Scan when requested": "",
|
||||
"Scan your room and look at it in 3D.": "",
|
||||
"Scan your room for mixed reality": "",
|
||||
@@ -1650,7 +1545,6 @@
|
||||
"Searched a moment ago.": "",
|
||||
"Searching…": "",
|
||||
"See every patch and turn them on or off.": "",
|
||||
"See what a game gets:": "",
|
||||
"See what the headset sees": "",
|
||||
"See what's new…": "",
|
||||
"See your room": "",
|
||||
@@ -1659,7 +1553,6 @@
|
||||
"Select all": "",
|
||||
"Select all shown": "",
|
||||
"Select games to install": "",
|
||||
"Send your boxes, doors and windows to the Frame. Games get them at their next start.": "",
|
||||
"Serve Meta scene and spatial entity APIs from the saved room (walls, furniture and mesh). Without a saved room, use a simple play-area box (for example Demeter).": "",
|
||||
"Set by you.": "",
|
||||
"Set up a play area in SteamVR first. Valve's default circle does not describe your room.": "",
|
||||
@@ -1673,8 +1566,6 @@
|
||||
"shader cache: faster starts; rebuilt if deleted": "",
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix) for OpenGL ES games, whose GLSL the Frame's GL driver (Zink) turns into SPIR-V: modules matching size + SHA-256 get words inserted at a byte offset, for example stores that set loop counters and accumulators a shader reads before writing (Vader Immortal's lightspeed jump hung the GPU; found by Klownicle, GitHub #49). Same format as vk_shader_fix. Comes from a game's recipe; the driver's output can change with a Frame update, then the fix no longer matches (logged).": "",
|
||||
"Shader-fix Vulkan layer (frame.zink_shader_fix): writes every distinct SPIR-V module the GL driver creates to Android/data/<package>/files/fp_spirv/<size>_<sha256>.spv, to capture a shader for a new zink_shader_fix. No effect on the game.": "",
|
||||
"Share GLES panel images in runtime atlases to avoid the Frame's swapchain count limit (about 15 per session). Useful when opening extra menus closes them or leaves panels gray. Vulkan apps are unaffected.": "",
|
||||
"Share panel images (swapchain limit)": "",
|
||||
"Share working recipe · {title}": "",
|
||||
"Share working recipe…": "",
|
||||
"Show 360° backgrounds and videos": "",
|
||||
@@ -1727,10 +1618,7 @@
|
||||
"Skips picture layers the Frame would refuse. Leave it on.": "",
|
||||
"Sleep": "",
|
||||
"Small (saves most)": "",
|
||||
"Small pieces": "",
|
||||
"Smooth as butter": "",
|
||||
"Smoothing": "",
|
||||
"Snap": "",
|
||||
"software encoder": "",
|
||||
"Some apps open a flat Android launcher that then starts a separate VR activity (for example WiiCompiled). Shown with Lepton's flat window, that window stays in view after the VR part starts. This makes the VR activity the one Lepton starts (its LAUNCHER category), so the app opens in VR with no flat window. Everything only the launcher does (choosing or importing content) is skipped: turn it off for a run when you need the launcher, and turn off \"Show the app's Android window\".": "",
|
||||
"Some apps run their game activity in a process of its own (android:process, e.g. Halo CE VR's \":halo_game\"), started from a 2D launcher activity. Lepton starts the game activity directly and watches only the app's main process, which then never exists: about a minute in, Lepton decides the app \"did not start\" and closes it, at whatever point the game has reached. This runs those activities in the main process (only their android:process value changes).": "",
|
||||
@@ -1876,7 +1764,6 @@
|
||||
"That's me!": "",
|
||||
"The activity of Unity's App UI package (for example PianoVision) gets the vibrator through Android 12's VibratorManager without checking the Android version; on Lepton's Android 11 the game quits at start with a NoClassDefFoundError. The activity then starts without a vibrator and its UI haptic feedback does nothing (an in-place edit of classes.dex).": "",
|
||||
"The APK file is damaged or incomplete. Get the game again and rescan the folder.": "",
|
||||
"The cleaned-up scan had more triangles than a room can hold: {n} were left out.": "",
|
||||
"The connection to the Frame is busy. Try again in a moment.": "",
|
||||
"The download didn't finish. Check your internet connection.": "",
|
||||
"the downloaded app": "",
|
||||
@@ -1901,7 +1788,6 @@
|
||||
"The Frame only offers its USB connection in Developer Mode:": "",
|
||||
"The Frame refused that password. Check it, or use Steam Frame → Start setup instead.": "",
|
||||
"The Frame restarts in a few seconds. FramePort reconnects when it's back.": "",
|
||||
"The Frame tidies up every scan: flat walls, floor and ceiling, no scan behind walls or where you walked, no small or floating bits. Apply runs it again on your saved scan with these settings.": "",
|
||||
"The Frame turns off in a few seconds. Turn it on again with its power button.": "",
|
||||
"The Frame went offline: {exc}": "",
|
||||
"The Frame's battery is at {pct}% and not charging. Plug it into a strong charger — installs pause at {pause}%.": "",
|
||||
@@ -1937,8 +1823,6 @@
|
||||
"The processor types the game has code for. The Frame runs only 64-bit ARM (arm64-v8a); 32-bit-only games (Gear VR, Oculus Go) run through FramePort's 32-bit bridge (experimental).": "",
|
||||
"The room is still loading.": "",
|
||||
"The room mixed-reality games see: walls, furniture and the scanned surfaces": "",
|
||||
"The scanner can't see doors or windows. Add them on a wall: games that ask for a door get it.": "",
|
||||
"The scanner names each box it finds. These rules check the names against the box's size and place: boxes behind walls go, desks stop being couches, a screen has to look like one.": "",
|
||||
"The scanner turns a recorded depth-mesh dump into a room. FramePort installs it when you build a recorded scan.": "",
|
||||
"The setup turns on Developer Mode, lets FramePort in and installs Lepton if needed. The Frame's desktop closes during it, then FramePort connects by itself. No password needed.": "",
|
||||
"The site's HTTPS certificate couldn't be verified. Something on the network, such as antivirus HTTPS scanning, a proxy or a school/work network, may have replaced it. Try another network or turn off HTTPS scanning.": "",
|
||||
@@ -1959,7 +1843,6 @@
|
||||
"They run on the Frame too.": "",
|
||||
"They're deleted on the Frame for good. Steam's own list may show them until Steam restarts.": "",
|
||||
"They're part of SteamOS. Ending them may restart Steam and stop the running game.": "",
|
||||
"Thin floating pieces": "",
|
||||
"This applies pending library changes and closes Desktop Mode and FramePort on the Frame.": "",
|
||||
"This deletes the {what} on the Frame. It can't be undone.": "",
|
||||
"This folder contains an installer without a launchable Linux program. FramePort runs programs, not installers. Use the project's own install instructions.": "",
|
||||
@@ -1989,22 +1872,19 @@
|
||||
"Today": "",
|
||||
"Tools": "",
|
||||
"Tools are up to date": "",
|
||||
"Top height": "",
|
||||
"Top of walls": "",
|
||||
"Trace Meta platform requests (diagnostics)": "",
|
||||
"transparent PNG": "",
|
||||
"Treat newer Quest controllers as Touch": "",
|
||||
"Triangles in the room": "",
|
||||
"Tried it in the headset? Share how it runs, and your recipe can become the built-in one for everyone.": "",
|
||||
"Troubleshoot": "",
|
||||
"Troubleshooting": "",
|
||||
"Trust the new key": "",
|
||||
"Try again": "",
|
||||
"Try it out: Test Quest MR shows the room, furniture and anchors a game receives from this scan.": "",
|
||||
"Try now": "",
|
||||
"Try Refresh. The Frame needs to be on and connected.": "",
|
||||
"Try the modes first: Test Quest Controllers shows which Quest button each Frame button sends.": "",
|
||||
"Try this setting": "",
|
||||
"Turn": "",
|
||||
"Turn it down if controller vibrations are too strong.": "",
|
||||
"Turn off only for games you play with your hands instead of controllers.": "",
|
||||
"Turn off Quest Vulkan foveation": "",
|
||||
@@ -2139,7 +2019,6 @@
|
||||
"Use the default": "",
|
||||
"Use the new recipe": "",
|
||||
"Use the repack's launcher (bundled Revive)": "",
|
||||
"Use the scan's boxes": "",
|
||||
"Use the setup command": "",
|
||||
"Use this PC's keyboard on the Frame": "",
|
||||
"Use this PC's keyboard on the Frame.": "",
|
||||
@@ -2197,19 +2076,13 @@
|
||||
"Waiting ({n})": "",
|
||||
"Waiting for the cable…": "",
|
||||
"Waiting for the Frame": "",
|
||||
"Waiting for the Frame…": "",
|
||||
"Waiting for your Frame": "",
|
||||
"Waiting for your Frame to ask…": "",
|
||||
"Waiting for your Frame… (code {code})": "",
|
||||
"waiting {pct}%": "",
|
||||
"Wall art": "",
|
||||
"Wall {n} ({length} m)": "",
|
||||
"Wall {n} ({length} m, unseen)": "",
|
||||
"wall {n} · {w}×{h} m": "",
|
||||
"Wall, floor and ceiling surfaces": "",
|
||||
"Walls": "",
|
||||
"Walls from your play area": "",
|
||||
"Walls, floor and ceiling": "",
|
||||
"Watch what the headset shows, in a window on this PC": "",
|
||||
"Web browser": "",
|
||||
"Welcome to FramePort": "",
|
||||
@@ -2227,14 +2100,11 @@
|
||||
"Where new games go on the Frame, such as a microSD card. Installed games stay put; right-click → Move to… moves one. Cards formatted as FAT, exFAT or NTFS can't hold games.": "",
|
||||
"Where the power goes": "",
|
||||
"Where the recipe came from: catalog (tested on a Frame), heuristics (suggested by FramePort) or user (changed by you).": "",
|
||||
"Where you walked": "",
|
||||
"Where you walked: skipped, this scan has no head path": "",
|
||||
"Which program starts {get}?": "",
|
||||
"Which Proton build runs the game on the Frame: the stable Proton (the default: smoother in our tests) or Proton Experimental (gets Valve's ARM64 fixes first; try it when a game doesn't start or runs badly). Also takes any compat tool name from the Frame's ARM64 list.": "",
|
||||
"Which Proton runs the game on the Frame. Stable is smoother; try Experimental if the game doesn't start or runs badly.": "",
|
||||
"Wide cover": "",
|
||||
"wide, 1920 × 620": "",
|
||||
"Width": "",
|
||||
"Window": "",
|
||||
"Windows": "",
|
||||
"Windows game (Proton)": "",
|
||||
@@ -2272,7 +2142,6 @@
|
||||
"You have {version}. {how}": "",
|
||||
"You only do this once. FramePort connects by itself when the setup is done.": "",
|
||||
"You pasted a link to {name}.": "",
|
||||
"You walked through this space while scanning, so nothing stands there: scan this far below your head's path is your own body or controllers. Furniture boxes are kept.": "",
|
||||
"Your changes to its patches and game settings are replaced by the suggested recipe.": "",
|
||||
"Your copy is installed unchanged (the Frame's copy gets launch patches)": "",
|
||||
"Your custom recipe": "",
|
||||
@@ -2283,12 +2152,9 @@
|
||||
"Your Frame restarted while {title} was running. If the game caused it, please report it.": "",
|
||||
"Your Frame's files, from this PC": "",
|
||||
"Your own artwork for {title}": "",
|
||||
"Your own boxes and doors stay.": "",
|
||||
"Your own furniture boxes, doors and windows are removed and the scan is cleaned up again.": "",
|
||||
"Your own game files aren't touched.": "",
|
||||
"Your room in 3D": "",
|
||||
"Your saves on the Frame and each game's signing key are kept.": "",
|
||||
"Your settings": "",
|
||||
"{app} opens these links on this PC.": "",
|
||||
"{count} files · {human}": "",
|
||||
"{count} on": "",
|
||||
@@ -2327,7 +2193,6 @@
|
||||
"{name} is still running.": "",
|
||||
"{name} isn't a Linux app (AppImage, Linux program or .zip/.tar archive)": "",
|
||||
"{name}: waiting for your answer": "",
|
||||
"{n} cm": "",
|
||||
"{n} file": [
|
||||
"",
|
||||
""
|
||||
@@ -2389,9 +2254,7 @@
|
||||
""
|
||||
],
|
||||
"{n} lines, the last 4000 shown": "",
|
||||
"{n} m": "",
|
||||
"{n} mesh triangles": "",
|
||||
"{n} m²": "",
|
||||
"{n} needs a newer FramePort": [
|
||||
"",
|
||||
""
|
||||
@@ -2424,8 +2287,6 @@
|
||||
"",
|
||||
""
|
||||
],
|
||||
"{n}°": "",
|
||||
"{n}×": "",
|
||||
"{old} → {new} · from {host}": "",
|
||||
"{on} on your Frame": "",
|
||||
"{rate}/s": "",
|
||||
@@ -2468,7 +2329,6 @@
|
||||
"{value} took too long — update by hand: {join}": "",
|
||||
"{walls} walls · {area:.1f} m² · {height:.1f} m high": "",
|
||||
"{walls} walls · {area:.1f} m² · {height:.1f} m high · {furniture} pieces of furniture": "",
|
||||
"{w}×{d}×{h} m": "",
|
||||
"Čeština": "",
|
||||
"Русский": "",
|
||||
"Українська": "",
|
||||
@@ -2481,9 +2341,6 @@
|
||||
""
|
||||
],
|
||||
"… {node} more (use the filter to find them)": "",
|
||||
"…no thicker than": "",
|
||||
"→ closet?": "",
|
||||
"→ door?": "",
|
||||
"中文(简体)": "",
|
||||
"中文(繁體)": "",
|
||||
"日本語": "",
|
||||
|
||||
@@ -278,113 +278,6 @@ def summary(room: Room) -> dict:
|
||||
"ceiling_height": room.ceiling - room.floor}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------ editing helpers
|
||||
VOLUME_LABELS = ("TABLE", "COUCH", "BED", "STORAGE", "SCREEN", "LAMP", "PLANT", "OTHER")
|
||||
PLANE_LABELS = ("DOOR_FRAME", "WINDOW_FRAME", "WALL_ART")
|
||||
PLANE_INSET = 0.01 # doors/windows sit this far in front of their wall (into the room)
|
||||
|
||||
|
||||
def new_id() -> str:
|
||||
return _id()
|
||||
|
||||
|
||||
def nearest_wall(room: Room, x: float, z: float) -> dict | None:
|
||||
"""The wall closest to (x, z): index i, distance d, t (0..1 along it), length L, direction (ux, uz) and the
|
||||
closest point (px, pz)."""
|
||||
best = None
|
||||
for i, w in enumerate(room.walls):
|
||||
abx, abz = w.x1 - w.x0, w.z1 - w.z0
|
||||
length = math.hypot(abx, abz)
|
||||
if length < 1e-9:
|
||||
continue
|
||||
t = max(0.0, min(1.0, ((x - w.x0) * abx + (z - w.z0) * abz) / (length * length)))
|
||||
px, pz = w.x0 + t * abx, w.z0 + t * abz
|
||||
d = math.hypot(x - px, z - pz)
|
||||
if best is None or d < best["d"]:
|
||||
best = dict(i=i, d=d, t=t, L=length, ux=abx / length, uz=abz / length, px=px, pz=pz)
|
||||
return best
|
||||
|
||||
|
||||
def plane_on_wall(room: Room, wall: int, along: float, bottom: float, w: float, h: float,
|
||||
label: str = "DOOR_FRAME", uuid: str | None = None) -> Plane:
|
||||
"""A door / window / wall-art plane on a wall (the cleanup page's planeGeom): `along` = 0..1 of the wall's length
|
||||
for its middle (kept fully on the wall), `bottom` = metres above the floor. Meta's convention: bounds in local XY,
|
||||
local +Z = the normal into the room."""
|
||||
wl = room.walls[wall]
|
||||
dx, dz = wl.x1 - wl.x0, wl.z1 - wl.z0
|
||||
length = math.hypot(dx, dz)
|
||||
ux, uz = dx / length, dz / length
|
||||
nx, nz = -uz, ux
|
||||
a = max(min(w / 2, length / 2), min(length - w / 2, along * length))
|
||||
th = math.atan2(-uz, ux) # about +Y: +X becomes the wall's direction, +Z its inward normal
|
||||
return Plane(uuid or _id(), label, wl.x0 + ux * a + nx * PLANE_INSET, room.floor + bottom + h / 2,
|
||||
wl.z0 + uz * a + nz * PLANE_INSET, 0.0, math.sin(th / 2), 0.0, math.cos(th / 2), w, h)
|
||||
|
||||
|
||||
def plane_place(room: Room, plane: Plane) -> dict:
|
||||
"""Where a plane sits for the editor: wall (index of the nearest wall), along (0..1), bottom (m above the floor)."""
|
||||
near = nearest_wall(room, plane.px, plane.pz)
|
||||
if near is None:
|
||||
return dict(wall=0, along=0.5, bottom=max(0.0, plane.py - plane.h / 2 - room.floor))
|
||||
return dict(wall=near["i"], along=near["t"], bottom=max(0.0, plane.py - plane.h / 2 - room.floor))
|
||||
|
||||
|
||||
def plane_corners(plane: Plane) -> list[tuple[float, float, float]]:
|
||||
"""A plane's four corners (bottom left, bottom right, top right, top left, seen from the room)."""
|
||||
th = 2 * math.atan2(plane.qy, plane.qw) # (planes are upright: a turn about +Y)
|
||||
ux, uz = math.cos(th), -math.sin(th)
|
||||
hw, hh = plane.w / 2, plane.h / 2
|
||||
x, y, z = plane.px, plane.py, plane.pz
|
||||
return [(x - ux * hw, y - hh, z - uz * hw), (x + ux * hw, y - hh, z + uz * hw),
|
||||
(x + ux * hw, y + hh, z + uz * hw), (x - ux * hw, y + hh, z - uz * hw)]
|
||||
|
||||
|
||||
def default_plane(room: Room, label: str) -> Plane:
|
||||
"""A new door (0.9 x 2.05 m from the floor) or window (1.2 x 1.2 m, 0.9 m up) in the middle of the first wall
|
||||
the scan saw."""
|
||||
wall = next((i for i, w in enumerate(room.walls) if w.label != "INVISIBLE_WALL_FACE"), 0)
|
||||
door = label == "DOOR_FRAME"
|
||||
return plane_on_wall(room, wall, 0.5, 0 if door else 0.9, 0.9 if door else 1.2, 2.05 if door else 1.2, label)
|
||||
|
||||
|
||||
def default_volume(room: Room) -> Volume:
|
||||
"""A new box: a 1.2 x 0.6 m table in the middle of the room."""
|
||||
xs = [x for x, _ in room.outline]
|
||||
zs = [z for _, z in room.outline]
|
||||
return Volume(_id(), "TABLE", (min(xs) + max(xs)) / 2, (min(zs) + max(zs)) / 2, room.floor + 0.75, 0.0,
|
||||
1.2, 0.6, 0.75)
|
||||
|
||||
|
||||
def accept_suggestion(room: Room, suggestion: dict, kind: str) -> Volume | Plane:
|
||||
"""A scanner suggestion (a tall thin slab by a wall) as a door on that wall or as a closet against it (the
|
||||
cleanup page's acceptSuggestion). Added to the room and returned."""
|
||||
box = suggestion.get("box") or {}
|
||||
cx, cz = float(box.get("cx", 0)), float(box.get("cz", 0))
|
||||
w, d = float(box.get("w", 0.9)), float(box.get("d", 0.1))
|
||||
h = float(box.get("h", 2.0))
|
||||
top = float(box.get("top_y", room.floor + h))
|
||||
width = max(w, d)
|
||||
near = nearest_wall(room, cx, cz)
|
||||
wall, along = (near["i"], near["t"]) if near else (0, 0.5)
|
||||
if isinstance(suggestion.get("wall"), int) and 0 <= suggestion["wall"] < len(room.walls):
|
||||
wall = suggestion["wall"]
|
||||
along = float(suggestion.get("along", along))
|
||||
if kind == "door":
|
||||
plane = plane_on_wall(room, wall, along, 0.0, min(max(width, 0.8), 1.2), min(max(h, 1.95), 2.2), "DOOR_FRAME")
|
||||
room.planes.append(plane)
|
||||
return plane
|
||||
wl = room.walls[wall]
|
||||
length = math.hypot(wl.x1 - wl.x0, wl.z1 - wl.z0)
|
||||
ux, uz = (wl.x1 - wl.x0) / length, (wl.z1 - wl.z0) / length
|
||||
px, pz = wl.x0 + ux * along * length, wl.z0 + uz * along * length
|
||||
nx, nz, depth = -uz, ux, 0.6 # against the wall, inside the room
|
||||
height = min(top - room.floor, 2.4)
|
||||
vol = Volume(_id(), "STORAGE", px + nx * depth / 2, pz + nz * depth / 2, room.floor + height,
|
||||
math.degrees(math.atan2(-uz, ux)), max(width, 0.6), depth, height)
|
||||
room.volumes.append(vol)
|
||||
return vol
|
||||
|
||||
|
||||
def preview_svg(room: Room) -> str:
|
||||
"""Self-contained top-down preview; text labels are escaped, never interpreted as markup."""
|
||||
points = room.outline + room.play_area
|
||||
|
||||
@@ -9,7 +9,7 @@ right and down. Walls and mesh triangles face into the room, so the walls neares
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field, replace
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .room import Mesh, Room
|
||||
|
||||
@@ -33,7 +33,6 @@ class Face:
|
||||
normal: tuple[float, float, float] # facing direction; the face is drawn when the camera is on this side
|
||||
kind: str = "wall" # wall | hidden_wall | box
|
||||
label: str = ""
|
||||
index: int = -1 # a box side: the volume's index in the room
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,8 +56,6 @@ class Scene:
|
||||
lo: tuple[float, float, float] = (-1.0, 0.0, -1.0)
|
||||
hi: tuple[float, float, float] = (1.0, 2.5, 1.0)
|
||||
triangles: int = 0 # of the full mesh
|
||||
planes: list[list[tuple[float, float, float]]] = field(default_factory=list) # doors / windows (corners)
|
||||
selected: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = field(default_factory=list)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ vector helpers
|
||||
@@ -186,7 +183,19 @@ def prepare(room: Room, mesh: Mesh | None = None, budget: int = REST_BUDGET,
|
||||
sc.faces.append(Face([(w.x0, w.y0, w.z0), (w.x1, w.y0, w.z1), (w.x1, w.y1, w.z1), (w.x0, w.y1, w.z0)], n,
|
||||
"hidden_wall" if w.label == "INVISIBLE_WALL_FACE" else "wall", w.label))
|
||||
sc.floor = [(x, y0, z) for x, z in room.outline]
|
||||
_add_furniture(sc, room)
|
||||
for v in room.volumes:
|
||||
foot = box_corners(v.cx, v.cz, v.yaw_deg, v.w, v.d)
|
||||
top, bottom = v.top_y, v.top_y - v.h
|
||||
lo_pts = [(x, bottom, z) for x, z in foot]
|
||||
hi_pts = [(x, top, z) for x, z in foot]
|
||||
center = (v.cx, (top + bottom) / 2, v.cz)
|
||||
sides = [[lo_pts[i], lo_pts[(i + 1) % 4], hi_pts[(i + 1) % 4], hi_pts[i]] for i in range(4)]
|
||||
for quad in sides + [hi_pts, lo_pts]:
|
||||
mid = tuple(sum(p[k] for p in quad) / 4 for k in range(3))
|
||||
sc.faces.append(Face(quad, _norm(_sub(mid, center)), "box", v.label)) # outwards
|
||||
for i in range(4):
|
||||
j = (i + 1) % 4
|
||||
sc.box_edges += [(lo_pts[i], lo_pts[j]), (hi_pts[i], hi_pts[j]), (lo_pts[i], hi_pts[i])]
|
||||
xs = [x for x, _ in room.outline]
|
||||
zs = [z for _, z in room.outline]
|
||||
lo, hi = [min(xs), y0, min(zs)], [max(xs), y1, max(zs)]
|
||||
@@ -207,75 +216,6 @@ def prepare(room: Room, mesh: Mesh | None = None, budget: int = REST_BUDGET,
|
||||
return sc
|
||||
|
||||
|
||||
def _box_quads(v):
|
||||
foot = box_corners(v.cx, v.cz, v.yaw_deg, v.w, v.d)
|
||||
top, bottom = v.top_y, v.top_y - v.h
|
||||
lo_pts = [(x, bottom, z) for x, z in foot]
|
||||
hi_pts = [(x, top, z) for x, z in foot]
|
||||
sides = [[lo_pts[i], lo_pts[(i + 1) % 4], hi_pts[(i + 1) % 4], hi_pts[i]] for i in range(4)]
|
||||
edges = []
|
||||
for i in range(4):
|
||||
j = (i + 1) % 4
|
||||
edges += [(lo_pts[i], lo_pts[j]), (hi_pts[i], hi_pts[j]), (lo_pts[i], hi_pts[i])]
|
||||
return sides + [hi_pts, lo_pts], edges, (v.cx, (top + bottom) / 2, v.cz)
|
||||
|
||||
|
||||
def _add_furniture(sc: Scene, room: Room, selected: tuple[str, int] | None = None) -> None:
|
||||
from .room import plane_corners
|
||||
|
||||
for n, v in enumerate(room.volumes):
|
||||
quads, edges, center = _box_quads(v)
|
||||
for quad in quads:
|
||||
mid = tuple(sum(p[k] for p in quad) / 4 for k in range(3))
|
||||
sc.faces.append(Face(quad, _norm(_sub(mid, center)), "box", v.label, n)) # outwards
|
||||
sc.box_edges += edges
|
||||
if selected == ("box", n):
|
||||
sc.selected = list(edges)
|
||||
sc.planes = [plane_corners(p) for p in room.planes]
|
||||
if selected and selected[0] == "plane" and 0 <= selected[1] < len(sc.planes):
|
||||
c = sc.planes[selected[1]]
|
||||
sc.selected = [(c[i], c[(i + 1) % 4]) for i in range(4)]
|
||||
|
||||
|
||||
def with_furniture(scene: Scene, room: Room, selected: tuple[str, int] | None = None) -> Scene:
|
||||
"""A copy of a prepared scene with the room's furniture boxes and doors / windows drawn anew (the box editor:
|
||||
cheap, the walls and the reduced mesh are shared); selected = ("box", i) / ("plane", i) is outlined."""
|
||||
sc = replace(scene, faces=[f for f in scene.faces if f.kind != "box"], box_edges=[], planes=[], selected=[])
|
||||
_add_furniture(sc, room, selected)
|
||||
return sc
|
||||
|
||||
|
||||
def _inside(poly, x, y) -> bool:
|
||||
hit = False
|
||||
for i, (ax, ay) in enumerate(poly):
|
||||
bx, by = poly[i - 1]
|
||||
if (ay > y) != (by > y) and x < ax + (bx - ax) * (y - ay) / (by - ay):
|
||||
hit = not hit
|
||||
return hit
|
||||
|
||||
|
||||
def pick(scene: Scene, cam: Camera, x: float, y: float) -> tuple[str, int] | None:
|
||||
"""What's under a click at screen (x, y): ("box", volume index) / ("plane", plane index), the nearest to the
|
||||
camera, or None."""
|
||||
pr = cam.frame()
|
||||
best = None
|
||||
for face in scene.faces:
|
||||
if face.kind != "box":
|
||||
continue
|
||||
poly = pr.polygon(face.points)
|
||||
if poly and _inside(poly, x, y):
|
||||
d = pr.depth(face.points)
|
||||
if best is None or d < best[0]:
|
||||
best = (d, ("box", face.index))
|
||||
for n, corners in enumerate(scene.planes):
|
||||
poly = pr.polygon(corners)
|
||||
if poly and _inside(poly, x, y):
|
||||
d = pr.depth(corners) - 0.05 # a door in front of the box it's on
|
||||
if best is None or d < best[0]:
|
||||
best = (d, ("plane", n))
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ camera
|
||||
@dataclass
|
||||
class Camera:
|
||||
@@ -415,12 +355,10 @@ class Frame2D:
|
||||
mesh: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list)
|
||||
grid: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list)
|
||||
marker: list[tuple[float, float]] | None = None # where the play area's centre is, pointing forward (-Z)
|
||||
planes: list[list[tuple[float, float]]] = field(default_factory=list) # doors / windows
|
||||
selected: list[tuple[tuple[float, float], tuple[float, float]]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def segments(self) -> int:
|
||||
return len(self.wall_lines) + len(self.box_lines) + len(self.mesh) + len(self.grid) + len(self.selected)
|
||||
return len(self.wall_lines) + len(self.box_lines) + len(self.mesh) + len(self.grid)
|
||||
|
||||
|
||||
def render(scene: Scene, cam: Camera, lod: str = "rest") -> Frame2D:
|
||||
@@ -476,14 +414,6 @@ def render(scene: Scene, cam: Camera, lod: str = "rest") -> Frame2D:
|
||||
if face_on[t]:
|
||||
out.mesh.append((pu, pv))
|
||||
break
|
||||
for corners in scene.planes:
|
||||
poly = pr.polygon(corners)
|
||||
if poly:
|
||||
out.planes.append(poly)
|
||||
for a, b in scene.selected:
|
||||
s = pr.segment(a, b)
|
||||
if s:
|
||||
out.selected.append(s)
|
||||
y = scene.floor[0][1] if scene.floor else 0.0
|
||||
tri = [(0.0, y, -0.35), (0.2, y, 0.15), (-0.2, y, 0.15)]
|
||||
out.marker = pr.polygon(tri)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Route statically linked Khronos OpenXR loaders through FrameBridge."""
|
||||
from __future__ import annotations
|
||||
|
||||
from ...analysis import static_openxr
|
||||
from ...tools import overport
|
||||
from ..base import ApkContext, Patch, Suggestion, register
|
||||
from . import artifact
|
||||
from .adapter import GENERIC, ORIGINAL
|
||||
|
||||
SHIM = "libfp_xrruntime.so"
|
||||
|
||||
|
||||
class StaticOpenXRLoader(Patch):
|
||||
id = "frame.static_openxr_loader"
|
||||
title = "Embedded OpenXR loader: use FrameBridge"
|
||||
description = ("Routes a game's statically linked Khronos OpenXR loader through FrameBridge by rewriting its "
|
||||
"runtime manifest paths in place into its own /sdcard (including assembled paths). Disables its "
|
||||
"Android runtime "
|
||||
"broker queries so they fall back to that file. Ships a runtime shim and an unmodified inner "
|
||||
"Khronos loader, which reads the system runtime manifest. Reinstall to write the per-game manifest.")
|
||||
requires = ("frame.adapter",)
|
||||
order = 9 # provide the inner loader before frame.adapter wraps it
|
||||
|
||||
def applies(self, a):
|
||||
return bool((a.extra or {}).get("static_openxr_loader"))
|
||||
|
||||
def detect(self, a):
|
||||
if self.applies(a):
|
||||
return Suggestion(True, "The embedded Khronos OpenXR loader bypasses FrameBridge.")
|
||||
return None
|
||||
|
||||
def apply(self, ctx: ApkContext) -> bool:
|
||||
ws = ctx.ws
|
||||
if ws.abi != "arm64-v8a":
|
||||
raise RuntimeError("Embedded OpenXR loader redirection requires arm64")
|
||||
libs = (ctx.analysis.extra or {}).get("static_openxr_loader") or []
|
||||
if not libs:
|
||||
raise RuntimeError("No embedded Khronos OpenXR loader detected; analyze the APK again")
|
||||
changed = False
|
||||
for name in libs:
|
||||
data = ws.read_lib(name)
|
||||
fixed, count = static_openxr.rewrite(data)
|
||||
if not count and not static_openxr.redirected(data):
|
||||
raise RuntimeError(f"{name}: embedded loader manifest paths could not be redirected")
|
||||
if count:
|
||||
ws.put(ws.lib(name), fixed)
|
||||
changed = True
|
||||
# Like a32_bridge, also works when the recipe doesn't run OVRPort at all.
|
||||
if not ws.has(ws.lib(ORIGINAL)) and not ws.has(ws.lib(GENERIC)):
|
||||
inner = overport.ensure_runtime_lib(GENERIC, ws.abi, ctx.reporter).read_bytes()
|
||||
ws.put(ws.lib(ORIGINAL), inner)
|
||||
ws.put(ws.lib(GENERIC), artifact(ws.abi, GENERIC))
|
||||
changed = True
|
||||
shim = artifact(ws.abi, SHIM)
|
||||
if not ws.has(ws.lib(SHIM)) or ws.read_lib(SHIM) != shim:
|
||||
ws.put(ws.lib(SHIM), shim)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
def validate(self, ctx: ApkContext):
|
||||
ws = ctx.ws
|
||||
libs = (ctx.analysis.extra or {}).get("static_openxr_loader") or []
|
||||
ok = bool(libs) and all(ws.has(ws.lib(n)) and static_openxr.redirected(ws.read_lib(n))
|
||||
for n in libs)
|
||||
ok = ok and ws.has(ws.lib(SHIM)) and ws.read_lib(SHIM) == artifact(ws.abi, SHIM)
|
||||
return [("Embedded OpenXR loader uses FrameBridge", ok, ", ".join(libs))]
|
||||
|
||||
|
||||
register(StaticOpenXRLoader)
|
||||
@@ -16,9 +16,6 @@ BASE_SETTINGS = {"scale": 1.0, "foveation_fix": 1, "controller_fix": 1}
|
||||
|
||||
# key, kind, default, title, description
|
||||
SETTINGS = [
|
||||
("fixed_resolution", "int", 0, "Keep full resolution (no dynamic resolution)",
|
||||
"Keeps OpenXR layer recommendations at the full swapchain size. Prevents dynamic resolution from moving "
|
||||
"Valve foveation off-centre or leaving a coarse white edge line. May increase GPU load."),
|
||||
("scale", "float", 1.0, "Resolution scale",
|
||||
"Multiplies the recommended eye-buffer width and height (0.5–2.0). 1.5 ≈ 2.25× pixels."),
|
||||
("foveation_fix", "int", 1, "Hide Quest foveation", "Hides Quest foveation extensions the Frame runtime lacks."),
|
||||
@@ -92,9 +89,6 @@ SETTINGS = [
|
||||
"Monkey's intro video), which the Frame's runtime refuses, get an Android Surface from FrameBridge: each video "
|
||||
"frame is copied into an ordinary swapchain the panel shows. Without it these panels are missing and a game "
|
||||
"waiting for its video stays black. 0 = leave them to the runtime."),
|
||||
("swapchain_pool", "int", 0, "Share panel images (swapchain limit)",
|
||||
"Share GLES panel images in runtime atlases to avoid the Frame's swapchain count limit (about 15 per session). "
|
||||
"Useful when opening extra menus closes them or leaves panels gray. Vulkan apps are unaffected."),
|
||||
("equirect_emul", "int", 0, "Show 360° layers",
|
||||
"360° (equirect) layers, for example a video player's virtual theater or 360° videos (for example 4XVR), which "
|
||||
"the Frame's runtime lacks, are drawn by a background thread into a layer behind the game's own picture: the "
|
||||
@@ -338,9 +332,6 @@ FOVEATION_UI = {
|
||||
}
|
||||
|
||||
UI: dict[str, dict] = {
|
||||
"fixed_resolution": dict(group="picture", level="advanced", label="Keep full resolution (no dynamic resolution)",
|
||||
help="Prevents dynamic resolution from moving Valve foveation off-centre or leaving a white edge line. "
|
||||
"May increase GPU load. Used at the next launch.", control=("switch",)),
|
||||
"scale": dict(group="picture", level="common", label="Render resolution (supersampling)",
|
||||
help="Changes the game's render resolution. Higher is sharper but slower; 1.0× is the game's own "
|
||||
"resolution.",
|
||||
@@ -429,8 +420,7 @@ UI: dict[str, dict] = {
|
||||
help="Scene games get your saved room. Without one, they get a simple room from the play area.",
|
||||
control=("switch",)),
|
||||
"scene_scan_request": dict(group="room", level="advanced", label="Scan when requested", depends="scene_emul",
|
||||
help="Scan your room in the headset when the game asks for a room scan.",
|
||||
control=("switch",)),
|
||||
help="Scan your room in the headset when the game asks for a room scan.", control=("switch",)),
|
||||
"scene_hide_bounds": dict(group="room", level="advanced", label="Hide play-area size", depends="scene_emul",
|
||||
help="For games that reject the room because of the play area.", control=("switch",)),
|
||||
"scene_height": dict(group="room", level="advanced", label="Room height", depends="scene_emul",
|
||||
@@ -439,10 +429,6 @@ UI: dict[str, dict] = {
|
||||
help="0 = the size of your play area.", control=("slider", 0.0, 10.0, 0.5, "{:.1f} m")),
|
||||
"scene_depth": dict(group="room", level="advanced", label="Room depth", depends="scene_emul",
|
||||
help="0 = the size of your play area.", control=("slider", 0.0, 10.0, 0.5, "{:.1f} m")),
|
||||
"swapchain_pool": dict(group="troubleshooting", level="advanced",
|
||||
label="Share panel images (swapchain limit)",
|
||||
help="Keeps extra menus and panels working when the Frame reaches its image count limit. "
|
||||
"GLES apps only; Vulkan apps are unaffected.", control=("switch",)),
|
||||
"equirect_emul": dict(group="video360", level="common", label="Show 360° backgrounds and videos",
|
||||
help="Video players' virtual theaters and 360° videos would otherwise stay black.",
|
||||
control=("switch",)),
|
||||
@@ -565,8 +551,7 @@ UI: dict[str, dict] = {
|
||||
# functions OVRPort's dispatcher doesn't know. scene_emul: the dispatcher refuses every scene/anchor function
|
||||
# ("Unknown proc addr: xrQuerySpacesFB", Pinball FX VR 2026-10-16), so the emulated room only reaches the game through
|
||||
# the shim (Demeter/NOPE only got it because haptic_fix happened to be on)
|
||||
XRSHIM_SETTINGS = ("fixed_resolution", "hide_fb_foveation", "controller_models", "haptic_fix", "surface_native",
|
||||
"scene_emul")
|
||||
XRSHIM_SETTINGS = ("hide_fb_foveation", "controller_models", "haptic_fix", "surface_native", "scene_emul")
|
||||
# shown with an "experimental" pill (Game settings, game page, CLI): not proven in the headset on many games yet
|
||||
EXPERIMENTAL_SETTINGS = ("quest_buttons",)
|
||||
|
||||
@@ -649,7 +634,6 @@ class AdapterSetting(Patch):
|
||||
or "com.oculus.feature.PASSTHROUGH" in (a.extra.get("features") or {})),
|
||||
"alpha_blend_emul": lambda a: (a.extra.get("mr_only")
|
||||
or "com.oculus.feature.PASSTHROUGH" in (a.extra.get("features") or {})),
|
||||
"fixed_resolution": lambda a: "libOVRPlugin.so" in a.libs or a.xr == "OpenXR",
|
||||
"hide_fb_foveation": ap.is_vulkan,
|
||||
"flip_emul": ap.is_vulkan,
|
||||
"flip_quads": ap.is_vulkan,
|
||||
@@ -680,8 +664,8 @@ class AdapterSetting(Patch):
|
||||
"proximity_emul": lambda a: "libOVRPlugin.so" in a.libs,
|
||||
"vk_spec_fixes": lambda a: a.engine == "Unreal" and ap.is_vulkan(a),
|
||||
"controller_models": ap.may_use_render_models,
|
||||
**{k: ap.is_gles for k in ("swapchain_pool", "equirect_emul", "equirect_face", "equirect_res",
|
||||
"equirect_flip", "equirect_fps", "equirect_stereo")},
|
||||
**{k: ap.is_gles for k in ("equirect_emul", "equirect_face", "equirect_res", "equirect_flip",
|
||||
"equirect_fps", "equirect_stereo")},
|
||||
}
|
||||
rule = rules.get(self.key)
|
||||
return bool(rule(a)) if rule else True
|
||||
|
||||
@@ -4,7 +4,6 @@ Game settings (adapter.*) use their Game settings help line (patches/settings.UI
|
||||
fallback for settings that dialog doesn't show."""
|
||||
|
||||
SUMMARIES = {
|
||||
"frame.static_openxr_loader": "Makes an embedded OpenXR loader use FrameBridge and your game settings.",
|
||||
# OVRPort (the Quest → OpenXR conversion)
|
||||
"patch_copy_libraries": "Adds the parts that let a Quest game run on the Steam Frame. Needed for every Quest "
|
||||
"game.",
|
||||
|
||||
@@ -66,8 +66,7 @@ def suggest(analysis: Analysis, use_catalog: bool = True) -> Recipe:
|
||||
and "frame.meta_permissions" not in entry.frame_remove):
|
||||
# the room needs Meta's scene/anchor permissions granted, or the game never asks for it (NOPE)
|
||||
recipe.patches.setdefault("frame.meta_permissions", {})
|
||||
recipe.reasons.setdefault("frame.meta_permissions",
|
||||
"Meta's room and anchor permissions for the saved room.")
|
||||
recipe.reasons.setdefault("frame.meta_permissions", "Meta's room and anchor permissions for the saved room.")
|
||||
if entry.device_files:
|
||||
recipe.patches["device.files"] = {"files": dict(entry.device_files)}
|
||||
recipe.reasons["device.files"] = why
|
||||
|
||||
@@ -623,18 +623,26 @@ def reveal(box: ft.Container, on: bool) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def segmented(options: list[tuple[str, str]], value: str | None, on_change: Callable[[str], None],
|
||||
full: bool = False) -> ft.Container:
|
||||
def segmented(options: list[tuple[str, str]], value: str | None, on_change: Callable[[str], None]) -> ft.Container:
|
||||
"""A pill track of choices (one selected): the selected one is the portal fade in dual themes, else SURFACE_3.
|
||||
Clicking restyles in place, then calls on_change(value). full = the track takes the whole width it gets, the
|
||||
choices sharing it equally (put it in a stretching Column or a Row as an expanding child)."""
|
||||
Clicking restyles in place, then calls on_change(value)."""
|
||||
items: dict[str, ft.Container] = {}
|
||||
|
||||
def paint(current):
|
||||
for v, box in items.items():
|
||||
on = v == current
|
||||
box.content.color = T.TEXT if on else T.TEXT_2
|
||||
if T.DUAL:
|
||||
box.bgcolor, box.gradient = None, portal_gradient(opacity=0.22) if on else None
|
||||
else:
|
||||
box.bgcolor, box.gradient = T.SURFACE_3 if on else None, None
|
||||
|
||||
def click(v):
|
||||
def handler(e):
|
||||
if track.data == v:
|
||||
return
|
||||
set_segmented(track, v)
|
||||
track.data = v
|
||||
paint(v)
|
||||
update(track)
|
||||
on_change(v)
|
||||
return handler
|
||||
@@ -642,34 +650,13 @@ def segmented(options: list[tuple[str, str]], value: str | None, on_change: Call
|
||||
for v, label in options:
|
||||
items[v] = ft.Container(ft.Text(label, size=T.T_META, weight=ft.FontWeight.W_600),
|
||||
padding=ft.Padding(T.px(12), T.px(6), T.px(12), T.px(6)), border_radius=T.px(20),
|
||||
on_click=click(v), ink=True, data=v,
|
||||
**(dict(expand=True, alignment=ft.Alignment.CENTER) if full else {}))
|
||||
track = ft.Container(ft.Row(list(items.values()), spacing=T.px(2), tight=not full), padding=T.px(3),
|
||||
on_click=click(v), ink=True)
|
||||
track = ft.Container(ft.Row(list(items.values()), spacing=T.px(2), tight=True), padding=T.px(3),
|
||||
border_radius=T.px(22), border=ft.Border.all(1, T.BORDER), data=value)
|
||||
set_segmented(track, value)
|
||||
paint(value)
|
||||
return track
|
||||
|
||||
|
||||
def set_segmented(track: ft.Container, value: str | None) -> None:
|
||||
"""Select `value` in a segmented() track from code (no on_change; the caller updates the track)."""
|
||||
track.data = value
|
||||
for box in track.content.controls:
|
||||
on = box.data == value
|
||||
box.content.color = T.TEXT if on else T.TEXT_2
|
||||
if T.DUAL:
|
||||
box.bgcolor, box.gradient = None, portal_gradient(opacity=0.22) if on else None
|
||||
else:
|
||||
box.bgcolor, box.gradient = T.SURFACE_3 if on else None, None
|
||||
|
||||
|
||||
def set_switch(control: ft.Control, value: bool) -> None:
|
||||
"""Turn a switch() on or off from code (no on_change; the caller updates the control)."""
|
||||
knob = control.controls[0] if getattr(control, "data", None) == "switch" else control
|
||||
track, sw = knob.controls
|
||||
sw.value = bool(value)
|
||||
paint_switch_track(track, bool(value), bool(sw.disabled))
|
||||
|
||||
|
||||
def hoverable(box: ft.Container) -> ft.Container:
|
||||
"""A clickable container: ink (the hover highlight and the click ripple, drawn over its background or the
|
||||
selected fade) like the buttons around it."""
|
||||
|
||||
@@ -33,39 +33,7 @@ HELP: dict[str, str] = _Translated({
|
||||
"to check a scan before you try it in a game.",
|
||||
"room_view": "Drag to turn the room, scroll or pinch to zoom, right-drag to move it, double-click to see all of "
|
||||
"it again. The walls nearest to you are left out so you can look inside. The full 3D view in your "
|
||||
"browser shows every detail of the scan. Click a box or a door to edit it.",
|
||||
# ---- room scan clean-up (install/rooms.REFINE_DEFAULTS)
|
||||
"room_cleanup": "The Frame tidies up every scan: flat walls, floor and ceiling, no scan behind walls or where you "
|
||||
"walked, no small or floating bits. Apply runs it again on your saved scan with these settings.",
|
||||
"room_labels": "The scanner names each box it finds. These rules check the names against the box's size and "
|
||||
"place: boxes behind walls go, desks stop being couches, a screen has to look like one.",
|
||||
"room_boxes": "Furniture games see. Pick a box here or click it in the view, then fix its type, place or size. "
|
||||
"Your boxes stay when you clean up the scan again.",
|
||||
"room_doors": "The scanner can't see doors or windows. Add them on a wall: games that ask for a door get it.",
|
||||
"refine_refine": "Off keeps the scanner's mesh exactly as recorded; the room's walls and boxes stay.",
|
||||
"refine_walls": "Keep scan leaves walls as scanned. Snap moves scan near a wall onto it. Flat panels replace the "
|
||||
"scanned walls, floor and ceiling with flat surfaces.",
|
||||
"refine_walked": "You walked through this space while scanning, so nothing stands there: scan this far below your "
|
||||
"head's path is your own body or controllers. Furniture boxes are kept.",
|
||||
"refine_smooth": "Evens out bumps in the scan without shrinking shapes (Taubin smoothing). More passes, smoother.",
|
||||
"refine_island": "Loose bits of scan smaller than this area are removed.",
|
||||
"refine_clip": "Scan further than this behind a seen wall is removed. At the maximum nothing is removed.",
|
||||
"refine_clip_open": "Extra room past edges the scanner closed without seeing a wall: they may be open space.",
|
||||
"refine_band": "Scan this far behind a wall, under the floor or above the ceiling counts as that surface.",
|
||||
"refine_band_in": "Only this thin layer on the room side counts as wall, so wardrobes and shelves against it stay.",
|
||||
"refine_floor_in": "Scan this close above the floor counts as floor.",
|
||||
"refine_ceil_in": "Scan this close below the ceiling counts as ceiling.",
|
||||
"refine_top_strip": "Furniture rarely reaches this close to the ceiling: here the whole band counts as wall.",
|
||||
"refine_facing_deg": "Scan only counts as a wall when it faces the room within this angle of the wall's direction.",
|
||||
"refine_near": "Flat panels only: other scan this close to a wall, floor or ceiling is removed. Furniture boxes "
|
||||
"are kept.",
|
||||
"refine_tubes": "Long, thin pieces hanging in the air (depth streaks), at least this many times longer than wide, "
|
||||
"are removed. Never furniture.",
|
||||
"refine_tube_thick": "Only thin floating pieces up to this thickness are removed.",
|
||||
"refine_tops": "Scan near the top of a table, bed or other box, facing up, goes onto the box's top.",
|
||||
"refine_protect": "Scan inside a furniture box is never removed by the other steps.",
|
||||
"refine_label_rules": "Checks every box the scanner found against size and place rules (see Labels). Off keeps the "
|
||||
"scanner's own names.",
|
||||
"browser shows every detail of the scan.",
|
||||
"steam_library":"Now waits until Steam can restart safely. Later saves shortcuts for the next Steam start. "
|
||||
"Off uses FramePort Play without Steam per-game integration; existing shortcuts stay.",
|
||||
# ---- game status / library
|
||||
|
||||
@@ -52,16 +52,13 @@ class RoomCanvas:
|
||||
"wall_lines": ("lines", dict(color=T.soft(blue, 0.6), stroke_width=T.px(1.4), style=stroke)),
|
||||
"boxes": ("fill", dict(color=T.soft(T.ACCENT, 0.16), style=fill)),
|
||||
"box_lines": ("lines", dict(color=T.soft(T.ACCENT, 0.85), stroke_width=T.px(1.3), style=stroke)),
|
||||
"planes": ("fill", dict(color=T.soft(T.OK, 0.35), style=fill)),
|
||||
"selected": ("lines", dict(color=T.TEXT, stroke_width=T.px(2.5), style=stroke)),
|
||||
"marker": ("fill", dict(color=T.ACCENT, style=fill)),
|
||||
}
|
||||
self.on_pick = None # callback(("box", i) / ("plane", i) / None) for a click on the room (the box editor)
|
||||
self.canvas = cv.Canvas([], expand=True, on_resize=self._resized)
|
||||
self.detector = ft.GestureDetector(
|
||||
self.canvas, mouse_cursor=ft.MouseCursor.MOVE, drag_interval=16,
|
||||
on_scale_start=self._scale_start, on_scale_update=self._scale_update, on_scale_end=self._scale_end,
|
||||
on_scroll=self._scroll, on_double_tap=lambda e: self.fit(), on_tap_up=self._tap,
|
||||
on_scroll=self._scroll, on_double_tap=lambda e: self.fit(),
|
||||
on_right_pan_start=self._right_start, on_right_pan_update=self._right_update,
|
||||
on_right_pan_end=lambda e: self._end_move())
|
||||
self.control = ft.Container(self.detector, height=self.height, expand=True, bgcolor=T.BG,
|
||||
@@ -112,11 +109,6 @@ class RoomCanvas:
|
||||
self._move()
|
||||
self._end_move()
|
||||
|
||||
def _tap(self, e) -> None:
|
||||
if self.on_pick is None or self.scene is None or e.local_position is None:
|
||||
return
|
||||
self.on_pick(V.pick(self.scene, self.cam, e.local_position.x, e.local_position.y))
|
||||
|
||||
def _right_start(self, e) -> None:
|
||||
self._right = (e.local_position.x, e.local_position.y)
|
||||
|
||||
@@ -189,7 +181,7 @@ class RoomCanvas:
|
||||
f = V.render(self.scene, self.cam, lod)
|
||||
data = {"grid": f.grid, "floor": [f.floor] if f.floor else [], "hidden": f.hidden_walls, "walls": f.walls,
|
||||
"mesh": f.mesh, "wall_lines": f.wall_lines, "boxes": f.boxes, "box_lines": f.box_lines,
|
||||
"planes": f.planes, "selected": f.selected, "marker": [f.marker] if f.marker else []}
|
||||
"marker": [f.marker] if f.marker else []}
|
||||
if lod == "drag" and len(f.boxes) > MAX_DRAG_BOX_FACES: # a room full of furniture: outlines while moving
|
||||
data["boxes"] = []
|
||||
shapes = []
|
||||
@@ -215,7 +207,6 @@ class RoomCanvas:
|
||||
return ft.Row([key(T.soft(T.SECONDARY, 0.5), tr("Walls")),
|
||||
key(T.soft(T.SECONDARY, 0.7), tr("Scanned surfaces"), line=True),
|
||||
key(T.soft(T.ACCENT, 0.6), tr("Furniture")),
|
||||
key(T.soft(T.OK, 0.6), tr("Doors and windows")),
|
||||
key(T.ACCENT, tr("Middle of your play area, facing forward"))],
|
||||
spacing=T.S4, run_spacing=T.S2, wrap=True)
|
||||
|
||||
|
||||
@@ -67,9 +67,7 @@ def model_key(status: dict | None) -> tuple | None:
|
||||
from ..mr.room import parse
|
||||
|
||||
room = parse(text)
|
||||
# + the text itself: a clean-up or a box edit may keep the room's ids and time
|
||||
return (room.universe, room.time, room.uuid, room.mesh.uuid if room.mesh else None,
|
||||
hashlib.sha256(text.encode()).hexdigest()[:16])
|
||||
return room.universe, room.time, room.uuid, room.mesh.uuid if room.mesh else None
|
||||
|
||||
|
||||
def _cache_root(target):
|
||||
@@ -116,17 +114,19 @@ def load_model(target, room_text: str, key: tuple) -> dict:
|
||||
the Frame), parsed and prepared for the canvas."""
|
||||
from ..install import rooms
|
||||
from ..mr import view3d
|
||||
from ..mr.room import dumps, parse, read_mesh
|
||||
from ..mr.room import parse, read_mesh
|
||||
|
||||
room = parse(room_text)
|
||||
root = _cache_root(target)
|
||||
folder = root / f"{room.universe}-{room.time}-{room.uuid[:8]}-{key[-1] if len(key) > 4 else ''}"
|
||||
folder = root / f"{room.universe}-{room.time}-{room.uuid[:8]}"
|
||||
mesh_bytes = None
|
||||
path = folder / "room.txt"
|
||||
fresh = path.is_file() and (room.mesh is None or (folder / room.mesh.filename).is_file())
|
||||
if fresh:
|
||||
try: # the same room file (a clean-up keeps the ids but changes the mesh: its counts and the text differ)
|
||||
fresh = path.read_text(encoding="utf-8") == dumps(room)
|
||||
try:
|
||||
cached_room = parse(path.read_text(encoding="utf-8"))
|
||||
fresh = cached_room.uuid == room.uuid and (cached_room.mesh and cached_room.mesh.uuid) == (
|
||||
room.mesh and room.mesh.uuid)
|
||||
except (OSError, ValueError):
|
||||
fresh = False
|
||||
if not fresh:
|
||||
@@ -221,60 +221,3 @@ def operate(app, action: str) -> None:
|
||||
state(app, force=True)
|
||||
app.refresh_view()
|
||||
app.run_bg(work)
|
||||
|
||||
|
||||
def _frame_job(app, working: str, title: str, run) -> None:
|
||||
"""A Room tab action on the Frame as an Activity job (kind tool-frame: a lost connection pauses it); the cache
|
||||
says what runs (working) and its stage (job_stage) for the tab's progress line, and takes the room status the
|
||||
action returns (the 3D model reloads when the room changed)."""
|
||||
cached = state(app)
|
||||
target = app.target
|
||||
if target is None or cached["loading"] or cached.get("working"):
|
||||
return
|
||||
cached.update(working=working, job_stage=tr("Waiting for the Frame…"))
|
||||
app.refresh_view()
|
||||
|
||||
def job(j):
|
||||
def stage(text):
|
||||
cached["job_stage"] = text
|
||||
j.reporter.stage(text)
|
||||
if app.target is target:
|
||||
app.refresh_view()
|
||||
try:
|
||||
result = run(target, stage)
|
||||
if isinstance(result, dict) and "room_text" in result:
|
||||
cached.update(status=result, time=time.monotonic(), error=None)
|
||||
return result.pop("_message", None) if isinstance(result, dict) else None
|
||||
finally:
|
||||
cached.update(working=False, job_stage=None)
|
||||
if app.target is target:
|
||||
state(app, force=True) # (the Frame page's preview too)
|
||||
_maybe_load_model(app, cached)
|
||||
app.refresh_view()
|
||||
app.submit(title, job, kind="tool-frame")
|
||||
|
||||
|
||||
def refine_room(app, settings: dict | None, keep_edits: bool = True) -> None:
|
||||
"""Clean up the saved room's scan again on the Frame with `settings` (also saved there for the next scans)."""
|
||||
def run(target, stage):
|
||||
from ..install import rooms
|
||||
|
||||
stage(tr("Cleaning up the scan on the Frame…"))
|
||||
result = rooms.refine(target.frame, settings, keep_edits=keep_edits)
|
||||
state(app)["refine_result"] = result.get("report") or result.get("summary") or {}
|
||||
result["_message"] = tr("Room scan cleaned up")
|
||||
return result
|
||||
_frame_job(app, "refine", tr("Clean up the room scan"), run)
|
||||
|
||||
|
||||
def save_room(app, room, mesh: bytes | None) -> None:
|
||||
"""Save the Room tab's edited furniture boxes, doors and windows on the Frame."""
|
||||
def run(target, stage):
|
||||
from ..install import rooms
|
||||
|
||||
stage(tr("Saving your room on the Frame…"))
|
||||
rooms.put_room(target.frame, room, mesh)
|
||||
result = rooms.status(target.frame)
|
||||
result["_message"] = tr("Room saved")
|
||||
return result
|
||||
_frame_job(app, "save", tr("Save room edits"), run)
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Room: the connected Frame's saved room for mixed-reality games. Scan it in the headset (with progress and Cancel),
|
||||
use the play area instead, delete it, and look at it in 3D: in the window (ui/room3d.RoomCanvas) and, with every
|
||||
detail, in the browser (mr/room_viewer.py, a local page stopped on disconnect / window close). Below it the editing
|
||||
cards of views/room_edit.py: the scan clean-up settings (Apply runs it again on the Frame), what the label rules
|
||||
decided (door / closet suggestions) and the furniture box + doors / windows editor (Save sends the room).
|
||||
detail, in the browser (mr/room_viewer.py, a local page stopped on disconnect / window close).
|
||||
|
||||
Persistent like the Monitor (built once per connection, re-mounted on every visit): state changes (the room cache in
|
||||
ui/room_state.py, scan progress every 2 s) update properties of the controls built here; buttons are never rebuilt."""
|
||||
@@ -23,7 +21,7 @@ from ..help import HELP
|
||||
if TYPE_CHECKING:
|
||||
from ..app import FramePortApp
|
||||
|
||||
CANVAS_HEIGHT = 560
|
||||
CANVAS_HEIGHT = 480
|
||||
|
||||
|
||||
def furniture_name(label: str) -> str:
|
||||
@@ -67,17 +65,17 @@ class RoomView:
|
||||
def _tester(self) -> ft.Control:
|
||||
"""Test Quest MR: Get (its install link: FramePort's own install question) or Open once it's in the library."""
|
||||
if library.game(TESTER_PACKAGE):
|
||||
label, go = tr("Open Test Quest MR"), lambda e: self.app.open_game(TESTER_PACKAGE)
|
||||
button = C.secondary(tr("Open Test Quest MR"), ft.Icons.VIEW_IN_AR_ROUNDED,
|
||||
lambda e: self.app.open_game(TESTER_PACKAGE), tooltip=C.tip(HELP["room_tester"]))
|
||||
else:
|
||||
from .link_dialog import open_link
|
||||
|
||||
label, go = tr("Get Test Quest MR"), lambda e: open_link(self.app, TESTER_MANIFEST)
|
||||
link = ft.Container(ft.Text(label, size=T.T_META, color=T.ACCENT, weight=ft.FontWeight.W_600), on_click=go,
|
||||
ink=True, border_radius=T.RADIUS_XS, tooltip=C.tip(HELP["room_tester"]))
|
||||
# one line at the bottom of "In this room" (not a card of its own)
|
||||
return ft.Row([ft.Icon(ft.Icons.LIGHTBULB_OUTLINE_ROUNDED, size=T.ICON_S, color=T.TEXT_3),
|
||||
C.meta(tr("See what a game gets:")), link], spacing=T.S1, wrap=True,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
button = C.secondary(tr("Get Test Quest MR"), ft.Icons.VIEW_IN_AR_ROUNDED,
|
||||
lambda e: open_link(self.app, TESTER_MANIFEST), tooltip=C.tip(HELP["room_tester"]))
|
||||
return C.callout(ft.Column([
|
||||
C.body(tr("Try it out: Test Quest MR shows the room, furniture and anchors a game receives from this "
|
||||
"scan."), T.TEXT),
|
||||
button], spacing=T.S3, tight=True), "info", ft.Icons.LIGHTBULB_OUTLINE_ROUNDED)
|
||||
|
||||
def _build(self) -> None:
|
||||
from ..room3d import RoomCanvas
|
||||
@@ -105,17 +103,13 @@ class RoomView:
|
||||
alignment=ft.Alignment.CENTER, left=0, right=0, top=0, bottom=0, bgcolor=T.soft(T.BG, 0.88))
|
||||
self.reset_btn = C.icon_btn(ft.Icons.CENTER_FOCUS_STRONG_ROUNDED, tr("Show the whole room"),
|
||||
lambda e: self.canvas.fit())
|
||||
# the legend sits on the view's bottom edge, so the card is exactly the view's height (the right column is
|
||||
# made the same height: the top row ends level)
|
||||
legend = ft.Container(ft.Row([ft.Container(self.canvas.legend(), expand=True), C.help_icon("room_view")],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
left=0, right=0, bottom=0, bgcolor=T.soft(T.BG, 0.82),
|
||||
padding=ft.Padding(T.S3, T.S2, T.S3, T.S2),
|
||||
border_radius=ft.BorderRadius(0, 0, T.RADIUS_SM, T.RADIUS_SM))
|
||||
self.view = ft.Stack([self.canvas.control, legend, self.placeholder,
|
||||
ft.Container(self.reset_btn, right=T.S2, top=T.S2)], height=T.px(CANVAS_HEIGHT))
|
||||
self.canvas_card = C.card(self.view, padding=T.S3, col={"xs": 12, "lg": 8})
|
||||
self.canvas_card.on_size_change = self._card_sized
|
||||
view = ft.Stack([self.canvas.control, self.placeholder,
|
||||
ft.Container(self.reset_btn, right=T.S2, top=T.S2)], height=T.px(CANVAS_HEIGHT))
|
||||
self.canvas_card = C.card(ft.Column([
|
||||
view,
|
||||
ft.Row([ft.Container(self.canvas.legend(), expand=True), C.help_icon("room_view")],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
], spacing=T.S3), padding=T.S3, col={"xs": 12, "lg": 8})
|
||||
|
||||
# status
|
||||
self.checks = ft.Container(C.checklist([C.Check(None, tr("Checking the room…"))]))
|
||||
@@ -138,37 +132,21 @@ class RoomView:
|
||||
self.summary = C.body("", T.TEXT)
|
||||
self.source = C.meta("")
|
||||
self.furniture = ft.Row([], spacing=T.S2, run_spacing=T.S2, wrap=True)
|
||||
# the right column sizes to its content (never clipped: window width, UI scale and the furniture chips change
|
||||
# its height); level() then makes the top row end level: a short column's last card grows to the 3D view's
|
||||
# card (self.level_gap), a tall column makes the 3D view taller. Every card with the same padding.
|
||||
self.level_gap = ft.Container(height=0)
|
||||
side = ft.Column([
|
||||
C.card(ft.Column([self.checks, self.error], spacing=T.S2), padding=T.S4),
|
||||
C.card(ft.Column([self.checks, self.error], spacing=T.S2),
|
||||
padding=ft.Padding(T.S4, T.S2, T.S4, T.S2)),
|
||||
C.card(ft.Column([C.section(tr("Scan"), self.scan_box,
|
||||
ft.Row([self.scan_btn, self.cancel_btn], spacing=T.S2, wrap=True),
|
||||
ft.Row([self.boundary_btn, self.delete_btn], spacing=T.S2, wrap=True),
|
||||
help="mr_room")], spacing=T.S3), padding=T.S4),
|
||||
C.card(ft.Column([C.meta(tr("In this room")), self.summary, self.source, self.furniture,
|
||||
self.level_gap, self._tester()], spacing=T.S2),
|
||||
padding=T.S4),
|
||||
], spacing=T.S4, col={"xs": 12, "lg": 4}, horizontal_alignment=ft.CrossAxisAlignment.STRETCH,
|
||||
on_size_change=self._side_sized)
|
||||
self._sizes = {"card": None, "side": None}
|
||||
# editing: the scan clean-up, what the label rules decided, furniture boxes + doors / windows
|
||||
from .room_edit import CleanupCard, FurnitureEditor, LabelsCard
|
||||
|
||||
self.editor = FurnitureEditor(self)
|
||||
self.cleanup = CleanupCard(self)
|
||||
self.labels = LabelsCard(self)
|
||||
self.canvas.on_pick = lambda hit: self.editor.select(hit) if self.editor.room is not None else None
|
||||
C.card(ft.Column([C.meta(tr("In this room")), self.summary, self.source, self.furniture],
|
||||
spacing=T.S2), padding=T.S4),
|
||||
self._tester(),
|
||||
], spacing=T.S3, col={"xs": 12, "lg": 4}, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
self.root = ft.Column([
|
||||
self._heading([self.refresh_btn, self.full_btn]),
|
||||
ft.ResponsiveRow([self.canvas_card, side], spacing=T.S4, run_spacing=T.S4,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
ft.Container(ft.ResponsiveRow([self.cleanup.control, self.editor.control, self.labels.control],
|
||||
spacing=T.S4, run_spacing=T.S4,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START),
|
||||
key=ft.ScrollKey("room-edit")),
|
||||
ft.Container(height=T.S4),
|
||||
], spacing=T.S4, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||
|
||||
@@ -251,17 +229,10 @@ class RoomView:
|
||||
# the canvas, or what's in its place
|
||||
key = model.get("key") if model else None
|
||||
if key != self._shown_key:
|
||||
first = self._shown_key in ("unset", None)
|
||||
self._shown_key = key
|
||||
self.editor.load(model) # edits start again from the new room
|
||||
self.canvas.set_scene(self.editor.scene(), fit=first or model is None)
|
||||
changed += [self.editor.box_list, self.editor.plane_list, self.editor.box_panel,
|
||||
self.editor.plane_panel]
|
||||
self.canvas.set_scene(model["scene"] if model else None)
|
||||
if self.viewer is not None and model is not None:
|
||||
self.viewer.set_room(model["room_text"], model["mesh"]) # the open page reloads by itself
|
||||
changed += self.cleanup.refresh(cached, status, room)
|
||||
changed += self.labels.refresh(cached, status, room)
|
||||
changed += self.editor.refresh(cached, status, room)
|
||||
self._placeholder(cached, status, room, model)
|
||||
if update:
|
||||
self._push(*changed)
|
||||
@@ -313,45 +284,6 @@ class RoomView:
|
||||
C.confirm(self.app.page, tr("Delete room?"), tr("Games will use a simple room until you save another room."),
|
||||
tr("Delete room"), lambda: RS.operate(self.app, "delete"), danger=True)
|
||||
|
||||
# ------------------------------------------------------------------ the top row ends level
|
||||
def _card_sized(self, e) -> None:
|
||||
self._sizes["card"] = (e.width, e.height)
|
||||
self.level()
|
||||
|
||||
def _side_sized(self, e) -> None:
|
||||
self._sizes["side"] = (e.width, e.height)
|
||||
self.level()
|
||||
|
||||
def level(self) -> None:
|
||||
"""The right column's height (its content's, measured) against the 3D view's card: a shorter column gets a
|
||||
gap in its last card, a taller one makes the 3D view taller. Stacked (narrow window): neither."""
|
||||
card, side = self._sizes["card"], self._sizes["side"]
|
||||
if not card or not side:
|
||||
return
|
||||
base = T.px(CANVAS_HEIGHT)
|
||||
base_card = base + 2 * T.S3 + 2 # (the card's padding and border)
|
||||
content = side[1] - (self.level_gap.height or 0)
|
||||
gap, view = 0, base
|
||||
if side[0] < card[0] * 0.9: # side by side
|
||||
if content <= base_card:
|
||||
gap = int(base_card - content)
|
||||
else:
|
||||
view = base + int(content - base_card + 0.999)
|
||||
changed = []
|
||||
if gap != (self.level_gap.height or 0):
|
||||
self.level_gap.height = gap
|
||||
changed.append(self.level_gap)
|
||||
if view != self.view.height:
|
||||
self.view.height = self.canvas.control.height = view
|
||||
changed.append(self.view)
|
||||
if changed:
|
||||
C.update(*changed)
|
||||
|
||||
async def show_editing(self) -> None:
|
||||
"""Scroll down to the editing cards (Clean-up, Furniture & screens, Labels)."""
|
||||
if self.root is not None:
|
||||
await self.root.scroll_to(scroll_key="room-edit", duration=0)
|
||||
|
||||
def open_full(self, e=None) -> None:
|
||||
"""The browser view: a local page with the whole mesh (started once, kept until disconnect / close)."""
|
||||
cached = RS.state(self.app, model=True)
|
||||
|
||||
@@ -1,737 +0,0 @@
|
||||
"""The Room tab's editing cards: Clean-up (the scan clean-up settings, Apply runs it again on the Frame), Labels (what
|
||||
the label rules decided, door / closet suggestions) and Furniture & screens (a box editor + doors and windows on a
|
||||
wall; Save sends the edited room to the Frame).
|
||||
|
||||
Built once with the Room tab (persistent): refresh() brings the cards up to date with the room cache and returns the
|
||||
controls it changed. Lists with buttons are rebuilt only when the room status or the edited room's structure changes
|
||||
(a user action), never on the scan's progress ticks; sliders and readouts are updated in place.
|
||||
The scan eraser of the clean-up page isn't here (the agent accepts erase spots: rooms.refine(erase=...))."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import flet as ft
|
||||
|
||||
from ...i18n import tr
|
||||
from ...install import rooms
|
||||
from ...mr import room as R
|
||||
from ...mr import view3d
|
||||
from .. import components as C
|
||||
from .. import room_state as RS
|
||||
from .. import theme as T
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .room import RoomView
|
||||
|
||||
|
||||
def _furniture_name(label: str) -> str:
|
||||
from .room import furniture_name
|
||||
|
||||
return furniture_name(label)
|
||||
|
||||
|
||||
def setting_label(key: str) -> str:
|
||||
return tr(rooms.REFINE_DEFAULTS[key]["label"])
|
||||
|
||||
|
||||
def setting_text(key: str, value) -> str:
|
||||
"""A clean-up setting's value in words (the readout next to its slider)."""
|
||||
meta = rooms.REFINE_DEFAULTS[key]
|
||||
if meta["kind"] == "choice":
|
||||
return tr(dict(meta["choices"])[value])
|
||||
if meta["kind"] == "bool":
|
||||
return tr("on") if value else tr("off")
|
||||
if "off" in meta and value == meta["off"]:
|
||||
return tr("off")
|
||||
if "all" in meta and value >= meta["all"]:
|
||||
return tr("keep all")
|
||||
unit = meta.get("unit")
|
||||
if unit == "cm":
|
||||
return tr("{n} cm").format(n=round(value * 100))
|
||||
if unit == "m2":
|
||||
return tr("{n} m²").format(n=f"{value:.2f}")
|
||||
if unit == "deg":
|
||||
return tr("{n}°").format(n=round(value))
|
||||
if unit == "times":
|
||||
return tr("{n}×").format(n=f"{value:g}")
|
||||
return f"{value:g}"
|
||||
|
||||
|
||||
# what the clean-up report's counts are called (scan.json "report"; unknown keys read as their name)
|
||||
def report_label(key: str) -> str:
|
||||
return {"triangles": tr("Scan triangles"), "kept": tr("Kept"), "structural": tr("Wall, floor and ceiling surfaces"),
|
||||
"near_walls": tr("Cleared near walls"), "outside": tr("Outside the room"),
|
||||
"small_pieces": tr("Small pieces"), "thin_pieces": tr("Thin floating pieces"), "erased": tr("Erased"),
|
||||
"walked": tr("Where you walked"), "tops": tr("Flattened onto furniture tops"),
|
||||
"panel_triangles": tr("Flat panel triangles"), "boxes": tr("Furniture boxes"),
|
||||
"removed_boxes": tr("Boxes removed"), "relabelled": tr("Boxes renamed"),
|
||||
"suggestions": tr("Door or closet suggestions"), "output_triangles": tr("Triangles in the room"),
|
||||
"output_volumes": tr("Furniture boxes in the room")}.get(key, key.replace("_", " ").capitalize())
|
||||
|
||||
|
||||
# scan.json report keys that aren't counts (heights, flags: see report_notes) - docs/mr/ROOM_FORMAT.md
|
||||
REPORT_NOT_COUNTS = ("floor_y", "ceiling_y", "walked_skipped", "truncated", "version")
|
||||
|
||||
|
||||
def report_notes(report: dict | None) -> tuple[list[str], str]:
|
||||
"""(notes, warning) about a clean-up: the walked pass skipped (no head path), triangles cut off (the room's
|
||||
triangle limit)."""
|
||||
report = report or {}
|
||||
notes = [tr("Where you walked: skipped, this scan has no head path")] if report.get("walked_skipped") else []
|
||||
cut = report.get("truncated")
|
||||
warning = tr("The cleaned-up scan had more triangles than a room can hold: {n} were left out.").format(
|
||||
n=f"{cut:,}" if isinstance(cut, int) else cut) if cut else ""
|
||||
return notes, warning
|
||||
|
||||
|
||||
def report_rows(report: dict | None) -> list[tuple[str, str]]:
|
||||
"""(label, value) for the clean-up's counts; nested groups are flattened, lists and empty values left out."""
|
||||
out = []
|
||||
|
||||
def walk(d, prefix=""):
|
||||
for k, v in d.items():
|
||||
if k in REPORT_NOT_COUNTS:
|
||||
continue
|
||||
if isinstance(v, dict):
|
||||
walk(v, prefix)
|
||||
elif isinstance(v, bool) or v is None or isinstance(v, (list, str)):
|
||||
continue
|
||||
elif isinstance(v, (int, float)):
|
||||
out.append((report_label(k), f"{v:,}" if isinstance(v, int) else f"{v:,.2f}".rstrip("0").rstrip(".")))
|
||||
walk(report or {})
|
||||
return out
|
||||
|
||||
|
||||
def _slider(lo, hi, step, value, on_change, width=None) -> ft.Slider:
|
||||
return ft.Slider(min=lo, max=hi, divisions=max(1, round((hi - lo) / step)), value=min(max(value, lo), hi),
|
||||
active_color=T.ACCENT, on_change=on_change, width=width, height=T.px(28),
|
||||
padding=ft.Padding(0, 0, 0, 0)) # the track spans the row: its ends under the label and value
|
||||
|
||||
|
||||
|
||||
def _full(control: ft.Control) -> ft.Row:
|
||||
"""`control` across the whole content width (a full segmented() track)."""
|
||||
return ft.Row([ft.Container(control, expand=True)])
|
||||
|
||||
|
||||
def _even(button: ft.Control) -> ft.Control:
|
||||
"""A button sharing its row equally with the others (same width and height)."""
|
||||
button.expand = True
|
||||
return button
|
||||
|
||||
|
||||
def _labelled(title: str, readout: ft.Text, control: ft.Control, help_key: str | None = None) -> ft.Column:
|
||||
head = ft.Row([ft.Container(C.with_help(C.body(title, T.TEXT), help_key), expand=True), readout],
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
return ft.Column([head, control], spacing=0, tight=True, horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
|
||||
|
||||
|
||||
# ===================================================================================================== Clean-up
|
||||
class CleanupCard:
|
||||
"""Recommended (the defaults) or your own settings: the key ones in sight, the rest under More settings. Apply
|
||||
cleans up the saved room's scan again on the Frame (a background job) and saves the settings there."""
|
||||
|
||||
def __init__(self, view: RoomView):
|
||||
self.view = view
|
||||
self.values = rooms.refine_defaults()
|
||||
self.mine: dict | None = None # your settings while Recommended is shown
|
||||
self.touched = False # changed here since the Frame's settings were loaded
|
||||
self._loaded = None # the Frame's saved settings last shown (json)
|
||||
self._report_seen = "unset"
|
||||
self.sliders: dict[str, ft.Slider] = {}
|
||||
self.readouts: dict[str, ft.Text] = {}
|
||||
self.switches: dict[str, ft.Control] = {}
|
||||
self.preset = C.segmented([("recommended", tr("Recommended")), ("mine", tr("Your settings"))], "recommended",
|
||||
self._preset, full=True)
|
||||
walls = rooms.REFINE_DEFAULTS["walls"]
|
||||
self.walls = C.segmented([(v, tr(label)) for v, label in walls["choices"]], self.values["walls"],
|
||||
lambda v: self.set("walls", v), full=True)
|
||||
keys = [k for k, m in rooms.REFINE_DEFAULTS.items() if m["key"] and m["kind"] == "number"]
|
||||
visible = [_labelled(tr("Walls"), C.meta(""), _full(self.walls), "refine_walls")]
|
||||
visible += [self._row(k) for k in keys]
|
||||
groups = []
|
||||
for group, title in rooms.REFINE_GROUPS:
|
||||
rows = [self._row(k) for k, m in rooms.REFINE_DEFAULTS.items() if m["group"] == group and not m["key"]]
|
||||
if rows:
|
||||
groups.append(ft.Column([C.body(tr(title), T.TEXT_2, weight=ft.FontWeight.W_600), *rows],
|
||||
spacing=T.S2, tight=True))
|
||||
self.more = ft.Column(groups, spacing=T.S4, visible=False)
|
||||
self.more_btn = C.ghost(tr("More settings"), ft.Icons.EXPAND_MORE_ROUNDED, self.toggle_more)
|
||||
self.apply_btn = C.primary(tr("Apply"), ft.Icons.AUTO_FIX_HIGH_ROUNDED, self.apply,
|
||||
tooltip=C.tip(tr("Clean up your saved scan again with these settings. They are "
|
||||
"also used for your next scans.")))
|
||||
self.state_text = C.meta("")
|
||||
self.progress = C.progress_bar(None)
|
||||
self.progress.visible = False
|
||||
self.result_title = C.meta(tr("Last clean-up"))
|
||||
self.result = ft.Column([], spacing=T.px(2), tight=True)
|
||||
# (no inset: the counts end at the same edge as the slider values above)
|
||||
self.result_notes = ft.Column([], spacing=T.px(2), tight=True)
|
||||
self.result_warn = C.callout("", "warn")
|
||||
self.result_warn.visible = False
|
||||
self.result_box = ft.Column([ft.Divider(height=1, color=T.BORDER), self.result_title, self.result,
|
||||
self.result_notes, self.result_warn], spacing=T.S2, tight=True, visible=False)
|
||||
self.control = C.card(ft.Column([
|
||||
C.section(tr("Clean-up"), _full(self.preset), *visible, self.more_btn, self.more,
|
||||
ft.Row([self.apply_btn, ft.Container(self.state_text, expand=True)], spacing=T.S3,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.progress, self.result_box, help="room_cleanup"),
|
||||
], spacing=T.S3), padding=T.S4, col={"xs": 12, "md": 6, "xl": 4})
|
||||
self._show()
|
||||
|
||||
def _row(self, key: str) -> ft.Control:
|
||||
meta = rooms.REFINE_DEFAULTS[key]
|
||||
if meta["kind"] == "bool":
|
||||
sw = C.switch(setting_label(key), value=bool(self.values[key]),
|
||||
on_change=lambda e, k=key: self.set(k, 1 if e.control.value else 0))
|
||||
self.switches[key] = sw
|
||||
return C.with_help(sw, meta["help"])
|
||||
self.readouts[key] = C.body("", T.TEXT, weight=ft.FontWeight.W_600)
|
||||
self.sliders[key] = _slider(meta["min"], meta["max"], meta["step"], self.values[key],
|
||||
lambda e, k=key: self._slid(k, e.control.value))
|
||||
return _labelled(setting_label(key), self.readouts[key], self.sliders[key], meta["help"])
|
||||
|
||||
# values -> controls
|
||||
def _show(self) -> list[ft.Control]:
|
||||
for k, s in self.sliders.items():
|
||||
meta = rooms.REFINE_DEFAULTS[k]
|
||||
s.value = min(max(self.values[k], meta["min"]), meta["max"])
|
||||
self.readouts[k].value = setting_text(k, self.values[k])
|
||||
for k, sw in self.switches.items():
|
||||
C.set_switch(sw, bool(self.values[k]))
|
||||
C.set_segmented(self.walls, self.values["walls"])
|
||||
C.set_segmented(self.preset, "recommended" if not rooms.normalize_refine(self.values) else "mine")
|
||||
self.sliders["near"].disabled = self.values["walls"] != "panels"
|
||||
return [*self.sliders.values(), *self.readouts.values(), *self.switches.values(), self.walls, self.preset]
|
||||
|
||||
def _slid(self, key: str, value: float) -> None:
|
||||
meta = rooms.REFINE_DEFAULTS[key]
|
||||
value = round(round((value - meta["min"]) / meta["step"]) * meta["step"] + meta["min"], 4)
|
||||
if key in rooms.INT_SETTINGS:
|
||||
value = int(round(value))
|
||||
if self.values[key] == value:
|
||||
return
|
||||
self.values[key] = value
|
||||
self.readouts[key].value = setting_text(key, value)
|
||||
self.touched = True
|
||||
changed = self.preset.data != ("mine" if rooms.normalize_refine(self.values) else "recommended")
|
||||
C.set_segmented(self.preset, "recommended" if not rooms.normalize_refine(self.values) else "mine")
|
||||
C.update(self.readouts[key], *([self.preset] if changed else []))
|
||||
|
||||
def set(self, key: str, value) -> None:
|
||||
self.values[key] = value
|
||||
self.touched = True
|
||||
C.update(*self._show())
|
||||
|
||||
def _preset(self, which: str) -> None:
|
||||
if which == "recommended":
|
||||
self.mine = dict(self.values) if rooms.normalize_refine(self.values) else self.mine
|
||||
self.values = rooms.refine_defaults()
|
||||
else:
|
||||
self.values = dict(self.mine) if self.mine else self.values
|
||||
self.touched = True
|
||||
C.update(*self._show())
|
||||
|
||||
def toggle_more(self, e=None) -> None:
|
||||
shown = self.more.visible = not self.more.visible
|
||||
self.more_btn.content = tr("Fewer settings") if shown else tr("More settings")
|
||||
self.more_btn.icon = ft.Icons.EXPAND_LESS_ROUNDED if shown else ft.Icons.EXPAND_MORE_ROUNDED
|
||||
C.update(self.more, self.more_btn)
|
||||
|
||||
def apply(self, e=None) -> None:
|
||||
self.touched = False
|
||||
RS.refine_room(self.view.app, dict(self.values))
|
||||
|
||||
# cache -> controls
|
||||
def refresh(self, cached: dict, status: dict, room: dict | None) -> list[ft.Control]:
|
||||
changed = [self.apply_btn, self.state_text, self.progress, self.result_box]
|
||||
saved = status.get("refine") if status else None
|
||||
key = json.dumps(saved, sort_keys=True) if saved is not None else None
|
||||
if key is not None and key != self._loaded and not self.touched:
|
||||
self._loaded = key
|
||||
self.values = rooms.current_refine(status)
|
||||
changed += self._show()
|
||||
working = cached.get("working")
|
||||
refining = working == "refine"
|
||||
self.apply_btn.disabled = bool(working) or not room or not status.get("scanner_installed", True) \
|
||||
or room.get("source") != "scan"
|
||||
self.progress.visible = refining
|
||||
if refining:
|
||||
self.state_text.value = cached.get("job_stage") or tr("Cleaning up the scan on the Frame…")
|
||||
elif room and room.get("source") != "scan":
|
||||
self.state_text.value = tr("Only a scanned room can be cleaned up.")
|
||||
elif status and status.get("edited"):
|
||||
self.state_text.value = tr("Your own boxes and doors stay.")
|
||||
else:
|
||||
self.state_text.value = ""
|
||||
report = (status or {}).get("report")
|
||||
if report is not self._report_seen:
|
||||
self._report_seen = report
|
||||
rows = report_rows(report)
|
||||
self.result.controls = [ft.Row([ft.Container(C.meta(a), expand=True), C.body(b, T.TEXT)],
|
||||
spacing=T.S2) for a, b in rows]
|
||||
notes, warning = report_notes(report)
|
||||
self.result_notes.controls = [C.meta(n) for n in notes]
|
||||
self.result_warn.visible = bool(warning)
|
||||
if warning: # (callout: icon, then the text's container)
|
||||
self.result_warn.content.controls[1].content.value = warning
|
||||
self.result_box.visible = bool(rows or notes or warning)
|
||||
return changed
|
||||
|
||||
|
||||
# ===================================================================================================== Labels
|
||||
LABEL_NAME_W = 72 # the Labels rows' first column (T.px)
|
||||
|
||||
class LabelsCard:
|
||||
"""What the label rules decided for each box the scanner found (scanner label -> label, and why), and door /
|
||||
closet suggestions with Add as door / Add as closet (into the box editor, saved with Save)."""
|
||||
|
||||
def __init__(self, view: RoomView):
|
||||
self.view = view
|
||||
self._seen = "unset"
|
||||
self.rows = ft.Column([], spacing=T.S2, tight=True)
|
||||
self.suggestions = ft.Column([], spacing=T.S2, tight=True)
|
||||
self.empty = C.meta(tr("Clean up a scan to see what the rules decided."))
|
||||
self.reset_btn = C.ghost(tr("Back to the scan's boxes"), ft.Icons.UNDO_ROUNDED, self._ask_reset,
|
||||
tooltip=C.tip(tr("Drop your own boxes, doors and windows and use the boxes the "
|
||||
"scan found.")))
|
||||
self.reset_btn.visible = False
|
||||
self.control = C.card(ft.Column([
|
||||
C.section(tr("Labels"), self.empty, self.suggestions, self.rows, self.reset_btn, help="room_labels"),
|
||||
], spacing=T.S3), padding=T.S4, col={"xs": 12, "md": 6, "xl": 4})
|
||||
|
||||
def _ask_reset(self, e=None) -> None:
|
||||
C.confirm(self.view.app.page, tr("Back to the scan's boxes?"),
|
||||
tr("Your own furniture boxes, doors and windows are removed and the scan is cleaned up again."),
|
||||
tr("Use the scan's boxes"), lambda: RS.refine_room(self.view.app, None, keep_edits=False))
|
||||
|
||||
def refresh(self, cached: dict, status: dict, room: dict | None) -> list[ft.Control]:
|
||||
self.reset_btn.visible = bool(status and status.get("edited"))
|
||||
self.reset_btn.disabled = bool(cached.get("working"))
|
||||
changed = [self.reset_btn]
|
||||
if cached.get("status") is self._seen:
|
||||
return changed
|
||||
self._seen = cached.get("status")
|
||||
labels = [x for x in (status or {}).get("labels") or [] if isinstance(x, dict)]
|
||||
suggestions = [x for x in (status or {}).get("suggestions") or [] if isinstance(x, dict)]
|
||||
self.rows.controls = [self._label_row(x) for x in labels]
|
||||
self.suggestions.controls = [self._suggestion_row(x) for x in suggestions]
|
||||
self.empty.visible = not labels and not suggestions
|
||||
return changed + [self.rows, self.suggestions, self.empty]
|
||||
|
||||
@staticmethod
|
||||
def _size(box: dict) -> str:
|
||||
try:
|
||||
return size_text(float(box["w"]), float(box["d"]), float(box["h"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
def _label_row(self, x: dict) -> ft.Control:
|
||||
src, dst = str(x.get("from") or ""), x.get("to")
|
||||
if x.get("suggest") in ("door", "closet"): # a tall thin slab: see the suggestions above
|
||||
change, color = (tr("→ door?") if x["suggest"] == "door" else tr("→ closet?")), T.OK
|
||||
elif dst is None:
|
||||
change, color = tr("removed"), T.TEXT_3
|
||||
elif dst == src:
|
||||
change, color = tr("kept"), T.TEXT_2
|
||||
else:
|
||||
change, color = "→ " + _furniture_name(str(dst)), T.ACCENT
|
||||
# columns: the scanner's name (fixed width), what it became, the size at the right edge
|
||||
head = ft.Row([ft.Container(ft.Text(_furniture_name(src), color=T.TEXT if dst is not None else T.TEXT_3,
|
||||
weight=ft.FontWeight.W_600, size=T.T_BODY, no_wrap=True),
|
||||
width=T.px(LABEL_NAME_W)),
|
||||
ft.Container(ft.Text(change, color=color, size=T.T_BODY), expand=True),
|
||||
C.meta(self._size(x.get("box") or {}))],
|
||||
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
reason = str(x.get("reason") or "")
|
||||
return ft.Column([head] + ([C.meta(reason)] if reason else []), spacing=T.px(2), tight=True)
|
||||
|
||||
def _suggestion_row(self, x: dict) -> ft.Control:
|
||||
kind = x.get("kind")
|
||||
text = tr("A door?") if kind == "door" else tr("A closet?")
|
||||
editor = self.view.editor
|
||||
return ft.Container(ft.Column([
|
||||
ft.Row([ft.Icon(ft.Icons.DOOR_FRONT_DOOR_ROUNDED if kind == "door" else ft.Icons.SHELVES, color=T.OK,
|
||||
size=T.ICON_S),
|
||||
C.body(text, T.TEXT, weight=ft.FontWeight.W_600), ft.Container(expand=True),
|
||||
C.meta(self._size(x.get("box") or {}))], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
C.meta(str(x.get("reason") or "") or
|
||||
tr("A tall, thin box by a wall: the scanner can't tell a door from a closet.")),
|
||||
ft.Row([_even(C.secondary(tr("Add as door"), None, lambda e, s=x: editor.accept(s, "door"))),
|
||||
_even(C.secondary(tr("Add as closet"), None, lambda e, s=x: editor.accept(s, "closet")))],
|
||||
spacing=T.S2),
|
||||
], spacing=T.S2, tight=True), bgcolor=T.soft(T.OK, 0.07), border=ft.Border.all(1, T.soft(T.OK, 0.3)),
|
||||
border_radius=T.RADIUS_SM, padding=T.S3)
|
||||
|
||||
|
||||
# ===================================================================================================== box editor
|
||||
BOX_FIELDS = ( # attribute, label, min, max, step, unit ("" = metres, "deg")
|
||||
("cx", "Left / right", None, None, 0.01, ""), ("cz", "Front / back", None, None, 0.01, ""),
|
||||
("top_y", "Top height", 0.0, 3.0, 0.01, ""), ("yaw_deg", "Turn", -180.0, 180.0, 1.0, "deg"),
|
||||
("w", "Width", 0.05, 4.0, 0.01, ""), ("d", "Depth", 0.05, 4.0, 0.01, ""), ("h", "Height", 0.02, 3.0, 0.01, ""))
|
||||
PLANE_FIELDS = (("along", "Along the wall", 0.0, 1.0, 0.01, "%"), ("bottom", "Bottom edge", 0.0, 2.0, 0.01, ""),
|
||||
("w", "Width", 0.3, 3.0, 0.01, ""), ("h", "Height", 0.2, 2.6, 0.01, ""))
|
||||
|
||||
|
||||
CONTROL_H = 44 # the editors' dropdowns and their Delete button share this height (T.px)
|
||||
|
||||
|
||||
def _picker(options, on_select) -> ft.Dropdown:
|
||||
"""A dropdown filling its row, as tall as the Delete button next to it."""
|
||||
return C.dropdown(options=options, on_select=on_select, dense=True, expand=True,
|
||||
content_padding=ft.Padding(T.px(12), 0, T.px(8), 0))
|
||||
|
||||
|
||||
def _delete_button(on_click) -> ft.OutlinedButton:
|
||||
btn = C.secondary(tr("Delete"), ft.Icons.DELETE_OUTLINE_ROUNDED, on_click)
|
||||
btn.style.padding = ft.Padding(T.px(14), 0, T.px(14), 0)
|
||||
btn.height = T.px(CONTROL_H)
|
||||
return btn
|
||||
|
||||
|
||||
def _panel(rows: list[ft.Control]) -> ft.Container:
|
||||
"""The selected box's / door's editor: its rows across the whole width."""
|
||||
return ft.Container(ft.Column(rows, spacing=T.S3, tight=True,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.STRETCH),
|
||||
bgcolor=T.SURFACE_2, border_radius=T.RADIUS_SM, padding=T.S3, visible=False)
|
||||
|
||||
|
||||
def metres(value: float) -> str:
|
||||
"""A box size in metres: one decimal, two below 10 cm (a rug's 3 cm isn't "0.0")."""
|
||||
return f"{value:.2f}" if abs(value) < 0.095 else f"{value:.1f}"
|
||||
|
||||
|
||||
def size_text(w: float, d: float, h: float) -> str:
|
||||
return tr("{w}×{d}×{h} m").format(w=metres(w), d=metres(d), h=metres(h))
|
||||
|
||||
|
||||
def field_label(name: str) -> str:
|
||||
return {"Left / right": tr("Left / right"), "Front / back": tr("Front / back"), "Top height": tr("Top height"),
|
||||
"Turn": tr("Turn"), "Width": tr("Width"), "Depth": tr("Depth"), "Height": tr("Height"),
|
||||
"Along the wall": tr("Along the wall"), "Bottom edge": tr("Bottom edge")}[name]
|
||||
|
||||
|
||||
def field_text(unit: str, value: float) -> str:
|
||||
if unit == "deg":
|
||||
return tr("{n}°").format(n=round(value))
|
||||
if unit == "%":
|
||||
return f"{round(value * 100)} %"
|
||||
return tr("{n} m").format(n=f"{value:.2f}")
|
||||
|
||||
|
||||
class FurnitureEditor:
|
||||
"""The room's furniture boxes and doors / windows, editable: select one (in the list or by clicking it in the 3D
|
||||
view), change its type, move / size / turn it; add or delete. Save sends the room to the Frame (rooms.put_room)."""
|
||||
|
||||
def __init__(self, view: RoomView):
|
||||
self.view = view
|
||||
self.model = None # the room cache's model the edits started from
|
||||
self.room: R.Room | None = None # the edited copy
|
||||
self.places: list[dict] = [] # per plane: wall, along, bottom (the editor's own terms)
|
||||
self.sel: tuple[str, int] | None = None
|
||||
self.dirty = False
|
||||
# boxes
|
||||
self.box_list = ft.Column([], spacing=T.px(2), tight=True)
|
||||
self.box_type = _picker([ft.DropdownOption(key=k, text=_furniture_name(k)) for k in R.VOLUME_LABELS],
|
||||
lambda e: self._box_label(e.control.value))
|
||||
self.box_sliders: dict[str, ft.Slider] = {}
|
||||
self.box_readouts: dict[str, ft.Text] = {}
|
||||
rows = []
|
||||
for attr, label, lo, hi, step, _unit in BOX_FIELDS: # one slider per row, the whole width
|
||||
self.box_readouts[attr] = C.body("", T.TEXT, weight=ft.FontWeight.W_600)
|
||||
self.box_sliders[attr] = _slider(lo if lo is not None else -5, hi if hi is not None else 5, step, 0,
|
||||
lambda e, a=attr: self._box_value(a, e.control.value))
|
||||
rows.append(_labelled(field_label(label), self.box_readouts[attr], self.box_sliders[attr]))
|
||||
self.box_delete = _delete_button(self._delete)
|
||||
self.box_panel = _panel([ft.Row([ft.Container(self.box_type, expand=True), self.box_delete],
|
||||
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER), *rows])
|
||||
self.add_box = C.secondary(tr("Add box"), ft.Icons.ADD_BOX_ROUNDED, self._add_box)
|
||||
# doors / windows
|
||||
self.plane_list = ft.Column([], spacing=T.px(2), tight=True)
|
||||
self.plane_type = _picker([ft.DropdownOption(key=k, text=_furniture_name(k)) for k in R.PLANE_LABELS],
|
||||
lambda e: self._plane_label(e.control.value))
|
||||
self.plane_wall = _picker([], lambda e: self._plane_wall(e.control.value))
|
||||
self.plane_sliders: dict[str, ft.Slider] = {}
|
||||
self.plane_readouts: dict[str, ft.Text] = {}
|
||||
rows = []
|
||||
for attr, label, lo, hi, step, _unit in PLANE_FIELDS:
|
||||
self.plane_readouts[attr] = C.body("", T.TEXT, weight=ft.FontWeight.W_600)
|
||||
self.plane_sliders[attr] = _slider(lo, hi, step, lo,
|
||||
lambda e, a=attr: self._plane_value(a, e.control.value))
|
||||
rows.append(_labelled(field_label(label), self.plane_readouts[attr], self.plane_sliders[attr]))
|
||||
self.plane_delete = _delete_button(self._delete)
|
||||
self.plane_panel = _panel([ft.Row([ft.Container(self.plane_type, expand=True), self.plane_delete],
|
||||
spacing=T.S2, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
self.plane_wall, *rows])
|
||||
self.add_door = C.secondary(tr("Add door"), ft.Icons.DOOR_FRONT_DOOR_ROUNDED,
|
||||
lambda e: self._add_plane("DOOR_FRAME"))
|
||||
self.add_window = C.secondary(tr("Add window"), ft.Icons.WINDOW_ROUNDED,
|
||||
lambda e: self._add_plane("WINDOW_FRAME"))
|
||||
# save
|
||||
self.save_btn = C.primary(tr("Save"), ft.Icons.SAVE_ROUNDED, self.save,
|
||||
tooltip=C.tip(tr("Send your boxes, doors and windows to the Frame. Games get them "
|
||||
"at their next start.")))
|
||||
self.discard_btn = C.ghost(tr("Discard changes"), ft.Icons.UNDO_ROUNDED, self.discard)
|
||||
self.save_text = C.meta("")
|
||||
self.empty = C.meta(tr("No room saved yet"))
|
||||
self.body = ft.Column([
|
||||
C.meta(tr("Pick a box here or click it in the view, then fix its type, place or size.")),
|
||||
self.box_list, self.box_panel, ft.Row([self.add_box], wrap=True),
|
||||
ft.Container(height=T.S2),
|
||||
C.section(tr("Doors & windows"), C.meta(tr("Add them on a wall: games that ask for a door get it.")),
|
||||
self.plane_list, self.plane_panel, ft.Row([self.add_door, self.add_window], spacing=T.S2,
|
||||
wrap=True), help="room_doors"),
|
||||
ft.Divider(height=1, color=T.BORDER),
|
||||
ft.Row([self.save_btn, self.discard_btn, self.save_text], spacing=T.S2, wrap=True,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
], spacing=T.S3, tight=True, visible=False)
|
||||
self.control = C.card(ft.Column([C.section(tr("Furniture & screens"), self.empty, self.body,
|
||||
help="room_boxes")], spacing=T.S3),
|
||||
padding=T.S4, col={"xs": 12, "md": 6, "xl": 4})
|
||||
|
||||
# ------------------------------------------------------------------ model
|
||||
def load(self, model: dict | None) -> None:
|
||||
"""A new room from the cache (a scan, a clean-up, a save): edits start again from it."""
|
||||
self.model = model
|
||||
self.room = copy.deepcopy(model["room"]) if model else None
|
||||
self.places = [R.plane_place(self.room, p) for p in self.room.planes] if self.room else []
|
||||
self.sel = None
|
||||
self.dirty = False
|
||||
if self.room is not None:
|
||||
xs = [x for x, _ in self.room.outline]
|
||||
zs = [z for _, z in self.room.outline]
|
||||
for attr, lo, hi in (("cx", min(xs) - 1, max(xs) + 1), ("cz", min(zs) - 1, max(zs) + 1),
|
||||
("top_y", self.room.floor, self.room.floor + 3)):
|
||||
s = self.box_sliders[attr]
|
||||
s.min, s.max = round(lo, 2), round(hi, 2)
|
||||
s.divisions = max(1, round((s.max - s.min) / 0.01))
|
||||
self.plane_wall.options = [
|
||||
ft.DropdownOption(key=str(i), text=(tr("Wall {n} ({length} m, unseen)") if w.label ==
|
||||
"INVISIBLE_WALL_FACE" else tr("Wall {n} ({length} m)")).format(
|
||||
n=i + 1, length=f"{((w.x1 - w.x0) ** 2 + (w.z1 - w.z0) ** 2) ** 0.5:.1f}"))
|
||||
for i, w in enumerate(self.room.walls)]
|
||||
self._lists()
|
||||
self._panels()
|
||||
|
||||
def scene(self):
|
||||
"""The 3D scene with the edited furniture (None while there's no room)."""
|
||||
if self.model is None or self.room is None:
|
||||
return None
|
||||
return view3d.with_furniture(self.model["scene"], self.room, self.sel)
|
||||
|
||||
def _redraw(self) -> None:
|
||||
canvas = self.view.canvas
|
||||
if canvas is not None and self.model is not None:
|
||||
canvas.set_scene(self.scene(), fit=False)
|
||||
|
||||
def _changed(self, structure: bool = False) -> None:
|
||||
self.dirty = True
|
||||
if structure:
|
||||
self._lists()
|
||||
self._redraw()
|
||||
C.update(*self._save_state(), *([self.box_list, self.plane_list] if structure else []))
|
||||
|
||||
# ------------------------------------------------------------------ lists
|
||||
@staticmethod
|
||||
def _box_size(v) -> str:
|
||||
return size_text(v.w, v.d, v.h)
|
||||
|
||||
def _plane_info(self, i: int) -> str:
|
||||
p = self.room.planes[i]
|
||||
return tr("wall {n} · {w}×{h} m").format(n=self.places[i]["wall"] + 1, w=f"{p.w:.2f}", h=f"{p.h:.2f}")
|
||||
|
||||
def _item(self, kind: str, i: int, name: str, info: str, color: str) -> ft.Container:
|
||||
on = self.sel == (kind, i)
|
||||
text = ft.Text(name, size=T.T_BODY, color=T.TEXT if on else T.TEXT_2, weight=ft.FontWeight.W_600)
|
||||
box = ft.Container(ft.Row([ft.Container(width=T.px(10), height=T.px(10), bgcolor=color,
|
||||
border_radius=T.px(3)),
|
||||
text, ft.Container(expand=True), C.meta(info)], spacing=T.S2,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
||||
padding=ft.Padding(T.S3, T.px(6), T.S3, T.px(6)), border_radius=T.RADIUS_XS,
|
||||
on_click=lambda e: self.select(None if self.sel == (kind, i) else (kind, i)))
|
||||
C.selected_style(box, on, text=text)
|
||||
return C.hoverable(box)
|
||||
|
||||
def _lists(self) -> None:
|
||||
room = self.room
|
||||
self.box_list.controls = [self._item("box", i, _furniture_name(v.label), self._box_size(v), T.ACCENT)
|
||||
for i, v in enumerate(room.volumes)] if room else []
|
||||
if room and not room.volumes:
|
||||
self.box_list.controls = [C.meta(tr("No furniture found"))]
|
||||
self.plane_list.controls = [self._item("plane", i, _furniture_name(p.label), self._plane_info(i), T.OK)
|
||||
for i, p in enumerate(room.planes)] if room else []
|
||||
|
||||
# ------------------------------------------------------------------ panels
|
||||
def _panels(self) -> list[ft.Control]:
|
||||
room, sel = self.room, self.sel
|
||||
box = room.volumes[sel[1]] if room and sel and sel[0] == "box" and sel[1] < len(room.volumes) else None
|
||||
plane = room.planes[sel[1]] if room and sel and sel[0] == "plane" and sel[1] < len(room.planes) else None
|
||||
self.box_panel.visible = box is not None
|
||||
self.plane_panel.visible = plane is not None
|
||||
if box is not None:
|
||||
self.box_type.value = box.label if box.label in R.VOLUME_LABELS else "OTHER"
|
||||
for attr, *_rest, unit in BOX_FIELDS:
|
||||
s, value = self.box_sliders[attr], getattr(box, attr)
|
||||
if attr == "yaw_deg":
|
||||
value = (value + 180) % 360 - 180
|
||||
s.value = min(max(value, s.min), s.max)
|
||||
self.box_readouts[attr].value = field_text(unit, value)
|
||||
if plane is not None:
|
||||
place = self.places[sel[1]]
|
||||
self.plane_type.value = plane.label if plane.label in R.PLANE_LABELS else "WALL_ART"
|
||||
self.plane_wall.value = str(place["wall"])
|
||||
values = {"along": place["along"], "bottom": place["bottom"], "w": plane.w, "h": plane.h}
|
||||
for attr, *_rest, unit in PLANE_FIELDS:
|
||||
s = self.plane_sliders[attr]
|
||||
s.value = min(max(values[attr], s.min), s.max)
|
||||
self.plane_readouts[attr].value = field_text(unit, values[attr])
|
||||
return [self.box_panel, self.plane_panel]
|
||||
|
||||
def select(self, sel: tuple[str, int] | None) -> None:
|
||||
self.sel = sel
|
||||
self._lists()
|
||||
changed = self._panels()
|
||||
self._redraw()
|
||||
C.update(self.box_list, self.plane_list, *changed)
|
||||
|
||||
# ------------------------------------------------------------------ edits
|
||||
def _box(self):
|
||||
if self.room and self.sel and self.sel[0] == "box" and self.sel[1] < len(self.room.volumes):
|
||||
return self.room.volumes[self.sel[1]]
|
||||
return None
|
||||
|
||||
def _box_value(self, attr: str, value: float) -> None:
|
||||
v = self._box()
|
||||
if v is None:
|
||||
return
|
||||
step = next(f[4] for f in BOX_FIELDS if f[0] == attr)
|
||||
value = round(round(value / step) * step, 4)
|
||||
setattr(v, attr, value)
|
||||
unit = next(f[5] for f in BOX_FIELDS if f[0] == attr)
|
||||
self.box_readouts[attr].value = field_text(unit, value)
|
||||
row = self.box_list.controls[self.sel[1]] if self.sel[1] < len(self.box_list.controls) else None
|
||||
if row is not None: # the list row's size, in place (no new clickable control on a slider tick)
|
||||
row.content.controls[-1].value = self._box_size(v)
|
||||
self._changed()
|
||||
C.update(self.box_readouts[attr], *([row] if row is not None else []))
|
||||
|
||||
def _box_label(self, label: str) -> None:
|
||||
v = self._box()
|
||||
if v is not None and label:
|
||||
v.label = label
|
||||
self._changed(structure=True)
|
||||
|
||||
def _plane_index(self) -> int | None:
|
||||
if self.room and self.sel and self.sel[0] == "plane" and self.sel[1] < len(self.room.planes):
|
||||
return self.sel[1]
|
||||
return None
|
||||
|
||||
def _rebuild_plane(self, i: int) -> None:
|
||||
p, place = self.room.planes[i], self.places[i]
|
||||
self.room.planes[i] = R.plane_on_wall(self.room, place["wall"], place["along"], place["bottom"], p.w, p.h,
|
||||
p.label, p.uuid)
|
||||
|
||||
def _plane_value(self, attr: str, value: float) -> None:
|
||||
i = self._plane_index()
|
||||
if i is None:
|
||||
return
|
||||
value = round(round(value / 0.01) * 0.01, 4)
|
||||
if attr in ("along", "bottom"):
|
||||
self.places[i][attr] = value
|
||||
else:
|
||||
setattr(self.room.planes[i], attr, value)
|
||||
self._rebuild_plane(i)
|
||||
unit = next(f[5] for f in PLANE_FIELDS if f[0] == attr)
|
||||
self.plane_readouts[attr].value = field_text(unit, value)
|
||||
row = self.plane_list.controls[i] if i < len(self.plane_list.controls) else None
|
||||
if row is not None:
|
||||
row.content.controls[-1].value = self._plane_info(i)
|
||||
self._changed()
|
||||
C.update(self.plane_readouts[attr], *([row] if row is not None else []))
|
||||
|
||||
def _plane_label(self, label: str) -> None:
|
||||
i = self._plane_index()
|
||||
if i is not None and label:
|
||||
self.room.planes[i].label = label
|
||||
self._changed(structure=True)
|
||||
|
||||
def _plane_wall(self, value: str) -> None:
|
||||
i = self._plane_index()
|
||||
if i is not None and value is not None:
|
||||
self.places[i]["wall"] = int(value)
|
||||
self._rebuild_plane(i)
|
||||
self._changed(structure=True)
|
||||
|
||||
def _delete(self, e=None) -> None:
|
||||
if not self.room or not self.sel:
|
||||
return
|
||||
kind, i = self.sel
|
||||
if kind == "box" and i < len(self.room.volumes):
|
||||
del self.room.volumes[i]
|
||||
elif kind == "plane" and i < len(self.room.planes):
|
||||
del self.room.planes[i]
|
||||
del self.places[i]
|
||||
self.sel = None
|
||||
self._changed(structure=True)
|
||||
C.update(*self._panels())
|
||||
|
||||
def _add_box(self, e=None) -> None:
|
||||
if self.room is None:
|
||||
return
|
||||
self.room.volumes.append(R.default_volume(self.room))
|
||||
self._select_new(("box", len(self.room.volumes) - 1))
|
||||
|
||||
def _add_plane(self, label: str) -> None:
|
||||
if self.room is None:
|
||||
return
|
||||
plane = R.default_plane(self.room, label)
|
||||
self.room.planes.append(plane)
|
||||
self.places.append(R.plane_place(self.room, plane))
|
||||
self._select_new(("plane", len(self.room.planes) - 1))
|
||||
|
||||
def accept(self, suggestion: dict, kind: str) -> None:
|
||||
"""A label-rules suggestion taken as a door (on its wall) or a closet (a storage box against it)."""
|
||||
if self.room is None:
|
||||
return
|
||||
made = R.accept_suggestion(self.room, suggestion, kind)
|
||||
if isinstance(made, R.Plane):
|
||||
self.places.append(R.plane_place(self.room, made))
|
||||
self._select_new(("plane", len(self.room.planes) - 1))
|
||||
else:
|
||||
self._select_new(("box", len(self.room.volumes) - 1))
|
||||
self.view.app.toast(tr("Added. Check it in the view, then Save."))
|
||||
|
||||
def _select_new(self, sel) -> None:
|
||||
self.dirty = True
|
||||
self.select(sel)
|
||||
C.update(*self._save_state())
|
||||
|
||||
def discard(self, e=None) -> None:
|
||||
self.load(self.model)
|
||||
self._redraw()
|
||||
C.update(self.box_list, self.plane_list, self.box_panel, self.plane_panel, *self._save_state())
|
||||
|
||||
def save(self, e=None) -> None:
|
||||
if self.room is None or self.model is None:
|
||||
return
|
||||
RS.save_room(self.view.app, copy.deepcopy(self.room), self.model.get("mesh"))
|
||||
|
||||
# ------------------------------------------------------------------ cache -> controls
|
||||
def _save_state(self) -> list[ft.Control]:
|
||||
working = (getattr(self.view.app, "_room_state", None) or {}).get("working")
|
||||
self.save_btn.disabled = not self.dirty or bool(working)
|
||||
self.discard_btn.visible = self.dirty
|
||||
self.save_text.value = (tr("Saving…") if working == "save" else
|
||||
tr("Not saved yet") if self.dirty else tr("No changes to save"))
|
||||
return [self.save_btn, self.discard_btn, self.save_text]
|
||||
|
||||
def refresh(self, cached: dict, status: dict, room: dict | None) -> list[ft.Control]:
|
||||
model = cached.get("model")
|
||||
has = model is not None and room is not None
|
||||
self.body.visible = has
|
||||
self.empty.visible = not has
|
||||
self.empty.value = tr("Loading the room…") if room and model is None else tr("No room saved yet")
|
||||
return [self.body, self.empty, *self._save_state()]
|
||||
@@ -1,34 +0,0 @@
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <openxr/openxr.h>
|
||||
static int swapchain_size(XrSwapchain handle, uint32_t *w, uint32_t *h) {
|
||||
if (!handle) return 0;
|
||||
*w = handle == (XrSwapchain)(uintptr_t)2 ? 1024 : 2768; *h = *w;
|
||||
return 1;
|
||||
}
|
||||
#include "fixed_resolution.h"
|
||||
int main(void) {
|
||||
XrExtent2Di full = {0};
|
||||
XrCompositionLayerProjectionView views[2] = {{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW},
|
||||
{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
|
||||
XrCompositionLayerProjection p = {XR_TYPE_COMPOSITION_LAYER_PROJECTION};
|
||||
p.views = views; p.viewCount = 2;
|
||||
for (int i=0; i<2; ++i) {
|
||||
views[i].subImage.swapchain = (XrSwapchain)(uintptr_t)1;
|
||||
views[i].subImage.imageRect = (XrRect2Di){{0,64},{2704,2704}};
|
||||
}
|
||||
assert(fixed_layer_size((void *)&p, &full));
|
||||
assert(full.width == 2768 && full.height == 2768);
|
||||
assert(views[0].subImage.imageRect.extent.width == 2704); // never stretch a submitted subrect
|
||||
views[1].subImage.swapchain = (XrSwapchain)(uintptr_t)2;
|
||||
assert(!fixed_layer_size((void *)&p, &full));
|
||||
views[1].subImage.swapchain = XR_NULL_HANDLE;
|
||||
assert(!fixed_layer_size((void *)&p, &full));
|
||||
p.viewCount=0; assert(!fixed_layer_size((void *)&p, &full));
|
||||
assert(!fixed_layer_size(NULL, &full));
|
||||
XrCompositionLayerQuad q = {XR_TYPE_COMPOSITION_LAYER_QUAD};
|
||||
q.subImage.swapchain=(XrSwapchain)(uintptr_t)2;
|
||||
assert(fixed_layer_size((void *)&q, &full) && full.width == 1024);
|
||||
q.type=XR_TYPE_UNKNOWN; assert(!fixed_layer_size((void *)&q, &full));
|
||||
return 0;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdarg.h>
|
||||
#include <openxr/openxr.h>
|
||||
static int missing, valid_size=1, calls;
|
||||
static XrInstance active_instance;
|
||||
static XrResult XRAPI_CALL runtime(XrSession session, const XrRecommendedLayerResolutionGetInfoMETA *info,
|
||||
XrRecommendedLayerResolutionMETA *out) {
|
||||
(void)session; (void)info; ++calls;
|
||||
out->recommendedImageDimensions=(XrExtent2Di){2704,2704};
|
||||
out->isValid=XR_FALSE;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance instance, const char *name) {
|
||||
(void)instance; (void)name; return missing ? NULL : (PFN_xrVoidFunction)runtime;
|
||||
}
|
||||
static int swapchain_size(XrSwapchain handle, uint32_t *w, uint32_t *h) {
|
||||
(void)handle; *w=2768; *h=2768; return valid_size;
|
||||
}
|
||||
static void log_stub(const char *fmt, ...) { (void)fmt; }
|
||||
#define LOG(...) log_stub(__VA_ARGS__)
|
||||
#include "fixed_resolution.h"
|
||||
/* WRAPPER */
|
||||
int main(void) {
|
||||
XrCompositionLayerQuad layer={XR_TYPE_COMPOSITION_LAYER_QUAD};
|
||||
XrRecommendedLayerResolutionGetInfoMETA info={XR_TYPE_RECOMMENDED_LAYER_RESOLUTION_GET_INFO_META};
|
||||
info.layer=(void *)&layer;
|
||||
XrRecommendedLayerResolutionMETA out={XR_TYPE_RECOMMENDED_LAYER_RESOLUTION_META};
|
||||
int tail=42; out.next=&tail;
|
||||
for (missing=0; missing<2; ++missing) {
|
||||
assert(fixed_recommended_resolution(XR_NULL_HANDLE,&info,&out)==XR_SUCCESS);
|
||||
assert(out.isValid==XR_TRUE && out.recommendedImageDimensions.width==2768);
|
||||
assert(out.recommendedImageDimensions.height==2768 && out.next==&tail && tail==42);
|
||||
}
|
||||
assert(calls==1);
|
||||
valid_size=0; missing=0;
|
||||
assert(fixed_recommended_resolution(XR_NULL_HANDLE,&info,&out)==XR_SUCCESS);
|
||||
assert(out.recommendedImageDimensions.width==2704 && !out.isValid);
|
||||
missing=1;
|
||||
assert(fixed_recommended_resolution(XR_NULL_HANDLE,&info,&out)==XR_ERROR_FUNCTION_UNSUPPORTED);
|
||||
assert(fixed_recommended_resolution(XR_NULL_HANDLE,NULL,&out)==XR_ERROR_VALIDATION_FAILURE);
|
||||
assert(fixed_recommended_resolution(XR_NULL_HANDLE,&info,NULL)==XR_ERROR_VALIDATION_FAILURE);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// HDR to SDR tone mapping of the software Main 10 path (native/hevc/hdr_tonemap.h).
|
||||
#include <assert.h>
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "hdr_tonemap.h"
|
||||
|
||||
static int code_for_nits(float nits) { return (int)lroundf(64 + 876 * fp_tm_pq_oetf(nits)); }
|
||||
static int sdr_code(float rel) { return (int)lroundf(16 + 219 * powf(rel, 1 / 2.4f)); }
|
||||
static int near(int a, int b, int tolerance) { return abs(a - b) <= tolerance; }
|
||||
|
||||
static void frame(const struct fp_tonemap *tm, int w, int h, int y, int u, int v, uint8_t *out_y, uint8_t *out_u,
|
||||
uint8_t *out_v) {
|
||||
int cw = (w + 1) / 2, ch = (h + 1) / 2;
|
||||
uint16_t *ly = malloc(sizeof(uint16_t) * w * h), *lu = malloc(sizeof(uint16_t) * cw * ch),
|
||||
*lv = malloc(sizeof(uint16_t) * cw * ch);
|
||||
for (int i = 0; i < w * h; i++) ly[i] = (uint16_t)y;
|
||||
for (int i = 0; i < cw * ch; i++) { lu[i] = (uint16_t)u; lv[i] = (uint16_t)v; }
|
||||
const uint8_t *src[3] = {(const uint8_t *)ly, (const uint8_t *)lu, (const uint8_t *)lv};
|
||||
int ss[3] = {w * 2, cw * 2, cw * 2}, ds[3] = {w, cw, cw};
|
||||
uint8_t *dst[3] = {out_y, out_u, out_v};
|
||||
fp_tonemap_rows(tm, w, h, 0, ch, src, ss, dst, ds);
|
||||
free(ly); free(lu); free(lv);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
static struct fp_tonemap tm;
|
||||
fp_tonemap_init(&tm, FP_TM_PQ, 0, 1, 1, 1000);
|
||||
assert(tm.peak == 1000);
|
||||
assert(tm.luma8[64] == 16 && tm.luma8[0] == 16); // black (and below it)
|
||||
for (int c = 1; c < 1024; c++) assert(tm.luma8[c] >= tm.luma8[c - 1]); // never darker for a brighter input
|
||||
assert(near(tm.luma8[code_for_nits(50)], sdr_code(50 / 203.0f), 1)); // below the knee: the same level
|
||||
int white = tm.luma8[code_for_nits(203)];
|
||||
assert(white >= 200 && white <= 235); // reference white, rolled off a little
|
||||
assert(tm.luma8[code_for_nits(1000)] == 235); // the content's peak = SDR white
|
||||
assert(tm.luma8[940] == 235); // above the peak: clipped to white
|
||||
|
||||
// content mastered no brighter than reference white: levels are kept exactly
|
||||
fp_tonemap_init(&tm, FP_TM_PQ, 0, 1, 1, 203);
|
||||
assert(tm.peak == 400); // (kept at 400 or above: MaxCLL that low is a mislabelled stream)
|
||||
assert(fp_tonemap_peak(FP_TM_PQ, 0) == 1000 && fp_tonemap_peak(FP_TM_PQ, 4000) == 4000);
|
||||
assert(fp_tonemap_peak(FP_TM_PQ, 20000) == 10000 && fp_tonemap_peak(FP_TM_HLG, 4000) == 1000);
|
||||
|
||||
// HLG: 75 % signal is reference white on a 1000 cd/m² display (BT.2408), like PQ's 203 cd/m²
|
||||
fp_tonemap_init(&tm, FP_TM_HLG, 0, 1, 1, 0);
|
||||
for (int c = 1; c < 1024; c++) assert(tm.luma8[c] >= tm.luma8[c - 1]);
|
||||
struct fp_tonemap pq;
|
||||
fp_tonemap_init(&pq, FP_TM_PQ, 0, 1, 1, 1000);
|
||||
assert(near(tm.luma8[(int)lroundf(64 + 876 * 0.75f)], pq.luma8[code_for_nits(203)], 2));
|
||||
|
||||
// grey stays grey and keeps its level, whatever the frame size (odd sizes repeat the last row/column)
|
||||
static uint8_t y8[9 * 7], u8[5 * 4], v8[5 * 4];
|
||||
for (int size = 0; size < 3; size++) {
|
||||
int w = (int[]){4, 3, 9}[size], h = (int[]){2, 3, 7}[size];
|
||||
memset(y8, 0xee, sizeof y8); memset(u8, 0xee, sizeof u8); memset(v8, 0xee, sizeof v8);
|
||||
int code = code_for_nits(100);
|
||||
frame(&pq, w, h, code, 512, 512, y8, u8, v8);
|
||||
for (int i = 0; i < w * h; i++) assert(near(y8[i], pq.luma8[code], 1));
|
||||
for (int i = 0; i < ((w + 1) / 2) * ((h + 1) / 2); i++) assert(near(u8[i], 128, 1) && near(v8[i], 128, 1));
|
||||
}
|
||||
|
||||
// a BT.2020 red beyond BT.709 (R' = 0.58, G' = B' = 0.3) stays red (V well above, U below neutral) and keeps
|
||||
// its brightness: moved toward grey, not clipped (clipping its negative green gave luma 66)
|
||||
{
|
||||
const float kr = 0.2627f, kb = 0.0593f, r = 0.58f, g = 0.3f, b = 0.3f;
|
||||
float ye = kr * r + (1 - kr - kb) * g + kb * b, cb = (b - ye) / 1.8814f, cr = (r - ye) / 1.4746f;
|
||||
frame(&pq, 2, 2, (int)lroundf(64 + 876 * ye), (int)lroundf(512 + 896 * cb), (int)lroundf(512 + 896 * cr),
|
||||
y8, u8, v8);
|
||||
assert(v8[0] > 128 + 30 && u8[0] < 128 && y8[0] > 110);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// HEVC profile detection that keeps Main 10 off Iris's NV12 path (native/hevc/hevc_profile.h).
|
||||
// Byte strings are from x265 encodes: Annex B VPS + SPS starts, and the start of an MP4 hvcC record.
|
||||
#include <assert.h>
|
||||
#include "hevc_profile.h"
|
||||
|
||||
int main(void) {
|
||||
// VPS (type 32, carries a profile too: must be skipped), then the SPS (type 33)
|
||||
static const uint8_t main8[] = {0, 0, 0, 1, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x02, 0x60,
|
||||
0, 0, 0, 1, 0x42, 0x01, 0x01, 0x01, 0x60, 0, 0, 3, 0, 0x90, 0};
|
||||
static const uint8_t main10[] = {0, 0, 1, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60,
|
||||
0, 0, 1, 0x42, 0x01, 0x01, 0x02, 0x20, 0, 0, 3, 0, 0x90, 0};
|
||||
static const uint8_t hvcc_main8[] = {0x01, 0x01, 0x60, 0, 0, 0, 0x90};
|
||||
static const uint8_t hvcc_main10[] = {0x01, 0x02, 0x20, 0, 0, 0, 0x90};
|
||||
static const uint8_t slice_only[] = {0, 0, 0, 1, 0x26, 0x01, 0xaf, 0x08, 0x42, 0x01, 0x02};
|
||||
static const uint8_t short_sps[] = {0, 0, 1, 0x42, 0x01, 0x01};
|
||||
|
||||
assert(hevc_profile(main8, sizeof main8) == 1);
|
||||
assert(hevc_profile(main10, sizeof main10) == 2);
|
||||
assert(hevc_profile(hvcc_main8, sizeof hvcc_main8) == 1);
|
||||
assert(hevc_profile(hvcc_main10, sizeof hvcc_main10) == 2);
|
||||
assert(hevc_profile(slice_only, sizeof slice_only) == -1); // no parameter sets in this access unit
|
||||
assert(hevc_profile(short_sps, sizeof short_sps) == -1); // truncated: no profile byte
|
||||
assert(hevc_profile(main8, 0) == -1);
|
||||
|
||||
assert(!hevc_needs_software(1)); // Main: Iris
|
||||
assert(!hevc_needs_software(3)); // Main Still Picture: Iris
|
||||
assert(!hevc_needs_software(-1)); // unknown: try Iris as before
|
||||
assert(hevc_needs_software(2)); // Main 10: software
|
||||
assert(hevc_needs_software(4)); // range extensions (4:2:2, 4:4:4, 12-bit): software
|
||||
return 0;
|
||||
}
|
||||
@@ -5,15 +5,8 @@
|
||||
#include <string.h>
|
||||
#include <openxr/openxr.h>
|
||||
static int enabled, requested;
|
||||
static XrResult XRAPI_CALL recommended(XrSession session, const XrRecommendedLayerResolutionGetInfoMETA *info,
|
||||
XrRecommendedLayerResolutionMETA *out) {
|
||||
(void)session; (void)info;
|
||||
out->recommendedImageDimensions = (XrExtent2Di){2768,2768}; out->isValid = XR_TRUE;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static int query(void) { return enabled; }
|
||||
static PFN_xrVoidFunction extension(const char *name) {
|
||||
if (enabled && !strcmp(name,"xrGetRecommendedLayerResolutionMETA")) return (PFN_xrVoidFunction)recommended;
|
||||
return !strcmp(name,"framebridge_hide_fb_foveation") ? (PFN_xrVoidFunction)query : NULL;
|
||||
}
|
||||
static XrResult XRAPI_CALL enumerate(const char *layer,uint32_t capacity,uint32_t *count,XrExtensionProperties *out) {
|
||||
@@ -64,13 +57,5 @@ int main(void) {
|
||||
assert(((PFN_xrCreateInstance)fn)(&ci,NULL)==XR_SUCCESS);
|
||||
assert(requested==(enabled ? 1 : 2) && ci.enabledExtensionCount==2);
|
||||
}
|
||||
enabled=0;
|
||||
assert(xrGetInstanceProcAddr((XrInstance)(uintptr_t)1,"xrGetRecommendedLayerResolutionMETA",&fn)
|
||||
== XR_ERROR_FUNCTION_UNSUPPORTED);
|
||||
enabled=1;
|
||||
assert(xrGetInstanceProcAddr((XrInstance)(uintptr_t)1,"xrGetRecommendedLayerResolutionMETA",&fn)==XR_SUCCESS);
|
||||
XrRecommendedLayerResolutionMETA out={XR_TYPE_RECOMMENDED_LAYER_RESOLUTION_META};
|
||||
assert(((PFN_xrGetRecommendedLayerResolutionMETA)fn)(XR_NULL_HANDLE,NULL,&out)==XR_SUCCESS);
|
||||
assert(out.isValid && out.recommendedImageDimensions.width==2768);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
// Host runtime/GL doubles exercise the actual adapter implementation, without a headset.
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <pthread.h>
|
||||
#include <EGL/egl.h>
|
||||
#include <GLES3/gl3.h>
|
||||
#include <openxr/openxr.h>
|
||||
#define CHECK(c) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
fprintf(stderr, "line %d: %s\n", __LINE__, #c); \
|
||||
return 1; \
|
||||
} \
|
||||
} while (0)
|
||||
#include <stdarg.h>
|
||||
static void log_ignore(const char *fmt, ...) { (void)fmt; }
|
||||
#define LOG(...) log_ignore(__VA_ARGS__)
|
||||
static uintptr_t fake = 100;
|
||||
#define FAKE_HANDLE(t) ((t)(++fake))
|
||||
static XrInstance active_instance;
|
||||
static int swapchain_pool = 1;
|
||||
static int creates, destroys, acquires, waits, releases, blits, deleted, fail_create, fail_enum, fail_wait,
|
||||
fail_release;
|
||||
static int bad_gl, fail_gl, scissor = 1, read_fb = 31, draw_fb = 32, texture = 33;
|
||||
static GLuint next_texture = 1000;
|
||||
typedef struct {
|
||||
XrStructureType type;
|
||||
void *next;
|
||||
uint32_t image;
|
||||
} emul_gles_image;
|
||||
static XrResult create(XrSession s, const XrSwapchainCreateInfo *ci, XrSwapchain *h) {
|
||||
(void)s;
|
||||
(void)ci;
|
||||
++creates;
|
||||
if (fail_create) return XR_ERROR_LIMIT_REACHED;
|
||||
*h = FAKE_HANDLE(XrSwapchain);
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XrResult destroy(XrSwapchain h) {
|
||||
(void)h;
|
||||
++destroys;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XrResult enumerate(XrSwapchain h, uint32_t cap, uint32_t *n, XrSwapchainImageBaseHeader *images) {
|
||||
(void)h;
|
||||
(void)cap;
|
||||
if (fail_enum) return XR_ERROR_RUNTIME_FAILURE;
|
||||
*n = 3;
|
||||
for (int j = 0; j < 3; ++j) ((emul_gles_image *)images)[j].image = 2000 + j;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XrResult acquire(XrSwapchain h, const XrSwapchainImageAcquireInfo *ai, uint32_t *index) {
|
||||
(void)h;
|
||||
(void)ai;
|
||||
*index = acquires++ % 3;
|
||||
return XR_SUCCESS;
|
||||
}
|
||||
static XrResult wait_image(XrSwapchain h, const XrSwapchainImageWaitInfo *wi) {
|
||||
(void)h;
|
||||
(void)wi;
|
||||
++waits;
|
||||
return fail_wait ? XR_TIMEOUT_EXPIRED : XR_SUCCESS;
|
||||
}
|
||||
static XrResult release(XrSwapchain h, const XrSwapchainImageReleaseInfo *ri) {
|
||||
(void)h;
|
||||
(void)ri;
|
||||
++releases;
|
||||
return fail_release ? XR_ERROR_RUNTIME_FAILURE : XR_SUCCESS;
|
||||
}
|
||||
static PFN_xrVoidFunction lookup(XrInstance instance, const char *name) {
|
||||
(void)instance;
|
||||
#define PROC(n, f) \
|
||||
if (!strcmp(name, n)) return (PFN_xrVoidFunction)f
|
||||
PROC("xrCreateSwapchain", create);
|
||||
PROC("xrDestroySwapchain", destroy);
|
||||
PROC("xrEnumerateSwapchainImages", enumerate);
|
||||
PROC("xrAcquireSwapchainImage", acquire);
|
||||
PROC("xrWaitSwapchainImage", wait_image);
|
||||
PROC("xrReleaseSwapchainImage", release);
|
||||
return NULL;
|
||||
}
|
||||
static void note_swapchain_size(XrSwapchain h, uint32_t w, uint32_t he) {
|
||||
(void)h;
|
||||
(void)w;
|
||||
(void)he;
|
||||
}
|
||||
static void remember_swapchain(XrSwapchain h) { (void)h; }
|
||||
static void forget_swapchain(XrSwapchain h) { (void)h; }
|
||||
static void emul_on_create_swapchain(XrSwapchain h, const XrSwapchainCreateInfo *ci) {
|
||||
(void)h;
|
||||
(void)ci;
|
||||
}
|
||||
static void emul_on_destroy_swapchain(XrSwapchain h) { (void)h; }
|
||||
#include "pool_clamp.h"
|
||||
|
||||
static EGLContext current(void) { return (EGLContext)1; }
|
||||
static EGLContext (*p_eglGetCurrentContext)(void) = current;
|
||||
static void (*p_eglCreateContext)(void);
|
||||
static int emul_load_gl(void) { return 1; }
|
||||
static void p_glGetIntegerv(GLenum p, GLint *v) {
|
||||
*v = p == GL_TEXTURE_BINDING_2D ? texture : p == GL_READ_FRAMEBUFFER_BINDING ? read_fb : draw_fb;
|
||||
}
|
||||
static void p_glGenTextures(GLsizei n, GLuint *v) {
|
||||
while (n--) *v++ = next_texture++;
|
||||
}
|
||||
static void p_glBindTexture(GLenum target, GLuint t) {
|
||||
(void)target;
|
||||
texture = t;
|
||||
}
|
||||
static void p_glTexStorage2D(GLenum t, GLsizei levels, GLenum f, GLsizei w, GLsizei h) {
|
||||
(void)t;
|
||||
(void)levels;
|
||||
(void)f;
|
||||
(void)w;
|
||||
(void)h;
|
||||
}
|
||||
static GLenum p_glGetError(void) { return fail_gl ? GL_INVALID_OPERATION : GL_NO_ERROR; }
|
||||
static void p_glDeleteTextures(GLsizei n, const GLuint *v) {
|
||||
(void)v;
|
||||
deleted += n;
|
||||
}
|
||||
static GLsync p_glFenceSync(GLenum c, GLbitfield f) {
|
||||
(void)c;
|
||||
(void)f;
|
||||
return (GLsync)1;
|
||||
}
|
||||
static void p_glDeleteSync(GLsync s) { (void)s; }
|
||||
static void p_glWaitSync(GLsync s, GLbitfield f, GLuint64 t) {
|
||||
(void)s;
|
||||
(void)f;
|
||||
(void)t;
|
||||
}
|
||||
static void p_glFlush(void) {}
|
||||
static GLboolean p_glIsEnabled(GLenum c) {
|
||||
(void)c;
|
||||
return scissor;
|
||||
}
|
||||
static void p_glEnable(GLenum c) {
|
||||
(void)c;
|
||||
scissor = 1;
|
||||
}
|
||||
static void p_glDisable(GLenum c) {
|
||||
(void)c;
|
||||
scissor = 0;
|
||||
}
|
||||
static void p_glGenFramebuffers(GLsizei n, GLuint *f) {
|
||||
while (n--) *f++ = next_texture++;
|
||||
}
|
||||
static void p_glDeleteFramebuffers(GLsizei n, const GLuint *f) {
|
||||
(void)n;
|
||||
(void)f;
|
||||
}
|
||||
static void p_glBindFramebuffer(GLenum t, GLuint f) {
|
||||
if (t == GL_READ_FRAMEBUFFER)
|
||||
read_fb = f;
|
||||
else
|
||||
draw_fb = f;
|
||||
}
|
||||
static void p_glFramebufferTexture2D(GLenum t, GLenum a, GLenum tex, GLuint id, GLint level) {
|
||||
(void)t;
|
||||
(void)a;
|
||||
(void)tex;
|
||||
(void)id;
|
||||
(void)level;
|
||||
}
|
||||
static GLenum p_glCheckFramebufferStatus(GLenum target) {
|
||||
(void)target;
|
||||
return bad_gl ? 0 : GL_FRAMEBUFFER_COMPLETE;
|
||||
}
|
||||
static void p_glBlitFramebuffer(GLint x, GLint y, GLint w, GLint h, GLint dx, GLint dy, GLint dw, GLint dh,
|
||||
GLbitfield mask, GLenum filter) {
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)w;
|
||||
(void)h;
|
||||
(void)dx;
|
||||
(void)dy;
|
||||
(void)dw;
|
||||
(void)dh;
|
||||
(void)mask;
|
||||
(void)filter;
|
||||
++blits;
|
||||
}
|
||||
#include "swapchain_pool.c"
|
||||
static int released(XrSwapchain h) {
|
||||
int i = pool_find(h);
|
||||
uint32_t index;
|
||||
CHECK(pool_cycle(i, 0, &index) == XR_SUCCESS && index == 0);
|
||||
CHECK(pool_cycle(i, 1, NULL) == XR_SUCCESS);
|
||||
CHECK(pool_cycle(i, 2, NULL) == XR_SUCCESS);
|
||||
return 0;
|
||||
}
|
||||
int main(void) {
|
||||
pool_session = (XrSession)1;
|
||||
XrSwapchainCreateInfo ci = {XR_TYPE_SWAPCHAIN_CREATE_INFO};
|
||||
ci.width = 1024;
|
||||
ci.height = 128;
|
||||
ci.format = GL_SRGB8_ALPHA8;
|
||||
ci.faceCount = ci.arraySize = ci.sampleCount = ci.mipCount = 1;
|
||||
ci.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
|
||||
XrSwapchain first, second, h;
|
||||
CHECK(!pool_should_create(pool_session));
|
||||
for (int k = 0; k < 8; ++k) pool_note_runtime(pool_session, (XrSwapchain)(uintptr_t)(k + 500));
|
||||
CHECK(pool_should_create(pool_session));
|
||||
pool_forget_runtime((XrSwapchain)500);
|
||||
CHECK(!pool_should_create(pool_session));
|
||||
CHECK(!pool_create((XrSession)2, &ci, &h, 0)); // Vulkan/other session
|
||||
swapchain_pool = 0;
|
||||
CHECK(!pool_create(pool_session, &ci, &h, 0));
|
||||
swapchain_pool = 1;
|
||||
ci.arraySize = 2;
|
||||
CHECK(!pool_create(pool_session, &ci, &h, 0));
|
||||
ci.arraySize = 1;
|
||||
ci.sampleCount = 4;
|
||||
CHECK(!pool_create(pool_session, &ci, &h, 0));
|
||||
ci.sampleCount = 1;
|
||||
ci.format = GL_DEPTH_COMPONENT24;
|
||||
CHECK(!pool_create(pool_session, &ci, &h, 0));
|
||||
ci.format = GL_SRGB8_ALPHA8;
|
||||
CHECK(pool_create(pool_session, &ci, &first, 0) && texture == 33 && creates == 1);
|
||||
CHECK(pool_create(pool_session, &ci, &second, 1) && creates == 1);
|
||||
int i = pool_find(first), j = pool_find(second);
|
||||
uint32_t n, index;
|
||||
CHECK(pool_enumerate(i, 0, &n, NULL) == XR_SUCCESS && n == 1);
|
||||
emul_gles_image image = {XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR};
|
||||
CHECK(pool_enumerate(i, 1, &n, (void *)&image) == XR_SUCCESS && image.image == pool_images[i].tex);
|
||||
CHECK(pool_cycle(i, 1, NULL) == XR_ERROR_CALL_ORDER_INVALID);
|
||||
CHECK(pool_cycle(i, 0, NULL) == XR_ERROR_VALIDATION_FAILURE);
|
||||
CHECK(pool_cycle(i, 0, &index) == XR_SUCCESS);
|
||||
CHECK(pool_cycle(i, 0, &index) == XR_ERROR_CALL_ORDER_INVALID);
|
||||
CHECK(pool_cycle(i, 2, NULL) == XR_ERROR_CALL_ORDER_INVALID);
|
||||
CHECK(pool_cycle(i, 1, NULL) == XR_SUCCESS && pool_cycle(i, 2, NULL) == XR_SUCCESS);
|
||||
CHECK(released(second) == 0);
|
||||
XrSwapchainSubImage src = {second, {{10, 5}, {100, 50}}, 0}, sub = src;
|
||||
pool_begin_frame();
|
||||
CHECK(pool_subimage(&sub));
|
||||
CHECK(sub.swapchain == pool_atlas[0].handle && sub.imageRect.offset.x == pool_images[j].rect.x + 10);
|
||||
CHECK(sub.imageRect.offset.y == 5 && sub.imageRect.extent.width == 100);
|
||||
CHECK(read_fb == 31 && draw_fb == 32 && scissor == 1 && texture == 33);
|
||||
sub = src;
|
||||
CHECK(pool_subimage(&sub) && blits == 1); // two eyes/strips copy source once
|
||||
CHECK(acquires == 1 && waits == 1 && releases == 0 && pool_finish() && releases == 1);
|
||||
pool_begin_frame();
|
||||
sub = src;
|
||||
CHECK(pool_subimage(&sub) && blits == 2 && acquires == 2);
|
||||
CHECK(pool_finish());
|
||||
// Failed wait keeps ownership, retries the same acquired image, never releases before wait.
|
||||
pool_begin_frame();
|
||||
fail_wait = 1;
|
||||
sub = src;
|
||||
CHECK(!pool_subimage(&sub));
|
||||
CHECK(pool_finish());
|
||||
CHECK(acquires == 3 && releases == 2);
|
||||
fail_wait = 0;
|
||||
CHECK(pool_subimage(&sub) && acquires == 3);
|
||||
CHECK(pool_finish());
|
||||
pool_begin_frame();
|
||||
bad_gl = 1;
|
||||
sub = src;
|
||||
CHECK(!pool_subimage(&sub));
|
||||
CHECK(pool_finish());
|
||||
bad_gl = 0;
|
||||
CHECK(read_fb == 31 && draw_fb == 32 && scissor == 1);
|
||||
pool_begin_frame();
|
||||
sub = src;
|
||||
CHECK(pool_subimage(&sub));
|
||||
fail_release = 1;
|
||||
CHECK(!pool_finish());
|
||||
fail_release = 0;
|
||||
CHECK(pool_finish());
|
||||
// Final layer submission preserves order, const inputs, strip crops, stereo and cylinder rects.
|
||||
XrCompositionLayerQuad q = {XR_TYPE_COMPOSITION_LAYER_QUAD};
|
||||
q.subImage = src;
|
||||
XrCompositionLayerCylinderKHR cylinder = {XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR};
|
||||
cylinder.subImage = src;
|
||||
XrCompositionLayerProjectionView views[2] = {{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW},
|
||||
{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
|
||||
views[0].subImage = views[1].subImage = src;
|
||||
XrCompositionLayerProjection projection = {XR_TYPE_COMPOSITION_LAYER_PROJECTION};
|
||||
projection.viewCount = 2;
|
||||
projection.views = views;
|
||||
const XrCompositionLayerBaseHeader *layers[] = {(void *)&projection, (void *)&q, (void *)&cylinder, (void *)&q};
|
||||
PoolFrame frame;
|
||||
int before_blits = blits, before_acquires = acquires, before_releases = releases;
|
||||
CHECK(pool_rewrite_layers(layers, 4, &frame) == 4);
|
||||
CHECK(layers[0]->type == XR_TYPE_COMPOSITION_LAYER_PROJECTION &&
|
||||
layers[2]->type == XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR);
|
||||
CHECK(frame.views[0][1].subImage.swapchain == pool_atlas[0].handle &&
|
||||
frame.cylinders[2].subImage.imageRect.offset.x == pool_images[j].rect.x + 10);
|
||||
CHECK(q.subImage.swapchain == second && views[1].subImage.swapchain == second);
|
||||
CHECK(blits == before_blits + 1 && acquires == before_acquires + 1 && releases == before_releases + 1);
|
||||
sub = src;
|
||||
sub.imageRect = (XrRect2Di){{0, 0}, {0, 0}};
|
||||
pool_begin_frame();
|
||||
CHECK(pool_subimage(&sub) && sub.imageRect.extent.width == 1024);
|
||||
CHECK(pool_finish());
|
||||
sub = src;
|
||||
sub.imageRect.offset.x = 2000;
|
||||
pool_begin_frame();
|
||||
CHECK(!pool_subimage(&sub));
|
||||
CHECK(pool_finish());
|
||||
sub = src;
|
||||
sub.imageRect.offset.x = -10;
|
||||
pool_begin_frame();
|
||||
CHECK(pool_subimage(&sub) && sub.imageRect.offset.x == pool_images[j].rect.x && sub.imageRect.extent.width == 90);
|
||||
CHECK(pool_finish());
|
||||
PoolRect old = pool_images[i].rect;
|
||||
pool_destroy(i);
|
||||
CHECK(pool_create(pool_session, &ci, &h, 0) && pool_images[pool_find(h)].rect.x == old.x);
|
||||
// Reporter-sized submenu images keep sharing one runtime handle even when further creates are refused.
|
||||
int before_creates = creates;
|
||||
fail_create = 1;
|
||||
int dimensions[][2] = {{1024, 128}, {1120, 440}, {2048, 256}, {600, 440}, {1000, 440}, {512, 256}, {400, 60}};
|
||||
for (int k = 0; k < 7; ++k) {
|
||||
ci.width = dimensions[k][0];
|
||||
ci.height = dimensions[k][1];
|
||||
CHECK(pool_create(pool_session, &ci, &h, 1));
|
||||
}
|
||||
CHECK(creates == before_creates);
|
||||
fail_create = 0;
|
||||
// Full atlas spills to a second one, then refuses instead of overlapping.
|
||||
pool_cleanup(pool_session);
|
||||
pool_session = (XrSession)1;
|
||||
ci.width = ci.height = 4096;
|
||||
CHECK(pool_create(pool_session, &ci, &first, 0));
|
||||
CHECK(pool_create(pool_session, &ci, &second, 0));
|
||||
CHECK(pool_images[pool_find(first)].atlas != pool_images[pool_find(second)].atlas);
|
||||
CHECK(!pool_create(pool_session, &ci, &h, 0));
|
||||
pool_cleanup(pool_session);
|
||||
CHECK(!pool_session && pool_find(first) < 0);
|
||||
pool_session = (XrSession)1;
|
||||
fail_create = 1;
|
||||
CHECK(!pool_create(pool_session, &ci, &h, 1));
|
||||
fail_create = 0;
|
||||
fail_enum = 1;
|
||||
CHECK(!pool_create(pool_session, &ci, &h, 0));
|
||||
CHECK(!pool_atlas[0].handle);
|
||||
fail_enum = 0;
|
||||
fail_gl = 1;
|
||||
CHECK(!pool_create(pool_session, &ci, &h, 0));
|
||||
fail_gl = 0;
|
||||
pool_cleanup(pool_session);
|
||||
pool_error();
|
||||
CHECK(pool_errors == 1 && deleted > 0 && destroys > 0);
|
||||
PoolRect occupied[4] = {{0, 0, 2, 2}, {2, 0, 2, 2}, {0, 2, 2, 2}, {2, 2, 2, 2}}, out;
|
||||
CHECK(!pool_place(occupied, 4, 4, 1, 1, &out));
|
||||
CHECK(!pool_place(occupied, 0, 4, 5, 1, &out));
|
||||
CHECK(!pool_place(occupied, 0, 4, 0, 1, &out));
|
||||
occupied[1] = occupied[3];
|
||||
CHECK(pool_place(occupied, 3, 4, 2, 2, &out) && out.x == 2 && out.y == 0);
|
||||
puts("ok");
|
||||
return 0;
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
"""Scan clean-up on a temporary fake Frame: refine.json, the options every build passes, room_refine (a fake
|
||||
fp_roomscan script writes the scanner interface's files) and the player's own furniture kept over a new clean-up."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from test_agent import load_agent
|
||||
|
||||
from frameport.install.rooms import REFINE_DEFAULTS
|
||||
from frameport.mr.room import Mesh, Volume, box_room, dumps, parse, write_mesh
|
||||
|
||||
# A stand-in for fp_roomscan (scanner interface of docs/mr): --offline <dump> --out <dir> builds a room; --refine-only
|
||||
# <dir> cleans one up again from room-raw.txt (an old room without raw files: room.txt becomes the raw one). It writes
|
||||
# room.txt + room-mesh.bin (refined), room-raw.txt + room-mesh-raw.bin, scan.json, and logs its arguments.
|
||||
FAKE_SCANNER = r'''#!PYTHON
|
||||
import json, os, shutil, struct, sys, uuid
|
||||
argv = sys.argv[1:]
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "scanner-calls.jsonl"), "a") as log:
|
||||
log.write(json.dumps(sys.argv) + "\n")
|
||||
def opt(name, default=None):
|
||||
return argv[argv.index(name) + 1] if name in argv else default
|
||||
if os.environ.get("FAKE_SCANNER_FAIL"):
|
||||
sys.stderr.write("boom")
|
||||
sys.exit(3)
|
||||
mesh = (struct.pack("<4sIII", b"FPRM", 1, 3, 3) + struct.pack("<9f", 0, 0, 0, 1, 0, 0, 0, 0, 1)
|
||||
+ struct.pack("<3I", 0, 1, 2))
|
||||
if "--offline" in argv:
|
||||
out = opt("--out")
|
||||
universe = opt("--universe")
|
||||
walls = ("wall {} -2 -1.5 2 -1.5 0 2.5\nwall {} 2 -1.5 2 1.5 0 2.5\n"
|
||||
"wall {} 2 1.5 -2 1.5 0 2.5\nwall {} -2 1.5 -2 -1.5 0 2.5\n")
|
||||
raw = ("frameport-room 1\nuniverse " + universe + "\nsource scan\ntime 100\nroom " + uuid.uuid4().hex +
|
||||
"\nfloor " + uuid.uuid4().hex + " 0\nceiling " + uuid.uuid4().hex + " 2.5\n" +
|
||||
walls.format(*(uuid.uuid4().hex for _ in range(4))) +
|
||||
"volume " + uuid.uuid4().hex + " COUCH 0 1 0.75 0 1.5 0.6 0.75\n"
|
||||
"mesh " + uuid.uuid4().hex + " room-mesh.bin 3 3\n")
|
||||
open(os.path.join(out, "room-raw.txt"), "w").write(raw.replace("room-mesh.bin", "room-mesh-raw.bin"))
|
||||
open(os.path.join(out, "room-mesh-raw.bin"), "wb").write(mesh)
|
||||
src = raw
|
||||
folder = out
|
||||
else:
|
||||
folder = opt("--refine-only")
|
||||
if not os.path.exists(os.path.join(folder, "room-raw.txt")):
|
||||
text = open(os.path.join(folder, "room.txt")).read()
|
||||
open(os.path.join(folder, "room-raw.txt"), "w").write(text.replace("room-mesh.bin", "room-mesh-raw.bin"))
|
||||
shutil.copy(os.path.join(folder, "room-mesh.bin"), os.path.join(folder, "room-mesh-raw.bin"))
|
||||
src = open(os.path.join(folder, "room-raw.txt")).read().replace("room-mesh-raw.bin", "room-mesh.bin")
|
||||
# the label rules: the couch is really a table (--label-rules 0 keeps the scanner's label)
|
||||
rules = opt("--label-rules", "1") == "1"
|
||||
refined = src.replace(" COUCH ", " TABLE ") if rules else src
|
||||
open(os.path.join(folder, "room-mesh.bin"), "wb").write(mesh)
|
||||
open(os.path.join(folder, "room.txt"), "w").write(refined)
|
||||
settings = {"walls": opt("--walls", "panels"), "smooth": int(opt("--smooth", "8"))}
|
||||
json.dump({"version": 1, "heads": [[0, 1.6, 0], [0.5, 1.6, 0]], "refine": settings,
|
||||
"report": {"triangles": 1, "kept": 1, "walked": 0},
|
||||
"labels": [{"from": "COUCH", "to": "TABLE" if rules else "COUCH", "reason": "top 0.75 m: a table",
|
||||
"box": {"cx": 0, "cz": 1, "top_y": 0.75, "yaw_deg": 0, "w": 1.5, "d": 0.6, "h": 0.75}}],
|
||||
"suggestions": [{"kind": "door", "box": {"cx": 2, "cz": 0, "top_y": 2, "yaw_deg": 90, "w": 0.9, "d": 0.1,
|
||||
"h": 2}, "wall": 1, "along": 0.5}]},
|
||||
open(os.path.join(folder, "scan.json"), "w"))
|
||||
print(json.dumps({"triangles": 1, "refine": {"kept": 1}}))
|
||||
'''
|
||||
|
||||
|
||||
def scanner(a, monkeypatch):
|
||||
path = Path(a.ROOM_SCANNER)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(FAKE_SCANNER.replace("PYTHON", sys.executable))
|
||||
path.chmod(0o755)
|
||||
monkeypatch.setattr(a, "room_current_universe", lambda: 7)
|
||||
return path.parent / "scanner-calls.jsonl"
|
||||
|
||||
|
||||
def calls(log):
|
||||
return [json.loads(line) for line in log.read_text().splitlines()]
|
||||
|
||||
|
||||
def test_defaults_match_the_pc_and_settings_validate(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
assert a.ROOM_REFINE_DEFAULTS == {k: m["default"] for k, m in REFINE_DEFAULTS.items()}
|
||||
assert set(a.ROOM_REFINE_LIMITS) | {"walls"} == set(REFINE_DEFAULTS)
|
||||
for key, meta in REFINE_DEFAULTS.items(): # every slider value the PC offers is accepted
|
||||
if meta["kind"] == "number":
|
||||
lo, hi = a.ROOM_REFINE_LIMITS[key]
|
||||
assert lo <= meta["min"] and meta["max"] <= hi, key
|
||||
assert a.room_refine_settings({"band": 0.35, "walls": "snap", "smooth": 4.0, "protect": True}) == \
|
||||
{"walls": "snap", "smooth": 4, "protect": 1}
|
||||
for bad in ({"nope": 1}, {"walls": "maybe"}, {"band": float("nan")}, {"smooth": 2.5}, {"band": 9},
|
||||
{"island": "big"}):
|
||||
with pytest.raises(a.AgentError):
|
||||
a.room_refine_settings(bad)
|
||||
assert a.room_refine_args({"walls": "snap", "facing_deg": 45.0, "clip_open": 0.2}) == \
|
||||
["--walls", "snap", "--facing-deg", "45", "--clip-open", "0.2"]
|
||||
assert a.room_refine_args({}, [[0, 1, 2.5, 0.25], {"x": 1, "y": 0, "z": 0, "r": 0.1}])[-2:] == \
|
||||
["--erase", "0,1,2.5,0.25;1,0,0,0.1"]
|
||||
for bad in ([[0, 1, 2]], [[0, 1, 2, 0]], [["a", 1, 2, 3]]):
|
||||
with pytest.raises(a.AgentError, match="erase"):
|
||||
a.room_refine_args({}, bad)
|
||||
assert a.room_refine_load() == {} # no file / a broken one = the defaults
|
||||
Path(a.ROOMS_DIR).mkdir(parents=True)
|
||||
Path(a.ROOM_REFINE_FILE).write_text("{broken")
|
||||
assert a.room_refine_load() == {}
|
||||
|
||||
|
||||
def test_build_passes_settings_and_keeps_raw_files(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
log = scanner(a, monkeypatch)
|
||||
Path(a.ROOMS_DIR).mkdir(parents=True)
|
||||
dump = Path(a.ROOMS_DIR, "scan.fprs")
|
||||
dump.write_bytes(b"FPRS")
|
||||
status = a.cmd_room_build({"dump": str(dump)}) # nothing saved: no clean-up options (an old scanner works)
|
||||
argv = calls(log)[-1]
|
||||
assert argv[:3] == [a.ROOM_SCANNER, "--offline", str(dump)] and argv[argv.index("--universe") + 1] == "7"
|
||||
assert "--walls" not in argv and argv[-1] == "--json"
|
||||
folder = Path(a.ROOMS_DIR, "7")
|
||||
assert {p.name for p in folder.iterdir()} >= {"room.txt", "room-mesh.bin", "room-raw.txt", "room-mesh-raw.bin",
|
||||
"scan.json"}
|
||||
assert status["report"] == {"triangles": 1, "refine": {"kept": 1}}
|
||||
assert status["labels"][0]["to"] == "TABLE" and status["suggestions"][0]["kind"] == "door"
|
||||
assert status["refine"] == {} and status["raw"] and not status["edited"]
|
||||
assert status["room"]["summary"]["volumes"] == {"TABLE": 1}
|
||||
a._write_json(a.ROOM_REFINE_FILE, {"walls": "snap", "smooth": 3})
|
||||
a.cmd_room_build({"dump": str(dump)})
|
||||
argv = calls(log)[-1]
|
||||
assert argv[argv.index("--walls") + 1] == "snap" and argv[argv.index("--smooth") + 1] == "3"
|
||||
|
||||
|
||||
def test_refine_saves_settings_republishes_and_keeps_edits(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
log = scanner(a, monkeypatch)
|
||||
with pytest.raises(a.AgentError, match="No room"):
|
||||
a.cmd_room_refine({})
|
||||
# an old room (no raw files): the scanner takes room.txt as the raw one
|
||||
r = box_room(4, 3)
|
||||
r.universe = 7
|
||||
r.source = "scan"
|
||||
r.volumes = [Volume("a" * 32, "COUCH", 0, 1, 0.75, 0, 1.5, 0.6, 0.75)]
|
||||
m = Mesh(vertices=[(0, 0, 0), (1, 0, 0), (0, 0, 1)], indices=[0, 1, 2])
|
||||
r.mesh = Mesh(vertex_count=3, index_count=3)
|
||||
a.room_store(dumps(r), write_mesh(m), "old scan log")
|
||||
status = a.cmd_room_refine({"settings": {"walls": "snap", "band": 0.35, "label_rules": 0}})
|
||||
assert json.loads(Path(a.ROOM_REFINE_FILE).read_text()) == {"walls": "snap", "label_rules": 0}
|
||||
argv = calls(log)[-1]
|
||||
assert argv[:2] == [a.ROOM_SCANNER, "--refine-only"] and argv[-1] == "--json"
|
||||
assert argv[argv.index("--walls") + 1] == "snap" and "--band" not in argv
|
||||
assert status["summary"] == {"triangles": 1, "refine": {"kept": 1}}
|
||||
assert status["refine"] == {"walls": "snap", "label_rules": 0} and status["raw"]
|
||||
assert status["room"]["summary"]["volumes"] == {"COUCH": 1} # label rules off
|
||||
folder = Path(a.ROOMS_DIR, "7")
|
||||
assert (folder / "scan.log").read_text() == "old scan log" and (folder / "room-raw.txt").is_file()
|
||||
assert not [n for n in os.listdir(a.ROOMS_DIR) if n.startswith(".refine-")] # no stage left behind
|
||||
# null settings keep the saved ones; erase spots go to the scanner
|
||||
a.cmd_room_refine({"settings": None, "erase": [[0, 1, 0, 0.3]]})
|
||||
argv = calls(log)[-1]
|
||||
assert argv[argv.index("--walls") + 1] == "snap" and argv[argv.index("--erase") + 1] == "0,1,0,0.3"
|
||||
# the Room tab's box editor: an edited room keeps the raw files and its edits survive a new clean-up
|
||||
edited = parse(status["room_text"])
|
||||
edited.volumes = [Volume("b" * 32, "BED", 1, 0, 0.5, 0, 2, 1.4, 0.5)]
|
||||
incoming = Path(a.ROOMS_DIR, ".incoming-x")
|
||||
incoming.mkdir()
|
||||
(incoming / "room.txt").write_text(dumps(edited))
|
||||
(incoming / "room-mesh.bin").write_bytes(write_mesh(m))
|
||||
a.cmd_room_put({"path": str(incoming / "room.txt"), "edited": True})
|
||||
assert (folder / "edits.txt").read_text().startswith("volume " + "b" * 32 + " BED")
|
||||
assert (folder / "room-raw.txt").is_file() and (folder / "scan.json").is_file()
|
||||
status = a.cmd_room_refine({"settings": {}})
|
||||
assert status["edited"] and status["room"]["summary"]["volumes"] == {"BED": 1}
|
||||
assert status["refine"] == {} and json.loads(Path(a.ROOM_REFINE_FILE).read_text()) == {}
|
||||
status = a.cmd_room_refine({"keep_edits": False}) # back to the scan's boxes
|
||||
assert not status["edited"] and status["room"]["summary"]["volumes"] == {"TABLE": 1}
|
||||
# a plain import (another room) doesn't keep this one's raw files
|
||||
a.cmd_room_put({"path": str(incoming / "room.txt")})
|
||||
assert not (folder / "room-raw.txt").exists() and not a.cmd_room_status({})["raw"]
|
||||
|
||||
|
||||
def test_refine_failures_leave_the_room(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
scanner(a, monkeypatch)
|
||||
r = box_room(4, 3)
|
||||
r.universe = 7
|
||||
text = dumps(r)
|
||||
a.room_store(text)
|
||||
with pytest.raises(a.AgentError, match="unknown"):
|
||||
a.cmd_room_refine({"settings": {"bogus": 1}})
|
||||
assert not Path(a.ROOM_REFINE_FILE).exists()
|
||||
monkeypatch.setenv("FAKE_SCANNER_FAIL", "1")
|
||||
with pytest.raises(a.AgentError, match="clean-up failed: boom"):
|
||||
a.cmd_room_refine({})
|
||||
assert Path(a.ROOMS_DIR, "7", "room.txt").read_text() == text
|
||||
monkeypatch.delenv("FAKE_SCANNER_FAIL")
|
||||
import fcntl
|
||||
with open(os.path.join(a.ROOMS_DIR, ".scan.lock"), "a") as lock: # a scan is building a room
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
with pytest.raises(a.AgentError, match="scan is running"):
|
||||
a.cmd_room_refine({})
|
||||
with pytest.raises(a.AgentError, match="unexpected"):
|
||||
a.room_store(text, extras={"../evil": b""})
|
||||
Path(a.ROOM_SCANNER).unlink()
|
||||
with pytest.raises(a.AgentError, match="not installed"):
|
||||
a.cmd_room_refine({})
|
||||
|
||||
|
||||
def test_status_reads_scan_json_defensively(monkeypatch, tmp_path):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(a, "room_current_universe", lambda: 7)
|
||||
r = box_room(4, 3)
|
||||
r.universe = 7
|
||||
info = b'{"report": [1], "labels": {"x": 1}, "suggestions": [{"kind": "door"}]}'
|
||||
a.room_store(dumps(r), extras={"scan.json": info})
|
||||
st = a.cmd_room_status({})
|
||||
assert st["report"] is None and st["labels"] == [] and st["suggestions"] == [{"kind": "door"}]
|
||||
a.room_store(dumps(r), extras={"scan.json": b"[]"})
|
||||
assert a.cmd_room_status({})["labels"] == []
|
||||
@@ -94,8 +94,7 @@ def test_launcher_sync_and_upgrade(monkeypatch, tmp_path, capsys):
|
||||
assert '9>&- & scanwatch=$!' in text
|
||||
assert text.index("_room_sync") < text.index("\nsetsid ")
|
||||
assert a.set_room_sync_line(text, pkg, True) == text
|
||||
old = "\n".join(line for line in text.splitlines()
|
||||
if "# frameport-room-sync" not in line and "# frameport-scan-watch" not in line) + "\n"
|
||||
old = "\n".join(line for line in text.splitlines() if "# frameport-room-sync" not in line and "# frameport-scan-watch" not in line) + "\n"
|
||||
(anchor / "launch.sh").write_text(old)
|
||||
a.upgrade_launchers()
|
||||
assert (anchor / "launch.sh").read_text().count("# frameport-room-sync") == 1
|
||||
|
||||
@@ -94,19 +94,3 @@ def test_library_load_with_a_cold_catalog_does_not_recurse(monkeypatch):
|
||||
monkeypatch.setattr(library, "load", counting)
|
||||
library.load()
|
||||
assert catalog.remote_enabled() is False and len(loads) <= 2
|
||||
|
||||
|
||||
def test_swapchain_pool_catalog_is_gated_for_older_apps(monkeypatch):
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from frameport.patches import base
|
||||
|
||||
path = Path(__file__).resolve().parents[1] / "catalog/games/cn.vr4p.oculus4xvrplayerov.yaml"
|
||||
entry = yaml.safe_load(path.read_text())
|
||||
assert entry["adapter"]["swapchain_pool"] == 1
|
||||
assert catalog.unusable_reason(entry) is None
|
||||
# Older installed apps see this catalog from main, but cannot register the new adapter key.
|
||||
monkeypatch.delitem(base.REGISTRY, "adapter.swapchain_pool")
|
||||
assert "adapter.swapchain_pool" in catalog.unusable_reason(entry)
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Full swapchain recommendations and opt-in OVRPlugin routing."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame.adapter import needs_xrshim
|
||||
from frameport.patches.settings import UI, adapter_settings
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_setting_is_opt_in_and_routes_ovrplugin():
|
||||
base.load_all()
|
||||
patch = base.get("adapter.fixed_resolution")
|
||||
assert patch.default == 0
|
||||
assert patch.detect(_analysis()) is None
|
||||
assert not adapter_settings({}).get("fixed_resolution")
|
||||
assert needs_xrshim({patch.id: {"value": 1}})
|
||||
assert not needs_xrshim({patch.id: {"value": 0}, "adapter.haptic_fix": {"value": 0}})
|
||||
assert UI[patch.key]["group"] == "picture"
|
||||
assert patch.applies(_analysis(xr="OpenXR", libs=[]))
|
||||
assert patch.applies(_analysis(xr="VrApi", libs=["libOVRPlugin.so"]))
|
||||
assert not patch.applies(_analysis(xr="VrApi", libs=[]))
|
||||
assert not patch.applies(_analysis(abis=["armeabi-v7a"], xr="OpenXR"))
|
||||
|
||||
|
||||
@pytest.mark.native
|
||||
def test_layer_dimensions(tmp_path):
|
||||
inc = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
|
||||
cc = shutil.which("cc")
|
||||
if not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not inc or not cc:
|
||||
pytest.skip("needs FRAMEPORT_NATIVE_TESTS=1, OpenXR headers and cc")
|
||||
exe = tmp_path / "fixed-resolution"
|
||||
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-missing-field-initializers", "-I", str(inc),
|
||||
"-I", str(ROOT / "native/adapter"),
|
||||
str(ROOT / "tests/fixtures/src/fixed_resolution_test.c"), "-o", str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
|
||||
|
||||
def test_triage_only_offers_fix_for_offset_rects():
|
||||
from frameport.validate.triage import triage
|
||||
log = "projection view 0 rect change: swapchain=0x123 rect=0,32 2736x2736 array=0"
|
||||
found = [f for f in triage(log).findings if f.id == "dynamic-resolution-foveation"]
|
||||
assert found and found[0].suggest == ["adapter.fixed_resolution"] and found[0].question
|
||||
assert not any(f.id == "dynamic-resolution-foveation" for f in
|
||||
triage(log.replace("rect=0,32", "rect=0,0")).findings)
|
||||
assert not any(f.id == "dynamic-resolution-foveation" for f in
|
||||
triage(log + "\nfixed_resolution: recommended 2704x2704 -> 2768x2768").findings)
|
||||
|
||||
|
||||
def test_catalog_requires_app_with_the_fix(monkeypatch):
|
||||
import yaml
|
||||
|
||||
import frameport
|
||||
from frameport.recommend.catalog import unusable_reason
|
||||
entry = yaml.safe_load((ROOT / "catalog/games/com.camouflaj.manta.yaml").read_text())
|
||||
assert entry["adapter"]["fixed_resolution"] == 1
|
||||
monkeypatch.setattr(frameport, "__version__", "1.2.1")
|
||||
assert unusable_reason(entry) == "needs FramePort 1.2.2"
|
||||
monkeypatch.setattr(frameport, "__version__", "1.2.2")
|
||||
assert unusable_reason(entry) is None
|
||||
|
||||
|
||||
@pytest.mark.native
|
||||
def test_recommendation_wrapper(tmp_path):
|
||||
inc = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
|
||||
cc = shutil.which("cc")
|
||||
if not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not inc or not cc:
|
||||
pytest.skip("needs FRAMEPORT_NATIVE_TESTS=1, OpenXR headers and cc")
|
||||
# Compile the actual wrapper with a stand-in runtime and swapchain table.
|
||||
source = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
wrapper = source[source.index("static XRAPI_ATTR XrResult XRAPI_CALL fixed_recommended_resolution"):
|
||||
source.index("// Clamp `r` to a w x h image;")]
|
||||
harness = (ROOT / "tests/fixtures/src/fixed_resolution_wrapper_test.c").read_text()
|
||||
unit = tmp_path / "wrapper.c"
|
||||
unit.write_text(harness.replace("/* WRAPPER */", wrapper))
|
||||
exe = tmp_path / "wrapper"
|
||||
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-missing-field-initializers", "-I", str(inc),
|
||||
"-I", str(ROOT / "native/adapter"), str(unit), "-o", str(exe)], check=True)
|
||||
subprocess.run([str(exe)], check=True)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""HDR (PQ/HLG, BT.2020) to SDR BT.709 tone mapping of the shared video codec's software Main 10 path."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_tone_mapping(tmp_path):
|
||||
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
||||
if not cc or os.name == "nt":
|
||||
pytest.skip("needs a POSIX host C compiler")
|
||||
binary = tmp_path / "hdr-tonemap-test"
|
||||
subprocess.run([cc, "-std=c11", "-O2", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "native/hevc"),
|
||||
str(ROOT / "tests/fixtures/src/hdr_tonemap_test.c"), "-o", str(binary), "-lm"], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
@@ -1,19 +0,0 @@
|
||||
"""HEVC Main 10 detection in the shared video codec: such streams decode in software, never on Iris's NV12 path."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_profile_from_parameter_sets(tmp_path):
|
||||
cc = shutil.which("cc") or shutil.which("gcc") or shutil.which("clang")
|
||||
if not cc or os.name == "nt":
|
||||
pytest.skip("needs a POSIX host C compiler")
|
||||
binary = tmp_path / "hevc-profile-test"
|
||||
subprocess.run([cc, "-std=c11", "-Wall", "-Wextra", "-Werror", "-I", str(ROOT / "native/hevc"),
|
||||
str(ROOT / "tests/fixtures/src/hevc_profile_test.c"), "-o", str(binary)], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
@@ -1065,14 +1065,3 @@ def test_hide_fb_foveation_setting():
|
||||
assert "hide_fb_foveation" in XRSHIM_SETTINGS
|
||||
assert needs_xrshim(recipe)
|
||||
assert UI["hide_fb_foveation"]["level"] == "advanced"
|
||||
|
||||
|
||||
def test_swapchain_pool_is_opt_in_and_gles_only():
|
||||
patch = base.get("adapter.swapchain_pool")
|
||||
assert patch.default == 0 and patch.revision == 1
|
||||
assert "swapchain_pool" not in adapter_settings({})
|
||||
assert adapter_settings({"adapter.swapchain_pool": {"value": "1"}})["swapchain_pool"] == 1
|
||||
gles = _analysis(graphics="GLES or unknown (no Vulkan declaration)")
|
||||
assert patch.applies(gles) and patch.detect(gles) is None
|
||||
assert not patch.applies(_analysis(graphics="Vulkan"))
|
||||
assert "Vulkan" in patch.description
|
||||
@@ -1,381 +0,0 @@
|
||||
"""Room scan clean-up and editing on the PC: install/rooms settings + refine/put_room, the mr/room editing helpers,
|
||||
view3d's furniture overlay and picking, and the Room tab's Clean-up / Labels / Furniture cards (fake Frame)."""
|
||||
import io
|
||||
import math
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from frameport.install import rooms
|
||||
from frameport.mr import view3d
|
||||
from frameport.mr.room import (
|
||||
Mesh,
|
||||
Volume,
|
||||
accept_suggestion,
|
||||
box_room,
|
||||
default_plane,
|
||||
dumps,
|
||||
nearest_wall,
|
||||
parse,
|
||||
plane_corners,
|
||||
plane_on_wall,
|
||||
plane_place,
|
||||
summary,
|
||||
write_mesh,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ settings
|
||||
def test_refine_defaults_are_the_owners_and_normalize():
|
||||
d = rooms.refine_defaults()
|
||||
assert d == {"refine": 1, "walls": "panels", "walked": 0.4, "smooth": 8, "island": 0.2, "clip": 0.05,
|
||||
"clip_open": 0.1, "band": 0.35, "band_in": 0.12, "floor_in": 0.08, "ceil_in": 0.25,
|
||||
"top_strip": 0.5, "facing_deg": 60, "near": 0.03, "tubes": 3.0, "tube_thick": 0.15, "tops": 0.1,
|
||||
"protect": 0, "label_rules": 1}
|
||||
for key, meta in rooms.REFINE_DEFAULTS.items():
|
||||
assert meta["flag"] == "--" + key.replace("_", "-") and meta["help"].startswith("refine_")
|
||||
if meta["kind"] == "number":
|
||||
assert meta["min"] <= meta["default"] <= meta["max"], key
|
||||
assert {k for k, m in rooms.REFINE_DEFAULTS.items() if m["key"]} == {"walls", "walked", "smooth", "island",
|
||||
"clip"}
|
||||
assert rooms.normalize_refine(d) == {}
|
||||
assert rooms.normalize_refine({"band": 0.351, "smooth": "4", "protect": "on", "walls": "snap", "clip": 7}) == \
|
||||
{"smooth": 4, "protect": 1, "walls": "snap", "clip": 1.0} # 0.351 snaps to the default 0.35
|
||||
assert rooms.normalize_refine({"band": 0.356}) == {"band": 0.36}
|
||||
for bad in ({"walls": "flat"}, {"nope": 1}, {"band": "x"}):
|
||||
with pytest.raises(ValueError):
|
||||
rooms.normalize_refine(bad)
|
||||
assert rooms.current_refine({"refine": {"smooth": 2}})["smooth"] == 2
|
||||
assert rooms.current_refine(None) == d
|
||||
|
||||
|
||||
class Frame:
|
||||
home = "/fake-frame"
|
||||
|
||||
def __init__(self):
|
||||
self.files, self.calls, self.answer = {}, [], {"room_text": "x"}
|
||||
self.sftp = SimpleNamespace(open=lambda path, mode: io.BytesIO(self.files[path]))
|
||||
|
||||
def run(self, cmd):
|
||||
return 0, "", ""
|
||||
|
||||
def put(self, local, remote, **kwargs):
|
||||
self.files[remote] = Path(local).read_bytes()
|
||||
|
||||
def agent(self, command, **args):
|
||||
self.calls.append((command, args))
|
||||
return self.answer
|
||||
|
||||
|
||||
def test_refine_and_put_room_go_to_the_agent(monkeypatch):
|
||||
f = Frame()
|
||||
synced = []
|
||||
monkeypatch.setattr(rooms, "ensure_scanner", lambda frame: synced.append(1))
|
||||
rooms.refine(f, {"walls": "snap", "band": 0.35}, erase=[(0, 1, 0, 0.2)])
|
||||
assert synced and f.calls[-1] == ("room_refine", {"settings": {"walls": "snap"}, "keep_edits": True,
|
||||
"erase": [[0.0, 1.0, 0.0, 0.2]]})
|
||||
rooms.refine(f, None, keep_edits=False)
|
||||
assert f.calls[-1] == ("room_refine", {"settings": None, "keep_edits": False})
|
||||
f.answer = {"error": "Room clean-up failed: boom"}
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
rooms.refine(f, {})
|
||||
# an edited room: the mesh comes from the Frame when not given, room_put says it's an edit
|
||||
f.answer = {"room": {}}
|
||||
r = box_room(4, 3)
|
||||
r.universe = 7
|
||||
m = write_mesh(Mesh(vertices=[(0, 0, 0), (1, 0, 0), (0, 0, 1)], indices=[0, 1, 2]))
|
||||
r.mesh = Mesh(vertex_count=3, index_count=3)
|
||||
f.files["/fake-frame/.local/share/frameport/rooms/7/room-mesh.bin"] = m
|
||||
r.volumes.append(Volume("c" * 32, "BED", 0, 0, 0.5, 0, 2, 1.4, 0.5))
|
||||
rooms.put_room(f, r)
|
||||
command, args = f.calls[-1]
|
||||
assert command == "room_put" and args["edited"] and args["path"].endswith("/room.txt")
|
||||
uploaded = {k.rsplit("/", 1)[1]: v for k, v in f.files.items() if "/.incoming-" in k}
|
||||
assert parse(uploaded["room.txt"].decode()).volumes[0].label == "BED" and uploaded["room-mesh.bin"] == m
|
||||
with pytest.raises(ValueError, match="changed"):
|
||||
rooms.put_room(f, r, write_mesh(Mesh(vertices=[(0, 0, 0)] * 4, indices=[0, 1, 2])))
|
||||
|
||||
|
||||
def test_cli_room_refine(monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from frameport import cli
|
||||
|
||||
f = Frame()
|
||||
f.answer = {"refine": {"smooth": 4}, "report": {"kept": 1}, "room_text": "x"}
|
||||
monkeypatch.setattr(cli, "_target", lambda frame: SimpleNamespace(frame=f))
|
||||
monkeypatch.setattr(rooms, "ensure_scanner", lambda frame: None)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli.app, ["frame", "room", "refine", "--set", "walls=snap", "--set", "island=0.5"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert f.calls[-1] == ("room_refine", {"settings": {"smooth": 4, "walls": "snap", "island": 0.5},
|
||||
"keep_edits": True})
|
||||
assert '"kept": 1' in result.output
|
||||
result = runner.invoke(cli.app, ["frame", "room", "refine", "--reset", "--scan-boxes"])
|
||||
assert f.calls[-1] == ("room_refine", {"settings": {}, "keep_edits": False})
|
||||
result = runner.invoke(cli.app, ["frame", "room", "refine"])
|
||||
assert f.calls[-1][1]["settings"] is None
|
||||
result = runner.invoke(cli.app, ["frame", "room", "refine", "--set", "walls=flat"])
|
||||
assert result.exit_code != 0
|
||||
result = runner.invoke(cli.app, ["frame", "room", "refine", "--list"])
|
||||
assert result.exit_code == 0 and "facing_deg" in result.output and "Where you walked" in result.output
|
||||
|
||||
|
||||
def test_report_follows_the_scanner_keys():
|
||||
from frameport.ui.views.room_edit import report_notes, report_rows
|
||||
|
||||
report = {"triangles": 1000, "kept": 800, "structural": 300, "near_walls": 5, "outside": 40, "small_pieces": 30,
|
||||
"thin_pieces": 2, "erased": 0, "walked": 20, "tops": 9, "panel_triangles": 12, "truncated": 0,
|
||||
"walked_skipped": True, "floor_y": -0.01, "ceiling_y": 2.5, "boxes": 6, "relabelled": 2,
|
||||
"removed_boxes": 1, "suggestions": 1, "plane_shifts": [{"kind": "wall", "wall": 0, "shift": 0.02}]}
|
||||
rows = dict(report_rows(report))
|
||||
assert rows["Wall, floor and ceiling surfaces"] == "300" and rows["Cleared near walls"] == "5"
|
||||
assert rows["Small pieces"] == "30" and rows["Thin floating pieces"] == "2"
|
||||
assert rows["Flat panel triangles"] == "12" and rows["Boxes renamed"] == "2" and rows["Kept"] == "800"
|
||||
assert not {"Floor y", "Ceiling y", "Walked skipped", "Truncated", "Plane shifts"} & set(rows)
|
||||
notes, warning = report_notes(report)
|
||||
assert notes == ["Where you walked: skipped, this scan has no head path"] and warning == ""
|
||||
assert "1,234" in report_notes({"truncated": 1234})[1]
|
||||
summary = dict(report_rows({"triangles": 9, "refine": {"output_triangles": 7, "output_volumes": 3}}))
|
||||
assert summary["Triangles in the room"] == "7" and summary["Furniture boxes in the room"] == "3"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ editing helpers
|
||||
def test_planes_on_walls_follow_the_cleanup_page():
|
||||
room = box_room(4, 3) # walls run counter-clockwise; their inward normal is (-uz, ux)
|
||||
w = room.walls[0]
|
||||
p = plane_on_wall(room, 0, 0.5, 0.0, 0.9, 2.05)
|
||||
ux, uz = (w.x1 - w.x0) / 4, (w.z1 - w.z0) / 4
|
||||
assert p.label == "DOOR_FRAME" and math.isclose(p.py, 2.05 / 2)
|
||||
mid = ((w.x0 + w.x1) / 2, (w.z0 + w.z1) / 2)
|
||||
assert math.isclose(p.px, mid[0] - uz * 0.01, abs_tol=1e-9) and math.isclose(p.pz, mid[1] + ux * 0.01, abs_tol=1e-9)
|
||||
# the quaternion turns +X onto the wall and +Z into the room
|
||||
th = 2 * math.atan2(p.qy, p.qw)
|
||||
assert math.isclose(math.cos(th), ux, abs_tol=1e-9) and math.isclose(-math.sin(th), uz, abs_tol=1e-9)
|
||||
c = plane_corners(p)
|
||||
assert math.isclose(math.dist(c[0], c[1]), 0.9) and math.isclose(c[3][1] - c[0][1], 2.05)
|
||||
edge = plane_on_wall(room, 0, 0.0, 0, 0.9, 2) # kept fully on the wall
|
||||
assert math.isclose(math.dist((edge.px, edge.pz), (w.x0, w.z0)), math.hypot(0.45, 0.01), rel_tol=1e-6)
|
||||
place = plane_place(room, p)
|
||||
assert place["wall"] == 0 and math.isclose(place["along"], 0.5, abs_tol=0.01) and math.isclose(place["bottom"], 0)
|
||||
window = default_plane(room, "WINDOW_FRAME")
|
||||
assert window.label == "WINDOW_FRAME" and math.isclose(plane_place(room, window)["bottom"], 0.9)
|
||||
near = nearest_wall(room, 0, -1.4)
|
||||
assert near["d"] < 0.11
|
||||
room.planes.append(p)
|
||||
assert parse(dumps(room)).planes[0].label == "DOOR_FRAME"
|
||||
|
||||
|
||||
def test_suggestions_become_doors_or_closets():
|
||||
room = box_room(4, 3)
|
||||
sug = {"kind": "door", "box": {"cx": 1.95, "cz": 0, "top_y": 2.0, "yaw_deg": 90, "w": 1.0, "d": 0.1, "h": 2.0},
|
||||
"wall": 1, "along": 0.5}
|
||||
door = accept_suggestion(room, sug, "door")
|
||||
assert room.planes == [door] and door.label == "DOOR_FRAME" and door.w == 1.0 and math.isclose(door.h, 2.0)
|
||||
assert plane_place(room, door)["wall"] == 1
|
||||
closet = accept_suggestion(room, dict(sug, wall=None), "closet")
|
||||
assert room.volumes == [closet] and closet.label == "STORAGE" and closet.d == 0.6 and closet.h == 2.0
|
||||
corners = view3d.box_corners(closet.cx, closet.cz, closet.yaw_deg, closet.w, closet.d)
|
||||
assert max(x for x, _ in corners) <= 2.0 + 1e-6 # against the wall, inside the room
|
||||
assert parse(dumps(room)).volumes[0].label == "STORAGE"
|
||||
|
||||
|
||||
def test_with_furniture_and_pick():
|
||||
room = box_room(4, 3)
|
||||
room.volumes.append(Volume("1" * 32, "TABLE", 0, 0, 0.75, 0, 1, 1, 0.75))
|
||||
scene = view3d.prepare(room)
|
||||
cam = view3d.Camera(width=800, height=480)
|
||||
cam.fit(scene)
|
||||
x, y = cam.project([(0, 0.75, 0)])[0]
|
||||
assert view3d.pick(scene, cam, x, y) == ("box", 0)
|
||||
assert view3d.pick(scene, cam, 2, 2) is None
|
||||
edited = box_room(4, 3)
|
||||
edited.volumes = [Volume("1" * 32, "BED", 1, 0.5, 0.5, 30, 2, 1.4, 0.5)]
|
||||
edited.planes = [plane_on_wall(edited, 0, 0.5, 0, 0.9, 2.05)]
|
||||
sc = view3d.with_furniture(scene, edited, ("plane", 0))
|
||||
assert sc is not scene and len([f for f in scene.faces if f.kind == "box"]) == 6 # the cache isn't touched
|
||||
assert [f.index for f in sc.faces if f.kind == "box"] == [0] * 6 and len(sc.planes) == 1
|
||||
assert len(sc.selected) == 4
|
||||
frame = view3d.render(sc, cam)
|
||||
assert frame.planes and frame.selected
|
||||
px, py = cam.project([(edited.planes[0].px, edited.planes[0].py, edited.planes[0].pz)])[0]
|
||||
assert view3d.pick(sc, cam, px, py) == ("plane", 0)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------ the Room tab
|
||||
class RefineFrame:
|
||||
"""A Frame with a scanned room, its clean-up results, room_refine / room_put and the mesh over SFTP."""
|
||||
|
||||
def __init__(self, tmp_path):
|
||||
self.home = str(tmp_path / "frame")
|
||||
self.room = box_room(4, 3)
|
||||
self.room.source, self.room.universe = "scan", 7
|
||||
self.room.volumes = [Volume("1" * 32, "TABLE", 0, 0, 0.75, 0, 1.2, 0.6, 0.75),
|
||||
Volume("2" * 32, "SCREEN", 0, -1.4, 1.4, 0, 1.2, 0.1, 0.7)]
|
||||
self.mesh = write_mesh(Mesh(vertices=[(-1, 0, -1), (1, 0, -1), (1, 0, 1)], indices=[0, 2, 1]))
|
||||
self.room.mesh = Mesh(vertex_count=3, index_count=3)
|
||||
self.folder = tmp_path / "frame/.local/share/frameport/rooms/7"
|
||||
self.folder.mkdir(parents=True)
|
||||
(self.folder / "room-mesh.bin").write_bytes(self.mesh)
|
||||
self.saved, self.calls, self.files = {}, [], {}
|
||||
self.report = {"triangles": 1200, "kept": 900, "walked": 40}
|
||||
|
||||
def agent(self, command, **kw):
|
||||
self.calls.append((command, kw))
|
||||
if command == "room_refine":
|
||||
if kw.get("settings") is not None:
|
||||
self.saved = kw["settings"]
|
||||
self.room.time += 1
|
||||
self.report = {"triangles": 1200, "kept": 850, "walked": 60}
|
||||
if command == "room_put":
|
||||
self.room = parse(Path(kw["path"]).read_text())
|
||||
self.edited = True
|
||||
return dict(room_text=dumps(self.room), room=dict(source=self.room.source, summary=summary(self.room)),
|
||||
aligned=True, scanner_installed=True, default_circle=False, refine=dict(self.saved),
|
||||
report=self.report, edited=getattr(self, "edited", False),
|
||||
labels=[{"from": "COUCH", "to": "TABLE", "reason": "top at 0.75 m: a table",
|
||||
"box": {"w": 1.2, "d": 0.6, "h": 0.75}},
|
||||
{"from": "TABLE", "to": None, "reason": "behind a wall", "box": {}}],
|
||||
suggestions=[{"kind": "closet", "box": {"cx": 1.9, "cz": 0.5, "top_y": 2.0, "yaw_deg": 90,
|
||||
"w": 1.0, "d": 0.3, "h": 2.0}, "wall": 1,
|
||||
"along": 0.6}])
|
||||
|
||||
def run(self, cmd):
|
||||
return 0, "", ""
|
||||
|
||||
def put(self, local, remote, **kw): # uploads land in a local folder (the agent reads room.txt from there)
|
||||
Path(remote).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(remote).write_bytes(Path(local).read_bytes())
|
||||
|
||||
@property
|
||||
def sftp(self):
|
||||
class SFTP:
|
||||
def open(self, path, mode="rb"):
|
||||
return open(path, mode) # noqa: SIM115
|
||||
return SFTP()
|
||||
|
||||
|
||||
def refine_app(frame):
|
||||
from test_ui_rooms import sync_app
|
||||
|
||||
app = sync_app(frame)
|
||||
jobs = []
|
||||
|
||||
def submit(title, run, *a, **kw):
|
||||
job = SimpleNamespace(title=title, reporter=SimpleNamespace(stage=lambda *a: None))
|
||||
jobs.append(title)
|
||||
job.result = run(job)
|
||||
return job
|
||||
app.submit, app.jobs_run = submit, jobs
|
||||
return app
|
||||
|
||||
|
||||
def test_room_tab_cleanup_labels_and_editor(tmp_path, monkeypatch):
|
||||
from frameport.ui.views.room import RoomView
|
||||
|
||||
monkeypatch.setattr(rooms, "ensure_scanner", lambda frame: None)
|
||||
monkeypatch.setattr(rooms, "_upload_dir", lambda frame: _local_dir(tmp_path))
|
||||
frame = RefineFrame(tmp_path)
|
||||
app = refine_app(frame)
|
||||
view = RoomView(app)
|
||||
view.mount()
|
||||
cleanup, labels, editor = view.cleanup, view.labels, view.editor
|
||||
# clean-up: the defaults, the key settings in sight, the rest under More settings
|
||||
assert cleanup.preset.data == "recommended" and cleanup.walls.data == "panels" and not cleanup.more.visible
|
||||
assert cleanup.readouts["walked"].value == "40 cm" and cleanup.readouts["island"].value == "0.20 m²"
|
||||
assert cleanup.readouts["smooth"].value == "8×" and not cleanup.apply_btn.disabled
|
||||
assert cleanup.result_box.visible and any("1,200" in r.controls[1].value for r in cleanup.result.controls)
|
||||
cleanup.toggle_more()
|
||||
assert cleanup.more.visible and set(cleanup.sliders) == {k for k, m in rooms.REFINE_DEFAULTS.items()
|
||||
if m["kind"] == "number"}
|
||||
cleanup._slid("smooth", 4.2)
|
||||
cleanup.set("walls", "snap")
|
||||
assert cleanup.preset.data == "mine" and cleanup.sliders["near"].disabled
|
||||
cleanup._preset("recommended")
|
||||
assert cleanup.values == rooms.refine_defaults()
|
||||
cleanup._preset("mine")
|
||||
assert cleanup.values["smooth"] == 4 and cleanup.values["walls"] == "snap"
|
||||
cleanup.apply()
|
||||
command, args = [c for c in frame.calls if c[0] == "room_refine"][-1]
|
||||
assert command == "room_refine" and args["settings"] == {"smooth": 4, "walls": "snap"}
|
||||
assert app.jobs_run == ["Clean up the room scan"] and not app._room_state["working"]
|
||||
view.refresh(update=False)
|
||||
assert cleanup.values["smooth"] == 4 and any("850" in r.controls[1].value for r in cleanup.result.controls)
|
||||
# labels: the scanner's label -> the rule's, the reason, and a suggestion to take
|
||||
assert len(labels.rows.controls) == 2 and len(labels.suggestions.controls) == 1 and not labels.empty.visible
|
||||
texts = [t.value for t in _walk(labels.rows) if isinstance(getattr(t, "value", None), str)]
|
||||
assert "Couch" in texts and "→ Table" in texts and "removed" in texts and "behind a wall" in texts
|
||||
# the box editor: select, change, add a door, take the closet suggestion, save
|
||||
assert len(editor.box_list.controls) == 2 and not editor.box_panel.visible
|
||||
editor.select(("box", 0))
|
||||
assert editor.box_panel.visible and editor.box_type.value == "TABLE"
|
||||
assert editor.box_sliders["cx"].min == -3 and editor.box_sliders["cx"].max == 3
|
||||
editor._box_value("w", 1.6)
|
||||
editor._box_label("COUCH")
|
||||
assert editor.dirty and editor.room.volumes[0].w == 1.6 and editor.room.volumes[0].label == "COUCH"
|
||||
assert view.canvas.scene.selected # the canvas shows the edited, selected box
|
||||
editor._add_plane("DOOR_FRAME")
|
||||
assert editor.plane_panel.visible and editor.plane_wall.value == "0" and len(editor.plane_list.controls) == 1
|
||||
editor._plane_value("along", 0.25)
|
||||
editor._plane_wall("2")
|
||||
p = editor.room.planes[0]
|
||||
assert plane_place(editor.room, p)["wall"] == 2 and math.isclose(plane_place(editor.room, p)["along"], 0.25,
|
||||
abs_tol=0.02)
|
||||
labels.refresh(app._room_state, app._room_state["status"], app._room_state["status"]["room"])
|
||||
editor.accept(frame.agent("room_status")["suggestions"][0], "closet")
|
||||
assert editor.room.volumes[-1].label == "STORAGE" and editor.sel == ("box", 2)
|
||||
editor._delete()
|
||||
assert len(editor.room.volumes) == 2 and editor.sel is None
|
||||
assert not editor.save_btn.disabled
|
||||
editor.save()
|
||||
command, args = [c for c in frame.calls if c[0] == "room_put"][-1]
|
||||
assert command == "room_put" and args["edited"]
|
||||
saved = frame.room
|
||||
assert [v.label for v in saved.volumes] == ["COUCH", "SCREEN"] and saved.planes[0].label == "DOOR_FRAME"
|
||||
view.refresh(update=False)
|
||||
assert not editor.dirty and editor.room.volumes[0].label == "COUCH" # reloaded from the saved room
|
||||
assert labels.reset_btn.visible # an edited room can go back to the scan's boxes
|
||||
# a click on the canvas selects what's under it
|
||||
view.canvas.on_pick(("plane", 0))
|
||||
assert editor.sel == ("plane", 0)
|
||||
|
||||
|
||||
def test_cleanup_waits_for_a_scanned_room(tmp_path):
|
||||
from frameport.ui.views.room import RoomView
|
||||
|
||||
frame = RefineFrame(tmp_path)
|
||||
frame.room.source = "boundary"
|
||||
app = refine_app(frame)
|
||||
view = RoomView(app)
|
||||
view.mount()
|
||||
assert view.cleanup.apply_btn.disabled and "scanned" in view.cleanup.state_text.value
|
||||
|
||||
|
||||
def _walk(control):
|
||||
yield control
|
||||
kids = list(getattr(control, "controls", None) or [])
|
||||
content = getattr(control, "content", None)
|
||||
if content is not None and not isinstance(content, str):
|
||||
kids.append(content)
|
||||
for child in kids:
|
||||
yield from _walk(child)
|
||||
|
||||
|
||||
class _local_dir:
|
||||
def __init__(self, tmp_path):
|
||||
self.path = tmp_path / "frame-upload"
|
||||
|
||||
def __enter__(self):
|
||||
self.path.mkdir(exist_ok=True)
|
||||
return str(self.path)
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
def test_box_sizes_keep_thin_sizes_readable():
|
||||
from frameport.ui.views.room_edit import metres, size_text
|
||||
|
||||
assert metres(0.03) == "0.03" and metres(0.07) == "0.07" and metres(0.5) == "0.5" and metres(2.05) == "2.0"
|
||||
assert size_text(1.6, 1.1, 0.03) == "1.6×1.1×0.03 m"
|
||||
@@ -36,8 +36,7 @@ def harness(tmp_path_factory):
|
||||
def run(harness, folder, mode, *args, process="org.frameport.test"):
|
||||
binary, _ = harness
|
||||
env = {**os.environ, "FRAMEBRIDGE_CONFIG": str(folder / "settings.conf")}
|
||||
result = subprocess.run([process, mode, *map(str, args)], executable=binary, capture_output=True, text=True,
|
||||
env=env)
|
||||
result = subprocess.run([process, mode, *map(str, args)], executable=binary, capture_output=True, text=True, env=env)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
return result.stderr
|
||||
|
||||
@@ -51,7 +50,7 @@ def room(folder, *, generated_walls=False):
|
||||
f"volume {uuid(40)} TABLE,OTHER 2 1 0.75 90 2 1 0.75",
|
||||
f"plane {uuid(41)} WINDOW_FRAME 1 1.5 0 0 0 0 1 0.8 1.2"]
|
||||
if not generated_walls:
|
||||
for i, ((x, z), (xx, zz)) in enumerate(zip(points, points[1:] + points[:1], strict=True)):
|
||||
for i, ((x, z), (xx, zz)) in enumerate(zip(points, points[1:] + points[:1])):
|
||||
lines.append(f"wall {uuid(10 + i)} {x} {z} {xx} {zz} 0 2.5")
|
||||
lines += [f"mesh {uuid(42)} room-mesh.bin 4 6", "future-data ignored"]
|
||||
(folder / "room.txt").write_text("\n".join(lines) + "\n")
|
||||
@@ -131,8 +130,7 @@ def test_rejects_corrupt_mesh_without_losing_room(harness, tmp_path, damage):
|
||||
assert "bad room line" in log and "0 mesh triangles" in log
|
||||
|
||||
|
||||
@pytest.mark.parametrize("record", [f"mesh {uuid(80)} ../escape.bin 4 6",
|
||||
f"mesh {uuid(80)} room-mesh.bin 600001 600000",
|
||||
@pytest.mark.parametrize("record", [f"mesh {uuid(80)} ../escape.bin 4 6", f"mesh {uuid(80)} room-mesh.bin 600001 600000",
|
||||
f"mesh {uuid(80)} room-mesh.bin 4 7"])
|
||||
def test_mesh_caps_and_path_confinement(harness, tmp_path, record):
|
||||
room(tmp_path)
|
||||
@@ -141,8 +139,7 @@ def test_mesh_caps_and_path_confinement(harness, tmp_path, record):
|
||||
run(harness, tmp_path, "room", 5, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [None, "", "frameport-room 2\n", "bad 1\n",
|
||||
"# comment before magic\nframeport-room 1\n",
|
||||
@pytest.mark.parametrize("text", [None, "", "frameport-room 2\n", "bad 1\n", "# comment before magic\nframeport-room 1\n",
|
||||
"frameport-room 1\noutline 3 0 0 1 1 2 2\n"])
|
||||
def test_box_fallback_unchanged(harness, tmp_path, text):
|
||||
if text is not None:
|
||||
@@ -194,7 +191,7 @@ def test_failed_anchor_write_is_reported_and_rolled_back(harness, tmp_path):
|
||||
def test_missing_outline_and_ceiling_are_derived(harness, tmp_path):
|
||||
points = [(0, 0), (4, 0), (4, 3), (0, 3)]
|
||||
lines = ["frameport-room 1", f"room {uuid(1)}", f"floor {uuid(2)} 1"]
|
||||
for i, ((x, z), (xx, zz)) in enumerate(zip(points, points[1:] + points[:1], strict=True)):
|
||||
for i, ((x, z), (xx, zz)) in enumerate(zip(points, points[1:] + points[:1])):
|
||||
lines.append(f"wall {uuid(10 + i)} {x} {z} {xx} {zz} 1 4")
|
||||
(tmp_path / "room.txt").write_text("\n".join(lines) + "\n")
|
||||
run(harness, tmp_path, "derived")
|
||||
@@ -224,8 +221,7 @@ def test_real_shim_bypasses_a_dispatcher_that_refuses_scene_calls(harness, tmp_p
|
||||
subprocess.run([*common, "-shared", "-fPIC", "-DFAKE_DISPATCH", str(native / "scene_shim_test.c"),
|
||||
"-o", str(tmp_path / "libopenxr_loader.so")], check=True)
|
||||
shim = tmp_path / "libframe_xrshim.so"
|
||||
subprocess.run([*common, "-shared", "-fPIC", "-fvisibility=hidden",
|
||||
"-I", str(ROOT / "tests/fixtures/src/host_include"),
|
||||
subprocess.run([*common, "-shared", "-fPIC", "-fvisibility=hidden", "-I", str(ROOT / "tests/fixtures/src/host_include"),
|
||||
str(ROOT / "native/xrshim/xrshim.c"), "-ldl", "-pthread", "-lm", "-o", str(shim)], check=True)
|
||||
binary = tmp_path / "test-shim"
|
||||
subprocess.run([*common, str(native / "scene_shim_test.c"), "-ldl", "-o", str(binary)], check=True)
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
"""Embedded loader redirection, independent of any game or device."""
|
||||
import json
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from test_agent import load_agent
|
||||
from test_patches import _analysis
|
||||
|
||||
from frameport.analysis import detect
|
||||
from frameport.analysis import static_openxr as sx
|
||||
from frameport.apk.workspace import ApkWorkspace
|
||||
from frameport.core.events import Reporter
|
||||
from frameport.patches import base
|
||||
from frameport.patches.frame import static_openxr_loader as patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def embedded(tmp_path):
|
||||
# A real ELF layout with synthetic strings; change only e_machine to model an arm64 game.
|
||||
compiler = shutil.which("cc")
|
||||
if not compiler:
|
||||
pytest.skip("needs host C compiler")
|
||||
strings = [f"/{p}/etc/openxr/1/active_runtime{abi}.json"
|
||||
for p in ("product", "odm", "oem", "vendor", "system") for abi in ("", ".aarch64", ".arm64-v8a")]
|
||||
strings += list(sx.BROKERS) + ["RuntimeManifestFile", "xrNegotiateLoaderRuntimeInterface", "unrelated"]
|
||||
src = tmp_path / "embedded.c"
|
||||
src.write_text("const char *strings[] = {" + ",".join(json.dumps(s) for s in strings) + "};")
|
||||
out = tmp_path / "embedded.so"
|
||||
subprocess.run([compiler, "-shared", "-fPIC", str(src), "-o", str(out)], check=True)
|
||||
data = bytearray(out.read_bytes())
|
||||
struct.pack_into("<H", data, 18, 183) # EM_AARCH64
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def test_detection_and_byte_stable_rewrite(embedded):
|
||||
assert sx.detected("libengine.so", embedded)
|
||||
assert not sx.detected("libopenxr_loader.so", embedded)
|
||||
assert not sx.detected("libengine.so", embedded.replace(b"RuntimeManifestFile", b"X" * 19))
|
||||
wrong_arch = bytearray(embedded)
|
||||
struct.pack_into("<H", wrong_arch, 18, 62)
|
||||
assert not sx.detected("libengine.so", bytes(wrong_arch))
|
||||
fixed, count = sx.rewrite(embedded)
|
||||
assert count == 17
|
||||
assert len(fixed) == len(embedded)
|
||||
expected = embedded
|
||||
for old in sx.paths(embedded) + list(sx.BROKERS):
|
||||
new = sx.MANIFEST if old.startswith("/") else old.replace("org.khronos", "org.fp_null")
|
||||
expected = expected.replace(old.encode() + b"\0", new.encode().ljust(len(old) + 1, b"\0"))
|
||||
assert fixed == expected # all other bytes, offsets and ELF metadata are identical
|
||||
assert sx.rewrite(fixed) == (fixed, 0)
|
||||
assert not sx.paths(fixed)
|
||||
assert detect.ANALYSIS_VERSION >= 23
|
||||
|
||||
|
||||
def test_patch_and_adapter_without_overport(tmp_path, monkeypatch, embedded):
|
||||
inner = tmp_path / "inner.so"
|
||||
inner.write_bytes(b"unmodified Khronos loader")
|
||||
monkeypatch.setattr(patch.overport, "ensure_runtime_lib", lambda *args: inner)
|
||||
apk = tmp_path / "in.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("lib/arm64-v8a/libengine.so", embedded)
|
||||
a = _analysis(extra={"static_openxr_loader": ["libengine.so"]})
|
||||
selection = {patch.StaticOpenXRLoader.id: {}, "frame.adapter": {}}
|
||||
p = patch.StaticOpenXRLoader()
|
||||
assert p.detect(a).recommended
|
||||
assert not p.applies(_analysis())
|
||||
with ApkWorkspace(apk) as ws:
|
||||
ctx = base.ApkContext(ws, a, {}, Reporter(), selection)
|
||||
assert p.apply(ctx)
|
||||
assert base.get("frame.adapter").apply(ctx)
|
||||
assert ws.read_lib("libopenxr_loader_original.so") == inner.read_bytes()
|
||||
assert ws.has(ws.lib("libframe_settings.so"))
|
||||
assert all(ok for _, ok, _ in p.validate(ctx))
|
||||
assert all(ok for _, ok, _ in base.get("frame.adapter").validate(ctx))
|
||||
assert not p.apply(ctx)
|
||||
assert not base.get("frame.adapter").apply(ctx)
|
||||
out = ws.write(tmp_path / "out.apk")
|
||||
with ApkWorkspace(out) as ws:
|
||||
ctx.ws = ws
|
||||
assert not p.apply(ctx)
|
||||
ws.put(ws.lib("libengine.so"), embedded)
|
||||
assert not p.validate(ctx)[0][1]
|
||||
|
||||
|
||||
def test_manifest_repair_and_launcher_upgrade(tmp_path, monkeypatch):
|
||||
a = load_agent(monkeypatch, tmp_path)
|
||||
root = tmp_path / "game"
|
||||
a.write_openxr_runtime_manifest(str(root), False)
|
||||
assert not root.exists()
|
||||
a.write_openxr_runtime_manifest(str(root), True)
|
||||
target = root / "lepton-data/external/fp_openxr_runtime.json"
|
||||
manifest = json.loads(target.read_text())
|
||||
assert manifest["runtime"]["library_path"] == patch.SHIM
|
||||
victim = tmp_path / "victim"
|
||||
victim.write_text("untouched")
|
||||
target.unlink()
|
||||
target.symlink_to(victim)
|
||||
a.write_openxr_runtime_manifest(str(root), True)
|
||||
assert victim.read_text() == "untouched"
|
||||
assert not target.is_symlink()
|
||||
anchor = tmp_path / "anchors/com.example.game"
|
||||
anchor.mkdir(parents=True)
|
||||
a.write_launcher(str(anchor), str(root), "com.example.game", "Test", 123, "/lepton", {}, static_openxr=True)
|
||||
text = (anchor / "launch.sh").read_text()
|
||||
assert " _openxr_manifest com.example.game\n" in text
|
||||
assert a.set_openxr_manifest_line(text, "com.example.game", True) == text
|
||||
assert "_openxr_manifest" not in a.set_openxr_manifest_line(text, "com.example.game", False)
|
||||
subprocess.run(["bash", "-n", str(anchor / "launch.sh")], check=True)
|
||||
|
||||
|
||||
def test_runtime_shim_host(tmp_path):
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
headers = next((root / "native/.cache").glob("openxr-*/openxr/openxr.h"), None)
|
||||
cc = shutil.which("cc")
|
||||
if not headers or not cc:
|
||||
pytest.skip("needs cached OpenXR headers and host C compiler")
|
||||
src = root / "native/xrruntime"
|
||||
inc = str(headers.parent.parent)
|
||||
subprocess.run([cc, "-shared", "-fPIC", "-I", inc, str(src / "test_bridge.c"),
|
||||
"-o", str(tmp_path / "libopenxr_loader_generic.so")], check=True)
|
||||
exe = tmp_path / "shim_test"
|
||||
subprocess.run([cc, "-Wall", "-Wextra", "-Werror", "-Wno-missing-field-initializers", "-I", inc,
|
||||
str(src / "xrruntime.c"), str(src / "test.c"), "-ldl", "-lpthread", "-o", str(exe)], check=True)
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=str(tmp_path))
|
||||
subprocess.run([str(exe)], env=env, check=True)
|
||||
(tmp_path / "libopenxr_loader_generic.so").unlink()
|
||||
subprocess.run([str(exe), "missing"], env=env, check=True)
|
||||
|
||||
|
||||
def test_composed_khronos_paths(tmp_path, embedded):
|
||||
# Rebuild the fixture with Khronos platform_utils.hpp's actual fragment table.
|
||||
# This is also the layout in OVRPort's unmodified Khronos loader.
|
||||
|
||||
from frameport.analysis import elf
|
||||
|
||||
cc = shutil.which("cc")
|
||||
src = tmp_path / "composed.c"
|
||||
strings = list(sx.PARTITIONS) + [sx.SUBDIR, "/active_runtime.json", "/active_runtime.aarch64.json"]
|
||||
strings += list(sx.BROKERS) + ["RuntimeManifestFile", "xrNegotiateLoaderRuntimeInterface"]
|
||||
src.write_text("const char *strings[] = {" + ",".join(json.dumps(s) for s in strings) + "};")
|
||||
out = tmp_path / "composed.so"
|
||||
subprocess.run([cc, "-shared", "-fPIC", str(src), "-o", str(out)], check=True)
|
||||
buf = bytearray(out.read_bytes())
|
||||
struct.pack_into("<H", buf, 18, 183)
|
||||
data = bytes(buf)
|
||||
assert sx.composed(data) and sx.detected("libgame.so", data)
|
||||
fixed, count = sx.rewrite(data)
|
||||
assert count == 8 and len(fixed) == len(data)
|
||||
assert sx.redirected(fixed) and sx.rewrite(fixed) == (fixed, 0)
|
||||
expected = data
|
||||
for old, new in [(p, "/sd") for p in sx.PARTITIONS] + [(sx.SUBDIR, "card/fp/")] + [
|
||||
(p, p.replace("org.khronos", "org.fp_null")) for p in sx.BROKERS]:
|
||||
expected, _ = elf.replace_rodata_string(expected, old, new)
|
||||
assert fixed == expected
|
||||
assert "/sd" + "card/fp/" + "1" + "/active_runtime.json" == sx.COMPOSED_MANIFEST
|
||||
|
||||
|
||||
def test_analysis_records_embedded_loader(tmp_path, embedded, quest_manifest):
|
||||
apk = tmp_path / "analyze.apk"
|
||||
with zipfile.ZipFile(apk, "w") as z:
|
||||
z.writestr("AndroidManifest.xml", quest_manifest)
|
||||
z.writestr("lib/arm64-v8a/libengine.so", embedded)
|
||||
assert detect.analyze(apk).extra["static_openxr_loader"] == ["libengine.so"]
|
||||
|
||||
|
||||
def test_triage_only_when_chain_absent():
|
||||
from frameport.validate.triage import triage
|
||||
|
||||
line = "RuntimeManifestFile::FindManifestFiles - using global runtime file /vendor/etc/openxr/1/active_runtime.json"
|
||||
assert "static-openxr-loader-bypass" in {f.id for f in triage(line, "RUNNING").findings}
|
||||
for chain in ("FrameBridge: settings read", "fp_xrruntime: negotiated"):
|
||||
assert "static-openxr-loader-bypass" not in {f.id for f in triage(line + "\n" + chain, "RUNNING").findings}
|
||||
|
||||
|
||||
def test_heuristic_selects_chain_without_game_specific_rules():
|
||||
from frameport.recommend.engine import suggest
|
||||
|
||||
a = _analysis(package="com.example.othergame", engine="Other", libs=["libengine.so"],
|
||||
extra={"static_openxr_loader": ["libengine.so"], "vr_kind": "openxr"})
|
||||
r = suggest(a, use_catalog=False)
|
||||
assert {"frame.static_openxr_loader", "frame.adapter"} <= r.patches.keys()
|
||||
assert not r.as_is
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Host harness for the GLES swapchain pool, with runtime and GL doubles."""
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
INC = ROOT / "native/.cache/openxr-f2448a8797"
|
||||
if not INC.is_dir():
|
||||
INC = None
|
||||
CC = shutil.which("cc") or shutil.which("clang")
|
||||
|
||||
|
||||
@pytest.mark.native
|
||||
@pytest.mark.skipif(not os.environ.get("FRAMEPORT_NATIVE_TESTS") or not INC or not CC,
|
||||
reason="set FRAMEPORT_NATIVE_TESTS=1; needs cached OpenXR and host GLES/EGL headers")
|
||||
def test_pool_runtime_and_gl(tmp_path):
|
||||
# Copy only graphics headers: adding the NDK sysroot itself shadows host libc headers.
|
||||
inc = tmp_path / "include"
|
||||
candidates = [Path("/usr/include"), *(ROOT / "native/.cache").glob(
|
||||
"ndk-*/toolchains/llvm/prebuilt/*/sysroot/usr/include")]
|
||||
graphics = next((p for p in candidates if all((p / d).is_dir() for d in ("GLES3", "EGL", "KHR"))), None)
|
||||
if graphics is None:
|
||||
pytest.skip("needs GLES3/EGL/KHR headers (host development packages or cached NDK)")
|
||||
for directory in ("GLES3", "EGL", "KHR"):
|
||||
shutil.copytree(graphics / directory, inc / directory)
|
||||
# Exercise production rect_clamp too, rather than a second implementation in the harness.
|
||||
adapter = (ROOT / "native/adapter/frame_adapter.c").read_text()
|
||||
start = adapter.index("static int clamp_rect(")
|
||||
end = adapter.index("\n}", start) + 2
|
||||
(inc / "pool_clamp.h").write_text(adapter[start:end])
|
||||
exe = tmp_path / "pool"
|
||||
subprocess.run([CC, "-std=c11", "-pthread", "-Wall", "-Wextra", "-Werror", "-Wno-missing-field-initializers",
|
||||
"-I", str(INC), "-I", str(inc), "-I", str(ROOT / "native/adapter"),
|
||||
str(ROOT / "tests/native/swapchain_pool_harness.c"), "-o", str(exe)], check=True)
|
||||
out = subprocess.run([str(exe)], capture_output=True, text=True)
|
||||
assert out.returncode == 0, out.stderr
|
||||
assert out.stdout.strip() == "ok"
|
||||