diff --git a/frame/Config.frame.toml b/frame/Config.frame.toml new file mode 100644 index 0000000..cf2faf4 --- /dev/null +++ b/frame/Config.frame.toml @@ -0,0 +1,35 @@ +# KartFrame defaults for the Steam Frame, seeded into Config.toml on the first run (setup.sh and the +# launch wrapper copy this file only when there is no Config.toml). The in-game VR settings panel +# edits Config.toml in place afterwards; this file is never read again. +[paths] +# The extracted game's DATA folder (the one holding sys/fst.bin and files/). The placeholder is +# replaced with the real path when the file is seeded. TODO(lane B): confirm the placeholder name. +dvd_root = "@DVD_ROOT@" + +[video] +widescreen = true +resolution_multiplier = 1.0 + +[vr] +enabled = true +required = false +# 1.0 renders the eyes at the size SteamVR's resolutionOverride (1728, set by the launch wrapper) asks for. +render_scale = 1.000000 +# 1 = render at the headset's refresh rate (120 Hz), interpolated from the game's 60 fps. +frame_interpolation_fps = 1 +# TODO(lane A): frame_pacing = "auto" | "inline" | "pipelined" once the key is parsed (KARTFRAME_PACING overrides it). +# Grips hold the wheel in the first-person cockpit (the right bumper drifts there); elsewhere they drift. +hand_steering = true +# 0 = plain dark background behind the menus (the raymarched one is far too heavy). +menu_shader_quality = 0 +hand_hud = false +# The virtual screen for menus and the race HUD, as the upstream defaults. +hud_virtual_screen = true +hud_distance_meters = 2.0 +hud_width_meters = 2.4 +foveation = "off" +passthrough = false +adaptive_resolution = false + +[diagnostics] +openxr_logging = false diff --git a/frame/collect-logs.sh b/frame/collect-logs.sh new file mode 100755 index 0000000..51f856d --- /dev/null +++ b/frame/collect-logs.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Collects the logs needed for a bug report into one archive, with personal data masked. +# +# frame/collect-logs.sh [SESSION_DIR] +# +# Without an argument the newest session is used ($KARTFRAME_HOME/sessions/latest). Collected: the +# session's launch.log, stdout.log, game.log, vrtiming.log, report.txt and session.env, the newest +# build log of setup, Config.toml, and a summary (SteamOS and KartFrame versions). +# Masked: IP and MAC addresses, UUIDs, hostnames, Wi-Fi network names, serial numbers, Steam IDs, +# email addresses, tokens, certificate and key blocks, and user names in /home paths. +# The masking is a best effort. Read the files before you post them. +set -euo pipefail +KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame} +STATE=$KFHOME/UserData +SESSION=${1:-$KFHOME/sessions/latest} +[ -d "$SESSION" ] || { echo "no session folder at $SESSION (launch the game once, or pass a session folder)"; exit 1; } +SESSION=$(readlink -f "$SESSION") + +STAMP=$(date +%Y%m%d-%H%M%S) +WORK=$(mktemp -d) +RAW=$WORK/raw; OUT=$WORK/kartframe-logs-$STAMP +mkdir -p "$RAW" "$OUT" +grab() { [ -f "$1" ] && cp "$1" "$RAW/$2" || true; } + +for f in launch.log stdout.log game.log vrtiming.log report.txt session.env; do grab "$SESSION/$f" "$f"; done +grab "$STATE/Config.toml" Config.toml +grab "$(ls -t "$KFHOME"/build/*.log "$KFHOME"/logs/*.log 2>/dev/null | head -1 || true)" setup.log + +{ + echo "collected: $(date -u +%Y-%m-%dT%H:%MZ)" + echo "session: $(basename "$SESSION")" + grep -E '^(VERSION_ID|BUILD_ID)=' /etc/os-release 2>/dev/null | sed 's/^/steamos /' || true + [ -x "$KFHOME/bin/kartframe" ] && echo "binary sha256: $(sha256sum "$KFHOME/bin/kartframe" | cut -d' ' -f1)" + [ -f "$KFHOME/bin/kartframe.fingerprint" ] && echo "built kit: $(head -c 64 "$KFHOME/bin/kartframe.fingerprint")" +} > "$RAW/summary.txt" + +python3 -I - "$RAW" "$OUT" "$(hostname 2>/dev/null || true)" <<'EOF' +import os, re, sys +src, dst, host = sys.argv[1], sys.argv[2], sys.argv[3] +I = re.I +DEFAULT_HOSTS = {'steamdeck', 'steamos', 'steamframe', 'frame', 'localhost'} +rules = [ + (re.compile(r'-----BEGIN [A-Z ]+-----.*?-----END [A-Z ]+-----', re.S), ''), + (re.compile(r'\b(?:(?:ghp|gho|ghu|ghs|github_pat|xox[bp])_|sk-)[A-Za-z0-9_-]{16,}'), ''), + (re.compile(r'[\w.+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.(?!(?:service|socket|mount|slice|target|scope|so|rc|xml|bin|conf|json|log)\b)[A-Za-z]{2,}\b'), ''), + (re.compile(r'\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b', I), ''), + (re.compile(r'\b[0-9a-f]{2}([:-])[0-9a-f]{2}(\1[0-9a-f]{2}){4}\b', I), ''), + (re.compile(r'\b(?:[0-9a-f]{1,4}:){4,7}[0-9a-f]{1,4}\b|\b(?:[0-9a-f]{1,4}:){1,6}:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4}\b', I), ''), + (re.compile(r'\b(?!0\.0\.0\.0\b)(?!127\.)(?:\d{1,3}\.){3}\d{1,3}\b'), ''), + (re.compile(r'\b(\w*(?:_sn|serial_?no|serial_?number|android_?id|device_?id|imei)\b["\']?\s*[:=]\s*)\S+', I), r'\1'), + (re.compile(r'\b(b?ssid\b["\']?\s*[:=]?\s*)("[^"]*"|\'[^\']*\'|\S+)', I), r'\1'), + (re.compile(r'(Hostname set to\s*)<*[^\s<>]+>*', I), r'\1'), + (re.compile(r'\b7656119\d{10}\b'), ''), + (re.compile(r'(userdata/)\d+'), r'\1'), + (re.compile(r'/home/[^/\s]+'), '/home/'), +] +for name in sorted(os.listdir(src)): + text = open(os.path.join(src, name), errors='replace').read() + if len(host) >= 3 and host.lower() not in DEFAULT_HOSTS: + text = re.sub(r'\b' + re.escape(host) + r'\b', '', text) + for pattern, repl in rules: + text = pattern.sub(repl, text) + open(os.path.join(dst, name), 'w').write(text) +EOF + +DEST=${KARTFRAME_LOG_DIR:-$HOME}/kartframe-logs-$STAMP.tar.gz +tar -czf "$DEST" -C "$WORK" "$(basename "$OUT")" +rm -rf "$WORK" +echo "wrote $DEST" +echo "Open it and read the files before you attach it anywhere. The masking can miss things." diff --git a/frame/frame-controller-models.py b/frame/frame-controller-models.py new file mode 100755 index 0000000..eb433f9 --- /dev/null +++ b/frame/frame-controller-models.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Converts SteamVR's Steam Frame controller render models into the game's controller model format. + +The native Linux build draws a controller model in each hand in its VR menus and tutorials. It never +asks OpenVR for Valve's full render models (about 93k vertices re-animated on the CPU every menu +frame dropped menus to 45 fps). Instead this script reads the Frame controller models that SteamVR's +own frame_controller driver keeps on every Frame, simplifies them, and writes them in the game's +`WCC1` format into the user data directory; the game loads them from there when present and draws +procedural controllers otherwise. Valve's models are only read from your own SteamVR install, never +redistributed or committed. + +What is converted, per hand: + - every visible component mesh (body, sticks, buttons, trigger, grips, bumper), triangulated, in + the OpenXR grip space the game draws controller models in (the model's `openxr_grip` component), + simplified toward 4000 triangles per hand (KF_FRAME_MODEL_TRIANGLES; texture seams leave the + right hand at about 4800); + - the base colour texture with its ambient occlusion baked in, at 1024x1024; + - the nine motions the game animates: thumbstick click, primary and secondary button (A and B on + the right; the left Frame controller has none), trigger, grip, and the thumbstick tilting left, + right, back and forward; + - five landmarks the tutorials point at: thumbstick, primary, secondary, trigger, grip (the D-pad + and View button stand in for primary and secondary on the left). + +usage: frame-controller-models.py [OUTDIR [SteamVR rendermodels dir]] +OUTDIR defaults to $KARTFRAME_HOME/UserData/controller_models/frame (KARTFRAME_HOME defaults to +~/.local/share/KartFrame), where the game looks for them. +Needs Python 3 and GdkPixbuf (python3-gobject), both on SteamOS. No numpy or PIL. +""" +import json +import math +import os +import struct +import sys + +RENDERMODELS = '/opt/steamvr/drivers/frame_controller/resources/rendermodels' +TEXTURE_SIZE = 1024 +TRIANGLES = int(os.environ.get('KF_FRAME_MODEL_TRIANGLES', '4000')) # per hand + + +# --- small vector maths ------------------------------------------------------------------------- + +def sub(a, b): return [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +def add(a, b): return [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +def scale(a, s): return [a[0] * s, a[1] * s, a[2] * s] +def dot(a, b): return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +def cross(a, b): return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] + + +def norm(a): + n = math.sqrt(dot(a, a)) + return [a[0] / n, a[1] / n, a[2] / n] if n else [0.0, 0.0, 0.0] + + +def rotate(p, pivot, axis, degrees): + """Rodrigues: p rotated about the line through pivot along axis (right-handed).""" + k = norm(axis) + v = sub(p, pivot) + c, s = math.cos(math.radians(degrees)), math.sin(math.radians(degrees)) + r = add(add(scale(v, c), scale(cross(k, v), s)), scale(k, dot(k, v) * (1 - c))) + return add(r, pivot) + + +def euler_xyz(degrees): + """3x3 rotation for SteamVR's rotate_xyz (degrees), applied X then Y then Z; rows of the matrix.""" + x, y, z = (math.radians(d) for d in degrees) + cx, sx, cy, sy, cz, sz = math.cos(x), math.sin(x), math.cos(y), math.sin(y), math.cos(z), math.sin(z) + rx = [[1, 0, 0], [0, cx, -sx], [0, sx, cx]] + ry = [[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]] + rz = [[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(rz, mul(ry, rx)) + + +# --- input files -------------------------------------------------------------------------------- + +def read_obj(path): + """Triangles of an OBJ as lists of (position, uv, normal) corners.""" + vs, vts, vns, tris = [], [], [], [] + for line in open(path): + parts = line.split() + if not parts: + continue + if parts[0] == 'v': + vs.append([float(x) for x in parts[1:4]]) + elif parts[0] == 'vt': + vts.append([float(x) for x in parts[1:3]]) + elif parts[0] == 'vn': + vns.append([float(x) for x in parts[1:4]]) + elif parts[0] == 'f': + corners = [] + for ref in parts[1:]: + i = (ref.split('/') + ['', ''])[:3] + corners.append((vs[int(i[0]) - 1], vts[int(i[1]) - 1] if i[1] else [0.0, 0.0], + vns[int(i[2]) - 1] if i[2] else [0.0, 1.0, 0.0])) + for k in range(1, len(corners) - 1): # fan + tris.append([corners[0], corners[k], corners[k + 1]]) + return tris + + +def texture(directory, prefix): + """Base colour times ambient occlusion, RGBA, TEXTURE_SIZE square.""" + import gi + gi.require_version('GdkPixbuf', '2.0') + from gi.repository import GdkPixbuf + + def load(name): + pb = GdkPixbuf.Pixbuf.new_from_file(os.path.join(directory, f'{prefix}_{name}.png')) + pb = pb.scale_simple(TEXTURE_SIZE, TEXTURE_SIZE, GdkPixbuf.InterpType.HYPER) + return pb.get_pixels(), pb.get_rowstride(), pb.get_n_channels() + + color, cstride, cch = load('color') + try: + ao, astride, ach = load('ao') + except Exception: + ao = None + out = bytearray(TEXTURE_SIZE * TEXTURE_SIZE * 4) + for y in range(TEXTURE_SIZE): + crow, o = y * cstride, y * TEXTURE_SIZE * 4 + arow = y * astride if ao is not None else 0 + for x in range(TEXTURE_SIZE): + c = crow + x * cch + a = ao[arow + x * ach] if ao is not None else 255 + out[o:o + 4] = bytes((color[c] * a // 255, color[c + 1] * a // 255, color[c + 2] * a // 255, 255)) + o += 4 + return struct.pack(' moved point) for the given motion at full deflection, keyed by what + drives them: 'press', or for a joystick 'left', 'right', 'back', 'forward' and 'click'.""" + kind = motion['type'] + full = motion.get('value_mapping', [0.0, 0.0])[1] + if kind == 'translate': + d = scale(norm(motion['axis']), full) + return {'press': lambda p: add(p, d)} + if kind == 'rotate': + return {'press': lambda p: rotate(p, motion['pivot'], motion['axis'], full)} + if kind in ('joystick', 'dpad'): + center = motion['center'] + up = norm(up_hint) + # Tilting the stick top toward direction t is a rotation about up x t. Model space is + # OpenXR's: +X right, +Y up, -Z forward. + def toward(direction, degrees): + t = norm(sub(direction, scale(up, dot(direction, up)))) + return lambda p: rotate(p, center, cross(up, t), degrees) + ax = abs(motion.get('joystick_rotation_x', [20, -20])[0]) + ay = abs(motion.get('joystick_rotation_y', [20, -20])[0]) + frame = euler_xyz(motion.get('rotate_xyz', [0, 0, 0])) + press = motion.get('press_translate', [0.0, 0.0, 0.0]) + press_model = [sum(frame[i][j] * press[j] for j in range(3)) for i in range(3)] + return {'left': toward([-1, 0, 0], ax), 'right': toward([1, 0, 0], ax), + 'back': toward([0, 0, 1], ay), 'forward': toward([0, 0, -1], ay), + 'click': lambda p: add(p, press_model)} + return {} + + +# --- simplification ----------------------------------------------------------------------------- + +def simplify(tris, target): + """Quadric error half-edge collapse (Garland and Heckbert) down to about `target` triangles. + + Only original vertices survive, so their texture coordinates and normals carry over. Vertices on + a texture seam (one position, several UVs), on an open edge or on a non-manifold edge never + move, which keeps the texture charts and the component's outline intact; the price is that a + heavily seamed mesh may stop above the target.""" + import heapq + from collections import defaultdict + + # Weld corners into shared positions; keep each face's own corner attributes. + ids, P, faces, attrs = {}, [], [], [] + for tri in tris: + f = [] + for pos, uv, n in tri: + key = (round(pos[0], 7), round(pos[1], 7), round(pos[2], 7)) + if key not in ids: + ids[key] = len(P) + P.append(pos) + f.append(ids[key]) + if len(set(f)) == 3: + faces.append(f) + attrs.append([(uv, n) for _, uv, n in tri]) + if len(faces) <= target: + return tris + + uv_sets = defaultdict(set) + edge_count = defaultdict(int) + vfaces = defaultdict(set) + for fi, f in enumerate(faces): + for k in range(3): + uv = attrs[fi][k][0] + uv_sets[f[k]].add((round(uv[0], 5), round(uv[1], 5))) + a, b = f[k], f[(k + 1) % 3] + edge_count[(min(a, b), max(a, b))] += 1 + vfaces[f[k]].add(fi) + locked = {p for p, s in uv_sets.items() if len(s) > 1} + for (a, b), c in edge_count.items(): + if c != 2: + locked.update((a, b)) + + def plane(f): + a, b, c = P[f[0]], P[f[1]], P[f[2]] + n = cross(sub(b, a), sub(c, a)) + area = math.sqrt(dot(n, n)) + if area == 0: + return None, 0.0 + n = scale(n, 1 / area) + return n + [-dot(n, a)], area + + Q = defaultdict(lambda: [0.0] * 10) + for f in faces: + p, w = plane(f) + if p is None: + continue + a, b, c, d = p + q = [a * a, a * b, a * c, a * d, b * b, b * c, b * d, c * c, c * d, d * d] + for v in f: + Q[v] = [x + w * y for x, y in zip(Q[v], q)] + + def error(q, p): + x, y, z = p + return (q[0] * x * x + 2 * q[1] * x * y + 2 * q[2] * x * z + 2 * q[3] * x + q[4] * y * y + + 2 * q[5] * y * z + 2 * q[6] * y + q[7] * z * z + 2 * q[8] * z + q[9]) + + alive = [True] * len(faces) + live = len(faces) + version = defaultdict(int) + heap = [] + + def neighbours(v): + return {w for fi in vfaces[v] for w in faces[fi] if w != v} + + def push(u, v): + if u in locked: + return + q = [x + y for x, y in zip(Q[u], Q[v])] + heapq.heappush(heap, (error(q, P[v]), u, v, version[u], version[v])) + + for (a, b) in edge_count: + push(a, b) + push(b, a) + + def face_normal(pts): + return cross(sub(pts[1], pts[0]), sub(pts[2], pts[0])) + + while live > target and heap: + _, u, v, vu, vv = heapq.heappop(heap) + if version[u] != vu or version[v] != vv or u in locked or not vfaces[u] or not vfaces[v]: + continue + shared = vfaces[u] & vfaces[v] + if len(shared) != 2: + continue + # Link condition: the edge's two opposite vertices are the only common neighbours. + if len(neighbours(u) & neighbours(v)) != 2: + continue + # No flipped or collapsed triangles around u. + ok = True + for fi in vfaces[u] - shared: + before = [P[w] for w in faces[fi]] + after = [P[v] if w == u else P[w] for w in faces[fi]] + nb, na = face_normal(before), face_normal(after) + la, lb = math.sqrt(dot(na, na)), math.sqrt(dot(nb, nb)) + if la < 1e-12 or lb < 1e-12 or dot(na, nb) / (la * lb) < 0.3: + ok = False + break + if not ok: + continue + # v's attributes in the chart the collapsed faces belong to. + sf = next(iter(shared)) + v_attr = attrs[sf][faces[sf].index(v)] + for fi in shared: + alive[fi] = False + live -= 1 + for w in faces[fi]: + vfaces[w].discard(fi) + for fi in list(vfaces[u]): + k = faces[fi].index(u) + faces[fi][k] = v + attrs[fi][k] = v_attr + vfaces[v].add(fi) + vfaces[u].clear() + Q[v] = [x + y for x, y in zip(Q[u], Q[v])] + version[v] += 1 + ring = neighbours(v) + for w in ring: + version[w] += 1 + for w in ring: + push(v, w) + push(w, v) + + return [[(P[faces[fi][k]], attrs[fi][k][0], attrs[fi][k][1]) for k in range(3)] + for fi in range(len(faces)) if alive[fi]] + + +def convert(hand, rendermodels, outdir): + name = f'frame_controller_{hand}' + directory = os.path.join(rendermodels, name) + components = json.load(open(os.path.join(directory, name + '.json')))['components'] + + grip = components.get('openxr_grip') or components.get('handgrip') + grip_origin = grip['component_local']['origin'] + grip_rot = euler_xyz(grip['component_local'].get('rotate_xyz', [0, 0, 0])) + to_grip = lambda p: [sum(grip_rot[j][i] * (p[j] - grip_origin[j]) for j in range(3)) for i in range(3)] + dir_to_grip = lambda d: [sum(grip_rot[j][i] * d[j] for j in range(3)) for i in range(3)] + + meshes = {} + for key, c in components.items(): + if 'filename' in c and c.get('visibility', {}).get('default', True): + meshes[key] = read_obj(os.path.join(directory, c['filename'])) + + def centroid(key): + pts = [corner[0] for tri in meshes[key] for corner in tri] + return [sum(p[i] for p in pts) / len(pts) for i in range(3)] + + # Which component each of the game's nine controls moves, and how. + primary, secondary = ('button_a', 'button_b') if hand == 'right' else (None, None) + controls = [None] * 9 # (component, motion key) + stick = 'thumbstick' + stick_motion = motion_moves(components[stick]['motion'], sub(centroid(stick), components[stick]['motion']['center'])) + controls[0] = (stick, stick_motion['click']) + if primary in meshes: + controls[1] = (primary, motion_moves(components[primary]['motion'], None)['press']) + if secondary in meshes: + controls[2] = (secondary, motion_moves(components[secondary]['motion'], None)['press']) + controls[3] = ('trigger', motion_moves(components['trigger']['motion'], None)['press']) + controls[4] = ('button_grip', motion_moves(components['button_grip']['motion'], None)['press']) + for slot, key in zip(range(5, 9), ('left', 'right', 'back', 'forward')): + controls[slot] = (stick, stick_motion[key]) + + # The landmarks from the full meshes, then the meshes cut down to the triangle budget, shared + # between components by size with a floor so small buttons keep their shape. The game + # re-animates every vertex on the CPU each frame a menu is up: the full models (~31,000 + # triangles for both hands) took the menus from 90 to 45-60 fps on the Frame; Touch Plus has + # ~8,900 for both. + marks = [stick, primary or 'dpad', secondary or 'button_view', 'trigger', 'button_grip'] + anchors = [] + for key in marks: + anchors += to_grip(centroid(key)) if key in meshes else [0.0, 0.0, 0.0] + # Largest first: the body's seams can stop it above its share, and the rest absorb that. + remaining, left_to_do = TRIANGLES, sum(len(t) for t in meshes.values()) + for key in sorted(meshes, key=lambda k: -len(meshes[k])): + n = len(meshes[key]) + meshes[key] = simplify(meshes[key], max(min(n, 60), round(n * remaining / left_to_do))) + remaining -= len(meshes[key]) + left_to_do -= n + + records = [] + for key, tris in meshes.items(): + for tri in tris: + for position, uv, normal in tri: + base = to_grip(position) + n = norm(dir_to_grip(normal)) + shade = .8 + .2 * min(1.0, max(0.0, n[1] * .5 + .5)) + deltas = [] + for control in controls: + if control and control[0] == key: + deltas += sub(to_grip(control[1](position)), base) + else: + deltas += [0.0, 0.0, 0.0] + records.append(struct.pack('<35f', *base, shade, shade, shade, uv[0], 1.0 - uv[1], *deltas)) + if len(records) > 100000: + sys.exit(f'{name}: {len(records)} vertices, more than the game accepts (100000)') + + os.makedirs(outdir, exist_ok=True) + with open(os.path.join(outdir, f'{hand}.wccontroller'), 'wb') as f: + f.write(struct.pack('<4sII', b'WCC1', len(records), 9)) + f.write(struct.pack('<15f', *anchors)) + f.write(b''.join(records)) + with open(os.path.join(outdir, f'{hand}.rgba'), 'wb') as f: + f.write(texture(directory, name)) + print(f'{hand}: {len(records) // 3} triangles from {len(meshes)} components') + + +if __name__ == '__main__': + if len(sys.argv) > 1 and sys.argv[1] in ('-h', '--help'): + sys.exit(__doc__) + home = os.environ.get('KARTFRAME_HOME') or os.path.expanduser('~/.local/share/KartFrame') + data = os.path.join(home, 'UserData') + out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(data, 'controller_models', 'frame') + os.makedirs(out, exist_ok=True) + src = sys.argv[2] if len(sys.argv) > 2 else RENDERMODELS + if not os.path.isdir(src): + sys.exit(f'{src}: no Steam Frame controller models here (is SteamVR installed?)') + for hand in ('left', 'right'): + convert(hand, src, out) diff --git a/frame/install.sh b/frame/install.sh new file mode 100755 index 0000000..09cbf41 --- /dev/null +++ b/frame/install.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# Installs KartFrame on the Steam Frame: the program into ~/Games/KartFrame/app, the launch wrapper +# and tools beside it, and a non-Steam shortcut in Steam's library (through Steam's own client API, +# so Steam must be running; no editing of shortcuts.vdf behind its back). +# +# frame/install.sh [options] [kartframe-*-linux-arm64.tar.gz | BUILD_DIR] +# frame/install.sh --dev [BUILD_DIR] a separate "KartFrame (dev)" shortcut that runs the build +# build's binary into KARTFRAME_HOME/bin (default /build-linux) +# +# --name NAME shortcut name (default "KartFrame", with --dev "KartFrame (dev)") +# --art DIR optional library art: p.png (portrait), wide.png, hero.png, logo.png, icon.png +# --no-models do not convert the Frame controller models +# -h, --help +# +# Without a source: the newest release tarball in ./release, else /build-linux. +# A BUILD_DIR is a CMake build directory: kartframe plus the resources next to it. +# +# Environment: +# KARTFRAME_APP=DIR where the program goes (default ~/Games/KartFrame/app; dev: app-dev) +# KARTFRAME_HOME=DIR data folder (default ~/.local/share/KartFrame); only created here +# KARTFRAME_SKIP_STEAM=1 no Steam shortcut at all (Steam need not be running) +# KARTFRAME_STEAM_DIR=DIR edit the shortcuts.vdf files of this (copied) Steam folder offline +# instead of the live one: DIR/userdata/*/config/shortcuts.vdf, grid art +# beside it. Used for dry runs; nothing of the real Steam is touched. +set -e +HERE=$(cd "$(dirname "$0")" && pwd) +REPO=$(cd "$HERE/.." && pwd) +DEV=0; NAME=; ART=; MODELS=1; SRC= +while [ $# -gt 0 ]; do + case $1 in + --dev) DEV=1 ;; + --name) NAME=$2; shift ;; + --art) ART=$2; shift ;; + --no-models) MODELS=0 ;; + -h | --help) sed -n '2,/^set -e/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; + -*) echo "unknown option $1" >&2; exit 2 ;; + *) SRC=$1 ;; + esac + shift +done +if [ "$DEV" = 1 ]; then + NAME=${NAME:-KartFrame (dev)} + APP=${KARTFRAME_APP:-$HOME/Games/KartFrame/app-dev} + BUILD=$(cd "${SRC:-$REPO/build-linux}" 2> /dev/null && pwd) || { echo "no build tree at ${SRC:-$REPO/build-linux}" >&2; exit 1; } + [ -x "$BUILD/kartframe" ] || { echo "no kartframe in $BUILD (build with kfbuild.sh first)" >&2; exit 1; } +else + NAME=${NAME:-KartFrame} + APP=${KARTFRAME_APP:-$HOME/Games/KartFrame/app} + [ -n "$SRC" ] || SRC=$(ls "$REPO"/release/kartframe-*-linux-arm64.tar.gz 2> /dev/null | sort -V | tail -1) + [ -n "$SRC" ] || { [ -x "$REPO/build-linux/kartframe" ] && SRC=$REPO/build-linux; } + [ -n "$SRC" ] && [ -e "$SRC" ] || { echo "usage: $0 [--dev] kartframe-*-linux-arm64.tar.gz | BUILD_DIR" >&2; exit 1; } +fi +KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame} +EXE=$KFHOME/bin/kartframe +case $NAME in *\'* | *\\*) echo "the shortcut name cannot hold a quote or backslash" >&2; exit 2 ;; esac +STEAM=${KARTFRAME_STEAM_DIR:-$HOME/.local/share/Steam} + +if [ -z "$KARTFRAME_SKIP_STEAM" ] && [ -z "$KARTFRAME_STEAM_DIR" ]; then + curl -s --max-time 3 127.0.0.1:8080/json > /dev/null || { + echo "Steam's debugging port is not reachable. Make sure Steam is running (on SteamOS it is on by default)." >&2 + exit 1 + } +fi + +# Layout (LANE_RULES.md "Install layout"): the app dir holds the wrapper, tools and, from a release +# tarball, kit/ toolchain/ translator/ setup.sh. The game's own files live in $KFHOME: bin/kartframe +# plus its resources, portable.txt, UserData/. A release install does not make bin/ (setup.sh builds +# it from the user's disc); --dev and a build-tree install copy the build's binary there. +mkdir -p "$APP/tools" "$KFHOME/bin" +: > "$KFHOME/portable.txt" +copy_build() { # BUILD_DIR: the binary and the resources the game wants beside its executable + local B=$1 r + [ -x "$B/kartframe" ] || { echo "no kartframe in $B" >&2; exit 1; } + cp "$B/kartframe" "$KFHOME/bin/kartframe.new" && mv "$KFHOME/bin/kartframe.new" "$KFHOME/bin/kartframe" # the game may be running + for r in dsp_coef.bin initial_pipeline_cache.db wii_bootstrap resources kartframe.fingerprint; do + [ -e "$B/$r" ] && { rm -rf "${KFHOME:?}/bin/$r"; cp -r "$B/$r" "$KFHOME/bin/$r"; } + done + return 0 +} +if [ "$DEV" = 1 ]; then + echo "dev install: copying the build in $BUILD to $KFHOME/bin (run again after a rebuild)" + copy_build "$BUILD" +elif [ -d "$SRC" ]; then + echo "installing the build in $SRC" + copy_build "$SRC" +else + echo "installing $SRC into $APP" + TOPS=$(tar -tzf "$SRC" | sed 's|^\./||; s|/.*||' | sort -u | wc -l) + STRIP=0; [ "$TOPS" = 1 ] && [ -z "$(tar -tzf "$SRC" | sed 's|^\./||' | grep -m1 '^kartframe.sh$')" ] && STRIP=1 + tar -xzf "$SRC" -C "$APP" --strip-components=$STRIP +fi +# Seed the game's Config.toml if there is none. +mkdir -p "$KFHOME/UserData" +[ -f "$KFHOME/UserData/Config.toml" ] || sed "s|@DVD_ROOT@|$KFHOME/game/DATA|" "$HERE/Config.frame.toml" > "$KFHOME/UserData/Config.toml" +cp "$HERE/kartframe.sh" "$APP/kartframe.sh" +cp "$HERE/Config.frame.toml" "$APP/Config.frame.toml" +for t in vrtiming.py session-report.py; do cp "$HERE/tools/$t" "$APP/tools/$t"; done +cp "$HERE/steam-cef.py" "$APP/tools/steam-cef.py" +chmod +x "$APP/kartframe.sh" "$APP/tools/steam-cef.py" +for f in collect-logs.sh uninstall.sh; do cp "$HERE/$f" "$APP/tools/$f" 2> /dev/null && chmod +x "$APP/tools/$f"; done + +# The Frame's own controller models, converted from this Frame's SteamVR (lane C's script, if present). +if [ "$MODELS" = 1 ] && [ -f "$HERE/frame-controller-models.py" ]; then + python3 "$HERE/frame-controller-models.py" "$KFHOME/UserData/controller_models/frame" || + echo "warning: could not convert SteamVR's Frame controller models" +fi + +APPID=none +LAUNCH="\"$APP/kartframe.sh\" %command%" +if [ -n "$KARTFRAME_SKIP_STEAM" ]; then + echo "KARTFRAME_SKIP_STEAM set: no Steam shortcut" +elif [ -n "$KARTFRAME_STEAM_DIR" ]; then + # Offline: every userdata/*/config/shortcuts.vdf of the (copied) Steam folder. + for CFG in "$STEAM"/userdata/*/config; do + [ -d "$CFG" ] || continue + APPID=$(python3 "$HERE/steam-cef.py" --vdf "$CFG/shortcuts.vdf" --name "$NAME" --exe "\"$EXE\"" \ + --startdir "\"$KFHOME/bin\"" --opts "$LAUNCH") + echo "shortcut '$NAME' written to $CFG/shortcuts.vdf (appid $APPID)" + GRID=$CFG/grid + done + [ "$APPID" != none ] || echo "warning: no userdata/*/config in $STEAM; no shortcut written" +else + CEF="python3 $HERE/steam-cef.py" + EXISTING=$($CEF "(() => { for (const a of (appStore.allApps || [])) { if (a.display_name === '$NAME' && a.app_type === 1073741824) return a.appid; } return 0; })()" 2> /dev/null || echo 0) + if [ "$EXISTING" != "0" ] && [ -n "$EXISTING" ]; then + APPID=$EXISTING + echo "Steam already has a '$NAME' shortcut (appid $APPID): updating it" + $CEF "(() => { SteamClient.Apps.SetShortcutExe($APPID, '\"$EXE\"'); SteamClient.Apps.SetShortcutStartDir($APPID, '\"$KFHOME/bin\"'); return 1; })()" > /dev/null + else + APPID=$($CEF "(async () => { const id = await SteamClient.Apps.AddShortcut('$NAME', '$EXE', '$KFHOME/bin', ''); SteamClient.Apps.SetShortcutName(id, '$NAME'); return id; })()") + echo "added the '$NAME' shortcut (appid $APPID)" + fi + $CEF "(() => { SteamClient.Apps.SetShortcutLaunchOptions($APPID, '$LAUNCH'); return 1; })()" > /dev/null + GRID=$(ls -d "$STEAM"/userdata/*/config 2> /dev/null | head -1)/grid +fi + +# Optional library art; Steam finds it by the shortcut's appid. +if [ -n "$ART" ] && [ "$APPID" != none ] && [ -n "$GRID" ]; then + mkdir -p "$GRID" + for kind in p:p wide: hero:_hero logo:_logo icon:_icon; do + f=$ART/${kind%%:*}.png + [ -f "$f" ] && cp "$f" "$GRID/$APPID${kind#*:}.png" && echo "art: $GRID/$APPID${kind#*:}.png" + done +fi + +cat << MSG + +Installed in $APP. +$([ "$APPID" != none ] && echo "Steam library entry \"$NAME\" (appid $APPID) runs $EXE through $APP/kartframe.sh.") +Game data: frame/setup.sh (once) +Then launch it from the library with SteamVR running. +Logs: $KFHOME/sessions/latest/ (launch.log, game.log, vrtiming.log, report.txt) +MSG diff --git a/frame/kartframe.sh b/frame/kartframe.sh new file mode 100755 index 0000000..30292ed --- /dev/null +++ b/frame/kartframe.sh @@ -0,0 +1,155 @@ +#!/bin/bash +# KartFrame launch wrapper, run by the Steam shortcut (" %command%"). There is no screen +# for messages in VR: everything goes to the session log, and a refusal is a non-zero exit. +# +# Every launch: re-applies SteamVR's per-app settings (it drops them now and then), starts the +# compositor timing logger, runs the game, then writes a session folder +# $KARTFRAME_HOME/sessions// with launch.log, stdout.log, game.log, vrtiming.log, report.txt. +# $KARTFRAME_HOME/sessions/latest points at the newest one. +# +# Environment (all optional; session.env below is the way to set them from the Steam library): +# KARTFRAME_HOME data folder, default ~/.local/share/KartFrame (game/, build/, sessions/) +# KARTFRAME_REFRESH_HZ refresh rate asked of SteamVR, 90 or 120 (default 120) +# KARTFRAME_FDM_LAYER=0 sets DISABLE_VULKAN_FDM_INJECTION_LAYER=1 (A/B: Valve's FDM layer off) +# KARTFRAME_SYSMEM=1 sets TU_DEBUG=sysmem (A/B: Turnip sysmem rendering) +# KARTFRAME_PACING passed through to the game (auto | inline | pipelined) +# KARTFRAME_VRTIMING=0 no compositor timing log +# KARTFRAME_SESSION_ENV_ONCE=1 (inside session.env) delete session.env after this launch +# KARTFRAME_KEEP_SESSIONS number of session folders kept (default 30) +# Test hooks: KARTFRAME_VRCMD, KARTFRAME_STEAMVR_DIR, KARTFRAME_VRSETTINGS, KARTFRAME_VRTIMING_PY, +# KARTFRAME_REPORT_PY, KARTFRAME_EXE. +HERE=$(dirname "$(readlink -f "$0")") +KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame} +STAMP=$(date +%Y%m%d-%H%M%S) +SESSION=$KFHOME/sessions/$STAMP +mkdir -p "$SESSION" || exit 1 +ln -sfn "$STAMP" "$KFHOME/sessions/latest" +exec > "$SESSION/launch.log" 2>&1 +log() { echo "$(date +%T) $*"; } +log "KartFrame wrapper, session $STAMP, steam appid '${SteamAppId:-unset}'" + +# One launch's test environment: VAR=value lines (KARTFRAME_*, DISABLE_VULKAN_FDM_INJECTION_LAYER, ...). +if [ -f "$KFHOME/session.env" ]; then + log "session.env:"; cat "$KFHOME/session.env"; cp "$KFHOME/session.env" "$SESSION/session.env" + set -a; . "$KFHOME/session.env"; set +a + [ "$KARTFRAME_SESSION_ENV_ONCE" = 1 ] && { rm -f "$KFHOME/session.env"; log "session.env deleted (KARTFRAME_SESSION_ENV_ONCE)"; } +fi +[ "$KARTFRAME_FDM_LAYER" = 0 ] && { export DISABLE_VULKAN_FDM_INJECTION_LAYER=1; log "A/B: Valve FDM layer disabled"; } +[ "$KARTFRAME_SYSMEM" = 1 ] && { export TU_DEBUG=sysmem; log "A/B: TU_DEBUG=sysmem"; } +[ -n "$KARTFRAME_PACING" ] && { export KARTFRAME_PACING; log "KARTFRAME_PACING=$KARTFRAME_PACING"; } + +# The game: Steam's %command% (the binary the shortcut names), else the installed one. +EXE=${KARTFRAME_EXE:-${1:-$KFHOME/bin/kartframe}} +[ $# -gt 0 ] && shift +[ -x "$EXE" ] || { log "REFUSING TO START: no game binary at $EXE (run frame/setup.sh)"; exit 1; } + +# Kit check: the installed binary must have been built against the kit that is installed now. +# Installed kit: /kit/kit.json "fingerprint"; the built game's record: $KFHOME/bin/kartframe.fingerprint. +# kit_fingerprint FILE prints the "fingerprint" of a kit.json, or the first line of a plain file. +kit_fingerprint() { + python3 -I -c ' +import json, sys +t = open(sys.argv[1]).read() +try: + print(json.loads(t)["fingerprint"]) +except Exception: + print(t.split("\n")[0].strip())' "$1" 2> /dev/null +} +kit_check() { # prints the reason and returns 1 when the binary and the kit disagree + local kit= built= + [ -f "$HERE/kit/kit.json" ] && kit=$HERE/kit/kit.json + [ -f "$KFHOME/bin/kartframe.fingerprint" ] && built=$KFHOME/bin/kartframe.fingerprint + [ -n "$kit" ] || { log "kit check: no kit fingerprint installed (development build), skipped"; return 0; } + [ -n "$built" ] || { echo "no record of the kit this binary was built against; run frame/setup.sh again"; return 1; } + local k b + k=$(kit_fingerprint "$kit"); b=$(kit_fingerprint "$built") + [ -n "$k" ] && [ "$k" = "$b" ] && { log "kit check: ok (${k:0:12})"; return 0; } + echo "the game was built for another kit (${b:0:12} vs installed ${k:0:12}); run frame/setup.sh again"; return 1 +} +if ! why=$(kit_check); then log "REFUSING TO START: $why"; exit 78; fi +[ -n "$why" ] && echo "$why" + +# Portable mode: portable.txt in KFHOME (a parent of bin/) makes the game's root $KFHOME/UserData. +STATE=$KFHOME/UserData +mkdir -p "$STATE"; [ -f "$KFHOME/portable.txt" ] || : > "$KFHOME/portable.txt" +if [ ! -f "$STATE/Config.toml" ] && [ -f "$HERE/Config.frame.toml" ]; then + sed "s|@DVD_ROOT@|$KFHOME/game/DATA|" "$HERE/Config.frame.toml" > "$STATE/Config.toml" && + log "seeded $STATE/Config.toml" +fi + +# SteamVR's per-app settings, every launch. SteamAppId is set by Steam for a library launch. +HZ=${KARTFRAME_REFRESH_HZ:-120} # TODO(lane A): follow Config's refresh choice if one is added +case "$HZ" in 90 | 120) ;; *) log "KARTFRAME_REFRESH_HZ=$HZ is not 90 or 120, using 120"; HZ=120 ;; esac +VRCMD=${KARTFRAME_VRCMD:-$(command -v vrcmd || echo /opt/steamvr/bin/linuxarm64/vrcmd)} +VRSETTINGS=${KARTFRAME_VRSETTINGS:-$HOME/.config/openvr/config/steamvr.vrsettings} +STEAMVR_DIR=${KARTFRAME_STEAMVR_DIR:-/opt/steamvr/bin/linuxarm64} +SETTINGS="preferredRefreshRate=$HZ framesToThrottle=0 additionalFramesToPredict=0 motionSmoothingOverride=2 resolutionOverride=1728" +if [ -z "$SteamAppId" ]; then + log "no SteamAppId: SteamVR per-app settings NOT applied (not a Steam library launch)" +elif [ ! -x "$VRCMD" ]; then + log "no vrcmd at $VRCMD: SteamVR per-app settings NOT applied" +else + for kv in $SETTINGS; do + "$VRCMD" --set-settings-int "steam.app.$SteamAppId.${kv%=*}" "${kv#*=}" > /dev/null 2>&1 || + log "WARNING: vrcmd could not set ${kv%=*} (is SteamVR running?)" + done + sleep 0.3 # SteamVR writes its settings file shortly after a change + # Read back from SteamVR's settings file and log every difference. + python3 -I - "$VRSETTINGS" "steam.app.$SteamAppId" $SETTINGS << 'PY' || log "settings read-back failed" +import json, sys, time +path, section, pairs = sys.argv[1], sys.argv[2], sys.argv[3:] +try: + got = json.load(open(path)).get(section, {}) +except (OSError, ValueError) as e: + print(f"{time.strftime('%T')} read-back: cannot read {path} ({e.__class__.__name__}); settings unverified") + sys.exit(0) +bad = 0 +for p in pairs: + k, v = p.split("=") + if str(got.get(k)) != v: + bad += 1 + print(f"{time.strftime('%T')} MISMATCH {section}.{k}: wanted {v}, SteamVR has {got.get(k)}") +print(f"{time.strftime('%T')} read-back: {len(pairs) - bad} of {len(pairs)} per-app settings as asked") +PY +fi + +# Compositor timing for the whole session; it cannot stop the game from starting. +VRT_PID= +VRT=${KARTFRAME_VRTIMING_PY:-$HERE/tools/vrtiming.py} +if [ "${KARTFRAME_VRTIMING:-1}" != 0 ] && [ -f "$VRT" ] && [ -f "$STEAMVR_DIR/libopenvr_api.so" ]; then + env -u LD_LIBRARY_PATH python3 -I "$VRT" 86400 "$SESSION/vrtiming.log" > /dev/null 2>&1 & + VRT_PID=$! +else + log "no vrtiming.log (disabled, or vrtiming.py / libopenvr_api.so missing)" +fi + +# Not exec: the wrapper stays to stop the logger and write the report. A signal for the wrapper +# (Steam stopping the app) goes on to the game. +log "starting $EXE" +touch "$SESSION/.start" +"$EXE" "$@" > "$SESSION/stdout.log" 2>&1 & +GAME_PID=$! +trap 'kill -TERM "$GAME_PID" 2> /dev/null' TERM INT HUP +rc=0 +while kill -0 "$GAME_PID" 2> /dev/null; do + wait "$GAME_PID"; rc=$? +done +trap - TERM INT HUP +log "game exited with status $rc" +[ -n "$VRT_PID" ] && { kill "$VRT_PID" 2> /dev/null; wait "$VRT_PID" 2> /dev/null; } + +# The game's own log: the newest console.log of this session, else what it printed. +GL=$(find "$STATE/Logs" -name console.log -newer "$SESSION/.start" 2> /dev/null | xargs -r ls -t 2> /dev/null | head -1) +if [ -n "$GL" ]; then cp "$GL" "$SESSION/game.log"; else log "no console.log from this run; the report reads stdout.log"; cp "$SESSION/stdout.log" "$SESSION/game.log"; fi +rm -f "$SESSION/.start" +REPORT=${KARTFRAME_REPORT_PY:-$HERE/tools/session-report.py} +if [ -f "$REPORT" ]; then + set -- "$SESSION/game.log"; [ -s "$SESSION/vrtiming.log" ] && set -- "$@" "$SESSION/vrtiming.log" + python3 -I "$REPORT" "$@" > "$SESSION/report.txt" 2>&1 || log "session-report exited non-zero" +else + log "no session-report.py at $REPORT" +fi +# Keep the newest sessions only. +KEEP=${KARTFRAME_KEEP_SESSIONS:-30} +ls -1d "$KFHOME"/sessions/2* 2> /dev/null | head -n -"$KEEP" | xargs -r rm -rf +exit $rc diff --git a/frame/steam-cef.py b/frame/steam-cef.py new file mode 100755 index 0000000..43ca372 --- /dev/null +++ b/frame/steam-cef.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Evaluate JavaScript in Steam's SharedJSContext over the CEF remote debugging port. +Usage: steam-cef.py 'JS expression' (awaits promises; prints JSON result) + +Offline mode, for a copied Steam config (install.sh with KARTFRAME_STEAM_DIR; Steam must not be +running when the real file is edited): the same shortcut operations on a shortcuts.vdf. + steam-cef.py --vdf FILE --name N --exe E --startdir D [--opts O] add or update; prints the appid + steam-cef.py --vdf FILE --name N --remove remove; prints 1 or 0 +""" +import json, os, socket, struct, sys, base64, urllib.request + +def ws_connect(url): + # ws://host:port/path + rest = url[len('ws://'):] + hostport, path = rest.split('/', 1) + host, port = hostport.split(':') + s = socket.create_connection((host, int(port)), timeout=30) + key = base64.b64encode(os.urandom(16)).decode() + req = (f"GET /{path} HTTP/1.1\r\nHost: {hostport}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n") + s.sendall(req.encode()) + buf = b'' + while b'\r\n\r\n' not in buf: + chunk = s.recv(4096) + if not chunk: raise RuntimeError('handshake failed') + buf += chunk + if b' 101 ' not in buf.split(b'\r\n')[0]: + raise RuntimeError('handshake rejected: ' + buf.decode(errors='replace')) + return s + +def ws_send(s, text): + data = text.encode() + hdr = bytearray([0x81]) + n = len(data) + if n < 126: hdr.append(0x80 | n) + elif n < 65536: hdr.append(0x80 | 126); hdr += struct.pack('>H', n) + else: hdr.append(0x80 | 127); hdr += struct.pack('>Q', n) + mask = os.urandom(4) + hdr += mask + s.sendall(bytes(hdr) + bytes(b ^ mask[i % 4] for i, b in enumerate(data))) + +def recv_exact(s, n): + buf = b'' + while len(buf) < n: + chunk = s.recv(n - len(buf)) + if not chunk: raise RuntimeError('connection closed') + buf += chunk + return buf + +def ws_recv(s): + while True: + b1, b2 = recv_exact(s, 2) + op = b1 & 0x0f + n = b2 & 0x7f + if n == 126: n = struct.unpack('>H', recv_exact(s, 2))[0] + elif n == 127: n = struct.unpack('>Q', recv_exact(s, 8))[0] + if b2 & 0x80: mask = recv_exact(s, 4) + else: mask = None + payload = recv_exact(s, n) + if mask: payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + if op == 1: return payload.decode() + if op == 8: raise RuntimeError('closed') + # ignore ping/pong/binary + +def evaluate(expr, title='SharedJSContext'): + pages = json.load(urllib.request.urlopen('http://127.0.0.1:8080/json', timeout=5)) + page = next(p for p in pages if p.get('title') == title) + s = ws_connect(page['webSocketDebuggerUrl']) + ws_send(s, json.dumps({'id': 1, 'method': 'Runtime.evaluate', + 'params': {'expression': expr, 'awaitPromise': True, 'returnByValue': True}})) + while True: + msg = json.loads(ws_recv(s)) + if msg.get('id') == 1: + s.close() + if 'error' in msg: raise RuntimeError(msg['error']) + r = msg['result'] + if 'exceptionDetails' in r: + raise RuntimeError(json.dumps(r['exceptionDetails'].get('exception', r['exceptionDetails']))[:2000]) + return r['result'].get('value') + + +# ---- offline shortcuts.vdf editing (binary KeyValues) ---- +import zlib + +def vdf_parse(data, pos=0): + out = {} + while True: + t = data[pos]; pos += 1 + if t == 8: + return out, pos + end = data.index(b'\0', pos); key = data[pos:end].decode(); pos = end + 1 + if t == 0: + out[key], pos = vdf_parse(data, pos) + elif t == 1: + end = data.index(b'\0', pos); out[key] = data[pos:end].decode(errors='replace'); pos = end + 1 + elif t == 2: + out[key] = struct.unpack(']*>)?\(\s*document,\s*"(\w+)",\s*"(\w+)"', src)) +known |= set(re.findall(r'readRangedFloat\("(\w+)"', src)) and {("vr", k) for k in re.findall(r'readRangedFloat\("(\w+)"', src)} +errs = [] +for sec, kv in cfg.items(): + for k, v in kv.items(): + if (sec, k) not in known: errs.append(f"unknown key {sec}.{k}") +v = cfg["vr"] +want = {("vr", "render_scale"): 1.0, ("video", "resolution_multiplier"): 1.0, ("vr", "frame_interpolation_fps"): 1, + ("vr", "menu_shader_quality"): 0, ("vr", "hand_steering"): True, ("vr", "foveation"): "off", + ("vr", "passthrough"): False, ("vr", "adaptive_resolution"): False, ("diagnostics", "openxr_logging"): False} +for (s, k), w in want.items(): + if cfg[s][k] != w: errs.append(f"{s}.{k} = {cfg[s][k]!r}, want {w!r}") +if v["foveation"] not in ("off", "low", "medium", "high"): errs.append("foveation value") +if not 0 <= v["menu_shader_quality"] <= 3: errs.append("menu_shader_quality range") +if v["render_scale"] <= 0: errs.append("render_scale") +for s, k in (("vr", "hud_distance_meters"), ("vr", "hud_width_meters")): + if not isinstance(cfg[s][k], float) or cfg[s][k] <= 0: errs.append(k) +print("\n".join(errs)); sys.exit(1 if errs else 0) +PY + +# ---- install.sh into a copied Steam folder ---- +STEAM=$T/steam; mkdir -p "$STEAM/userdata/1/config" +REAL=$(ls "$REALHOME"/.local/share/Steam/userdata/*/config/shortcuts.vdf 2> /dev/null | head -1) # read only, copied +[ -n "$REAL" ] && cp "$REAL" "$STEAM/userdata/1/config/shortcuts.vdf" +VDF=$STEAM/userdata/1/config/shortcuts.vdf +count() { python3 -I -c ' +import importlib.util, sys +s = importlib.util.spec_from_file_location("c", sys.argv[1]); m = importlib.util.module_from_spec(s); s.loader.exec_module(m) +d = m.vdf_load(sys.argv[2])["shortcuts"] +print(sum(1 for v in d.values() if v.get("AppName") == sys.argv[3]) if len(sys.argv) > 3 else len(d))' "$HERE/steam-cef.py" "$VDF" "$@"; } +field() { python3 -I -c ' +import importlib.util, sys +s = importlib.util.spec_from_file_location("c", sys.argv[1]); m = importlib.util.module_from_spec(s); s.loader.exec_module(m) +d = m.vdf_load(sys.argv[2])["shortcuts"] +print(next(v[sys.argv[4]] for v in d.values() if v.get("AppName") == sys.argv[3]))' "$HERE/steam-cef.py" "$VDF" "$@"; } +BEFORE=$(count) +BUILD=$T/build; mkdir -p "$BUILD/resources" "$BUILD/wii_bootstrap" +printf '#!/bin/bash\necho fake game\n' > "$BUILD/kartframe"; chmod +x "$BUILD/kartframe" +echo x > "$BUILD/dsp_coef.bin"; echo x > "$BUILD/resources/a" +export KARTFRAME_APP=$T/app KARTFRAME_HOME=$T/kfhome KARTFRAME_STEAM_DIR=$STEAM +"$HERE/install.sh" "$BUILD" > "$T/install1.log" 2>&1; check "install.sh from a build tree exits 0" test $? -eq 0 +K=$T/kfhome +check "binary and resources copied to KARTFRAME_HOME/bin" test -x "$K/bin/kartframe" -a -f "$K/bin/dsp_coef.bin" -a -f "$K/bin/resources/a" +check "portable.txt and seeded UserData/Config.toml" test -f "$K/portable.txt" && has "$K/UserData/Config.toml" "dvd_root = \"$K/game/DATA\"" +check "wrapper, tools, config installed" test -x "$T/app/kartframe.sh" -a -f "$T/app/tools/vrtiming.py" -a -f "$T/app/tools/session-report.py" -a -f "$T/app/Config.frame.toml" -a ! -e "$T/app/kartframe" +check "shortcut added once" test "$(count KartFrame)" = 1 +check "shortcut count grew by one" test "$(count)" = $((BEFORE + 1)) +check "launch options run the wrapper" test "$(field KartFrame LaunchOptions)" = "\"$T/app/kartframe.sh\" %command%" +check "exe is the installed binary" test "$(field KartFrame Exe)" = "\"$K/bin/kartframe\"" +"$HERE/install.sh" "$BUILD" > "$T/install2.log" 2>&1 +check "second install updates, not duplicates" test "$(count KartFrame)" = 1 +mkdir -p "$T/art"; echo png > "$T/art/p.png"; echo png > "$T/art/icon.png" +"$HERE/install.sh" --art "$T/art" "$BUILD" > "$T/install3.log" 2>&1 +APPID=$(field KartFrame appid) +check "art copied under the appid" test -f "$STEAM/userdata/1/config/grid/${APPID}p.png" -a -f "$STEAM/userdata/1/config/grid/${APPID}_icon.png" +KARTFRAME_APP=$T/app-dev "$HERE/install.sh" --dev "$BUILD" > "$T/install4.log" 2>&1 +check "--dev adds a separate shortcut" test "$(count 'KartFrame (dev)')" = 1 -a "$(count KartFrame)" = 1 +check "--dev copies the build to bin and points at it" test "$(field 'KartFrame (dev)' Exe)" = "\"$K/bin/kartframe\"" -a -x "$T/app-dev/kartframe.sh" +# a tarball (top folder or flat) +mkdir -p "$T/tb/kartframe-1"; cp -r "$BUILD"/* "$T/tb/kartframe-1/"; tar -czf "$T/rel.tar.gz" -C "$T/tb" kartframe-1 +rm -rf "$T/app"; "$HERE/install.sh" "$T/rel.tar.gz" > "$T/install5.log" 2>&1 +check "tarball unpacks into the app dir only" test -x "$T/app/kartframe" -a -f "$T/app/resources/a" +mkdir -p "$T/kf"; mkdir -p "$KARTFRAME_HOME/game"; mkdir -p "$HOME/.local/share/WiiCompiled"; echo mine > "$HOME/.local/share/WiiCompiled/x"; echo save > "$KARTFRAME_HOME/keep.txt" +"$HERE/uninstall.sh" > "$T/un1.log" 2>&1 +check "uninstall removes shortcut and app" test "$(count KartFrame)" = 0 -a ! -d "$T/app" +check "uninstall keeps the dev shortcut and the user data" test "$(count 'KartFrame (dev)')" = 1 -a -f "$KARTFRAME_HOME/keep.txt" +KARTFRAME_APP=$T/app-dev "$HERE/uninstall.sh" --dev > "$T/un2.log" 2>&1 +check "uninstall --dev removes it" test "$(count 'KartFrame (dev)')" = 0 -a "$(count)" = "$BEFORE" +"$HERE/uninstall.sh" --purge > "$T/un3.log" 2>&1 +check "--purge removes KARTFRAME_HOME only" test ! -e "$KARTFRAME_HOME" -a -f "$HOME/.local/share/WiiCompiled/x" + +# ---- kartframe.sh with a fake vrcmd, fake vrtiming and a fake game ---- +unset KARTFRAME_STEAM_DIR +W=$T/w; mkdir -p "$W/bin" "$W/app/tools" "$W/openvr" "$W/svr" "$W/home/bin" +export KARTFRAME_HOME=$W/home KARTFRAME_STEAMVR_DIR=$W/svr KARTFRAME_VRSETTINGS=$W/vrsettings.json +touch "$W/svr/libopenvr_api.so" +cp "$HERE/kartframe.sh" "$HERE/Config.frame.toml" "$W/app/"; cp "$HERE/tools/session-report.py" "$W/app/tools/" +cat > "$W/app/tools/vrtiming.py" << 'PY' +import sys, time +open(sys.argv[2], "w").write("fake vrtiming\n"); time.sleep(60) +PY +cat > "$W/bin/vrcmd" << 'SH' +#!/bin/bash +echo "$@" >> "$FAKE_VRCMD_LOG" +[ -n "$FAKE_VRCMD_NOWRITE" ] && exit 0 +python3 -I - "$2" "$3" "$FAKE_VRSETTINGS" << 'PY' +import json, os, sys +key, val, path = sys.argv[1:] +sec, name = key.rsplit(".", 1) +d = json.load(open(path)) if os.path.exists(path) else {} +d.setdefault(sec, {})[name] = int(val) +json.dump(d, open(path, "w")) +PY +SH +cat > "$W/home/bin/kartframe" << 'SH' +#!/bin/bash +D=$(dirname "$0")/../UserData/Logs/run1; mkdir -p "$D"; [ -f "$(dirname "$0")/../portable.txt" ] || echo "NO PORTABLE" > "$D/bad" +{ echo "env FDM=$DISABLE_VULKAN_FDM_INJECTION_LAYER TU=$TU_DEBUG PACING=$KARTFRAME_PACING"; echo "args: $*"; } > "$D/console.log" +echo "game stdout"; sleep 3 +SH +chmod +x "$W/bin/vrcmd" "$W/home/bin/kartframe" "$W/app/kartframe.sh" +export FAKE_VRCMD_LOG=$W/vrcmd.log FAKE_VRSETTINGS=$KARTFRAME_VRSETTINGS +run() { env PATH="$W/bin:$PATH" SteamAppId=4242 "$@" "$W/app/kartframe.sh" "$W/home/bin/kartframe" --steamarg > "$W/out.txt" 2>&1; echo $? > "$W/rc"; } +run +S=$KARTFRAME_HOME/sessions/latest +check "wrapper exits with the game's status" test "$(cat "$W/rc")" = 0 +for k in preferredRefreshRate=120 framesToThrottle=0 additionalFramesToPredict=0 motionSmoothingOverride=2 resolutionOverride=1728; do + check "vrcmd --set-settings-int steam.app.4242.${k%=*} ${k#*=}" has "$W/vrcmd.log" "^--set-settings-int steam.app.4242.${k%=*} ${k#*=}\$" +done +check "read-back logged as matching" has "$S/launch.log" "read-back: 5 of 5" +check "game log copied into the session" has "$S/game.log" "args: --steamarg" +check "vrtiming.log written" has "$S/vrtiming.log" "fake vrtiming" +check "report.txt written" test -s "$S/report.txt" +check "stdout.log written" has "$S/stdout.log" "game stdout" +check "portable.txt made" test -f "$KARTFRAME_HOME/portable.txt" +check "wrapper never uses WiiCompiled/XDG" test ! -e "$XDG_DATA_HOME/WiiCompiled" +check "Config.toml seeded with the game path" has "$KARTFRAME_HOME/UserData/Config.toml" "dvd_root = \"$KARTFRAME_HOME/game/DATA\"" +check "vrtiming logger stopped at exit" test -z "$(pgrep -f "$W/app/tools/vrtiming.py")" +check "no switches set by default" has "$S/game.log" "env FDM= TU= PACING=" +sleep 1 +# mismatch logged +echo '{"steam.app.4242": {"preferredRefreshRate": 72, "framesToThrottle": 0}}' > "$KARTFRAME_VRSETTINGS"; run FAKE_VRCMD_NOWRITE=1 +check "mismatch logged when SteamVR dropped the settings" has "$KARTFRAME_HOME/sessions/latest/launch.log" "MISMATCH steam.app.4242.preferredRefreshRate" +check "missing settings logged as mismatches, not as passed" has "$KARTFRAME_HOME/sessions/latest/launch.log" "read-back: 1 of 5" +sleep 1 +# switches and session.env +printf 'KARTFRAME_SYSMEM=1\nKARTFRAME_SESSION_ENV_ONCE=1\n' > "$KARTFRAME_HOME/session.env" +run KARTFRAME_FDM_LAYER=0 KARTFRAME_PACING=pipelined KARTFRAME_REFRESH_HZ=90 +S=$KARTFRAME_HOME/sessions/latest +check "A/B switches reach the game" has "$S/game.log" "env FDM=1 TU=sysmem PACING=pipelined" +check "90 Hz choice applied" has "$W/vrcmd.log" "preferredRefreshRate 90" +check "session.env deleted after use, copy kept" test ! -f "$KARTFRAME_HOME/session.env" -a -f "$S/session.env" +sleep 1 +# kit check +mkdir -p "$W/app/kit"; echo '{"fingerprint":"kitA"}' > "$W/app/kit/kit.json"; echo kitB > "$W/home/bin/kartframe.fingerprint" +run +check "kit mismatch refuses (exit 78)" test "$(cat "$W/rc")" = 78 +check "refusal logged" has "$KARTFRAME_HOME/sessions/latest/launch.log" "REFUSING TO START" +sleep 1 +echo kitA > "$W/home/bin/kartframe.fingerprint"; run +check "matching kit starts" test "$(cat "$W/rc")" = 0 +sleep 1 +# not a library launch +run SteamAppId=; check "no SteamAppId: settings skipped, game still runs" has "$KARTFRAME_HOME/sessions/latest/launch.log" "no SteamAppId" +echo +[ $fails = 0 ] && echo "all passed" || { echo "$fails failed"; exit 1; } diff --git a/frame/uninstall.sh b/frame/uninstall.sh new file mode 100755 index 0000000..f498e9a --- /dev/null +++ b/frame/uninstall.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Removes KartFrame's Steam shortcut and program folder. The data folder (game files, built game, +# saves, Config.toml, sessions) stays unless --purge. +# +# frame/uninstall.sh [--dev] [--name NAME] [--purge] +# +# Environment: KARTFRAME_APP, KARTFRAME_HOME, KARTFRAME_SKIP_STEAM, KARTFRAME_STEAM_DIR as in install.sh. +# --purge also removes $KARTFRAME_HOME (game files, saves, Config.toml); there is no undo. +set -e +HERE=$(cd "$(dirname "$0")" && pwd) +DEV=0; NAME=; PURGE=0 +while [ $# -gt 0 ]; do + case $1 in + --dev) DEV=1 ;; + --name) NAME=$2; shift ;; + --purge) PURGE=1 ;; + -h | --help) sed -n '2,/^set -e/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option $1" >&2; exit 2 ;; + esac + shift +done +if [ "$DEV" = 1 ]; then + NAME=${NAME:-KartFrame (dev)}; APP=${KARTFRAME_APP:-$HOME/Games/KartFrame/app-dev} +else + NAME=${NAME:-KartFrame}; APP=${KARTFRAME_APP:-$HOME/Games/KartFrame/app} +fi +KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame} +STEAM=${KARTFRAME_STEAM_DIR:-$HOME/.local/share/Steam} +if pgrep -f "^${KARTFRAME_HOME:-$HOME/.local/share/KartFrame}/bin/kartframe( |$)" > /dev/null 2>&1; then echo "the game is running; quit it first" >&2; exit 1; fi + +if [ -n "$KARTFRAME_SKIP_STEAM" ]; then + echo "KARTFRAME_SKIP_STEAM set: Steam left alone" +elif [ -n "$KARTFRAME_STEAM_DIR" ]; then + for CFG in "$STEAM"/userdata/*/config; do + [ -d "$CFG" ] || continue + N=$(python3 "$HERE/steam-cef.py" --vdf "$CFG/shortcuts.vdf" --name "$NAME" --remove) + echo "shortcut '$NAME' in $CFG/shortcuts.vdf: removed=$N" + done +else + curl -s --max-time 3 127.0.0.1:8080/json > /dev/null || { echo "Steam is not reachable; start it, or the shortcut stays" >&2; exit 1; } + CEF="python3 $HERE/steam-cef.py" + ID=$($CEF "(() => { for (const a of (appStore.allApps || [])) { if (a.display_name === '$NAME' && a.app_type === 1073741824) return a.appid; } return 0; })()" 2> /dev/null || echo 0) + if [ "$ID" != 0 ] && [ -n "$ID" ]; then + $CEF "(() => { SteamClient.Apps.RemoveShortcut($ID); return 1; })()" > /dev/null + echo "removed the '$NAME' shortcut (appid $ID)" + rm -f "$HOME"/.local/share/Steam/userdata/*/config/grid/"$ID"{,p,_hero,_logo,_icon}.png + else + echo "Steam has no '$NAME' shortcut" + fi +fi + +[ -d "$APP" ] && { rm -rf "$APP"; echo "removed $APP"; } +if [ "$PURGE" = 1 ]; then + echo "--purge: removing $KFHOME (game files, built game, saves, config, logs)" + rm -rf "$KFHOME" +else + echo "kept your data in $KFHOME; --purge removes it" +fi diff --git a/runtime/include/vr/frame_controls.h b/runtime/include/vr/frame_controls.h new file mode 100644 index 0000000..de728b6 --- /dev/null +++ b/runtime/include/vr/frame_controls.h @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +// Steam Frame controller layout as pure functions, so the table test can check it without OpenXR. +// +// right trigger, A accelerate left trigger reverse +// B brake / back X, left bumper use item +// Y look back both grips, right bumper drift +// left D-pad D-pad right Menu Start +// left View tap: back (B), hold 0.5 s: VR settings, press while open: close + +#include "vr/quest_input.h" + +#include +#include +#include + +namespace mkw::vr::frame { + +struct Inputs { + float right_trigger = 0, left_trigger = 0; + float right_squeeze = 0, left_squeeze = 0; + bool a = false, b = false, x = false, y = false; + bool menu = false, view = false; + bool left_bumper = false, right_bumper = false; + std::array dpad{}; // up, down, left, right + float left_stick_x = 0, left_stick_y = 0, right_stick_x = 0, right_stick_y = 0; +}; + +struct Context { + bool grips_hold_wheel = false; // cockpit with hand steering on + bool view_back_pulse = false; // a View tap, sent as B for a short pulse after release +}; + +// Fills the gameplay fields of a QuestInput (everything but poses and wheel state). +inline QuestInput Map(const Inputs& in, const Context& ctx, QuestInput base = {}) noexcept { + QuestInput q = base; + q.active = true; + q.cockpit_controls = false; + q.steering_x = in.left_stick_x; + q.steering_y = in.left_stick_y; + q.tricks_x = in.right_stick_x; + q.tricks_y = in.right_stick_y; + q.accelerate = std::max(in.right_trigger, in.a ? 1.0f : 0.0f); + q.confirm = in.a; + q.reverse = in.left_trigger > 0.5f; + q.brake = in.b || ctx.view_back_pulse; + q.item = (in.x || in.left_bumper) ? 1.0f : 0.0f; + q.look_back = in.y; + q.drift = (in.right_bumper || (!ctx.grips_hold_wheel && std::max(in.left_squeeze, in.right_squeeze) > 0.5f)) ? 1.0f : 0.0f; + q.trick = false; + q.tricks_y = std::clamp(q.tricks_y + (in.dpad[0] ? 1.f : 0.f) - (in.dpad[1] ? 1.f : 0.f), -1.f, 1.f); + q.tricks_x = std::clamp(q.tricks_x + (in.dpad[3] ? 1.f : 0.f) - (in.dpad[2] ? 1.f : 0.f), -1.f, 1.f); + q.pause = in.menu; + return q; +} + +// View button: a tap goes back on release, a hold of kHoldNs opens the VR settings, a press while +// the settings are open closes them (and never also goes back). +class ViewButton { +public: + static constexpr int64_t kHoldNs = 500'000'000, kPulseNs = 120'000'000; + struct Result { + bool open_panel = false; // hold completed: open the settings + bool close_press = false; // pressed while open: close them + bool back_pulse = false; // tap released recently: B + }; + Result Update(bool down, int64_t now, bool panel_open) noexcept { + Result r; + const bool pressed = down && !held_; + if (pressed) { + down_at_ = now; + consumed_ = panel_open; + r.close_press = panel_open; + } + if (down && !consumed_ && now - down_at_ >= kHoldNs) { + r.open_panel = true; + consumed_ = true; + } + if (!down && held_ && !consumed_) pulse_until_ = now + kPulseNs; + held_ = down; + r.back_pulse = now < pulse_until_; + return r; + } + void Reset() noexcept { held_ = consumed_ = false; down_at_ = pulse_until_ = 0; } +private: + bool held_ = false, consumed_ = false; + int64_t down_at_ = 0, pulse_until_ = 0; +}; + +} // namespace mkw::vr::frame diff --git a/runtime/include/vr/openxr_input.h b/runtime/include/vr/openxr_input.h index 741e064..f39ad3e 100644 --- a/runtime/include/vr/openxr_input.h +++ b/runtime/include/vr/openxr_input.h @@ -5,6 +5,7 @@ #if defined(MKW_ENABLE_OPENXR) #include "vr/camera_toggle.h" +#include "vr/frame_controls.h" #include "vr/openxr_driving.h" #include "vr/openxr_runtime.h" #include "vr/openxr_settings_panel.h" @@ -171,11 +172,15 @@ private: XrAction m_frame_start = XR_NULL_HANDLE; XrAction m_frame_back = XR_NULL_HANDLE; std::array m_frame_dpad{}; // up, down, left, right + // Bound directly on the Frame profile (XR_VALVE_frame_controller_interaction): throttle, + // reverse and brake, and the right bumper's drift. Nothing goes through the Touch mapping. + XrAction m_frame_accelerate = XR_NULL_HANDLE; // float: right trigger, A + XrAction m_frame_reverse = XR_NULL_HANDLE; // float: left trigger + XrAction m_frame_brake = XR_NULL_HANDLE; // boolean: B + XrPath m_frame_profile = XR_NULL_PATH; + bool m_frame_profile_suggested = false; bool m_frame_layout = false; - bool m_frame_back_held = false; - bool m_frame_back_consumed = false; - XrTime m_frame_back_down = 0; - XrTime m_frame_back_pulse_until = 0; + frame::ViewButton m_frame_view; XrPath m_hand_paths[kHands]{}; XrSpace m_aim_spaces[kHands]{}; XrSpace m_grip_spaces[kHands]{}; diff --git a/runtime/src/vr/openxr_input.cpp b/runtime/src/vr/openxr_input.cpp index 3618bfb..51addc6 100644 --- a/runtime/src/vr/openxr_input.cpp +++ b/runtime/src/vr/openxr_input.cpp @@ -333,8 +333,11 @@ bool OpenXRInput::CreateActions() { info.countSubactionPaths=1;info.subactionPaths=m_hand_paths; if(!Check(xrCreateAction(m_action_set,&info,spec.action),spec.name)) return false; } - // Steam Frame actions carry no hand: SteamVR may bind them to either controller. - for (const Spec& spec : std::array{{ + // Steam Frame actions carry no hand: the Frame profile binds them in code, and a SteamVR binding file may bind them to either controller. + for (const Spec& spec : std::array{{ + {&m_frame_accelerate,"frame_accelerate","Accelerate",XR_ACTION_TYPE_FLOAT_INPUT}, + {&m_frame_reverse,"frame_reverse","Reverse",XR_ACTION_TYPE_FLOAT_INPUT}, + {&m_frame_brake,"frame_brake","Brake / back",XR_ACTION_TYPE_BOOLEAN_INPUT}, {&m_frame_item,"frame_item","Use item",XR_ACTION_TYPE_BOOLEAN_INPUT}, {&m_frame_look_back,"frame_look_back","Look back",XR_ACTION_TYPE_BOOLEAN_INPUT}, {&m_frame_drift,"frame_drift","Drift / hop",XR_ACTION_TYPE_BOOLEAN_INPUT}, @@ -362,6 +365,7 @@ bool OpenXRInput::SuggestBindings() { } std::vector suggested; if(std::string_view(profile)=="/interaction_profiles/htc/vive_controller") m_vive_profile=profile_path; + if(std::string_view(profile)=="/interaction_profiles/valve/frame_controller") m_frame_profile=profile_path; suggested.reserve(bindings.size()); for (const Binding& binding : bindings) { XrPath path = XR_NULL_PATH; @@ -395,6 +399,72 @@ bool OpenXRInput::SuggestBindings() { return true; }; + // Steam Frame controllers (XR_VALVE_frame_controller_interaction), bound entirely in code. + // Paths follow Valve's frame_controller input profile: right hand a, b, x, y, menu, bumper, + // trigger, squeeze, thumbstick; left hand dpad_*, view, bumper, trigger, squeeze, thumbstick; + // aim pose on both. One invalid path fails the whole call, so the grip pose and the grip + // component name (squeeze/value, else grip/value) are tried in turn. + { + const auto& extensions = m_runtime->EnabledExtensions(); + if (std::find(extensions.begin(), extensions.end(), std::string("XR_VALVE_frame_controller_interaction")) == + extensions.end()) { + Log(OpenXRLogLevel::Info, "XR_VALVE_frame_controller_interaction not enabled; Frame controller bindings skipped"); + } else { + const std::string L = "/user/hand/left/input/", R = "/user/hand/right/input/"; + for (const bool grip_pose : {true, false}) { + for (const char* squeeze : {"squeeze/value", "grip/value"}) { + std::vector paths; // keeps the c_str()s alive + paths.reserve(64); + std::vector bindings; + const auto add = [&](XrAction* action, const std::string& path) { + paths.push_back(path); + bindings.push_back({action, paths.back().c_str()}); + }; + paths.clear(); + add(&m_thumbstick, L + "thumbstick"); + add(&m_thumbstick, R + "thumbstick"); + add(&m_thumbstick_click, L + "thumbstick/click"); + add(&m_thumbstick_click, R + "thumbstick/click"); + // Menus and the settings panel: triggers select, A confirms, B backs out. + add(&m_trigger, L + "trigger/value"); + add(&m_trigger, R + "trigger/value"); + add(&m_squeeze, L + squeeze); + add(&m_squeeze, R + squeeze); + add(&m_button_primary, R + "a/click"); + add(&m_button_secondary, R + "b/click"); + add(&m_aim_pose, L + "aim/pose"); + add(&m_aim_pose, R + "aim/pose"); + if (grip_pose) { + add(&m_grip_pose, L + "grip/pose"); + add(&m_grip_pose, R + "grip/pose"); + } + add(&m_haptic, "/user/hand/left/output/haptic"); + add(&m_haptic, "/user/hand/right/output/haptic"); + add(&m_frame_accelerate, R + "trigger/value"); + add(&m_frame_accelerate, R + "a/click"); + add(&m_frame_reverse, L + "trigger/value"); + add(&m_frame_brake, R + "b/click"); + add(&m_frame_item, R + "x/click"); + add(&m_frame_item, L + "bumper/click"); + add(&m_frame_look_back, R + "y/click"); + add(&m_frame_drift, R + "bumper/click"); + add(&m_frame_start, R + "menu/click"); + add(&m_frame_back, L + "view/click"); + add(&m_frame_dpad[0], L + "dpad_up/click"); + add(&m_frame_dpad[1], L + "dpad_down/click"); + add(&m_frame_dpad[2], L + "dpad_left/click"); + add(&m_frame_dpad[3], L + "dpad_right/click"); + if (suggest("/interaction_profiles/valve/frame_controller", bindings, false)) { + m_frame_profile_suggested = true; + Log(OpenXRLogLevel::Info, std::string("Steam Frame controller bindings accepted (") + squeeze + + (grip_pose ? ", with grip pose)" : ", no grip pose)")); + break; + } + } + if (m_frame_profile_suggested) break; + } + } + } // Meta Quest Touch controllers (Quest 2 / 3 / Pro all expose this profile). const std::vector touch{ {&m_thumbstick, "/user/hand/left/input/thumbstick"}, @@ -625,9 +695,12 @@ void OpenXRInput::Destroy() { m_button_primary = m_button_secondary = m_menu = m_haptic = XR_NULL_HANDLE; m_options_x = m_options_y = XR_NULL_HANDLE; m_frame_item = m_frame_look_back = m_frame_drift = m_frame_start = m_frame_back = XR_NULL_HANDLE; + m_frame_accelerate = m_frame_reverse = m_frame_brake = XR_NULL_HANDLE; + m_frame_profile = XR_NULL_PATH; + m_frame_profile_suggested = false; + m_frame_view.Reset(); m_frame_dpad = {}; - m_frame_layout = m_frame_back_held = m_frame_back_consumed = false; - m_frame_back_down = m_frame_back_pulse_until = 0; + m_frame_layout = false; m_options_previous=0; m_aim_pose = m_grip_pose = XR_NULL_HANDLE; m_hand_paths[0] = m_hand_paths[1] = XR_NULL_PATH; @@ -663,6 +736,7 @@ void OpenXRInput::Idle() { m_panel_select_held = false; OpenXRPublishSettingsPanelPointer(false, 0.0f, 0.0f, false, 0.0f); m_first_person_click.Reset(); + m_frame_view.Reset(); ResetDriving(); StopRumble(); // Nothing stays held on the gamepad either while input is away. @@ -777,7 +851,9 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& ? static_cast(input_time - m_last_input_time) * 1.0e-9f : 0.0f; m_last_input_time = input_time; - // Steam Frame layout (see m_frame_item). An action counts as bound once SteamVR reports it active. + // Steam Frame layout (see m_frame_item). The layout latches when the runtime reports the Frame + // profile on either hand; as a fallback (a SteamVR binding file on another profile) when any + // frame_* action reports active. bool frame_bound=false; const auto frame_button=[&](XrAction action) { if(action==XR_NULL_HANDLE) return false; @@ -788,39 +864,48 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& frame_bound=true; return state.currentState==XR_TRUE; }; + const auto frame_float=[&](XrAction action) { + if(action==XR_NULL_HANDLE) return 0.f; + XrActionStateGetInfo info{XR_TYPE_ACTION_STATE_GET_INFO}; + info.action=action; + XrActionStateFloat state{XR_TYPE_ACTION_STATE_FLOAT}; + if(XR_FAILED(xrGetActionStateFloat(m_runtime->Session(),&info,&state)) || state.isActive!=XR_TRUE) return 0.f; + frame_bound=true; + return state.currentState; + }; const bool frame_item=frame_button(m_frame_item), frame_look_back=frame_button(m_frame_look_back); const bool frame_drift=frame_button(m_frame_drift), frame_start=frame_button(m_frame_start); - const bool frame_back=frame_button(m_frame_back); + const bool frame_back=frame_button(m_frame_back), frame_brake=frame_button(m_frame_brake); + const float frame_accelerate=frame_float(m_frame_accelerate), frame_reverse=frame_float(m_frame_reverse); std::array frame_dpad{}; for(size_t i=0;iSession(),m_hand_paths[hand],¤t)) && + current.interactionProfile==m_frame_profile) { + m_frame_layout=true; + Log(OpenXRLogLevel::Info,"Steam Frame button layout active (interaction profile)"); + } + } + } if(frame_bound && !m_frame_layout) { m_frame_layout=true; - Log(OpenXRLogLevel::Info,"Steam Frame button layout active"); + Log(OpenXRLogLevel::Info,"Steam Frame button layout active (frame actions bound)"); } // View: a tap is GameCube B (back), a 0.5 s hold opens the VR settings, and with the settings // open a press closes them. The tap is sent on release so that a hold never also goes back. - constexpr XrDuration kFrameBackHoldNs=500'000'000, kFrameBackPulseNs=120'000'000; - const bool frame_back_pressed=frame_back && !m_frame_back_held; - if(frame_back_pressed) { - m_frame_back_down=input_time; - m_frame_back_consumed=OpenXRSettingsPanelOpen(); - } - bool frame_back_panel=false; - if(frame_back && !m_frame_back_consumed && input_time-m_frame_back_down>=kFrameBackHoldNs) { - frame_back_panel=true; - m_frame_back_consumed=true; - } - if(!frame_back && m_frame_back_held && !m_frame_back_consumed) m_frame_back_pulse_until=input_time+kFrameBackPulseNs; - m_frame_back_held=frame_back; - const bool frame_back_pulse=input_time panel_hands = hands; panel_hands[0].primary |= boolean(m_options_x,0); panel_hands[0].secondary |= boolean(m_options_y,0); if(frame_back_panel) panel_hands[0].primary=panel_hands[0].secondary=true; - if(frame_back_pressed && m_frame_back_consumed) panel_hands[0].menu=true; + if(view.close_press) panel_hands[0].menu=true; const auto physical=m_runtime->RuntimeInfo().runtime_name.find("SteamVR")!=std::string::npos ? OpenXRReadPhysicalOptionsButtons() : PhysicalOptionsButtons{}; if(physical.valid) { @@ -924,22 +1009,24 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen& if(pause.pause && !panel.withheld) OpenXRRequestTutorialPause(); } else port.pause=hands[0].menu; if(m_frame_layout) { - // A modern gamepad layout over the GameCube pad, the same in both views: A or the right - // trigger accelerates, the right bumper drifts (no cockpit A = drift swap), and Menu is - // Start. D-pad up goes through the tricks axis, not `trick`, which MapQuestInput reads with - // the item as the VR settings chord. Either grip also drifts, except in the cockpit with - // hand steering on, where the grips only hold the wheel. - const bool grips_hold_wheel=port.cockpit_controls && RuntimeConfigFile::VrHandSteering(); - port.cockpit_controls=false; - port.item=(frame_item || port.item>.5f)?1.f:0.f; - port.look_back=frame_look_back; - port.drift=std::max(grips_hold_wheel ? 0.f : std::max(ui.buttons[0].squeeze,ui.buttons[1].squeeze), - frame_drift?1.f:0.f); - port.trick=false; - port.tricks_y=std::clamp(port.tricks_y+(frame_dpad[0]?1.f:0.f)-(frame_dpad[1]?1.f:0.f),-1.f,1.f); - port.tricks_x=std::clamp(port.tricks_x+(frame_dpad[3]?1.f:0.f)-(frame_dpad[2]?1.f:0.f),-1.f,1.f); - port.pause=frame_start; - port.brake=port.brake || frame_back_pulse; + // A modern gamepad layout over the GameCube pad, the same in both views (frame_controls.h). + // D-pad up goes through the tricks axis, not `trick`, which MapQuestInput reads with the + // item as the VR settings chord. Grips only hold the wheel in the cockpit with hand + // steering on; the right bumper drifts everywhere. + frame::Inputs in; + in.right_trigger=std::max(frame_accelerate,hands[1].trigger); + in.left_trigger=std::max(frame_reverse,hands[0].trigger); + in.right_squeeze=ui.buttons[1].squeeze;in.left_squeeze=ui.buttons[0].squeeze; + in.a=hands[1].primary;in.b=frame_brake||hands[1].secondary; + in.x=frame_item;in.left_bumper=false;in.y=frame_look_back; + in.right_bumper=frame_drift;in.menu=frame_start;in.view=frame_back; + in.dpad=frame_dpad; + in.left_stick_x=hands[0].stick_x;in.left_stick_y=hands[0].stick_y; + in.right_stick_x=hands[1].stick_x;in.right_stick_y=hands[1].stick_y; + frame::Context ctx; + ctx.grips_hold_wheel=port.cockpit_controls && RuntimeConfigFile::VrHandSteering(); + ctx.view_back_pulse=frame_back_pulse; + port=frame::Map(in,ctx,port); } if(panel.withheld) { const auto rawX=port.raw_steering_x,rawY=port.raw_steering_y; diff --git a/runtime/src/vr/quest_controller_models.inl b/runtime/src/vr/quest_controller_models.inl index 9399aa3..158d488 100644 --- a/runtime/src/vr/quest_controller_models.inl +++ b/runtime/src/vr/quest_controller_models.inl @@ -14,15 +14,29 @@ bool PublishQuestControllers() { #if defined(__ANDROID__) const char* resources=std::getenv("MKW_ANDROID_RESOURCES_DIR"); if(!resources) return false; -#else + const std::string modelDir=std::string(resources)+"/quest_touch_plus/"; + const char* modelLabel="Touch Plus"; +#elif defined(_WIN32) const char* base=SDL_GetBasePath(); if(!base) return false; - const std::string resources=std::string(base)+"resources"; + const std::string modelDir=std::string(base)+"resources/quest_touch_plus/"; + const char* modelLabel="Touch Plus"; +#else + // Linux (the Steam Frame): the simplified Frame models that frame/frame-controller-models.py + // converted from the user's own SteamVR install at setup time. Without them the procedural + // controllers stay. Nothing here ever asks OpenVR for the full render models (about 93k + // vertices animated on the CPU, 45 fps menus). + const std::string modelDir=(RuntimeConfigFile::ApplicationDataDirectory()/"controller_models"/"frame").string()+"/"; + const char* modelLabel="Steam Frame"; + if(!std::filesystem::exists(modelDir+"left.wccontroller") || !std::filesystem::exists(modelDir+"right.wccontroller")) { + std::fprintf(stderr,"[vr-menu] No converted Frame controller models in %s; procedural controllers\n",modelDir.c_str()); + return false; + } #endif ready=true; for(uint32_t hand=0;hand<2;++hand) { auto& asset=g_questControllerAssets[hand]; - const std::string prefix=std::string(resources)+"/quest_touch_plus/"+(hand?"right":"left"); + const std::string prefix=modelDir+(hand?"right":"left"); std::ifstream model(prefix+".wccontroller",std::ios::binary); char magic[4]{};uint32_t count=0,deltas=0; model.read(magic,4);model.read(reinterpret_cast(&count),4);model.read(reinterpret_cast(&deltas),4); @@ -40,7 +54,7 @@ bool PublishQuestControllers() { if(!texture) {ready=false;break;} aurora_set_vr_controller_texture(0x51530000+hand,width,height,rgba.data()); aurora_set_vr_controller_anchors(hand,asset.anchors.data()); - std::fprintf(stderr,"[vr-menu] Loaded Touch Plus %s: %u textured vertices\n",hand?"right":"left",count); + std::fprintf(stderr,"[vr-menu] Loaded %s %s controller: %u textured vertices\n",modelLabel,hand?"right":"left",count); } } if(!ready) return false; diff --git a/runtime/tests/frame_controls_tests.cpp b/runtime/tests/frame_controls_tests.cpp new file mode 100644 index 0000000..c8eab8b --- /dev/null +++ b/runtime/tests/frame_controls_tests.cpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "vr/frame_controls.h" +#include + +using namespace mkw::vr; +static int failures = 0; +static void Check(bool c, const char* m) { if (!c) { ++failures; std::cerr << "FAILED: " << m << '\n'; } } + +static PADStatus Pad(const frame::Inputs& in, frame::Context ctx = {}) { + return MapQuestInput(frame::Map(in, ctx)); +} +static bool Has(const PADStatus& p, uint16_t b) { return (p.button & b) == b; } + +int main() { + const uint16_t all = PAD_BUTTON_A | PAD_BUTTON_B | PAD_BUTTON_X | PAD_BUTTON_Y | PAD_BUTTON_START | + PAD_BUTTON_UP | PAD_BUTTON_DOWN | PAD_BUTTON_LEFT | PAD_BUTTON_RIGHT | + PAD_TRIGGER_L | PAD_TRIGGER_R | PAD_TRIGGER_Z; + { + frame::Inputs in; + const auto p = Pad(in); + Check(p.err == PAD_ERR_NONE && (p.button & all) == 0, "idle controllers press nothing"); + } + { frame::Inputs in; in.right_trigger = 1; auto p = Pad(in); + Check(Has(p, PAD_BUTTON_A) && p.analogA == 255, "right trigger accelerates (GC A)"); } + { frame::Inputs in; in.right_trigger = 0.3f; Check(!Has(Pad(in), PAD_BUTTON_A), "light right trigger does not"); } + { frame::Inputs in; in.a = true; Check(Has(Pad(in), PAD_BUTTON_A), "A accelerates"); } + { frame::Inputs in; in.left_trigger = 1; auto p = Pad(in); + Check(Has(p, PAD_BUTTON_B) && !Has(p, PAD_BUTTON_A), "left trigger reverses (GC B held)"); } + { frame::Inputs in; in.left_trigger = 1; in.right_trigger = 1; auto p = Pad(in); + Check(Has(p, PAD_BUTTON_B) && !Has(p, PAD_BUTTON_A), "reverse overrides throttle"); } + { frame::Inputs in; in.b = true; Check(Has(Pad(in), PAD_BUTTON_B), "B brakes / backs (GC B)"); } + { frame::Inputs in; in.x = true; auto p = Pad(in); Check(Has(p, PAD_TRIGGER_L) && p.triggerL == 255, "X uses item (GC L)"); } + { frame::Inputs in; in.left_bumper = true; Check(Has(Pad(in), PAD_TRIGGER_L), "left bumper uses item"); } + { frame::Inputs in; in.y = true; Check(Has(Pad(in), PAD_BUTTON_X), "Y looks back (GC X)"); } + { frame::Inputs in; in.right_bumper = true; Check(Has(Pad(in), PAD_TRIGGER_R), "right bumper drifts (GC R)"); } + { frame::Inputs in; in.left_squeeze = 1; Check(Has(Pad(in), PAD_TRIGGER_R), "left grip drifts"); } + { frame::Inputs in; in.right_squeeze = 1; Check(Has(Pad(in), PAD_TRIGGER_R), "right grip drifts"); } + { frame::Inputs in; in.right_squeeze = 1; in.left_squeeze = 1; frame::Context c; c.grips_hold_wheel = true; + Check(!Has(Pad(in, c), PAD_TRIGGER_R), "grips hold the wheel in the cockpit, no drift"); + in.right_bumper = true; Check(Has(Pad(in, c), PAD_TRIGGER_R), "right bumper drifts in the cockpit too"); } + { frame::Inputs in; in.dpad = {true, false, false, false}; Check(Has(Pad(in), PAD_BUTTON_UP), "D-pad up"); } + { frame::Inputs in; in.dpad = {false, true, false, false}; Check(Has(Pad(in), PAD_BUTTON_DOWN), "D-pad down"); } + { frame::Inputs in; in.dpad = {false, false, true, false}; Check(Has(Pad(in), PAD_BUTTON_LEFT), "D-pad left"); } + { frame::Inputs in; in.dpad = {false, false, false, true}; Check(Has(Pad(in), PAD_BUTTON_RIGHT), "D-pad right"); } + { frame::Inputs in; in.menu = true; Check(Has(Pad(in), PAD_BUTTON_START), "Menu is Start"); } + { frame::Inputs in; in.left_stick_x = 1; Check(Pad(in).stickX == 100, "left stick steers"); } + { frame::Inputs in; in.right_stick_y = 1; Check(Has(Pad(in), PAD_BUTTON_UP), "right stick up is a trick (D-pad up)"); } + { frame::Inputs in; in.view = true; Check((Pad(in).button & all) == 0, "View held alone presses nothing yet"); } + { frame::Context c; c.view_back_pulse = true; Check(Has(Pad({}, c), PAD_BUTTON_B), "View tap pulses B"); } + { // View: tap, hold, close + frame::ViewButton v; const int64_t ms = 1'000'000; + auto r = v.Update(true, 0, false); + Check(!r.open_panel && !r.back_pulse, "view press does nothing at once"); + r = v.Update(true, 400 * ms, false); Check(!r.open_panel, "not yet at 0.4 s"); + r = v.Update(true, 500 * ms, false); Check(r.open_panel, "opens settings at 0.5 s"); + r = v.Update(true, 900 * ms, false); Check(!r.open_panel, "opens once"); + r = v.Update(false, 950 * ms, true); Check(!r.back_pulse, "release after a hold does not go back"); + r = v.Update(true, 2000 * ms, true); Check(r.close_press && !r.open_panel, "press while open closes"); + r = v.Update(true, 3000 * ms, true); Check(!r.open_panel, "holding after close does not reopen"); + r = v.Update(false, 3100 * ms, false); Check(!r.back_pulse, "release after close does not go back"); + v.Update(true, 4000 * ms, false); + r = v.Update(false, 4100 * ms, false); Check(r.back_pulse, "tap goes back on release"); + r = v.Update(false, 4100 * ms + 200 * ms, false); Check(!r.back_pulse, "back is a short pulse"); + } + std::cerr << (failures ? "frame_controls_tests FAILED\n" : "frame_controls_tests ok\n"); + return failures ? 1 : 0; +}