Steam Frame controller models via XR_FB_render_model (adapter controller_models)

Games that ask the runtime for controller models (Meta's runtime controller
models) get the Steam Frame controllers instead of Quest Touch ones:

- adapter (render_model.c): emulates XR_FB_render_model when the runtime lacks
  it and serves files/framebridge/controller_{left,right}.glb. Off by default;
  suggested for games declaring the RENDER_MODEL permission/feature (none of
  the 34 catalog games do).
- agent v20: converts the Frame's own SteamVR render models (frame_controller
  driver, OBJ+PNG, openxr_grip -> grip space) to glb on the device at install /
  set_settings; Valve's models never leave the Frame. `frameport frame
  controller-models` lists what it finds.
- xrshim: overport's dispatcher only forwards functions in its own table
  ("Unknown proc addr"), so OVRPlugin's dlopen("libopenxr_loader.so") string
  is pointed at libframe_xrshim.so, which serves the emulated functions and
  forwards everything else to overport. Only added with controller_models=1;
  other builds differ from known-good only in the adapter binary.

Verified on the device with Toy Master: models converted, adapter emulation on,
all three render-model functions resolved through FrameBridge; launches match
the known-good build. Not yet seen rendering in a game (none requests a model).
This commit is contained in:
spoopyghosty0 committed 2026-09-30 12:58:52 -04:00
1 parent 1d83eae3ed
commit fc4aa9c8e5
24 files changed
+894 -20

No files matched your search

+17
View File
@@ -238,6 +238,23 @@ Output is deterministic (same input + runtime → same bytes), which is what mak
- VrApi-direct engines (CryEngine Climb 2, POTW) need the VrApi bridge; the bridge drops whole frames on unknown layer
types (→ black screen with audio).
**Steam Frame controller models (2026-09-30, not yet seen in a game):** adapter setting `controller_models`
(`native/adapter/render_model.c`) emulates XR_FB_render_model and serves `files/framebridge/controller_{left,right}.glb`;
agent v20 (`install_controller_models`, command `controller_models`) converts the Frame's SteamVR render models
(folder name matching "frame" + left/right, OBJ+PNG, `openxr_grip` component → grip space) at finalize/set_settings.
Valve's models are never committed or copied off the Frame. Only games that use Meta's runtime controller models
benefit (manifest `RENDER_MODEL` permission/feature → suggested); **none of the 34 catalog games do** (they ship their
own meshes: that would need per-game asset replacement). Model discovery and conversion
checked on the device 2026-09-30: `/opt/steamvr/drivers/frame_controller/resources/rendermodels/frame_controller_{left,right}`
(component OBJs in model space = the whole `<name>.obj`, one `_color.png` 2048² near-black, `openxr_grip` rotates about
X only; hidden-by-default components like `status` are skipped) → ~2 MB glb each, ~1 s. Not yet seen in a game (none
of the installed builds has the new adapter, and none requests runtime models), so whether Meta's SDK attaches runtime models at the grip pose is unverified.
**overport's dispatcher (`libopenxr_loader.so`) only forwards functions in its own table** (`overportOXR: Unknown
proc addr: …`), so adapter emulations of functions it doesn't know are unreachable. For XR_FB_render_model,
`native/xrshim` fills the gap: OVRPlugin's `dlopen("libopenxr_loader.so")` string is rewritten to the shim (see
native/README). Verified on the device with Toy Master (2026-09-30): `extension shim: xrLoadRenderModelFB -> FrameBridge`.
Toy Master doesn't request a model after that (it uses its own), so the glb loading path is only unit-tested.
**Unresolved (as of 2026-09-28):** Arcsmith (right-eye distortion) and Time Stall (both eyes) — swap, tracking, Valve
layers, depth, pacing ruled out. Sniper Elite VR (DEVICE LOST), Espire 1 (Mesa GL upload crash), HITMAN 3 (freedreno
crash): use PC versions.
+306 -4
View File
@@ -5,7 +5,8 @@ The PC app uploads this file to ~/.local/share/frameport/agent/ and calls:
python3 frameport_agent.py <command> (JSON arguments on stdin, one JSON object on stdout)
Commands: info, prepare, finalize, shortcuts, shortcut_status, launch_test, stop, set_settings, uninstall,
install_lepton, list_installed, proton_status, install_proton, prepare_pcvr, finalize_pcvr.
install_lepton, list_installed, proton_status, install_proton, prepare_pcvr, finalize_pcvr,
controller_models.
Install layout (one Lepton container per game; same as the manual installs from 2026-09):
~/Applications/quest-frame/<pkg>/ anchor: launch.sh, deployment.json, artwork/ (always internal storage)
@@ -32,7 +33,7 @@ import sys
import time
import zlib
AGENT_VERSION = 19
AGENT_VERSION = 20
HOME = os.path.expanduser("~")
STEAM = os.path.join(HOME, ".local/share/Steam")
ANCHORS = os.path.join(HOME, "Applications/quest-frame")
@@ -1069,6 +1070,7 @@ def cmd_finalize(args):
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "w") as f:
f.write(content)
models = install_controller_models(files_dir, str(settings.get("controller_models", 0)) not in ("0", "0.0"))
write_launcher(anchor, base, pkg, title, appid, lepton, args.get("env"))
art_in = os.path.join(base, "incoming-artwork")
if os.path.isdir(art_in):
@@ -1080,7 +1082,306 @@ def cmd_finalize(args):
"agent_version": AGENT_VERSION, "time": time.time()}
with open(os.path.join(anchor, "deployment.json"), "w") as f:
json.dump(dep, f, indent=2)
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved}
return {"ok": True, "base": base, "appid": appid, "moved_data_files": moved, "controller_models": models}
# ------------------------------------------------------------------ Steam Frame controller models (XR_FB_render_model)
# The FrameBridge adapter (setting controller_models=1) serves these to games that ask the runtime for controller models.
# They are converted here, on the Frame, from the SteamVR render models the Frame already has (OBJ + PNG) into glTF
# binaries, so Valve's models never leave the device.
MODELS_CACHE = os.path.join(HOME, ".local/share/frameport/controller-models")
FRAME_MODEL_RE = re.compile(r"frame", re.I)
SKIP_COMPONENTS = ("status", "led", "scroll_wheel_touch")
def steamvr_roots():
roots = []
rt = openxr_runtime()
if rt and rt.get("path"):
roots.append(os.path.dirname(rt["path"]))
roots += [os.path.join(lib, "steamapps/common/SteamVR") for lib in steam_libraries()]
roots += ["/opt/steamvr", os.path.join(STEAM, "steamapps/common/SteamVR")]
out = []
for r in roots:
r = os.path.realpath(r)
if os.path.isdir(r) and r not in out:
out.append(r)
return out
def render_model_dirs(roots=None):
"""name -> directory of every SteamVR render model (a folder with .obj files)."""
found = {}
for root in roots if roots is not None else steamvr_roots():
for pattern in ("resources/rendermodels/*", "drivers/*/resources/rendermodels/*"):
for d in sorted(glob.glob(os.path.join(root, pattern))):
if os.path.isdir(d) and glob.glob(os.path.join(d, "*.obj")):
found.setdefault(os.path.basename(d), d)
return found
def model_side(name):
n = name.lower()
for side in ("left", "right"):
if side in n:
return side
for side in ("left", "right"): # e.g. controller_l / controller-r
if re.search(rf"(^|[_\-. ]){side[0]}([_\-. ]|$)", n):
return side
return None
def pick_controller_models(dirs, pattern=FRAME_MODEL_RE):
"""{'left': dir, 'right': dir} for the Steam Frame controllers (names matching `pattern`), or {}."""
best = {}
for name, d in dirs.items():
side = model_side(name)
if not side or not pattern.search(name):
continue
score = ("controller" in name.lower()) * 10 - len(name)
if side not in best or score > best[side][0]:
best[side] = (score, d)
return {s: d for s, (_, d) in best.items()} if len(best) == 2 else {}
def _rotation_xyz(deg):
"""3x3 rotation for SteamVR's rotate_xyz (degrees; applied about X, then Y, then Z)."""
import math
rx, ry, rz = (math.radians(float(v)) for v in (list(deg) + [0, 0, 0])[:3])
cx, sx, cy, sy, cz, sz = math.cos(rx), math.sin(rx), math.cos(ry), math.sin(ry), math.cos(rz), math.sin(rz)
X = [[1, 0, 0], [0, cx, -sx], [0, sx, cx]]
Y = [[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]]
Z = [[cz, -sz, 0], [sz, cz, 0], [0, 0, 1]]
mul = lambda a, b: [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
return mul(Z, mul(Y, X))
def model_description(model_dir):
"""(obj files, grip transform or None) from the model's JSON (components) or the folder's .obj files."""
name = os.path.basename(model_dir)
desc = {}
for cand in (os.path.join(model_dir, name + ".json"), *sorted(glob.glob(os.path.join(model_dir, "*.json")))):
try:
desc = json.load(open(cand))
if isinstance(desc, dict) and "components" in desc:
break
except (OSError, ValueError):
desc = {}
objs, grip = [], None
for cname, comp in (desc.get("components") or {}).items():
if not isinstance(comp, dict):
continue
if cname == "openxr_grip" and isinstance(comp.get("component_local"), dict):
local = comp["component_local"]
grip = (list(local.get("origin") or [0, 0, 0]), _rotation_xyz(local.get("rotate_xyz") or [0, 0, 0]))
fn = comp.get("filename")
hidden = (comp.get("visibility") or {}).get("default") is False # e.g. status LEDs
if fn and not hidden and not any(s in cname.lower() for s in SKIP_COMPONENTS):
p = os.path.join(model_dir, fn)
if os.path.exists(p):
objs.append(p)
if not objs:
whole = os.path.join(model_dir, name + ".obj")
objs = [whole] if os.path.exists(whole) else sorted(glob.glob(os.path.join(model_dir, "*.obj")))
return objs, grip
def parse_mtl(path):
"""material name -> texture file (map_Kd)."""
out, cur = {}, None
try:
for line in open(path, errors="replace"):
parts = line.strip().split(None, 1)
if not parts:
continue
if parts[0] == "newmtl" and len(parts) > 1:
cur = parts[1].strip()
elif parts[0] == "map_Kd" and cur and len(parts) > 1:
out[cur] = os.path.join(os.path.dirname(path), parts[1].strip().split()[-1])
except OSError:
pass
return out
def parse_obj(path):
"""-> list of (texture path or None, positions, normals, uvs, triangles as (v, vt, vn) index triples)."""
pos, nrm, uv = [], [], []
groups = {} # texture -> list of corner triples
mats, tex = {}, None
for line in open(path, errors="replace"):
parts = line.split()
if not parts:
continue
tag = parts[0]
if tag == "v":
pos.append(tuple(float(x) for x in parts[1:4]))
elif tag == "vn":
nrm.append(tuple(float(x) for x in parts[1:4]))
elif tag == "vt":
uv.append((float(parts[1]), float(parts[2]) if len(parts) > 2 else 0.0))
elif tag == "mtllib":
mats.update(parse_mtl(os.path.join(os.path.dirname(path), " ".join(parts[1:]))))
elif tag == "usemtl":
tex = mats.get(" ".join(parts[1:]))
elif tag == "f":
corners = []
for c in parts[1:]:
idx = (c.split("/") + ["", ""])[:3]
ref = []
for i, n in zip(idx, (len(pos), len(uv), len(nrm))):
ref.append(None if not i else (int(i) - 1 if int(i) > 0 else n + int(i)))
corners.append(tuple(ref))
for i in range(1, len(corners) - 1): # fan triangulation
groups.setdefault(tex, []).extend((corners[0], corners[i], corners[i + 1]))
return pos, nrm, uv, groups
def controller_glb(model_dir):
"""glTF binary (one mesh, one primitive per texture, PNG/JPEG textures, in the controller's OpenXR grip space
when the model defines openxr_grip) for a SteamVR render model folder."""
objs, grip = model_description(model_dir)
if not objs:
raise AgentError(f"no .obj files in {model_dir}")
# texture -> (positions, normals, uvs, indices), vertices de-duplicated per corner triple
prims = {}
for obj in objs:
pos, nrm, uv, groups = parse_obj(obj)
for tex, corners in groups.items():
if tex and os.path.splitext(tex)[1].lower() not in (".png", ".jpg", ".jpeg"):
tex = None
p = prims.setdefault(tex, {"pos": [], "nrm": [], "uv": [], "idx": [], "map": {}})
for c in corners:
key = (obj, c)
if key not in p["map"]:
p["map"][key] = len(p["pos"])
v = pos[c[0]]
n = nrm[c[2]] if c[2] is not None and c[2] < len(nrm) else (0.0, 0.0, 1.0)
t = uv[c[1]] if c[1] is not None and c[1] < len(uv) else (0.0, 0.0)
if grip: # raw device space -> grip space: R^T (v - origin)
o, r = grip
d = [v[k] - float(o[k]) for k in range(3)]
v = tuple(sum(r[k][j] * d[k] for k in range(3)) for j in range(3))
n = tuple(sum(r[k][j] * n[k] for k in range(3)) for j in range(3))
p["pos"].append(v)
p["nrm"].append(n)
p["uv"].append((t[0], 1.0 - t[1])) # OBJ origin bottom-left, glTF top-left
p["idx"].append(p["map"][key])
blob = bytearray()
views, accessors, images, textures, materials, primitives = [], [], [], [], [], []
def add_view(data, target=None):
while len(blob) % 4:
blob.append(0)
view = {"buffer": 0, "byteOffset": len(blob), "byteLength": len(data)}
if target:
view["target"] = target
blob.extend(data)
views.append(view)
return len(views) - 1
def add_accessor(values, width, ctype, kind, target, minmax=False):
fmt = {5126: "f", 5125: "I"}[ctype]
flat = [x for v in values for x in (v if width > 1 else (v,))]
acc = {"bufferView": add_view(struct.pack(f"<{len(flat)}{fmt}", *flat), target), "componentType": ctype,
"count": len(values), "type": kind}
if minmax:
acc["min"] = [min(v[k] for v in values) for k in range(width)]
acc["max"] = [max(v[k] for v in values) for k in range(width)]
accessors.append(acc)
return len(accessors) - 1
for tex, p in prims.items():
if not p["idx"]:
continue
attrs = {"POSITION": add_accessor(p["pos"], 3, 5126, "VEC3", 34962, minmax=True),
"NORMAL": add_accessor(p["nrm"], 3, 5126, "VEC3", 34962),
"TEXCOORD_0": add_accessor(p["uv"], 2, 5126, "VEC2", 34962)}
indices = add_accessor(p["idx"], 1, 5125, "SCALAR", 34963)
mat = {"pbrMetallicRoughness": {"metallicFactor": 0.0, "roughnessFactor": 0.8}}
if tex and os.path.exists(tex):
mime = "image/png" if tex.lower().endswith(".png") else "image/jpeg"
images.append({"bufferView": add_view(open(tex, "rb").read()), "mimeType": mime})
textures.append({"source": len(images) - 1})
mat["pbrMetallicRoughness"]["baseColorTexture"] = {"index": len(textures) - 1}
else:
mat["pbrMetallicRoughness"]["baseColorFactor"] = [0.1, 0.1, 0.1, 1.0]
materials.append(mat)
primitives.append({"attributes": attrs, "indices": indices, "material": len(materials) - 1})
if not primitives:
raise AgentError(f"no triangles in {model_dir}")
while len(blob) % 4:
blob.append(0)
gltf = {"asset": {"version": "2.0", "generator": "FramePort agent"}, "scene": 0, "scenes": [{"nodes": [0]}],
"nodes": [{"name": os.path.basename(model_dir), "mesh": 0}],
"meshes": [{"primitives": primitives}], "materials": materials, "accessors": accessors,
"bufferViews": views, "buffers": [{"byteLength": len(blob)}]}
if images:
gltf.update(images=images, textures=textures, samplers=[{}])
for t in textures:
t["sampler"] = 0
js = json.dumps(gltf, separators=(",", ":")).encode()
js += b" " * (-len(js) % 4)
total = 12 + 8 + len(js) + 8 + len(blob)
return (struct.pack("<III", 0x46546C67, 2, total) + struct.pack("<I4s", len(js), b"JSON") + js
+ struct.pack("<I4s", len(blob), b"BIN\x00") + bytes(blob))
def _tree_stamp(d):
h = hashlib.sha256()
for p in sorted(glob.glob(os.path.join(d, "*"))):
st = os.stat(p)
h.update(f"{os.path.basename(p)}:{st.st_size}:{int(st.st_mtime)}\n".encode())
return h.hexdigest()[:16]
def frame_controller_models():
"""Converted (cached) glb paths for the Frame controllers: {'left': path, 'right': path, 'sources': {...}}."""
picked = pick_controller_models(render_model_dirs())
if not picked:
raise AgentError("no Steam Frame controller render models found in SteamVR (looked in: "
+ ", ".join(steamvr_roots() or ["no SteamVR install"]) + ")")
os.makedirs(MODELS_CACHE, exist_ok=True)
out = {"sources": picked}
for side, d in picked.items():
cached = os.path.join(MODELS_CACHE, f"{os.path.basename(d)}-{_tree_stamp(d)}.glb")
if not os.path.exists(cached):
data = controller_glb(d)
with open(cached + ".tmp", "wb") as f:
f.write(data)
os.replace(cached + ".tmp", cached)
out[side] = cached
return out
def install_controller_models(files_dir, enabled):
"""Put (or remove) framebridge/controller_{left,right}.glb in a game's files dir. Never fails the install."""
target = os.path.join(files_dir, "framebridge")
if not enabled:
for side in ("left", "right"):
p = os.path.join(target, f"controller_{side}.glb")
if os.path.exists(p):
os.remove(p)
return None
try:
models = frame_controller_models()
except (AgentError, OSError, ValueError, IndexError) as e:
return {"ok": False, "error": str(e)}
os.makedirs(target, exist_ok=True)
for side in ("left", "right"):
shutil.copyfile(models[side], os.path.join(target, f"controller_{side}.glb"))
return {"ok": True, "sources": models["sources"]}
def cmd_controller_models(args):
"""Diagnostics: SteamVR roots, every render model found, and which ones are used as the Frame controllers."""
dirs = render_model_dirs()
result = {"roots": steamvr_roots(), "render_models": dirs, "picked": pick_controller_models(dirs)}
if args.get("convert"):
try:
result["converted"] = frame_controller_models()
except (AgentError, OSError, ValueError, IndexError) as e:
result["error"] = str(e)
return result
# ------------------------------------------------------------------------------------------ PC VR (Rift) under Proton
@@ -1371,7 +1672,8 @@ def cmd_set_settings(args):
os.makedirs(files_dir, exist_ok=True)
with open(os.path.join(files_dir, "framebridge.conf"), "w") as f:
f.write(text)
return {"settings": current}
models = install_controller_models(files_dir, current.get("controller_models", "0") not in ("0", "0.0"))
return {"settings": current, "controller_models": models}
def cmd_uninstall(args):
+3 -2
View File
@@ -1,8 +1,9 @@
8e00552d1ab90f82749db3caad4f773351fa8a28edcc5ea5cbd1b31c434ff32a ./arm64-v8a/libframe_xrshim.so
3dfcb0f2bec47857bcecc7261c58e076d872d4649718c40850f29e10c75477dd ./arm64-v8a/libglshim.so
ca27ffa2122def910697ddcaff64f25cfdbc63e2017c738155866ff2fcdb5e12 ./arm64-v8a/libopenxr_loader_generic.so
56faaebb41a7ac615bb146b60be5ece31af13d38395ade64fa2f6a80b65a8b77 ./arm64-v8a/libopenxr_loader_generic.so
1feaeafad467c4cafdf2b018a4d84b0bee200c3f711697b4ce97e66a3ba256ca ./arm64-v8a/libovrplatformcompat.so
32525cd0a9ee3d9993d8871cd6905bdcf22cde4f4ba28f5db6434ce7d6554758 ./arm64-v8a/libvrapi.so
f3aa9419155b8970617fc0d68527a73c39abc82303214c159194c439bb776fd6 ./armeabi-v7a/libopenxr_loader_generic.so
63010ca5d0fa243c111abccd6ceb02a55cfb69c6ec7374356d85c4f3e0a932c0 ./armeabi-v7a/libopenxr_loader_generic.so
1871eae093432d277da4bc751bf5f3269dfcf11f9168260b0c3caadb0dedb19d ./dex/oculusos-stubs.dex
1aa733117cf57ccff7cf23f0425dbfd73b0332a3ef1c905ceeca0ccd0449c4e0 ./linux-arm64/XR_APILAYER_FRAMEPORT_timefix.json
28c2430a02bbd8902c5bfb9562c6fd0e318654f9e05c095bfeb94b0450ab1b07 ./linux-arm64/libxr_frameport_timefix.so
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -64,6 +64,19 @@ signatures:
severity: error
diagnosis: The game needs Meta passthrough; the adapter emulates it.
suggest: [adapter.passthrough_emul, patch_force_passthrough]
- id: render-model-requested
pattern: 'dropped unsupported extension XR_FB_render_model'
severity: info
diagnosis: The game asks the headset for its controller models (XR_FB_render_model), which the Frame lacks, so it
shows its own (Quest) controllers or none. The adapter can serve the Steam Frame controllers instead.
suggest: [adapter.controller_models]
- id: controller-models-missing
pattern: 'FrameBridge: controller models not found'
severity: warning
diagnosis: Steam Frame controller models are on, but the converted models aren't in the game's files dir (the
agent found no Frame controller render models in SteamVR at install time; see the install log, or run the
agent's controller_models command).
suggest: []
- id: scene-missing
pattern: 'XR_FB_scene|XR_FB_spatial_entity.*(not supported|UNSUPPORTED)|xrQuerySpacesFB'
severity: warning
+2 -1
View File
@@ -22,7 +22,8 @@
FB_swapchain_update_state, FB_space_warp, EXT_frame_synthesis, … (full list in any launch.log, tag overportOXR).
- Missing (emulated by the adapter): XR_FB_passthrough, XR_FB_scene / spatial_entity(_query/_storage/_container) /
scene_capture, XR_FB_composition_layer_image_layout (flip), XR_KHR_convert_timespec_time (returns
FUNCTION_UNSUPPORTED). Missing (dropped): XR_KHR_composition_layer_equirect2, XR_KHR_composition_layer_cylinder
FUNCTION_UNSUPPORTED). XR_FB_render_model is presumably missing too (not checked on the device yet); the adapter
emulates it only with `controller_models=1` and uses a native one if the runtime ever has it. Missing (dropped): XR_KHR_composition_layer_equirect2, XR_KHR_composition_layer_cylinder
(the VrApi bridge converts cylinders to quads).
- Swapchain formats: GLES `GL_SRGB8_ALPHA8` (35907) / `GL_SRGB8` (35905) only, samples = 1. Vulkan: 43 (R8G8B8A8_SRGB)
and 50, not 37/44 (UNORM).
+2
View File
@@ -48,6 +48,8 @@ version is `catalog/triage.yaml` (used by `frameport test` / the Job screen); ke
| Upside-down image in a GL bridge game | GL images start at the bottom row | fixed in the bridge (swap angleUp/Down for GL chains) |
| UI panels upside down (AC Nexus) | XrCompositionLayerImageLayoutFB VERTICAL_FLIP unsupported | adapter `flip_emul=1` (default); rotating quads makes them vanish |
| Passthrough black (BAM) | XR_FB_passthrough missing | adapter `passthrough_emul=1` (default) + `patch_force_passthrough` for MR-only games |
| Quest Touch controllers (or none) shown in a game that uses Meta's runtime controller models | XR_FB_render_model missing on the Frame | adapter `controller_models=1`: the agent converts the Frame's SteamVR controller models to glb at install, the adapter serves them. Games with their own controller meshes aren't affected (none of the 34 catalog games use runtime models) |
| `overportOXR: Unknown proc addr: xr…` for a function the adapter emulates | overport's dispatcher only forwards functions in its own table | route the plugin's lookups through `native/xrshim` (done for XR_FB_render_model when `controller_models=1`) |
| MR game stuck waiting for room data (Demeter) | no Meta scene API | adapter `scene_emul=1` (+ `frame.meta_permissions`) |
| Hand-tracking game janky (Silhouette) | Frame synthesizes hands from controllers | `controller_fix=0` passes hands through; not really fixable |
| Eye distortion while moving (Arcsmith, Time Stall) | unknown (not eye swap, tracking, Valve layers, depth or pacing) | unresolved |
+2 -1
View File
@@ -7,12 +7,13 @@ embeds debug paths (`-g`) and the dex depends on the d8 version, so those differ
| Dir | Artifact | What / why |
|---|---|---|
| `adapter/` | `{arm64-v8a,armeabi-v7a}/libopenxr_loader_generic.so` | **FrameBridge**: replaces overport's generic loader (which is renamed `libopenxr_loader_original.so`) and fixes Steam Frame runtime gaps. Hooks xrCreateInstance, swapchains, xrEndFrame, spaces, xrPollEvent, … everything else is forwarded by `gen_forwarders.py`-generated tail calls. `scene_emu.c` = Meta scene/spatial-entity emulation; `flip_vk.c` = Vulkan blit for VERTICAL_FLIP quads. Settings: `libframe_settings.so` in the APK + `settings.conf`/`framebridge.conf` on the Frame. Hook technique from Quest2Frame's `frame_bridge.c` (GPL-3.0). |
| `adapter/` | `{arm64-v8a,armeabi-v7a}/libopenxr_loader_generic.so` | **FrameBridge**: replaces overport's generic loader (which is renamed `libopenxr_loader_original.so`) and fixes Steam Frame runtime gaps. Hooks xrCreateInstance, swapchains, xrEndFrame, spaces, xrPollEvent, … everything else is forwarded by `gen_forwarders.py`-generated tail calls. `scene_emu.c` = Meta scene/spatial-entity emulation; `flip_vk.c` = Vulkan blit for VERTICAL_FLIP quads. `render_model.c` = XR_FB_render_model emulation (setting `controller_models`, off by default): serves `files/framebridge/controller_{left,right}.glb`, which the agent converts on the Frame from SteamVR's own Frame controller render models (never shipped by FramePort); tested on the host by `tests/test_render_model.py`. Settings: `libframe_settings.so` in the APK + `settings.conf`/`framebridge.conf` on the Frame. Hook technique from Quest2Frame's `frame_bridge.c` (GPL-3.0). |
| `vrapi-bridge/` | `arm64-v8a/libvrapi.so` | VrApi → OpenXR bridge from [Android-XR-Bridge/OVRPort](https://github.com/Android-XR-Bridge/OVRPort) `native/vrapi` at 5e7df52 (GPL-3.0, `LICENSE.upstream`), with our changes in `upstream-patches/`: GLES sessions + GL texture swapchains, cylinder→quad layers, GL vertical flip, UNORM↔sRGB format twins, 30 s VR-mode deadline, VALID-only recenter, loading-icon layers skipped, diagnostics behind `-DOVP_GL_DIAG`. |
| `platformcompat/` | `arm64-v8a/libovrplatformcompat.so` | Real `ovrMessageType_ToString` (same fork, `native/platform`). |
| `glshim/` | `arm64-v8a/libglshim.so` | Mesa GLSL compatibility for GLAD engines (hooks eglGetProcAddress): comments out `#pragma` before `#extension`, enables `GL_EXT_shader_implicit_conversions`, hides GL_OVR_multiview (`gl_hide_multiview`, default 1) and logs failed shaders. `-DGLSHIM_TRACE` adds per-FBO draw/error tracing. |
| `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). |
| `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. |
| `java-stubs/` | `dex/oculusos-stubs.dex` | No-op `com.oculus.os.AnalyticsEvent` / `UnifiedTelemetryLogger` for native code that looks them up. |
The ovr_* stub library is *not* prebuilt: `src/frameport/analysis/stubgen.py` generates it per game from the exact
+33 -6
View File
@@ -23,7 +23,9 @@
// (passthrough_emul);
// * optionally tells the app its reference spaces changed once the session is focused, so apps that
// created them before tracking started recreate them (respace_kick);
// * optionally flips quad layers vertically for apps whose UI panels show upside down (flip_quads).
// * optionally flips quad layers vertically for apps whose UI panels show upside down (flip_quads);
// * optionally serves Steam Frame controller models through XR_FB_render_model (controller_models,
// render_model.c).
//
// Settings (key=value lines), later sources override earlier ones:
// <libdir>/libframe_settings.so
@@ -68,6 +70,8 @@ static int runtime_has_image_layout; // runtime supports XR_FB_composition_laye
// Layer types whose extension the runtime lacks (dropped at xrCreateInstance) are removed from frames.
static int no_equirect, no_equirect2, no_cylinder, no_cube;
static int runtime_has_fb_passthrough, emulate_passthrough, alpha_blend_failed;
static int controller_models; // serve Frame controller models via XR_FB_render_model (render_model.c)
static void render_model_init(const char *package);
static void read_settings(const char *path) {
FILE *f = fopen(path, "r");
@@ -91,6 +95,7 @@ static void read_settings(const char *path) {
if (sscanf(line, "scene_height=%f", &value) == 1 && value > 1.5f && value < 5.0f) scene_height = value;
if (sscanf(line, "scene_width=%f", &value) == 1 && value > 0.5f && value < 20.0f) scene_width = value;
if (sscanf(line, "scene_depth=%f", &value) == 1 && value > 0.5f && value < 20.0f) scene_depth = value;
if (sscanf(line, "controller_models=%f", &value) == 1) controller_models = value != 0;
}
fclose(f);
LOG("settings read from %s", path);
@@ -127,10 +132,11 @@ static void initialize(void) {
}
const char *env = getenv("FRAMEBRIDGE_CONFIG");
if (env && *env) read_settings(env);
if (controller_models && *process && !strchr(process, '/')) render_model_init(process);
LOG("scale=%.2f foveation_fix=%d controller_fix=%d swapchain_fix=%d layer_fix=%d mutable_fix=%d swap_eyes=%d passthrough_emul=%d respace_kick=%d flip_quads=%d loader=%s",
LOG("scale=%.2f foveation_fix=%d controller_fix=%d swapchain_fix=%d layer_fix=%d mutable_fix=%d swap_eyes=%d passthrough_emul=%d respace_kick=%d flip_quads=%d controller_models=%d loader=%s",
scale, foveation_fix, controller_fix, swapchain_fix, layer_fix, mutable_fix, swap_eyes, passthrough_emul,
respace_kick, flip_quads, next_gipa ? "OK" : (dlerror() ?: "FAILED"));
respace_kick, flip_quads, controller_models, next_gipa ? "OK" : (dlerror() ?: "FAILED"));
}
static PFN_xrVoidFunction lookup(XrInstance instance, const char *name) {
@@ -142,6 +148,7 @@ static PFN_xrVoidFunction lookup(XrInstance instance, const char *name) {
}
#include "scene_emu.c"
#include "render_model.c"
XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info, XrInstance *instance) {
PFN_xrCreateInstance fn = (PFN_xrCreateInstance)lookup(XR_NULL_HANDLE, "xrCreateInstance");
@@ -184,6 +191,9 @@ XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(const XrInstanceCreateInfo *info
else if (scene_emul && is_scene_extension(ext)) {
if (!emulate_scene) LOG("emulating Meta scene/spatial-entity extensions from the guardian bounds");
emulate_scene = 1;
} else if (controller_models && render_models_available && !strcmp(ext, XR_FB_RENDER_MODEL_EXTENSION_NAME)) {
emulate_render_model = 1;
LOG("emulating XR_FB_render_model with Steam Frame controller models");
} else if (passthrough_emul && !strcmp(ext, "XR_FB_passthrough")) {
emulate_passthrough = 1;
LOG("emulating XR_FB_passthrough with alpha-blended environment");
@@ -450,7 +460,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetSystemProperties(XrInstance instance, XrSyst
PFN_xrGetSystemProperties fn = (PFN_xrGetSystemProperties)lookup(instance, "xrGetSystemProperties");
if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
XrResult result = fn(instance, system, properties);
if (XR_SUCCEEDED(result) && (emulate_passthrough || emulate_scene) && properties)
if (XR_SUCCEEDED(result) && (emulate_passthrough || emulate_scene || emulate_render_model) && properties)
for (XrBaseOutStructure *p = (XrBaseOutStructure *)properties->next; p; p = p->next) {
if (p->type == XR_TYPE_SYSTEM_PASSTHROUGH_PROPERTIES_FB)
((XrSystemPassthroughPropertiesFB *)p)->supportsPassthrough = XR_TRUE;
@@ -458,6 +468,8 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetSystemProperties(XrInstance instance, XrSyst
((XrSystemSpatialEntityPropertiesFB *)p)->supportsSpatialEntity = XR_TRUE;
if (p->type == XR_TYPE_SYSTEM_PASSTHROUGH_PROPERTIES2_FB)
((XrSystemPassthroughProperties2FB *)p)->capabilities = XR_PASSTHROUGH_CAPABILITY_BIT_FB;
if (p->type == XR_TYPE_SYSTEM_RENDER_MODEL_PROPERTIES_FB && emulate_render_model)
((XrSystemRenderModelPropertiesFB *)p)->supportsRenderModelLoading = XR_TRUE;
}
return result;
}
@@ -882,12 +894,13 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(const char
XR_NULL_HANDLE, "xrEnumerateInstanceExtensionProperties");
if (!fn) return XR_ERROR_INITIALIZATION_FAILED;
if (!count) return XR_ERROR_VALIDATION_FAILURE;
if ((!foveation_fix && !passthrough_emul && !scene_emul) || layer) return fn(layer, capacity, count, properties);
if ((!foveation_fix && !passthrough_emul && !scene_emul && !controller_models) || layer)
return fn(layer, capacity, count, properties);
uint32_t total = 0;
XrResult result = fn(layer, 0, &total, NULL);
if (XR_FAILED(result)) return result;
XrExtensionProperties *all = calloc(total + 1 + SCENE_EXTENSION_COUNT, sizeof(*all));
XrExtensionProperties *all = calloc(total + 2 + SCENE_EXTENSION_COUNT, sizeof(*all));
if (!all) return XR_ERROR_OUT_OF_MEMORY;
for (uint32_t i = 0; i < total; ++i) all[i].type = XR_TYPE_EXTENSION_PROPERTIES;
result = fn(layer, total, &total, all);
@@ -896,6 +909,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(const char
uint32_t kept = 0;
for (uint32_t i = 0; i < total; ++i) {
if (!strcmp(all[i].extensionName, "XR_FB_passthrough")) runtime_has_fb_passthrough = 1;
if (!strcmp(all[i].extensionName, XR_FB_RENDER_MODEL_EXTENSION_NAME)) runtime_has_render_model = 1;
if (foveation_fix && strstr(all[i].extensionName, "foveation")) continue;
if (properties && kept < capacity) properties[kept] = all[i];
++kept;
@@ -916,6 +930,12 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(const char
if (properties && kept < capacity) properties[kept] = fake;
++kept;
}
if (controller_models && render_models_available && !runtime_has_render_model) {
XrExtensionProperties fake = {XR_TYPE_EXTENSION_PROPERTIES, NULL, XR_FB_RENDER_MODEL_EXTENSION_NAME,
XR_FB_render_model_SPEC_VERSION};
if (properties && kept < capacity) properties[kept] = fake;
++kept;
}
free(all);
*count = kept;
return (capacity && capacity < kept) ? XR_ERROR_SIZE_INSUFFICIENT : XR_SUCCESS;
@@ -928,6 +948,12 @@ XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateApiLayerProperties(uint32_t capacity,
return fn ? fn(capacity, count, properties) : XR_ERROR_INITIALIZATION_FAILED;
}
// For native/xrshim: emulated functions that overport's dispatcher doesn't know (it never asks us for them).
__attribute__((visibility("default"))) PFN_xrVoidFunction framebridge_extension_proc(const char *name) {
pthread_once(&init_once, initialize);
return name ? render_model_emulation(name) : NULL;
}
XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const char *name,
PFN_xrVoidFunction *function) {
pthread_once(&init_once, initialize);
@@ -948,6 +974,7 @@ XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const
}
PFN_xrVoidFunction emulated = passthrough_emulation(name);
if (!emulated) emulated = scene_emulation(name);
if (!emulated) emulated = render_model_emulation(name);
if (!emulated && emulate_scene && !strcmp(name, "xrLocateSpacesKHR")) emulated = (PFN_xrVoidFunction)emu_locate_spaces_khr;
if (emulated) { *function = emulated; return XR_SUCCESS; }
HOOK(xrGetSystemProperties)
+122
View File
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: GPL-3.0-only
// XR_FB_render_model emulation (controller_models): serves Steam Frame controller models to games that ask the
// runtime for controller models (Meta XR SDK "runtime controller" / OVRRuntimeController, Unreal OculusXR), so they
// show the controllers in your hands instead of Quest Touch controllers. Games that ship their own controller meshes
// don't use this extension and are not affected.
//
// The models are glTF binaries converted on the Frame from its own SteamVR render models by the FramePort agent:
// /sdcard/Android/data/<package>/files/framebridge/controller_{left,right}.glb
// Only active when both files exist and the runtime lacks XR_FB_render_model (a native one is used as is).
// Included by frame_adapter.c.
static int render_models_available; // both .glb files present
static int runtime_has_render_model;
static int emulate_render_model;
static char render_model_dir[512];
static const char *const render_model_paths[2] = {"/model_fb/controller/left", "/model_fb/controller/right"};
static const char *const render_model_files[2] = {"controller_left.glb", "controller_right.glb"};
static const char *const render_model_names[2] = {"Steam Frame Controller Left", "Steam Frame Controller Right"};
#define RENDER_MODEL_VENDOR_VALVE 0x28DE
#define RENDER_MODEL_KEY_BASE 0x46500000u // keys: base + 1 (left), base + 2 (right)
static void render_model_scan(const char *dir) {
snprintf(render_model_dir, sizeof(render_model_dir), "%s", dir);
char path[640];
int found = 0;
for (int i = 0; i < 2; ++i) {
snprintf(path, sizeof(path), "%s/%s", render_model_dir, render_model_files[i]);
FILE *f = fopen(path, "rb");
if (f) { ++found; fclose(f); }
}
render_models_available = found == 2;
LOG("controller models %s in %s", render_models_available ? "found" : "not found", render_model_dir);
}
static void render_model_init(const char *package) {
if (!package || !*package) return;
char dir[512];
snprintf(dir, sizeof(dir), "/sdcard/Android/data/%s/files/framebridge", package);
render_model_scan(dir);
}
static int render_model_index(XrPath path) {
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(active_instance, "xrStringToPath");
if (!to_path || !active_instance) return -1;
for (int i = 0; i < 2; ++i) {
XrPath p = XR_NULL_PATH;
if (XR_SUCCEEDED(to_path(active_instance, render_model_paths[i], &p)) && p == path) return i;
}
return -1;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_enumerate_render_model_paths(XrSession session, uint32_t capacity,
uint32_t *count, XrRenderModelPathInfoFB *paths) {
(void)session;
if (!count) return XR_ERROR_VALIDATION_FAILURE;
*count = 2;
if (!capacity) return XR_SUCCESS;
if (capacity < 2 || !paths) return XR_ERROR_SIZE_INSUFFICIENT;
PFN_xrStringToPath to_path = (PFN_xrStringToPath)lookup(active_instance, "xrStringToPath");
if (!to_path) return XR_ERROR_RUNTIME_FAILURE;
for (int i = 0; i < 2; ++i) {
XrResult r = to_path(active_instance, render_model_paths[i], &paths[i].path);
if (XR_FAILED(r)) return r;
}
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_get_render_model_properties(XrSession session, XrPath path,
XrRenderModelPropertiesFB *properties) {
(void)session;
if (!properties) return XR_ERROR_VALIDATION_FAILURE;
XrRenderModelFlagsFB requested = XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_1_BIT_FB;
for (const XrBaseInStructure *p = (const XrBaseInStructure *)properties->next; p; p = p->next)
if (p->type == XR_TYPE_RENDER_MODEL_CAPABILITIES_REQUEST_FB)
requested = ((const XrRenderModelCapabilitiesRequestFB *)p)->flags;
int i = render_model_index(path);
if (i < 0) {
properties->modelKey = XR_NULL_RENDER_MODEL_KEY_FB;
return XR_ERROR_PATH_UNSUPPORTED;
}
properties->vendorId = RENDER_MODEL_VENDOR_VALVE;
snprintf(properties->modelName, sizeof(properties->modelName), "%s", render_model_names[i]);
properties->modelKey = (XrRenderModelKeyFB)(RENDER_MODEL_KEY_BASE + 1 + i);
properties->modelVersion = 1;
// One mesh, one PNG texture, no animation: fits subset 1 and therefore every subset an app can ask for.
properties->flags = requested & XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB
? XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB : XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_1_BIT_FB;
static int logged[2];
if (!logged[i]++) LOG("render model %s -> %s/%s", render_model_paths[i], render_model_dir, render_model_files[i]);
return XR_SUCCESS;
}
static XRAPI_ATTR XrResult XRAPI_CALL emu_load_render_model(XrSession session, const XrRenderModelLoadInfoFB *info,
XrRenderModelBufferFB *buffer) {
(void)session;
if (!info || !buffer) return XR_ERROR_VALIDATION_FAILURE;
uint64_t key = (uint64_t)info->modelKey;
if (key != RENDER_MODEL_KEY_BASE + 1 && key != RENDER_MODEL_KEY_BASE + 2) return XR_ERROR_RENDER_MODEL_KEY_INVALID_FB;
int i = (int)(key - RENDER_MODEL_KEY_BASE - 1);
char path[640];
snprintf(path, sizeof(path), "%s/%s", render_model_dir, render_model_files[i]);
FILE *f = fopen(path, "rb");
if (!f) { LOG("render model %s missing", path); return XR_ERROR_RENDER_MODEL_KEY_INVALID_FB; }
long size = -1;
if (!fseek(f, 0, SEEK_END)) size = ftell(f);
if (size <= 0 || size > 0x7fffffffL || fseek(f, 0, SEEK_SET)) { fclose(f); return XR_ERROR_RUNTIME_FAILURE; }
buffer->bufferCountOutput = (uint32_t)size;
if (!buffer->bufferCapacityInput) { fclose(f); return XR_SUCCESS; }
if (buffer->bufferCapacityInput < (uint32_t)size || !buffer->buffer) { fclose(f); return XR_ERROR_SIZE_INSUFFICIENT; }
size_t got = fread(buffer->buffer, 1, (size_t)size, f);
fclose(f);
return got == (size_t)size ? XR_SUCCESS : XR_ERROR_RUNTIME_FAILURE;
}
static PFN_xrVoidFunction render_model_emulation(const char *name) {
if (!emulate_render_model) return NULL;
if (!strcmp(name, "xrEnumerateRenderModelPathsFB")) return (PFN_xrVoidFunction)emu_enumerate_render_model_paths;
if (!strcmp(name, "xrGetRenderModelPropertiesFB")) return (PFN_xrVoidFunction)emu_get_render_model_properties;
if (!strcmp(name, "xrLoadRenderModelFB")) return (PFN_xrVoidFunction)emu_load_render_model;
return NULL;
}
+12 -4
View File
@@ -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), and
rewrites artifacts/SHA256SUMS. Run `frameport parity` afterwards to see which games change.
python native/build.py [--only adapter,bridge,compat,glshim,dex,xrlayer,oculushmd] [--ndk PATH]
python native/build.py [--only adapter,bridge,compat,glshim,dex,xrlayer,oculushmd,xrshim] [--ndk PATH]
"""
from __future__ import annotations
@@ -210,6 +210,14 @@ def build_glshim(tc: Path):
"-Wl,-z,max-page-size=16384", "glshim.c", "-ldl", "-llog", "-o", ART / "arm64-v8a/libglshim.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"
run([exe(tc, "aarch64-linux-android29-clang"), "-shared", "-fPIC", "-O2", "-Wall", "-Wextra", "-Werror",
"-fvisibility=hidden", "-I", openxr_include("adapter"), "-Wl,-soname,libframe_xrshim.so",
"-Wl,-z,max-page-size=16384", "xrshim.c", "-ldl", "-llog", "-o", ART / "arm64-v8a/libframe_xrshim.so"], cwd=src)
def build_dex():
javac = shutil.which("javac")
if not javac:
@@ -254,7 +262,7 @@ def write_sums():
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--only", default="adapter,bridge,compat,glshim,dex,xrlayer,oculushmd")
ap.add_argument("--only", default="adapter,bridge,compat,glshim,dex,xrlayer,oculushmd,xrshim")
ap.add_argument("--ndk")
args = ap.parse_args()
parts = set(args.only.split(","))
@@ -262,9 +270,9 @@ 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),
"glshim": lambda: build_glshim(tc), "dex": build_dex, "xrlayer": lambda: build_xrlayer(tc),
"glshim": lambda: build_glshim(tc), "xrshim": lambda: build_xrshim(tc), "dex": build_dex, "xrlayer": lambda: build_xrlayer(tc),
"oculushmd": lambda: build_oculushmd(tc)}
for name in ("adapter", "bridge", "compat", "glshim", "dex", "xrlayer", "oculushmd"):
for name in ("adapter", "bridge", "compat", "glshim", "xrshim", "dex", "xrlayer", "oculushmd"):
if name in parts:
log(f"build {name}")
steps[name]()
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-only
// FrameBridge extension shim: lets engine plugins reach OpenXR functions that FrameBridge emulates but overport's
// dispatcher (libopenxr_loader.so) doesn't know. overport answers xrGetInstanceProcAddr from a fixed table and returns
// "Unknown proc addr" for anything else (e.g. the XR_FB_render_model functions), so the adapter underneath never sees
// the request.
//
// Meta's OVRPlugin finds xrGetInstanceProcAddr with dlopen("libopenxr_loader.so") + dlsym, so FramePort points that
// string in libOVRPlugin.so at this library instead (patches/frame/adapter.py). Names the adapter emulates are served
// by the adapter (framebridge_extension_proc); everything else goes to overport's xrGetInstanceProcAddr unchanged.
#include <openxr/openxr.h>
#include <android/log.h>
#include <dlfcn.h>
#include <pthread.h>
#include <stddef.h>
#define TAG "FrameBridge"
#define EXPORT __attribute__((visibility("default")))
typedef PFN_xrVoidFunction (*PFN_extension_proc)(const char *name);
static PFN_xrGetInstanceProcAddr overport_gipa;
static pthread_once_t once = PTHREAD_ONCE_INIT;
static void resolve_overport(void) {
void *h = dlopen("libopenxr_loader.so", RTLD_NOW | RTLD_NOLOAD);
if (!h) h = dlopen("libopenxr_loader.so", RTLD_NOW);
if (h) overport_gipa = (PFN_xrGetInstanceProcAddr)dlsym(h, "xrGetInstanceProcAddr");
__android_log_print(ANDROID_LOG_INFO, TAG, "extension shim: overport xrGetInstanceProcAddr %s",
overport_gipa ? "OK" : "MISSING");
}
static PFN_xrVoidFunction adapter_proc(const char *name) {
static PFN_extension_proc fn;
if (!fn) {
// Loaded by overport's dispatcher once an instance exists; NOLOAD so the shim never loads it by itself.
void *h = dlopen("libopenxr_loader_generic.so", RTLD_NOW | RTLD_NOLOAD);
if (h) fn = (PFN_extension_proc)dlsym(h, "framebridge_extension_proc");
if (!fn) return NULL;
}
return fn(name);
}
EXPORT XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(XrInstance instance, const char *name,
PFN_xrVoidFunction *function) {
pthread_once(&once, resolve_overport);
if (name && function && instance != XR_NULL_HANDLE) {
PFN_xrVoidFunction emulated = adapter_proc(name);
if (emulated) {
__android_log_print(ANDROID_LOG_INFO, TAG, "extension shim: %s -> FrameBridge", name);
*function = emulated;
return XR_SUCCESS;
}
}
if (!overport_gipa) return XR_ERROR_INITIALIZATION_FAILED;
return overport_gipa(instance, name, function);
}
+19
View File
@@ -87,6 +87,25 @@ def text_instructions(data: bytes):
yield ins[0], ins[2], ins[3]
def replace_rodata_string(data: bytes, old: str, new: str) -> tuple[bytes, int]:
"""Replace NUL-terminated `old` with `new` (not longer; NUL-padded) inside .rodata only, in place (nothing moves;
.dynstr, e.g. DT_NEEDED names, is untouched). Returns (data, number of replacements)."""
if len(new) > len(old):
raise ValueError("replacement must not be longer")
sec = _elf(data).get_section_by_name(".rodata")
if sec is None:
return data, 0
start, end = sec["sh_offset"], sec["sh_offset"] + sec["sh_size"]
needle, repl = old.encode() + b"\0", new.encode().ljust(len(old) + 1, b"\0")
buf, count, i = bytearray(data), 0, start
while (i := buf.find(needle, i, end)) != -1:
if i == start or buf[i - 1] == 0: # a whole string, not the tail of a longer one
buf[i:i + len(needle)] = repl
count += 1
i += len(needle)
return bytes(buf), count
def add_needed(data: bytes, library: str) -> bytes:
"""Add a DT_NEEDED entry in front of the existing ones (same effect as `patchelf --add-needed`; being first matters
for symbol interposition, e.g. the GL shim must precede libEGL).
+7
View File
@@ -315,6 +315,13 @@ def frame_info(frame: Optional[str] = None):
typer.echo(json.dumps(_target(frame).describe(), indent=1, default=str))
@frame_app.command("controller-models")
def frame_controller_models(frame: Optional[str] = None,
convert: bool = typer.Option(False, help="also convert them (cached on the Frame)")):
"""SteamVR render models on the Frame and which ones serve as Steam Frame controller models (controller_models)."""
typer.echo(json.dumps(_target(frame).frame.agent("controller_models", convert=convert), indent=1))
@frame_app.command("proton")
def frame_proton(frame: Optional[str] = None,
install_: bool = typer.Option(False, "--install", help="install it (Steam on the Frame restarts once "
+10
View File
@@ -91,9 +91,19 @@ def install(frame: Frame, plan: InstallPlan, reporter: Reporter) -> dict:
recipe={"patches": sorted(plan.recipe.patches), "source": plan.recipe.source, "alt": plan.recipe.use_alt},
)
reporter.log(f"installed at {result['base']} (Steam shortcut id {result['appid']})")
log_controller_models(result.get("controller_models"), reporter)
return result
def log_controller_models(models: dict | None, reporter: Reporter) -> None:
"""Outcome of the agent's Steam Frame controller model conversion (adapter setting controller_models)."""
if not models:
return
names = ", ".join(posixpath.basename(v) for v in (models.get("sources") or {}).values())
reporter.check("Steam Frame controller models", bool(models.get("ok")),
f"from SteamVR: {names}" if models.get("ok") else str(models.get("error")))
@dataclass
class PcvrPlan:
package: str # "rift.<slug>"
+11
View File
@@ -50,3 +50,14 @@ def unreal_version(a: Analysis) -> tuple[int, int] | None:
return int(major), int(minor)
except (AttributeError, ValueError):
return None
def uses_render_models(a: Analysis) -> bool:
"""The game declares Meta's runtime controller models (XR_FB_render_model): permission or feature RENDER_MODEL."""
perms = a.extra.get("meta_permissions_used") or a.meta_permissions
return any(p.endswith("RENDER_MODEL") for p in perms) or "com.oculus.feature.RENDER_MODEL" in (a.extra.get("features") or {})
def may_use_render_models(a: Analysis) -> bool:
"""Could ask for runtime controller models at all: declares them, or ships Meta's OVRPlugin (Unity/Unreal)."""
return uses_render_models(a) or "libOVRPlugin.so" in a.libs
+36 -1
View File
@@ -1,12 +1,16 @@
"""FrameBridge: wraps overport's generic OpenXR loader to paper over Steam Frame runtime gaps."""
from __future__ import annotations
from ...analysis import elf
from ..base import ApkContext, Patch, Suggestion, register
from . import artifact
GENERIC = "libopenxr_loader_generic.so"
ORIGINAL = "libopenxr_loader_original.so"
SETTINGS = "libframe_settings.so"
XRSHIM = "libframe_xrshim.so"
OVERPORT_LOADER = "libopenxr_loader.so"
OVRPLUGIN = "libOVRPlugin.so"
def settings_text(recipe_patches: dict) -> bytes:
@@ -22,7 +26,8 @@ class FrameBridgeAdapter(Patch):
"Wraps overport's generic OpenXR loader (renamed libopenxr_loader_original.so). Fixes the Frame runtime's gaps: "
"retries rejected GLES swapchain formats/MSAA (Frame takes sRGB only), drops unsupported instance extensions "
"and layers, emulates XR_FB_passthrough (ALPHA_BLEND), Meta scene/spatial entities (guardian-sized room), "
"XR_KHR_convert_timespec_time, and vertically flipped quad layers; maps Frame controllers to Touch. "
"XR_KHR_convert_timespec_time, and vertically flipped quad layers; maps Frame controllers to Touch; optionally "
"serves Steam Frame controller models (XR_FB_render_model). "
"Settings are in the Adapter settings group."
)
category = "frame"
@@ -49,6 +54,8 @@ class FrameBridgeAdapter(Patch):
if not ws.has(ws.lib(SETTINGS)) or ws.read_lib(SETTINGS) != settings:
ws.put(ws.lib(SETTINGS), settings)
changed = True
if needs_xrshim(ctx.recipe_patches) and ws.abi == "arm64-v8a":
changed |= add_xrshim(ctx)
return changed
def validate(self, ctx: ApkContext):
@@ -57,4 +64,32 @@ class FrameBridgeAdapter(Patch):
return [("FrameBridge adapter present", ok, "adapter, original loader and settings library")]
def needs_xrshim(recipe_patches: dict) -> bool:
"""Adapter features whose functions overport's dispatcher doesn't forward (it only knows a fixed table)."""
from ..settings import adapter_settings
return bool(adapter_settings(recipe_patches).get("controller_models"))
def add_xrshim(ctx: ApkContext) -> bool:
"""Route Meta OVRPlugin's OpenXR lookups through native/xrshim so XR_FB_render_model reaches the adapter.
OVRPlugin gets xrGetInstanceProcAddr with dlopen("libopenxr_loader.so") + dlsym (checked in Toy Master's
OVRPlugin), so the loader name string it dlopens is pointed at the shim instead (same length, in place); the shim
forwards every other lookup to overport's dispatcher."""
ws = ctx.ws
shim = artifact(ws.abi, XRSHIM)
changed = False
if not ws.has(ws.lib(XRSHIM)) or ws.read_lib(XRSHIM) != shim:
ws.put(ws.lib(XRSHIM), shim)
changed = True
if ws.has(ws.lib(OVRPLUGIN)):
data, count = elf.replace_rodata_string(ws.read_lib(OVRPLUGIN), OVERPORT_LOADER, XRSHIM)
if count:
ws.put(ws.lib(OVRPLUGIN), data)
ctx.notes.append(f"{OVRPLUGIN} looks up OpenXR functions through {XRSHIM}")
changed = True
return changed
register(FrameBridgeAdapter)
+10 -1
View File
@@ -28,6 +28,11 @@ SETTINGS = [
("scene_emul", "int", 0, "Emulate Meta scene (room)",
"Fake XR_FB_scene/spatial entities: a guardian-sized room with floor, ceiling and four walls, for mixed-reality "
"games that build their level from the room (e.g. Demeter)."),
("controller_models", "int", 0, "Steam Frame controller models",
"Games that ask the headset for its controller models (Meta's runtime controller models, XR_FB_render_model) get "
"the Steam Frame controllers instead of Quest Touch controllers. The models come from the Frame's own SteamVR and "
"are converted on the Frame at install time. Games that ship their own controller meshes aren't affected. Turning "
"it on needs a rebuild (it adds a small library in front of overport's loader)."),
("scene_height", "float", 2.5, "Emulated room height (m)", "Ceiling height for scene_emul."),
("scene_width", "float", 0.0, "Emulated room width (m)", "Override the guardian width (0 = use guardian, min 1.5 m)."),
("scene_depth", "float", 0.0, "Emulated room depth (m)", "Override the guardian depth (0 = use guardian, min 1.5 m)."),
@@ -56,11 +61,14 @@ class AdapterSetting(Patch):
self.default = default
def detect(self, a):
from .applicability import needs_scene
from .applicability import needs_scene, uses_render_models
if self.key == "controller_fix" and a.extra.get("hand_tracking_only"):
return Suggestion(True, "Hand tracking is required by the game: pass hands through instead of reporting "
"Touch controllers (e.g. Silhouette).", {"value": 0})
if self.key == "controller_models" and uses_render_models(a):
return Suggestion(True, "The game asks the headset for its controller models: show Steam Frame controllers "
"instead of Quest Touch controllers.", {"value": 1})
if self.key == "scene_emul" and needs_scene(a):
return Suggestion(True, "Mixed-reality game that builds its level from the room model: emulate a "
"guardian-sized room (e.g. Demeter).", {"value": 1})
@@ -80,6 +88,7 @@ class AdapterSetting(Patch):
"mutable_fix": ap.is_vulkan,
"swapchain_fix": ap.is_gles,
"gl_hide_multiview": lambda a: a.direct_vrapi and ap.is_gles(a),
"controller_models": ap.may_use_render_models,
}
rule = rules.get(self.key)
return bool(rule(a)) if rule else True
Binary file not shown.
+87
View File
@@ -422,3 +422,90 @@ def test_libovr_redirect_symlink(monkeypatch, tmp_path):
real.write_bytes(b"REAL")
a.set_libovr_redirect(str(base), exe_rel, enabled=True)
assert not real.is_symlink() and real.read_bytes() == b"REAL"
PNG_1PX = bytes.fromhex("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63f8"
"cfc0f01f0005000201a5d6f1c80000000049454e44ae426082")
def _fake_render_model(root, name, grip_origin=(0.0, 0.0, 0.0), grip_rot=(0, 0, 0)):
d = root / "resources/rendermodels" / name
d.mkdir(parents=True)
(d / "diffuse.png").write_bytes(PNG_1PX)
(d / "body.mtl").write_text("newmtl skin\nmap_Kd diffuse.png\n")
# a quad (fan-triangulated) with v/vt/vn corners, plus a negative-index face
(d / "body.obj").write_text("mtllib body.mtl\nusemtl skin\n"
"v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 1 1\nvt 0 1\nvn 0 0 1\n"
"f 1/1/1 2/2/1 3/3/1 4/4/1\nf -4/-4/-1 -3/-3/-1 -1/-1/-1\n")
(d / "status.obj").write_text("v 5 5 5\nv 6 5 5\nv 5 6 5\nf 1 2 3\n")
(d / f"{name}.json").write_text(json.dumps({"components": {
"body": {"filename": "body.obj"}, "status": {"filename": "status.obj"},
"openxr_grip": {"component_local": {"origin": list(grip_origin), "rotate_xyz": list(grip_rot)}}}}))
return d
def _read_glb(data):
import struct as st
magic, version, total = st.unpack_from("<III", data)
assert magic == 0x46546C67 and version == 2 and total == len(data)
jlen, jtype = st.unpack_from("<I4s", data, 12)
assert jtype == b"JSON" and jlen % 4 == 0
gltf = json.loads(data[20:20 + jlen])
blen, btype = st.unpack_from("<I4s", data, 20 + jlen)
assert btype == b"BIN\x00" and blen == gltf["buffers"][0]["byteLength"]
return gltf, data[28 + jlen:28 + jlen + blen]
def test_controller_models_pick_and_convert(monkeypatch, tmp_path):
import struct as st
a = load_agent(monkeypatch, tmp_path)
root = tmp_path / "steamvr"
_fake_render_model(root, "valve_frame_controller_left", grip_origin=(1.0, 0.0, 0.0))
_fake_render_model(root, "valve_frame_controller_right", grip_rot=(0, 0, 90))
_fake_render_model(root, "vr_controller_vive_1_5")
dirs = a.render_model_dirs([str(root)])
assert set(dirs) == {"valve_frame_controller_left", "valve_frame_controller_right", "vr_controller_vive_1_5"}
picked = a.pick_controller_models(dirs)
assert picked == {"left": dirs["valve_frame_controller_left"], "right": dirs["valve_frame_controller_right"]}
assert a.pick_controller_models({"frame_left": "/x"}) == {} # needs both sides
assert a.model_side("controller_r") == "right" and a.model_side("hmd") is None
gltf, blob = _read_glb(a.controller_glb(picked["left"]))
prim = gltf["meshes"][0]["primitives"]
assert len(prim) == 1 # status component skipped, one texture
acc = gltf["accessors"]
pos = acc[prim[0]["attributes"]["POSITION"]]
assert pos["count"] == 4 and acc[prim[0]["indices"]]["count"] == 9 # 4 unique corners, 3 triangles
# grip origin (1, 0, 0) -> vertices shifted by -1 on x
assert pos["min"] == [-1.0, 0.0, 0.0] and pos["max"] == [0.0, 1.0, 0.0]
assert gltf["images"][0]["mimeType"] == "image/png"
view = gltf["bufferViews"][gltf["images"][0]["bufferView"]]
assert blob[view["byteOffset"]:view["byteOffset"] + view["byteLength"]] == PNG_1PX
uv_view = gltf["bufferViews"][acc[prim[0]["attributes"]["TEXCOORD_0"]]["bufferView"]]
uvs = st.unpack_from("<8f", blob, uv_view["byteOffset"])
assert uvs[:2] == (0.0, 1.0) # OBJ (0, 0) -> glTF (0, 1)
# 90° about z: raw +x becomes grip -y (R^T applied)
gltf, blob = _read_glb(a.controller_glb(picked["right"]))
pos = gltf["accessors"][gltf["meshes"][0]["primitives"][0]["attributes"]["POSITION"]]
x = st.unpack_from("<6f", blob, gltf["bufferViews"][pos["bufferView"]]["byteOffset"])
assert abs(x[3]) < 1e-6 and abs(x[4] + 1.0) < 1e-6 # vertex (1, 0, 0)
def test_controller_models_install(monkeypatch, tmp_path):
a = load_agent(monkeypatch, tmp_path)
root = tmp_path / "steamvr"
_fake_render_model(root, "frame_controller_left")
_fake_render_model(root, "frame_controller_right")
monkeypatch.setattr(a, "steamvr_roots", lambda: [str(root)])
files = tmp_path / "files"
result = a.install_controller_models(str(files), True)
assert result["ok"] and set(result["sources"]) == {"left", "right"}
left = files / "framebridge/controller_left.glb"
assert left.read_bytes()[:4] == b"glTF" and (files / "framebridge/controller_right.glb").exists()
assert len(list((tmp_path / ".local/share/frameport/controller-models").glob("*.glb"))) == 2 # cached
assert a.install_controller_models(str(files), False) is None and not left.exists()
monkeypatch.setattr(a, "steamvr_roots", lambda: [])
missing = a.install_controller_models(str(files), True)
assert missing["ok"] is False and "no Steam Frame controller render models" in missing["error"]
assert a.cmd_controller_models({})["picked"] == {}
+52
View File
@@ -53,12 +53,64 @@ def test_adapter_and_launcher(tmp_path, quest_manifest):
assert names.count("lib/arm64-v8a/libopenxr_loader_generic.so") == 1
def test_controller_models_adds_xrshim(tmp_path, quest_manifest):
from pathlib import Path
from frameport.analysis import elf
# like Meta's OVRPlugin: DT_NEEDED libopenxr_loader.so + dlopen("libopenxr_loader.so") / dlsym(xrGetInstanceProcAddr)
plugin = (Path(__file__).with_name("fixtures") / "libfakeovrplugin_arm64.so").read_bytes()
for enabled in (True, False):
apk = _apk(tmp_path, quest_manifest)
with zipfile.ZipFile(apk, "a") as z:
z.writestr("lib/arm64-v8a/libOVRPlugin.so", plugin)
recipe = {"frame.adapter": {}, **({"adapter.controller_models": {"value": 1}} if enabled else {})}
with ApkWorkspace(apk) as ws:
assert base.get("frame.adapter").apply(base.ApkContext(ws, _analysis(), {}, Reporter(), recipe))
out = ws.write(tmp_path / f"out{enabled}.apk")
with zipfile.ZipFile(out) as z:
names = z.namelist()
patched = z.read("lib/arm64-v8a/libOVRPlugin.so")
if enabled:
assert "lib/arm64-v8a/libframe_xrshim.so" in names
assert len(patched) == len(plugin) and b"libframe_xrshim.so\0\0" in patched
assert patched.count(b"libopenxr_loader.so\0") == 1 # only the DT_NEEDED name (.dynstr) is left
assert elf.needed(patched) == elf.needed(plugin)
else: # off: builds stay byte-identical to before
assert "lib/arm64-v8a/libframe_xrshim.so" not in names and patched == plugin
def test_replace_rodata_string_whole_strings_only():
from pathlib import Path
from frameport.analysis import elf
plugin = (Path(__file__).with_name("fixtures") / "libfakeovrplugin_arm64.so").read_bytes()
out, n = elf.replace_rodata_string(plugin, "libopenxr_loader.so", "libframe_xrshim.so")
assert n == 1 and elf.replace_rodata_string(out, "libopenxr_loader.so", "x")[1] == 0
assert elf.replace_rodata_string(plugin, "openxr_loader.so", "y")[1] == 0 # a tail of a longer string
def test_settings_order_and_types():
s = adapter_settings({"adapter.controller_fix": {"value": 0}, "adapter.scene_height": {"value": "3"}})
assert list(s) == ["scale", "foveation_fix", "controller_fix", "scene_height"]
assert s["controller_fix"] == 0 and s["scene_height"] == 3.0
def test_controller_models_setting():
patch = base.get("adapter.controller_models")
plain = _analysis()
assert patch.detect(plain) is None and not patch.applies(plain)
meta_sdk = _analysis(libs=["libOVRPlugin.so"])
assert patch.applies(meta_sdk) and patch.detect(meta_sdk) is None # SDK present, runtime models not declared
for a in (_analysis(meta_permissions=["com.oculus.permission.RENDER_MODEL"]),
_analysis(extra={"features": {"com.oculus.feature.RENDER_MODEL": False}})):
s = patch.detect(a)
assert s.recommended and s.params == {"value": 1} and patch.applies(a)
assert adapter_settings({"adapter.controller_models": {"value": 1}})["controller_models"] == 1
assert "controller_models" not in adapter_settings({}) # off unless selected: existing builds are unchanged
def test_detect_direct_vrapi_suggests_bridge_and_shim():
a = _analysis(xr="VrApi", direct_vrapi=True, uses_glad_gl=True, graphics="GLES or unknown", package="x.y.unknown")
from frameport.recommend import engine
+94
View File
@@ -0,0 +1,94 @@
"""FrameBridge's XR_FB_render_model emulation (native/adapter/render_model.c), compiled for this host with a small C
harness that stands in for the adapter (lookup, xrStringToPath, logging).
Opt-in (FRAMEPORT_NATIVE_TESTS=1, -m native): needs the OpenXR headers in native/.cache (from native/build.py)."""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
INC = next(iter((ROOT / "native/.cache").glob("openxr-*")), None)
CC = shutil.which("cc") or shutil.which("clang")
pytestmark = [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 the OpenXR headers from native/build.py and a host C compiler)")]
HARNESS = r"""
#define XR_EXTENSION_PROTOTYPES
#include <openxr/openxr.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define LOG(...) (fprintf(stderr, __VA_ARGS__), fputc('\n', stderr))
static XrInstance active_instance = (XrInstance)(uintptr_t)1;
static XRAPI_ATTR XrResult XRAPI_CALL fake_string_to_path(XrInstance i, const char *s, XrPath *out) {
(void)i; *out = 0; for (const char *c = s; *c; ++c) *out = *out * 131 + (unsigned char)*c; return XR_SUCCESS;
}
static PFN_xrVoidFunction lookup(XrInstance i, const char *name) {
(void)i; return !strcmp(name, "xrStringToPath") ? (PFN_xrVoidFunction)fake_string_to_path : NULL;
}
#include "render_model.c"
#define CHECK(c) do { if (!(c)) { fprintf(stderr, "FAILED line %d: %s\n", __LINE__, #c); return 1; } } while (0)
int main(int argc, char **argv) {
render_model_scan(argv[1]);
if (argc > 2) { CHECK(!render_models_available); return 0; }
CHECK(render_models_available);
emulate_render_model = 1;
PFN_xrEnumerateRenderModelPathsFB enumerate = (PFN_xrEnumerateRenderModelPathsFB)render_model_emulation("xrEnumerateRenderModelPathsFB");
PFN_xrGetRenderModelPropertiesFB props_fn = (PFN_xrGetRenderModelPropertiesFB)render_model_emulation("xrGetRenderModelPropertiesFB");
PFN_xrLoadRenderModelFB load = (PFN_xrLoadRenderModelFB)render_model_emulation("xrLoadRenderModelFB");
CHECK(enumerate && props_fn && load && !render_model_emulation("xrCreatePassthroughFB"));
uint32_t n = 0;
CHECK(enumerate(XR_NULL_HANDLE, 0, &n, NULL) == XR_SUCCESS && n == 2);
XrRenderModelPathInfoFB paths[2] = {{XR_TYPE_RENDER_MODEL_PATH_INFO_FB}, {XR_TYPE_RENDER_MODEL_PATH_INFO_FB}};
CHECK(enumerate(XR_NULL_HANDLE, 1, &n, paths) == XR_ERROR_SIZE_INSUFFICIENT);
CHECK(enumerate(XR_NULL_HANDLE, 2, &n, paths) == XR_SUCCESS);
XrPath right; fake_string_to_path(0, "/model_fb/controller/right", &right);
CHECK(paths[1].path == right);
XrRenderModelCapabilitiesRequestFB req = {XR_TYPE_RENDER_MODEL_CAPABILITIES_REQUEST_FB, NULL,
XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB};
XrRenderModelPropertiesFB props = {XR_TYPE_RENDER_MODEL_PROPERTIES_FB, &req};
CHECK(props_fn(XR_NULL_HANDLE, right, &props) == XR_SUCCESS);
CHECK(props.vendorId == 0x28DE && strstr(props.modelName, "Right") && props.modelKey != XR_NULL_RENDER_MODEL_KEY_FB);
CHECK(props.flags == XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB);
XrRenderModelPropertiesFB bad = {XR_TYPE_RENDER_MODEL_PROPERTIES_FB};
CHECK(props_fn(XR_NULL_HANDLE, 12345, &bad) == XR_ERROR_PATH_UNSUPPORTED && bad.modelKey == XR_NULL_RENDER_MODEL_KEY_FB);
XrRenderModelLoadInfoFB info = {XR_TYPE_RENDER_MODEL_LOAD_INFO_FB, NULL, props.modelKey};
XrRenderModelBufferFB buf = {XR_TYPE_RENDER_MODEL_BUFFER_FB};
CHECK(load(XR_NULL_HANDLE, &info, &buf) == XR_SUCCESS && buf.bufferCountOutput == 11);
uint8_t small[4]; buf.bufferCapacityInput = 4; buf.buffer = small;
CHECK(load(XR_NULL_HANDLE, &info, &buf) == XR_ERROR_SIZE_INSUFFICIENT);
uint8_t data[64] = {0}; buf.bufferCapacityInput = sizeof(data); buf.buffer = data;
CHECK(load(XR_NULL_HANDLE, &info, &buf) == XR_SUCCESS && !memcmp(data, "right-model", 11));
XrRenderModelLoadInfoFB wrong = {XR_TYPE_RENDER_MODEL_LOAD_INFO_FB, NULL, 7};
CHECK(load(XR_NULL_HANDLE, &wrong, &buf) == XR_ERROR_RENDER_MODEL_KEY_INVALID_FB);
emulate_render_model = 0;
CHECK(!render_model_emulation("xrLoadRenderModelFB"));
return 0;
}
"""
@pytest.fixture(scope="module")
def harness(tmp_path_factory):
d = tmp_path_factory.mktemp("render_model")
(d / "harness.c").write_text(HARNESS)
subprocess.run([CC, "-std=c11", "-D_GNU_SOURCE", "-Wall", "-Wextra", "-Werror", "-Wno-unused-function", "-Wno-unused-variable",
"-Wno-missing-field-initializers", "-I", str(INC), "-I", str(ROOT / "native/adapter"),
str(d / "harness.c"), "-o", str(d / "harness")], check=True)
return d / "harness"
def test_serves_controller_models(harness, tmp_path):
(tmp_path / "controller_left.glb").write_bytes(b"left-model")
(tmp_path / "controller_right.glb").write_bytes(b"right-model")
p = subprocess.run([str(harness), str(tmp_path)], capture_output=True, text=True)
assert p.returncode == 0, p.stderr
def test_needs_both_models(harness, tmp_path):
(tmp_path / "controller_left.glb").write_bytes(b"left-model")
p = subprocess.run([str(harness), str(tmp_path), "expect-missing"], capture_output=True, text=True)
assert p.returncode == 0, p.stderr