Merge branch 'native' into lane/B

This commit is contained in:
simplysanatori committed 2026-10-12 23:28:33 -04:00
commit 047f48a00a
13 files changed
+1494 -46

No files matched your search

+35
View File
@@ -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
+70
View File
@@ -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), '<pem removed>'),
(re.compile(r'\b(?:(?:ghp|gho|ghu|ghs|github_pat|xox[bp])_|sk-)[A-Za-z0-9_-]{16,}'), '<token>'),
(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'), '<email>'),
(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), '<uuid>'),
(re.compile(r'\b[0-9a-f]{2}([:-])[0-9a-f]{2}(\1[0-9a-f]{2}){4}\b', I), '<mac>'),
(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), '<ipv6>'),
(re.compile(r'\b(?!0\.0\.0\.0\b)(?!127\.)(?:\d{1,3}\.){3}\d{1,3}\b'), '<ip>'),
(re.compile(r'\b(\w*(?:_sn|serial_?no|serial_?number|android_?id|device_?id|imei)\b["\']?\s*[:=]\s*)\S+', I), r'\1<serial>'),
(re.compile(r'\b(b?ssid\b["\']?\s*[:=]?\s*)("[^"]*"|\'[^\']*\'|\S+)', I), r'\1<ssid>'),
(re.compile(r'(Hostname set to\s*)<*[^\s<>]+>*', I), r'\1<host>'),
(re.compile(r'\b7656119\d{10}\b'), '<steamid>'),
(re.compile(r'(userdata/)\d+'), r'\1<id>'),
(re.compile(r'/home/[^/\s]+'), '/home/<user>'),
]
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', '<host>', 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."
+386
View File
@@ -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('<II', TEXTURE_SIZE, TEXTURE_SIZE) + bytes(out)
# --- motions ------------------------------------------------------------------------------------
def motion_moves(motion, up_hint):
"""Functions (point -> 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)
+153
View File
@@ -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 <repo>/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 <repo>/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 <your Mario Kart Wii disc image> (once)
Then launch it from the library with SteamVR running.
Logs: $KFHOME/sessions/latest/ (launch.log, game.log, vrtiming.log, report.txt)
MSG
+155
View File
@@ -0,0 +1,155 @@
#!/bin/bash
# KartFrame launch wrapper, run by the Steam shortcut ("<this script> %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/<date-time>/ 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: <app>/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
+159
View File
@@ -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('<I', data[pos:pos + 4])[0]; pos += 4
else:
raise ValueError('unsupported vdf type %d' % t)
def vdf_dump(d):
out = b''
for k, v in d.items():
kb = k.encode() + b'\0'
if isinstance(v, dict): out += b'\0' + kb + vdf_dump(v)
elif isinstance(v, int): out += b'\2' + kb + struct.pack('<I', v & 0xffffffff)
else: out += b'\1' + kb + str(v).encode() + b'\0'
return out + b'\x08'
def vdf_load(path):
try:
data = open(path, 'rb').read()
except OSError:
return {'shortcuts': {}}
d, _ = vdf_parse(data, 0)
return d if 'shortcuts' in d else {'shortcuts': {}}
def vdf_save(path, d):
tmp = path + '.tmp'
open(tmp, 'wb').write(vdf_dump(d))
os.replace(tmp, path)
def vdf_shortcut(path, name, exe, startdir, opts):
d = vdf_load(path); sc = d['shortcuts']
key = next((k for k, v in sc.items() if v.get('AppName') == name), None)
if key is None:
key = str(max([int(k) for k in sc] + [-1]) + 1)
appid = zlib.crc32((exe + name).encode()) | 0x80000000
old = sc.get(key, {})
old.update({'appid': appid, 'AppName': name, 'Exe': exe, 'StartDir': startdir, 'LaunchOptions': opts})
for k, v in (('icon', ''), ('ShortcutPath', ''), ('IsHidden', 0), ('AllowDesktopConfig', 1), ('AllowOverlay', 1),
('OpenVR', 1), ('Devkit', 0), ('DevkitGameID', ''), ('LastPlayTime', 0)):
old.setdefault(k, v)
old.setdefault('tags', {})
sc[key] = old
vdf_save(path, d)
return appid
def vdf_remove(path, name):
d = vdf_load(path); sc = d['shortcuts']
keys = [k for k, v in sc.items() if v.get('AppName') == name]
for k in keys: del sc[k]
if keys: vdf_save(path, d)
return len(keys)
if __name__ == '__main__':
a = sys.argv[1:]
if a and a[0] == '--vdf':
opt = {}
i = 2
while i < len(a):
if a[i] == '--remove': opt['remove'] = True; i += 1
else: opt[a[i][2:]] = a[i + 1]; i += 2
if opt.get('remove'):
print(1 if vdf_remove(a[1], opt['name']) else 0)
else:
print(vdf_shortcut(a[1], opt['name'], opt['exe'], opt['startdir'], opt.get('opts', '')))
else:
print(json.dumps(evaluate(sys.argv[1]), indent=1))
+168
View File
@@ -0,0 +1,168 @@
#!/bin/bash
# Tests for install.sh, uninstall.sh, kartframe.sh and Config.frame.toml. Touches nothing real: a
# scratch Steam folder (a copy of shortcuts.vdf if there is one), a fake vrcmd on PATH, a fake game.
# frame/test-installer.sh [SCRATCH_DIR] (default ~/.cache/kf-test-installer, emptied first)
HERE=$(cd "$(dirname "$0")" && pwd)
REPO=$(cd "$HERE/.." && pwd)
T=${1:-$HOME/.cache/kf-test-installer}
rm -rf "$T"; mkdir -p "$T"
fails=0
ok() { echo "ok $*"; }
bad() { echo "FAIL $*"; fails=$((fails + 1)); }
check() { local d=$1; shift; if "$@"; then ok "$d"; else bad "$d"; fi; }
has() { grep -q -- "$2" "$1"; }
REALHOME=$HOME
export KARTFRAME_SKIP_STEAM= HOME=$T/home XDG_DATA_HOME=$T/home/xdg # XDG must stay unused
mkdir -p "$HOME"
# ---- Config.frame.toml: every key is one the parser reads, every value valid ----
python3 -I - "$HERE/Config.frame.toml" "$REPO/runtime/include/runtime_config.h" << 'PY' && ok "Config.frame.toml keys and values" || bad "Config.frame.toml keys and values"
import re, sys, tomllib
cfg = tomllib.load(open(sys.argv[1], "rb"))
src = open(sys.argv[2]).read()
known = set(re.findall(r'FindConfig\w*(?:<[^>]*>)?\(\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; }
+58
View File
@@ -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
+91
View File
@@ -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 <algorithm>
#include <array>
#include <cstdint>
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<bool, 4> 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
+9 -4
View File
@@ -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<XrAction, 4> 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]{};
+125 -38
View File
@@ -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<Spec,9>{{
// 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<Spec,12>{{
{&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<XrActionSuggestedBinding> 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<std::string> paths; // keeps the c_str()s alive
paths.reserve(64);
std::vector<Binding> 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<Binding> 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<float>(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<bool,4> frame_dpad{};
for(size_t i=0;i<frame_dpad.size();++i) frame_dpad[i]=frame_button(m_frame_dpad[i]);
// Latched for the session: SteamVR briefly reports the actions inactive (a controller dozing,
// focus changes), and the layout must not switch under the player mid-race.
if(!m_frame_layout && m_frame_profile!=XR_NULL_PATH) {
for(uint32_t hand=0;hand<kHands && !m_frame_layout;++hand) {
XrInteractionProfileState current{XR_TYPE_INTERACTION_PROFILE_STATE};
if(XR_SUCCEEDED(xrGetCurrentInteractionProfile(m_runtime->Session(),m_hand_paths[hand],&current)) &&
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<m_frame_back_pulse_until;
const auto view=m_frame_view.Update(frame_back,input_time,OpenXRSettingsPanelOpen());
const bool frame_back_panel=view.open_panel;
const bool frame_back_pulse=view.back_pulse;
std::array<wii_remote::HandInputs, kHands> 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;
+18 -4
View File
@@ -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<char*>(&count),4);model.read(reinterpret_cast<char*>(&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;
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "vr/frame_controls.h"
#include <iostream>
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;
}