Author SHA1 Message Date
simplysanatoriandClaude Opus 5.5 742811e5ef Steam Frame controller models on by default, simplified to Touch Plus size
frame-controller-models.py now runs a QEM edge-collapse simplifier on each
hand (about 4,000 triangles, seams, boundaries and moving parts kept), so the
menus stay at 90 Hz; install.sh converts them unless WCF_FRAME_MODELS=0.
README: change 9, and the known issue about Quest controllers removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7dBRsvawKKn1F9gf68SYv
2026-10-08 14:55:14 -04:00
simplysanatoriandClaude Opus 5.5 8287058a20 Optional Steam Frame controller models; README for the grip change
frame-controller-models.py converts the Frame controller render models SteamVR
keeps on every Frame into the game's WCC1 controller format (grip space, baked
AO texture, nine animated controls, tutorial landmarks), and the launch wrapper
puts them in place of the Touch Plus files. They looked right in the headset
but cost too much: ~7x the vertices, re-animated on the CPU each frame, and the
menus fell to 45-60 fps. install.sh therefore only converts them with
WCF_FRAME_MODELS=1. README documents that and change 8 (grips hold the wheel in
the cockpit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7dBRsvawKKn1F9gf68SYv
2026-10-08 13:14:13 -04:00
simplysanatoriandClaude Opus 5.5 ba0d21a1df Frame layout: grips hold the wheel in the cockpit, the right bumper drifts
With hand steering on, a grip squeeze in the first-person cockpit both grabbed
the wheel and drifted. Grips now drift only outside the cockpit (or with hand
steering off); the right bumper drifts everywhere. The SteamVR binding no
longer sends a grip click to frame_drift, and the seed config turns hand
steering back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7dBRsvawKKn1F9gf68SYv
2026-10-08 12:22:29 -04:00
7 changed files with 442 additions and 28 deletions

No files matched your search

+35 -15
View File
@@ -7,7 +7,7 @@ moment the game starts; with the changes here it runs at 90 Hz with the Frame co
like a normal gamepad, straight from the Steam library, no PC involved.
The branch to use is `steam-frame`. Everything the fork adds lives in [`steam-frame/`](steam-frame/)
plus five small commits to `runtime/` and `aurora-main/`. Upstream's own README is kept as
plus six small commits to `runtime/` and `aurora-main/`. Upstream's own README is kept as
[README.upstream.md](README.upstream.md); read it for what the port itself does, this file is only
about the Frame.
@@ -96,8 +96,8 @@ What the patches do, in the order they were needed:
6. **Settings that suit the Frame**, seeded into `Config.toml` on the first launch by the wrapper
([`steam-frame/Config.frame.toml`](steam-frame/Config.frame.toml)): interpolation on auto, menu
background quality 0, hand steering off (the grips are drift on this layout and kept grabbing the
wheel in first person), render scale 1.0 and resolution multiplier 1.0. In SteamVR the app gets
background quality 0, hand steering on (see change 8), render scale 1.0 and resolution
multiplier 1.0. In SteamVR the app gets
`preferredRefreshRate 90`, `framesToThrottle 0` and `additionalFramesToPredict 0`; those went in
together with the interpolation and I have not tested them one at a time.
@@ -107,6 +107,28 @@ What the patches do, in the order they were needed:
come up and starts the game activity itself, so a launch from the Steam library goes straight to
the title screen in about ten seconds.
8. **Grips in the cockpit.** With upstream's hand steering on, a grip squeeze in first person both
grabbed the wheel and drifted, because the grips are drift on this layout. Now the grips drift
in the original and diorama cameras and only hold the wheel in first person; the right bumper
drifts in every camera. (`openxr_input.cpp`; the binding no longer sends a grip click to
drift.) With `hand_steering = false` the grips drift everywhere and you steer with the stick.
9. **Frame controllers in the menus.** Upstream draws SteamVR's own model of the controller you
hold when SteamVR offers one and its built-in Quest Touch Plus models otherwise; inside Lepton
the app gets no SteamVR render models, so it always showed Quest controllers. SteamVR keeps the
Frame controller models on every Frame, and
[`steam-frame/frame-controller-models.py`](steam-frame/frame-controller-models.py) converts
them into the game's format, with the buttons, trigger, grip and stick animated. `install.sh`
runs it and the wrapper copies the result over the Touch files on each launch. The first
version used the full models, about seven times the Touch Plus vertex count; the game
re-animates every vertex on the CPU each frame a menu is up, so the menus fell to 45 to 60 fps
and the background flickered when the VR settings opened mid-race. The script now simplifies
each hand to about 4,000 triangles (roughly the Touch Plus size), keeping the outline, the
UV seams and the moving parts intact, and the menus are back at 90. `WCF_FRAME_MODELS=0` when
installing keeps the Quest controllers. The tutorials still point at Quest buttons; the
controls table below is what the Frame buttons actually do. After an APK update the Frame
models show from the second launch on, because the app unpacks its own resources again first.
Everything else, the cameras, the hand IK, the HUD, Retro Rewind support, is upstream's work and is
unchanged.
@@ -126,17 +148,10 @@ Known problems and things I have not tested:
- **The empty item box stays on screen** after the roulette slides up. The attempted fix (change 5)
does not do it. It is cosmetic, so I live with it.
- **The controllers drawn in the menus are Quest Touch controllers**, not the Frame's. Upstream
draws SteamVR's own model of whatever controller you hold when SteamVR offers one and falls back
to its built-in Touch Plus models otherwise; inside Lepton the app gets no SteamVR render models,
so it always falls back. The buttons the tutorials point at are the Quest ones too; the table
below is what the Frame buttons actually do.
- **Grips do not grab the wheel in first person.** That is on purpose: on this layout the grips
are hop and drift, and with upstream's hand steering on, squeezing a grip to drift also grabbed
the wheel and steering jumped to your hand. So `hand_steering = false`, and you steer with the
stick in every camera. If you would rather steer with your hands in the cockpit, set
`hand_steering = true` under `[vr]` in `Config.toml` (path under "Where things are" below) and
drift with the right bumper instead. I have not tried that combination on the Frame.
- **Steering with the grips in first person works, roughly.** Change 8 is newer than the rest and
has had one session; grabbing the wheel works but upstream's grab range and steering response
were tuned for Quest controllers and I have not adjusted them. Set `hand_steering = false` under
`[vr]` in `Config.toml` (path under "Where things are" below) to steer with the stick instead.
- **Subtle slowdowns remain.** Races are a solid 90 on the counter, but there are brief hitches
now and then that I notice and cannot pin to anything in the logs. Good enough that I play it;
not perfect.
@@ -169,7 +184,8 @@ The binding is installed per app by `install.sh`; the game picks the layout up b
| Left trigger, or B | Brake, reverse / cancel |
| X, or left bumper | Use or hold item |
| Y | Look back |
| Grips, or right bumper | Hop and drift |
| Right bumper | Hop and drift |
| Grips | Hop and drift; in first person, hold the wheel instead |
| D-pad | D-pad (up is a trick) |
| Right stick directions | Tricks and wheelies |
| Right stick click | Cycle the three cameras |
@@ -346,6 +362,10 @@ Over about a week in October 2026, in a series of Claude Code sessions on the Fr
- The last change before publishing was a mistake: raising the resolution for sharper menus,
which I reported as a performance regression the next day and which was rolled back. The
numbers in the "Resolution" note above are from that log.
- After publishing: the grips change (change 8), and the Frame controller models (change 9), which
were reverted once because the full models made the menus lag, and came back after the AI wrote
a mesh simplifier for them. I checked both in the headset; the frame-timing log of that run
shows 90 Hz throughout the menus.
- This README was written by the AI from the session logs and its memory notes, at my request and
in my voice, and I read it before publishing. If something in it is wrong, it is because I
missed it; open an issue with the Lepton log and the game's `console.log` attached.
+7 -4
View File
@@ -925,13 +925,16 @@ void OpenXRInput::Sync(XrTime predicted_display_time, const OpenXRPointerScreen&
} 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 grips or right bumper drift (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.
// 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(port.drift,frame_drift?1.f:0.f);
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);
+2 -2
View File
@@ -13,8 +13,8 @@ enabled = true
render_scale = 1.000000
# 1 = render at the headset's refresh rate (90 Hz under SteamVR), interpolated from the game's 60 fps.
frame_interpolation_fps = 1
# Grips are drift on the Frame layout; with this on they grabbed the wheel in first person instead.
hand_steering = false
# 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 held the menus at 40 fps on the Frame.
menu_shader_quality = 0
hand_hud = false
+1 -7
View File
@@ -4,7 +4,7 @@
"app_key": "@APPKEY@",
"category": "openxr",
"controller_type": "frame_controller",
"description": "Left stick steer; RT or A accelerate; LT or B brake/reverse; X or LB item; Y look back; grips or RB drift/hop; D-pad = D-pad (up = trick); Menu = Start/pause; View tap = back, hold = VR settings; right stick click = first-person toggle",
"description": "Left stick steer; RT or A accelerate; LT or B brake/reverse; X or LB item; Y look back; RB drift/hop, grips too except in first person with hand steering (grips hold the wheel there); D-pad = D-pad (up = trick); Menu = Start/pause; View tap = back, hold = VR settings; right stick click = camera",
"interaction_profile": "",
"name": "WiiCompiled VR - Steam Frame layout (local)",
"options": {},
@@ -102,9 +102,6 @@
"inputs": {
"pull": {
"output": "/actions/mkw_gameplay/in/squeeze"
},
"click": {
"output": "/actions/mkw_gameplay/in/frame_drift"
}
}
},
@@ -114,9 +111,6 @@
"inputs": {
"pull": {
"output": "/actions/mkw_gameplay/in/squeeze"
},
"click": {
"output": "/actions/mkw_gameplay/in/frame_drift"
}
}
},
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/env python3
"""Converts SteamVR's Steam Frame controller render models into the game's controller model format.
The game draws a controller model in each hand in its VR menus and tutorials. Upstream ships Meta's
Touch Plus models (runtime/assets/quest_touch_plus, from WebXR Input Profiles) and, on Windows, asks
SteamVR for the real controller's model; under Lepton that SteamVR interface is not available, so
on the Frame you get Quest controllers. This reads the Frame controller models that SteamVR's own
frame_controller driver keeps on every Frame and writes them in the same format, `WCC1`, so the
launch wrapper can put them in place of the Touch Plus files. Valve's models are only read from
your own SteamVR install, never redistributed.
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 (WCF_FRAME_MODEL_TRIANGLES; texture seams leave the
right hand at about 4800, Touch Plus has 4470);
- the base colour texture with its ambient occlusion baked in, at 1024x1024;
- the nine motions the game animates, as SteamVR's model describes them: 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]
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('WCF_FRAME_MODEL_TRIANGLES', '4000')) # per hand; Touch Plus has 4470
# --- 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) < 2:
sys.exit(__doc__)
out = sys.argv[1]
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)
+7
View File
@@ -25,6 +25,13 @@ touch "$DEST/app/lepton-show-flatscreen" # lets Lepton show the 2D launcher wi
cp "$HERE/wiicompiled-launch.sh" "$DEST/wiicompiled-launch.sh"; chmod +x "$DEST/wiicompiled-launch.sh"
cp "$HERE/Config.frame.toml" "$DEST/Config.frame.toml"
cp "$HERE/steam-cef.py" "$DEST/tools/steam-cef.py"; chmod +x "$DEST/tools/steam-cef.py"
# The Frame's own controller models for the game's menus, converted from this Frame's SteamVR install
# and simplified to about the Touch Plus models' size (the game re-animates every vertex on the CPU
# each frame a menu is up). WCF_FRAME_MODELS=0 keeps the Quest controllers.
if [ "${WCF_FRAME_MODELS:-1}" = 1 ]; then
python3 "$HERE/frame-controller-models.py" "$DEST/frame_controller_models" \
|| echo "warning: could not convert SteamVR's Frame controller models; the menus will show Quest controllers"
fi
CEF="python3 $DEST/tools/steam-cef.py"
# The Steam shortcut, through Steam's own client API (no editing of shortcuts.vdf behind its back).
+9
View File
@@ -26,6 +26,15 @@ if [ -n "$STEAM_COMPAT_DATA_PATH" ]; then
cp "$HERE/Config.frame.toml" "$CFG/Config.toml" && chmod 664 "$CFG/Config.toml"
echo "$(date +%T) seeded $CFG/Config.toml with the Frame settings" >> "$LOG"
fi
# Steam Frame controller models (made by install.sh from SteamVR's own) in place of the Touch Plus
# ones. The app unpacks its resources again after an APK update, so after an update the Frame
# models show from the second launch on.
RES=$STEAM_COMPAT_DATA_PATH/internal/$PKG/files/runtime_resources/quest_touch_plus
if [ -d "$RES" ] && [ -f "$HERE/frame_controller_models/right.wccontroller" ]; then
for f in left.wccontroller left.rgba right.wccontroller right.rgba; do
cmp -s "$HERE/frame_controller_models/$f" "$RES/$f" || cp "$HERE/frame_controller_models/$f" "$RES/$f"
done
fi
MANIFEST=$STEAM_COMPAT_DATA_PATH/internal/$PKG/files/game/base/game.json
APK=$(ls "$HERE"/app/*.apk 2>/dev/null | head -1)
if [ ! -f "$MANIFEST" ]; then