mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-06 09:00:38 +02:00
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:
1 parent
1d83eae3ed
commit
fc4aa9c8e5
24 files changed
+894
-20
No files matched your search
@@ -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
@@ -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):
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
@@ -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"] == {}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user