mirror of
https://github.com/simplysanatori/KartFrame.git
synced 2026-10-17 12:01:20 +02:00
Merge branch 'native' into lane/B
This commit is contained in:
commit
047f48a00a
13 files changed
+1494
-46
No files matched your search
@@ -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
|
||||
Executable
+70
@@ -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."
|
||||
Executable
+386
@@ -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)
|
||||
Executable
+153
@@ -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
|
||||
Executable
+155
@@ -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
|
||||
Executable
+159
@@ -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))
|
||||
Executable
+168
@@ -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; }
|
||||
Executable
+58
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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],¤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<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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user