Author SHA1 Message Date
Sana 261975561d README: Support section with the Ko-fi links 2026-10-11 08:50:17 -04:00
simplysanatoriandClaude Opus 5.5 446ddad8d0 README: rewrite for Steam Frame owners
Requirements, install, first run, updating, the full controller layout as
the code implements it, settings and script variables, known problems,
troubleshooting, logs and how to report a problem, building, credits. The AI
statement is shorter. CONTRIBUTING.md points bug reports at the right README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:12:42 -04:00
simplysanatoriandClaude Opus 5.5 daa9457b5e Privacy audit; log collector that masks personal data
steam-frame/collect-logs.sh gathers the Lepton, wrapper, game and build logs
plus Config.toml into one archive and masks IP and MAC addresses, UUIDs, host
names, Wi-Fi names, serial numbers, Steam IDs, email addresses, tokens and
certificate blocks. Lepton's log carries the Frame's kernel log, which lists
hardware serial numbers. steam-frame/PRIVACY_AUDIT.md records what was
checked and the commands that repeat the check. A build.sh comment no longer
names an unrelated project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:12:42 -04:00
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
simplysanatoriandClaude Opus 5.5 004f33aa0d README: release path confirmed on the headset; item box, controller models and grips as found
The second release built and ran on the Frame from a clean start. The item box
change does not hide the empty box; the menus show Quest Touch models because
Lepton gets no SteamVR render models; grips do not grab the wheel because hand
steering is off on purpose for this layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7dBRsvawKKn1F9gf68SYv
2026-10-08 12:14:08 -04:00
simplysanatoriandClaude Fable 5.1 45d71a37df Build the kit objects with NDK r29 and reject new C++ imports
The first release's objects were compiled with NDK r30, whose libc++ headers
emit std::__hash_memory; the kit's r29 libc++ has no such symbol and the
on-device link failed. Use HomuHomu833's r29 build on arm64 (Google's r29 on
x86_64) and fail the build when an object imports a mangled symbol the kit's
original does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7dBRsvawKKn1F9gf68SYv
2026-10-08 11:53:54 -04:00
simplysanatoriandClaude Fable 5.1 cfa6aa1ded Ignore __pycache__
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7dBRsvawKKn1F9gf68SYv
2026-10-08 11:39:19 -04:00
simplysanatoriandClaude Fable 5.1 ed7c28f37c Launch wrapper: start the game only when the built game matches this APK's kit
Lepton keeps the app's private data across an APK update, so a game built
against upstream's kit survives; the app refuses it as stale and the wrapper
used to start the game activity anyway (black screen). Compare game.json's
kitFingerprint with the APK's kit.json and leave the launcher up otherwise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7dBRsvawKKn1F9gf68SYv
2026-10-08 11:39:11 -04:00
13 changed files with 970 additions and 300 deletions

No files matched your search

+1
View File
@@ -92,3 +92,4 @@ output.txt
/steam-frame/out/
/steam-frame/release/
/steam-frame/signing/
__pycache__/
+2 -1
View File
@@ -41,7 +41,8 @@ That's ok, but rules apply:
## Bug reports
See the FAQ in the [README](README.md)
For the Steam Frame build, see "Logs and reporting a problem" in the [README](README.md).
For the upstream port, see "Troubleshooting" in [README.upstream.md](README.upstream.md).
## A note on related projects
+349 -266
View File
@@ -1,214 +1,160 @@
# Mario Kart Wii VR on the Steam Frame
This is a fork of [heurazy/Wiicompiled_VR-PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS),
the Mario Kart Wii VR port, that runs standalone on Valve's **Steam Frame** through Lepton (the
Frame's Android compatibility tool). Upstream's Quest APK installs on the Frame but crashes the
moment the game starts; with the changes here it runs at 90 Hz with the Frame controllers laid out
like a normal gamepad, straight from the Steam library, no PC involved.
the Mario Kart Wii VR port. It runs standalone on Valve's Steam Frame through Lepton, the Frame's
Android compatibility tool. No PC is 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
[README.upstream.md](README.upstream.md); read it for what the port itself does, this file is only
about the Frame.
Upstream's Quest APK installs on the Frame, but the game crashes as soon as it starts. With the
changes here it starts from the Steam library, runs at 90 Hz, and uses a gamepad-style layout on
the Frame controllers.
**Status, October 2026:** working on my Steam Frame (SteamOS 0.4.5, Lepton 2.8.11, SteamVR), in
the original and first-person cameras, single player. See [What works and what doesn't](#what-works-and-what-doesnt)
before you spend an afternoon on it.
The branch is `steam-frame`. The fork's files are in [`steam-frame/`](steam-frame/), plus six
commits to `runtime/` and `aurora-main/`. Upstream's README is kept as
[README.upstream.md](README.upstream.md). Read it for what the port itself does. This file only
covers the Frame.
## The honest part: an AI wrote this
**Status, October 2026.** Release `v1.3.1-frame.3` works on my Steam Frame with SteamOS 0.4.5,
Lepton 2.8.14 and SteamVR. Tested: single player, the original and first-person cameras, the
menus and the VR settings panel. Read [Known problems](#known-problems) before you start.
I did not write the code in this fork, and I did not write this README either. Both were produced
by Anthropic's Claude (the models called **Claude Opus 5.5** and **Claude Fable 5.1**), running
through Claude Code on the Steam Frame itself over SSH. What I did was decide what to try, launch
every build in the headset, play it, and report back what I saw: "it's a flickering mess", "the
menus are a slideshow", "the item box stays on screen", "that resolution change made it stutter,
roll it back". The AI read the logs, read the code, made the change, and we went round again.
Everything in the status and testing sections below comes from those rounds on real hardware, not
from reasoning about the code.
**AI use.** The code and the docs in this fork were written with Claude Code (Anthropic's Claude
Opus 5.5 and Claude Fable 5.1), running on the Frame. I chose what to try, tested every build in
the headset and reported what I saw. Nobody from the upstream project has reviewed this fork.
Read the diffs before you trust them.
So: this is AI-generated code, by someone who could not have written it by hand, published because
it works and because I think it proves something worth proving, that a Wii game can run natively in
VR on a Steam Frame at 90 Hz without streaming. Treat it accordingly. Read the diffs before you trust
them. Nobody from the upstream project has reviewed any of it, and I asked the AI to say so plainly
rather than dress it up. The [How this was made](#how-this-was-made) section at the end has the
longer version, including the things it got wrong along the way.
## Contents
If you want a project that is honest about the same thing in a much funnier way, read the README of
[baijiSteamVR](https://github.com/thejerf/baijiSteamVR); it is where I took the idea of just saying
it out loud.
- [What the fork changes](#what-the-fork-changes)
- [Requirements](#requirements)
- [Install](#install)
- [First run: import the disc and build the game](#first-run-import-the-disc-and-build-the-game)
- [Updating](#updating)
- [Controls](#controls)
- [Settings](#settings)
- [Known problems](#known-problems)
- [Troubleshooting](#troubleshooting)
- [Logs and reporting a problem](#logs-and-reporting-a-problem)
- [Building the APK yourself](#building-the-apk-yourself)
- [Where things are](#where-things-are)
- [How this was made](#how-this-was-made)
- [Licence and credits](#licence-and-credits)
## What is different from upstream
## What the fork changes
Upstream ships three things: a Windows build, a Quest APK, and the design where **your game is
compiled on your own device from your own disc**: the APK carries a "game kit" (precompiled runtime
objects, headers, a toolchain) and the headset translates and links `libmain.so` from your disc
against that kit. No Nintendo code is ever in the APK or in this repository, and that stays true
here.
Upstream's design stays the same. The APK carries a "game kit": precompiled runtime objects,
headers and a toolchain. The headset translates your own disc and links `libmain.so` against that
kit. No Nintendo code is in the APK or in this repository.
This fork changes the kit, not the design. The Steam Frame APK is upstream's v1.3.1 Quest APK with
four kit files swapped for ones compiled from the patched sources, one patched `classes.dex`, and a
new kit fingerprint. When the headset builds your game it links it against the Frame objects, so you
get the Frame version from the same disc import and build that upstream documents.
The Steam Frame APK is upstream's v1.3.1 Quest APK with four kit files replaced by ones compiled
from this branch, one patched `classes.dex`, and a new kit fingerprint. When the headset builds
your game, it links it against the Frame objects.
What the patches do, in the order they were needed:
1. **The app starts.** Upstream's app registers a clipboard listener at startup. Lepton's Android
has no clipboard service, so the app crashed with a `NullPointerException`. That call is
replaced with no-ops in `classes.dex` ([`steam-frame/dexpatch.py`](steam-frame/dexpatch.py)).
1. **It starts.** Upstream's app registers a clipboard listener when SDL comes up. Lepton's Android
image has no clipboard service, the lookup returns null, and the game process dies with a
`NullPointerException` before it draws anything. The ten bytes of that call are replaced with
nops in `classes.dex` ([`steam-frame/dexpatch.py`](steam-frame/dexpatch.py)). That is the whole
reason the stock APK "doesn't work" on the Frame.
2. **90 Hz.** Upstream turns VR frame interpolation off on Android. On the Frame that showed as
judder and ghosting when turning the head. Interpolation is on again, so the eyes render at the
headset's refresh rate with a fresh head pose each frame while the game runs at 60 fps.
SteamVR's OpenXR in Lepton lacks `XR_KHR_convert_timespec_time`, so there is a
`CLOCK_MONOTONIC` fallback. (`runtime/src/vr/openxr_integration.cpp`)
2. **90 Hz.** Upstream deliberately disables VR frame interpolation on Android (its README says so:
it "makes the standalone presentation unstable" on Quest). On the Frame that leaves the game
presenting its 60 fps simulation into a 90 Hz headset, which looked like judder and ghosting
whenever I turned my head. The Android runtime overrides that forced it off are removed, so
Aurora's stereo replay renders the eyes at the headset's refresh rate with a fresh head pose each
frame, from the game's 60 fps. SteamVR's OpenXR inside Lepton has no
`XR_KHR_convert_timespec_time`, which the pacing needs, so there is a `CLOCK_MONOTONIC` fallback
that anchors on the first frame. (`runtime/src/vr/openxr_integration.cpp`;
`setprop debug.wiicompiled.vr_interp 0|1|72|90|120` overrides it per launch.) Races hold 90 fps
on the counter, with frames submitted well inside the budget. Not perfectly smooth, see below.
3. **Frame controller layout.** The game's OpenXR actions were written for Quest Touch
controllers. On the Frame, SteamVR mapped A to three actions and left B, X and Y unused. The
runtime now also has hand-independent actions (`frame_item`, `frame_drift`,
`frame_look_back`, `frame_start`, `frame_back` and four D-pad actions).
[`steam-frame/bindings_frame_controller.json`](steam-frame/bindings_frame_controller.json)
binds the Frame buttons to them. When SteamVR reports them bound, the game uses the layout in
[Controls](#controls) for the rest of the session. (`runtime/src/vr/openxr_input.cpp`)
3. **Frame controllers.** The game's actions are hand-specific OpenXR actions written for Quest Touch
and SteamVR had auto-mapped them onto the Frame controllers so that A did three things and B, X
and Y did nothing. The runtime now also creates hand-independent actions (`frame_item`,
`frame_drift`, `frame_look_back`, `frame_start`, `frame_back`, a D-pad), a SteamVR binding file
assigns the Frame's buttons to them, and when SteamVR reports them bound the game switches to a
gamepad-style layout, the same in every camera (no cockpit "A becomes drift" swap). The layout
latches for the session, because SteamVR briefly reports the actions inactive when a controller
dozes and you do not want the controls changing mid-race. (`runtime/src/vr/openxr_input.cpp`,
[`steam-frame/bindings_frame_controller.json`](steam-frame/bindings_frame_controller.json).)
4. **Menus at 90 Hz.** Upstream's animated menu background held the menus at 37 to 45 fps. On
Android the menus are now always drawn into the eye images, and menu background quality 0
gives a plain dark background. (`openxr_integration.cpp`, `menu_shader_quality = 0`)
4. **Menus at 90 instead of 40.** Races were fine but the menus, which upstream draws on a spatial
panel with an animated "dielectric" raymarched background, ran at 37 to 45 fps with each frame
taking 15 to 24 ms to submit. Turning the background quality to 0 upstream would have moved the
menus to a quad layer, which under Lepton's SteamVR bridge is a slow submission of its own. On
Android the menus are now always drawn into the eye images, and quality 0 means a plain dark
background, which costs nothing. (`openxr_integration.cpp`; `menu_shader_quality = 0` in the
config.)
5. **Item box (attempted, does not work yet).** The port now skips 2D HUD draws that lie wholly
outside the game's scissor rectangle. The empty item box still shows in the headset. The
change is harmless and stays in. (`aurora-main/lib/gfx/common.cpp`)
5. **The item box.** In VR the item roulette slid up after use and stayed visible. Aurora's
virtual-screen HUD draws ignore the game's scissor rectangle; the port now skips a 2D draw that
lies entirely outside a scissor the game narrowed, which is what hides it on a TV.
(`aurora-main/lib/gfx/common.cpp`.) During the slide itself a sliver can still show.
6. **Frame settings.** The launch wrapper seeds `Config.toml` from
[`steam-frame/Config.frame.toml`](steam-frame/Config.frame.toml) on the first launch, and
`install.sh` sets per-app SteamVR values. See [Settings](#settings).
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
`preferredRefreshRate 90`, `framesToThrottle 0` and `additionalFramesToPredict 0`; those went in
together with the interpolation and I have not tested them one at a time.
7. **Launch wrapper.** The app's 2D launcher never shows in the headset on the Frame.
[`steam-frame/wiicompiled-launch.sh`](steam-frame/wiicompiled-launch.sh) waits for the
launcher and starts the game activity itself, once a game built for this APK's kit is
installed. A launch from the library reaches the title screen in about ten seconds.
7. **A launch wrapper**, because the app's 2D launcher never shows up in the headset on the Frame
(SteamVR does not pick up Lepton's flat window for this app). Once a game is built, the wrapper
waits for the launcher to 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.** In the first-person cockpit the grips hold the steering wheel. In
the other cameras they drift. The right bumper drifts in every camera.
Everything else, the cameras, the hand IK, the HUD, Retro Rewind support, is upstream's work and is
unchanged.
9. **Frame controller models.** Inside Lepton the app gets no SteamVR render models, so upstream
showed Quest controllers in the menus.
[`steam-frame/frame-controller-models.py`](steam-frame/frame-controller-models.py) converts the
Frame controller models from your own SteamVR install into the game's format and simplifies
them to about 4,000 triangles per hand. `install.sh` runs it, and the launch wrapper copies the
result over the Quest files on each launch. Valve's models are never included in the repository
or the APK.
## What works and what doesn't
The cameras, hand IK, HUD and Retro Rewind support are upstream's work and are unchanged.
Confirmed on my Frame, in the headset:
## Requirements
- Launching from the Steam library into the game, title screen to race, at 90 Hz.
- Racing in the original camera; the first-person cockpit, briefly; the menus at full rate; the
VR settings panel; the controller layout in the table below, including pause and back.
- The item box fix is in the build I play and I have not seen the box stuck since; it had the
least testing of the five changes.
- A Steam Frame.
- **Lepton**, installed from Steam (Library > Tools).
- Your own clean PAL Mario Kart Wii disc image, game ID `RMCP01`. ISO works. Upstream's importer
also lists RVZ, WBFS and other formats. Other regions and modified images are rejected.
- About 5 GB free.
- A terminal on the Frame: Konsole in desktop mode, or SSH from another computer.
- Steam running while you install. No root and no `pacman` are needed.
Known problems and things I have not tested:
## Install
- **The release APK has not yet been through a clean run.** What I play is the same four objects,
linked into `libmain.so` on the Frame by the AI's host-side build. The APK on the Releases page
was made by `build.sh` from those objects and passes the same archive, alignment and signature
checks as upstream's APK, but the full install, disc import and on-headset build with it is the
next thing I do, and this line changes when it has happened. If you get there first, an issue
with the build log would be welcome.
- **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.
- **The 2D launcher is invisible** in the headset, so the one-time disc import and build are done
from a terminal with two commands (below). Upstream's "Select disc image" button needs a document
picker Lepton does not have anyway.
- **Resolution.** I tried 1.5x game resolution with 2160x2160 eyes for sharper menu text. It
looked better and dropped frames all over the place (10 to 24 ms late, 30 to 50 ms stalls), so
it went back to 1.0x and 1728x1728, which is what the installer sets. If you want sharper menus,
make the menu screen bigger or closer in VR settings instead.
- **Item roulette** can still show a sliver while sliding.
- **Not tested:** Retro Rewind, online play, the diorama camera for more than a minute, USB
wheels, multiplayer, passthrough (there is nothing to pass through to; the Frame shows SteamVR's
void around the menus), hand tracking.
- **Not for Quest.** The kit objects are compiled for Android arm64 and would run on a Quest, but
the interpolation change is exactly what upstream turned off there because it misbehaved on
Quest, and the controller layout only activates when SteamVR binds the Frame actions. Use
upstream's APK on a Quest.
- **Updates.** This is built on upstream v1.3.1. A newer upstream APK has a different kit, and
`build.sh` refuses to make an APK when the objects it compiled do not export the same symbols as
the ones in the APK; the patches will need porting when upstream moves.
## Controls
The binding is installed per app by `install.sh`; the game picks the layout up by itself.
| Steam Frame | In the game |
| --- | --- |
| Left stick | Steer, navigate menus |
| Right trigger, or A | Accelerate / confirm |
| 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 |
| D-pad | D-pad (up is a trick) |
| Right stick directions | Tricks and wheelies |
| Right stick click | Cycle the three cameras |
| Menu (right) | Start / pause |
| View (left), tap | Back |
| View (left), hold half a second | VR settings panel (press again to close) |
The left View tap is sent on release so that a hold never also goes back. Steam Input and SteamVR's
binding UI are the right place to change any of this: the file is a normal SteamVR binding and shows
up under Manage Controller Bindings for the app.
## Setting it up on a Steam Frame
You need: a Steam Frame; **Lepton** installed from Steam (Library > Tools); your own clean PAL
`RMCP01` Mario Kart Wii disc image (I used an ISO; upstream's launcher also accepts RVZ, WBFS and
the others it lists); about 5 GB free; and a terminal on the Frame: Konsole in desktop mode, or SSH
from another machine with developer mode on. Nothing needs root and nothing is installed with pacman.
1. Get the repository and the APK:
1. Get the repository:
```sh
git clone -b steam-frame https://github.com/simplysanatori/Wiicompiled_VR-PLUS-steam-frame.git
cd Wiicompiled_VR-PLUS-steam-frame
```
Download `WiiCompiled-VR-SteamFrame-*.apk` from the
[Releases](https://github.com/simplysanatori/Wiicompiled_VR-PLUS-steam-frame/releases) page, or build
it yourself (next section). The APK contains no game data, exactly like upstream's.
2. Download `WiiCompiled-VR-SteamFrame-*.apk` from the
[Releases](https://github.com/simplysanatori/Wiicompiled_VR-PLUS-steam-frame/releases) page.
Use `v1.3.1-frame.2` or newer. Each release lists the APK's SHA-256 in `SHA256SUMS.txt`:
2. Install it, with Steam running:
```sh
sha256sum ~/Downloads/WiiCompiled-VR-SteamFrame-*.apk
```
You can also build the APK yourself ([Building the APK yourself](#building-the-apk-yourself)).
The APK contains no game data.
3. Run the installer with Steam running. Use the `install.sh` from the same release tag or newer:
```sh
steam-frame/install.sh ~/Downloads/WiiCompiled-VR-SteamFrame-*.apk
```
This copies the APK and the launch wrapper to `~/Games/WiiCompiledVR-SteamFrame/`, adds a
**Mario Kart Wii VR** entry to the Steam library set to the Lepton compatibility tool (through
Steam's own client API, so no editing of `shortcuts.vdf` behind Steam's back), and writes the
controller binding and the 90 Hz settings into SteamVR for that entry. It prints the entry's
appid; you need it for the next step. Running it again later updates the same entry.
The installer:
3. Put the disc image in `~/Documents/` on the Frame (Lepton shows that folder to the app as
`/sdcard/Documents`).
- copies the APK, the launch wrapper and the seed config to `~/Games/WiiCompiledVR-SteamFrame/`;
- converts the Frame controller models from SteamVR;
- adds a **Mario Kart Wii VR** entry to the Steam library, set to the Lepton compatibility
tool, through Steam's client API;
- writes the controller binding and the 90 Hz settings into SteamVR for that entry;
- prints the entry's appid. You need it for the first run.
4. Put the headset on and launch **Mario Kart Wii VR** from the library. The first launch takes a
minute while Lepton installs the APK. You will see SteamVR's empty space and nothing else: the
Running it again updates the same entry.
## First run: import the disc and build the game
1. Put the disc image in `~/Documents/` on the Frame. Lepton shows that folder to the app as
`/sdcard/Documents`.
2. Put the headset on and launch **Mario Kart Wii VR** from the library. The first launch takes
about a minute while Lepton installs the APK. You will only see SteamVR's empty space. The
app's launcher is running but cannot be seen. Leave it running.
5. From the terminal, start the disc import, then the build. `<appid>` is the number from step 2
and `MarioKart.iso` is your file's name:
3. In the terminal, import the disc. Replace `<appid>` with the number from `install.sh` and
`MarioKart.iso` with your file name:
```sh
LEPTON=~/.local/share/Steam/steamapps/common/Lepton/lepton
@@ -216,33 +162,184 @@ from another machine with developer mode on. Nothing needs root and nothing is i
-d file:///sdcard/Documents/MarioKart.iso --es task ExtractDisc
```
This checks the disc (it must be the clean PAL game; anything else is rejected) and extracts
it into the app's data folder, a few minutes. Then:
This checks the disc and extracts it into the app's data folder. It takes a few minutes.
4. Build the game:
```sh
$LEPTON exec steamlaunch-<appid> am start-foreground-service -n org.wiicompiled.quest/.launcher.GameSetupService \
--es task BuildGame
```
The headset now translates and compiles the game against the Frame kit. It took about a
quarter of an hour on my Frame (upstream quotes half an hour on a Quest 3). Progress goes to
`~/.local/share/Steam/steamapps/compatdata/<appid>/external/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/Logs/build_*.log`,
and the extraction and build both log through `adb`:
The headset translates and compiles the game against the Frame kit. This took about 13
minutes on my Frame. Progress goes to
`~/.local/share/Steam/steamapps/compatdata/<appid>/external/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/Logs/build_*.log`.
You can also follow it over adb:
```sh
/usr/lib/android-sdk/platform-tools/adb connect localhost:5555
/usr/lib/android-sdk/platform-tools/adb -s localhost:5555 logcat | grep -iE 'GameSetup|DiscExtract|GameBuild|WiiCompiled'
```
6. When the build log ends with the game installed, quit the app (Steam button > exit) and launch
it again. From now on every launch goes straight into the game. The first time, the game asks
you to pick a camera with the pointer; that choice and everything in the VR settings panel are
saved to `Config.toml`.
5. When the build log says the game is installed, quit the app (Steam button, then exit) and
launch it again. From now on every launch goes straight into the game. On the first start the
game asks you to pick a camera with the pointer. Your choices are saved to `Config.toml`.
If you already had upstream's APK set up on the Frame under the same library entry name, step 2
repoints that entry, and Lepton clears the old app's data on the next launch because the APK
changed; the extracted disc files survive in `external/`, the built game does not, so you repeat
the `BuildGame` command (not the extraction).
## Updating
- **From an older Frame release.** Run the newer `install.sh` with the new APK. It updates the
same library entry. Lepton reinstalls the app on the next launch. A new release usually has a
new kit fingerprint, so run the `BuildGame` command from the first run again. The disc import,
saves and settings are kept. After an update the Frame controller models show from the second
launch, because the app unpacks its own files again first.
- **From upstream's Quest APK on the Frame.** If your library entry has the same name, `install.sh`
points it at the Frame APK. The extracted disc stays, but the old game was built against
upstream's kit and will not run. Run `BuildGame` again. The disc import is not needed.
- **Newer upstream versions.** This fork is built on upstream v1.3.1. A newer upstream APK has a
different kit, and the patches need porting first. `build.sh` stops if the kits do not match.
## Controls
`install.sh` installs the binding for the library entry. The game switches to this layout by
itself when SteamVR reports the Frame actions as bound. The layout is the same in every camera.
The Frame's left controller has the D-pad and View button. The right controller has A, B, X, Y
and the Menu button.
| Frame controller | Racing | Game menus |
| --- | --- | --- |
| Left stick | Steer | Move |
| Right trigger | Accelerate | Confirm |
| A | Accelerate | Confirm |
| B | Brake and reverse | Back |
| Left trigger | Brake and reverse (works with accelerate held) | Back |
| X | Use item (hold to drag it behind) | |
| Left bumper | Use item | |
| Y | Look back | |
| Right bumper | Hop and drift | |
| Grips (either) | Hop and drift. In the first-person cockpit: hold the wheel | |
| D-pad | GameCube D-pad. Up does a trick, or a wheelie on a bike | D-pad |
| Right stick | Same as the D-pad (push up for a trick) | |
| Right stick click | Next camera: original, first person, diorama | |
| Menu | Pause (Start) | Start |
| View, tap | Back | Back |
| View, hold 0.5 s | Open the VR settings panel | Open the VR settings panel |
Notes:
- The View tap is sent when you let go, so a hold never also goes back.
- The right stick click does not change the camera during the race intro. It can be turned off
with `first_person_toggle_click = false` under `[vr]` in `Config.toml`.
- **Cockpit wheel.** In the first-person camera, reach for the wheel and squeeze a grip to hold
it, then turn it to steer. Let go to steer with the stick again. With `hand_steering = false`
under `[vr]`, the grips drift in every camera and you always steer with the stick.
- **VR settings panel.** Hold View for half a second to open it. Point with a controller. The hand
whose trigger you pulled last does the pointing. A trigger or A selects, either stick scrolls,
and a press of View closes the panel. While the panel is open the game gets no input.
- **Controller models.** The menus show Frame controllers when `install.sh` converted them. The
tutorials still name Quest buttons. Use the table above.
- To change the layout, use SteamVR's binding UI. The binding is a normal SteamVR binding and shows
up under Manage Controller Bindings for the entry.
## Settings
The launch wrapper copies these values into `Config.toml` on the first launch only. The VR
settings panel edits the same file. Upstream's README lists all settings under "VR settings".
| `Config.toml` | Seed value | What it does |
| --- | --- | --- |
| `[vr] frame_interpolation_fps` | `1` | Render at the headset's refresh rate (90 Hz), interpolated from the game's 60 fps. |
| `[vr] hand_steering` | `true` | Grips hold the wheel in the first-person cockpit. |
| `[vr] menu_shader_quality` | `0` | Plain dark menu background. Higher values cost frame rate on the Frame. |
| `[vr] render_scale` | `1.0` | Eye render scale. |
| `[video] resolution_multiplier` | `1.0` | Game resolution. 1.5 dropped frames on the Frame. |
| `[vr] hand_hud` | `false` | Upstream's hand HUD, off. |
| `[diagnostics] openxr_logging` | not set (off) | Logs frame timing once a second to `console.log`. Leave it off for play. It caused small hitches. |
`install.sh` also sets these SteamVR values for the entry: `preferredRefreshRate 90`,
`framesToThrottle 0` and `additionalFramesToPredict 0`. They are in the `steam.app.<appid>`
section of `~/.config/openvr/config/steamvr.vrsettings`.
Environment variables for the scripts:
| Variable | Script | Effect |
| --- | --- | --- |
| `WCF_FRAME_MODELS=0` | `install.sh` | Keep the Quest controller models. |
| `WCF_FRAME_MODEL_TRIANGLES` | `frame-controller-models.py` | Triangle target per hand (default 4000). |
| `WCF_SHORTCUT_NAME` | `install.sh` | Library entry name (default `Mario Kart Wii VR`). |
| `WCF_STAY_IN_LAUNCHER=1` | `wiicompiled-launch.sh` | Leave the invisible launcher up. Put it before the wrapper in the entry's launch options. |
| `WCF_TOOLS` | `build.sh` | Toolchain folder (default `~/.local/opt/android-build`). |
| `WCF_SIGNING` | `build.sh` | Signing key folder (default `steam-frame/signing`). |
| `WCF_LOG_DIR` | `collect-logs.sh` | Where the log archive goes (default your home folder). |
Two Android properties help when testing. Set them over adb before the game starts:
`setprop debug.wiicompiled.vr_interp 0|1|72|90|120` overrides the interpolation setting for one
launch, and `setprop debug.wiicompiled.fpslog 1` logs the game's frame rate.
## Known problems
- **The empty item box stays on screen** after the roulette slides up. Change 5 does not fix it.
- **Grip steering in the cockpit is rough.** Grabbing and steering work, but upstream tuned the
grab range and steering response for Quest controllers. They have not been adjusted. Set
`hand_steering = false` to steer with the stick.
- **Small hitches.** Races hold 90 fps on the counter, with brief hitches now and then. The logs
do not show a cause yet.
- **The 2D launcher is invisible** in the headset, so the disc import and build use the terminal
commands above. Upstream's "Select disc image" button needs a file picker Lepton does not have.
- **`v1.3.1-frame.1` does not work.** Its game build fails at the final link on the headset. Use
`v1.3.1-frame.2` or newer.
- **Not tested:** Retro Rewind, online play, the diorama camera for more than a minute, USB wheels,
multiplayer, passthrough, hand tracking, Lepton 3.x.
- **Not for Quest.** Use upstream's APK on a Quest. The interpolation change is what upstream
turned off on Quest, and the Frame layout only works with the SteamVR binding.
## Troubleshooting
- **`install.sh` says Steam's debugging port is not reachable.** Steam must be running. The script
talks to Steam on `127.0.0.1:8080`, which SteamOS enables by default.
- **The launch shows only SteamVR's empty space.** No game is built yet, or the built game is for
another kit. `/tmp/wiicompiled-launch.log` says which. Run the `BuildGame` command.
- **The disc is rejected.** The importer accepts only a clean PAL `RMCP01` image.
- **The game build fails at the last step with `undefined symbol: std::__ndk1::__hash_memory`.**
You have the `v1.3.1-frame.1` APK. Install `v1.3.1-frame.2` or newer and build again.
- **The buttons act like a Quest layout** (A does several things, X and Y do nothing). The SteamVR
binding is not applied. Run `install.sh` again, then check Manage Controller Bindings for the
entry. The game's `console.log` has the line `Steam Frame button layout active` when the Frame
layout is on.
- **The menus show Quest controllers.** Launch once more after an update. If they still show,
run `install.sh` again and look for a warning about the controller models.
- **The menus or races stutter.** Check `Config.toml` for `menu_shader_quality = 0`,
`resolution_multiplier = 1.0` and `openxr_logging` off. Higher resolution settings dropped
frames on the Frame.
- **Settings are not saved after you copied `Config.toml` in by hand.** Run
`chmod 664 Config.toml` on it. The app cannot write it otherwise.
- **Anything else.** Collect the logs (next section) and look at `console.log` and `lepton.log`.
## Logs and reporting a problem
| Log | Where |
| --- | --- |
| Launch wrapper | `/tmp/wiicompiled-launch.log` |
| Lepton | `~/.local/share/Steam/logs/lepton-steamlaunch-<appid>.log` |
| Game, one folder per run | `.../WiiCompiledOpenXRVR/Logs/<run>/console.log` |
| Disc import and build | `.../WiiCompiledOpenXRVR/Logs/build_*.log` |
`...` is `~/.local/share/Steam/steamapps/compatdata/<appid>/external/Android/data/org.wiicompiled.quest/files`.
To report a problem:
1. Run `steam-frame/collect-logs.sh` (or `steam-frame/collect-logs.sh <appid>`). It writes
`~/wiicompiled-frame-logs-<date>.tar.gz` with the logs above, your `Config.toml` and a short
summary (SteamOS, Lepton, APK and kit versions). It masks IP and MAC addresses, UUIDs, host
names, Wi-Fi names, serial numbers, Steam IDs, email addresses, tokens and certificate blocks.
Lepton's log includes the Frame's kernel log, which lists hardware serial numbers. Read the
files before you post them. The masking can miss things.
2. Open an issue on this repository. Give the release tag, what you did, what happened and what
you expected, and attach the archive.
3. If the problem also happens with upstream's APK on a Quest, report it upstream instead.
Pull requests are welcome. Say what you tested and on which headset. Upstream's
[CONTRIBUTING.md](CONTRIBUTING.md) rules apply. Never attach game files or disc images.
## Building the APK yourself
@@ -251,98 +348,84 @@ steam-frame/build.sh # downloads upstream's v1.3.1 APK and chec
steam-frame/build.sh path/to/WiiCompiled-VR-Quest3-v1.3.1.apk
```
On the Frame this takes about 20 seconds once the toolchain is there, and produces
`steam-frame/release/WiiCompiled-VR-SteamFrame-<git tag>.apk`. It needs `curl`, `tar`, `python3`
and `openssl`, all on SteamOS already, and no root. What it does:
The output is `steam-frame/release/WiiCompiled-VR-SteamFrame-<git tag>.apk`. On the Frame this
takes about 20 seconds once the toolchain is downloaded. It needs `curl`, `tar`, `python3` and
`openssl`, which SteamOS has. It needs no root.
1. Fetches an Android NDK into `~/.local/opt/android-build/` (Google ships no arm64 Linux NDK, so
on the Frame it uses [HomuHomu833's r30 build](https://github.com/HomuHomu833/android-ndk-custom);
on x86_64 it would use Google's r29, untried). If you have built
[Moonlight XR for the Frame](https://github.com/simplysanatori/moonlight-android-xr-steam-frame) the
NDK is already there.
2. Compiles the two runtime unity groups that contain the patched files and Aurora's `common.cpp`
with the same flags upstream's Android build uses. The third-party headers those three units need
are vendored in [`steam-frame/build-deps/`](steam-frame/build-deps/) (see its `NOTICE.md`),
because upstream fetches its dependencies with CMake on Windows and this build runs no CMake.
3. Extracts the matching objects from the upstream APK's kit and checks that each new object exports
exactly the same symbols. If not, it stops: the sources and the APK are out of step.
4. `make-apk.py` rewrites the APK: the four kit files (`007_unity_runtime_4`, `001_unity_runtime_6`,
`020_libaurora_gx.a` with its `common.cpp.o` swapped, and `include/vr/openxr_input.h`), the new
kit fingerprint in `kit.json`, the patched `classes.dex`, zipalign-equivalent alignment, and an
APK Signature Scheme v2 signature made with `openssl`. No Java, apksigner or zipalign. The signing
key is generated in `steam-frame/signing/` on first use; keep it, an APK signed with another key
will not install over the previous one. `make-apk.py verify file.apk` checks any APK's signature.
1. It downloads Android NDK r29 into `~/.local/opt/android-build/`. Google ships no arm64 Linux
NDK, so on the Frame it uses [HomuHomu833's r29 build](https://github.com/HomuHomu833/android-ndk-custom).
On x86_64 it uses Google's r29, which is untested. The NDK must be r29, the version the kit
was built with. Objects built with r30 fail the final link on the headset.
2. It compiles the two runtime unity groups that hold the patched files, and Aurora's
`common.cpp`, with upstream's Android flags. The third-party headers they need are in
[`steam-frame/build-deps/`](steam-frame/build-deps/) (see its `NOTICE.md`).
3. It compares each new object with the original in the upstream kit. The exported symbols must
match, and the new object may not import a C++ symbol the original does not. Otherwise it stops.
4. [`make-apk.py`](steam-frame/make-apk.py) writes the APK: the four kit files, the new kit
fingerprint in `kit.json`, the patched `classes.dex`, zip alignment and an APK Signature
Scheme v2 signature made with `openssl`. No Java, apksigner or zipalign is needed.
The game itself (`libmain.so`) is never built here. It needs upstream's translator and your disc,
and the headset does that part. For what it is worth, during development the AI also linked
`libmain.so` on the Frame directly against the patched objects, using a translation produced by
upstream's .NET translator on the Frame; that is how the changes were iterated on in minutes. Those
tools and that translation output are not in this repository (the translator is upstream's, the
translation is Nintendo's code), so the on-device build is the path for everyone else.
The signing key is created in `steam-frame/signing/` on first use. Keep it. An APK signed with
another key will not install over the previous one. `steam-frame/signing/` is ignored by git, so
the key never ends up in the repository. `make-apk.py verify file.apk` checks an APK's signature.
## Where things are, for digging
The game itself (`libmain.so`) is never built here. The headset builds it from your disc.
- Steam library entry: a non-Steam shortcut whose executable is the APK and whose compatibility
tool is `lepton`; `lepton-show-flatscreen` next to the APK would show the 2D window if SteamVR
ever picked it up. Launch options run `~/Games/WiiCompiledVR-SteamFrame/wiicompiled-launch.sh`.
- The app's data: `~/.local/share/Steam/steamapps/compatdata/<appid>/`. `external/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/`
has `Config.toml`, `DATA` (the extracted disc), saves and `Logs/` (one folder per run with the
game's `console.log`, plus the build logs). `internal/org.wiicompiled.quest/files/game/base/` has
the built `libmain.so` and its `game.json`.
- Lepton's log for the launch: `~/.local/share/Steam/logs/lepton-steamlaunch-<appid>.log`; the
wrapper's: `/tmp/wiicompiled-launch.log`.
- SteamVR's per-app settings are the `steam.app.<appid>` section of
`~/.config/openvr/config/steamvr.vrsettings`; `/opt/steamvr/bin/linuxarm64/vrcmd --set-settings-int steam.app.<appid>.preferredRefreshRate 90`
is how the installer writes them while SteamVR runs.
- `[diagnostics] openxr_logging = true` in `Config.toml` makes the game log frame timing to its
`console.log` (late frames, stalls); `setprop debug.wiicompiled.fpslog 1` over adb before the game
starts logs the frame rate. Both were how the menu and resolution problems above were found.
- Lepton's Android reaches the host's `~/Documents`, `~/Downloads` and `~/Videos` as
`/sdcard/Documents`, `/sdcard/Download` and `/sdcard/Movies`. `lepton exec steamlaunch-<appid> <command>`
runs a command inside the running container; `adb connect localhost:5555` reaches it too.
## Where things are
- **Library entry:** a non-Steam shortcut whose executable is the APK and whose compatibility tool
is `lepton`. Its launch options run `~/Games/WiiCompiledVR-SteamFrame/wiicompiled-launch.sh`.
The empty file `lepton-show-flatscreen` next to the APK lets Lepton show the 2D window.
- **App data:** `~/.local/share/Steam/steamapps/compatdata/<appid>/`.
`external/Android/data/org.wiicompiled.quest/files/WiiCompiledOpenXRVR/` holds `Config.toml`,
`DATA` (the extracted disc), saves and `Logs/`.
`internal/org.wiicompiled.quest/files/game/base/` holds the built `libmain.so` and `game.json`.
- **SteamVR settings:** the `steam.app.<appid>` section of
`~/.config/openvr/config/steamvr.vrsettings`. While SteamVR runs, the installer writes them with
`/opt/steamvr/bin/linuxarm64/vrcmd --set-settings-int`.
- **Shared folders:** Lepton's Android sees `~/Documents`, `~/Downloads` and `~/Videos` as
`/sdcard/Documents`, `/sdcard/Download` and `/sdcard/Movies`.
- **Shell access:** `lepton exec steamlaunch-<appid> <command>` runs a command in the running
container. `adb connect localhost:5555` reaches it too.
## How this was made
Over about a week in October 2026, in a series of Claude Code sessions on the Frame. The rough order:
The work took about a week in October 2026, in Claude Code sessions on the Frame. I tested each
build in the headset and described what I saw. The AI read the logs and the code and made the
changes.
- The first attempt, a few days earlier, got upstream's v1.3 Quest APK installed through Lepton,
found the clipboard crash in a logcat dump and patched it, drove the invisible launcher over adb
to import the disc, and got the game running. It was, in my words at the time, "a flickering
mess with shaky head tracking, vomit inducing". The AI tried a long list of SteamVR settings
(refresh rates, motion smoothing on and off, resolution overrides); one of those left the image
warped in a corner, and the actual cause of a lot of it turned out to be that the in-game panel
had quietly written foveation, adaptive resolution and passthrough into `Config.toml`.
- I then asked whether the PC version through Proton would be better. The AI cross-compiled
upstream's Windows build on the Frame and ran it under Proton for ARM64 in VR: it worked and
flickered because x86 emulation could not keep up (27 ms frames). It then wrote a native Linux
ARM64 OpenXR port, which also worked and which I did not want either. Both were deleted at my
request once the Android build was good; this fork is the Android path only.
- The real fix was reading upstream's runtime and finding that frame interpolation was compiled
out on Android. The AI rebuilt the two runtime objects with that enabled, found that SteamVR under
Lepton has no clock conversion extension and wrote the fallback, re-signed the APK, and the result
was "very good". Then the controls, in code rather than only in the binding file because the
game reads X and Y as the left hand's buttons. Then the menus, after the frame-timing diagnostics
showed exactly which draw was slow. Then the item box.
- 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.
- 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.
I will look at issues and pull requests, but I am one person with one headset and no ability to
fix emulation-level problems; the useful contribution here, as the Baiji README puts it better
than I can, is **testing**. If you make a change, say what you tested it on and how.
- The first attempt used upstream's v1.3 Quest APK. The clipboard crash showed in logcat and was
patched. The game ran, but it flickered and the head tracking was shaky.
- Upstream's Windows build under Proton and a native Linux ARM64 build were also tried. The Proton
build was too slow under x86 emulation. Both were dropped. This fork is the Android path only.
- Turning interpolation back on, with the clock fallback, fixed most of the flicker and judder.
The controller layout, the menu change and the item box attempt followed.
- The first release was built with NDK r30 and failed to link on the headset. `build.sh` now uses
r29 and checks the C++ imports of each object.
- A higher resolution for sharper menus caused dropped frames and was rolled back.
- After the first releases came the grips change and the Frame controller models. The full
models slowed the menus to 45 to 60 fps. With the mesh simplifier the menus hold 90 Hz.
## Licence and credits
GPL-3.0, as upstream (`LICENSE`). The fork's own additions are under the same licence. Third-party
headers vendored for the build are listed with their licences in
[`steam-frame/build-deps/NOTICE.md`](steam-frame/build-deps/NOTICE.md).
GPL-3.0, as upstream ([LICENSE](LICENSE)). The fork's additions use the same licence.
Third-party headers used for the build are listed with their licences in
[`steam-frame/build-deps/NOTICE.md`](steam-frame/build-deps/NOTICE.md). The rest of the
third-party code is listed in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
The port is [heurazy's Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS), itself
built on [iChris4's Wiicompiled VR](https://github.com/iChris4/Wiicompiled_VR) and the WiiCompiled
static recompilation project, on [Aurora](https://github.com/encounter/aurora), Dawn, SDL3 and
OpenXR; the credits in [README.upstream.md](README.upstream.md) are theirs and they did the hard
part. Lepton is Valve's. Mario Kart Wii is Nintendo's; nothing of theirs is in this repository or
in the APK, and you need your own disc.
- The VR port is [heurazy's Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS),
built on [iChris4's Wiicompiled VR](https://github.com/iChris4/Wiicompiled_VR) and the
WiiCompiled static recompilation project. It uses
[Aurora](https://github.com/encounter/aurora), Dawn, SDL3 and OpenXR. The full credits are in
[README.upstream.md](README.upstream.md).
- The Android NDK build for arm64 Linux is
[HomuHomu833's](https://github.com/HomuHomu833/android-ndk-custom).
- Lepton, SteamVR and the Frame controller models are Valve's. The models are read from your own
SteamVR install and are not redistributed.
- Mario Kart Wii is Nintendo's. Nothing of Nintendo's is in this repository or in the APK. You
need your own disc.
- Steam Frame port by simplysanatori, written with Claude Code as described above.
## Support
If you would like more Steam Frame ports, you can [buy me a coffee](https://ko-fi.com/sana748653). It pays for the Claude API credits these ports are built with, spent as carefully as I can. The credit for the games themselves belongs to the upstream projects named above.
+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
+79
View File
@@ -0,0 +1,79 @@
# Privacy audit of the Steam Frame fork
Date: 2026-10-11. Branch: `steam-frame`. This file records what was checked for personal data,
what was changed, and the commands that repeat the check.
## What was searched
All tracked files on `steam-frame`: the READMEs, `DISTRIBUTION.md`, `RELEASE_VALIDATION.md`,
`OPENXR.md`, `CONTRIBUTING.md`, `THIRD-PARTY-NOTICES.md`, `docs/`, `.github/` (CI and issue
templates), `steam-frame/` (scripts, binding, seed config, generated build files), test fixtures
and binary assets (with `strings`). Also the commit messages, author and committer identities
and tag identities of the fork's commits, and the history for secrets.
Categories:
- Names and old account names of the owner, and school or employer names.
- Host names of the owner's computers and client names.
- LAN, hotspot, VPN and public IP addresses. MAC addresses.
- Wi-Fi network names, including the Frame's own access point name.
- UUIDs and IDs tied to the owner's computers, Steam user IDs, device serial numbers, system IDs,
headset and Android device IDs in probe dumps, logcat excerpts and validation logs.
- GitHub and API tokens, API keys, keystore passwords, paths to a signing key,
PEM and certificate material, email addresses.
The owner's specific strings are kept in a private list outside the repository. They are not
written here.
## Results
- Tracked files: no personal data found. Email addresses appear only in third-party copyright
headers and upstream contributors' co-author lines. `/home/steamos` paths are SteamOS's default
user and stay.
- Commit and tag identities of the fork use the GitHub noreply address.
- History of `steam-frame`: no tokens, private keys, certificates or keystore references.
- Release APK `v1.3.1-frame.3`: the v2 signing certificate has a generic subject and no personal
fields. Unpacked, the APK contains no personal strings. Two kit objects contain the generic build
path under `/home/steamos`. `SHA256SUMS.txt` holds only the hash and file name.
## Changes
- `steam-frame/build.sh`: a comment that referred to another project of the owner was made
generic.
- `steam-frame/collect-logs.sh` (new): gathers the logs for a bug report and masks IP and MAC
addresses, UUIDs, host names, Wi-Fi names, serial numbers, Steam IDs, email addresses, tokens,
certificate blocks and user names in `/home` paths. Lepton's log contains the Frame's kernel log,
which lists hardware serial numbers, so this matters for anyone who posts logs.
- `README.md`: rewritten. It tells people to run `collect-logs.sh` and read the result before
posting.
## Verification
Run from the repository root. Each command must print `0`.
```sh
# The owner's private list: one string per line, kept outside the repository.
git grep -IinF -f ~/.config/wcf-private-terms.txt | wc -l
git log --all --format='%an %ae %cn %ce %B' | grep -icF -f ~/.config/wcf-private-terms.txt
# Generic patterns in the fork's own files.
FORK="README.md CONTRIBUTING.md .github steam-frame :!steam-frame/build-deps/Dependencies :!steam-frame/build-deps/generated/sdl-build"
git grep -IhE '\b(10\.[0-9]+|192\.168|172\.(1[6-9]|2[0-9]|3[01])|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7]))\.[0-9]+\.[0-9]+\b' -- $FORK | wc -l
git grep -IhE '\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b' -- $FORK | wc -l
git grep -IhE '\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\b' -- $FORK | wc -l
git grep -IhE '[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z.]{2,}' -- $FORK | wc -l
git grep -IhoE '/home/[A-Za-z0-9_.-]+' -- $FORK | sort -u | grep -vxE '/home/steamos' | wc -l
# Secrets anywhere in the tree and in the branch history.
git grep -IhE '\b(ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9]{20,}|BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----|(store|key)Password' | wc -l
git log steam-frame -p -G '(ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{20,}' -- | wc -l
git log steam-frame -p -G 'BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----' -- | wc -l
git log steam-frame -p -G '[.](jks|keystore)\b|(store|key)Password' -- | wc -l
```
The fork's commit identities can be listed with
`git log --format='%ae%n%ce' origin/openxr-work..steam-frame | sort -u`. It must print only the
GitHub noreply address.
To check a release APK's signing certificate without Java, extract the certificate from the APK
Signing Block and run `openssl x509 -inform DER -noout -subject -issuer` on it.
Binary file not shown.
+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"
}
}
},
+24 -15
View File
@@ -9,7 +9,7 @@
# checked against its published SHA-256. Output: steam-frame/release/WiiCompiled-VR-SteamFrame-<tag>.apk
#
# What it does:
# 1. an Android NDK into ~/.local/opt/android-build (shared with the Moonlight XR Frame build);
# 1. an Android NDK into ~/.local/opt/android-build (WCF_TOOLS changes the folder);
# 2. compiles the two runtime unity groups that hold the Frame-patched sources (openxr_input.cpp,
# openxr_integration.cpp, and the rest of each group) and Aurora's gfx/common.cpp, with the
# same flags the kit's own objects were built with;
@@ -35,15 +35,18 @@ if ! echo "$UPSTREAM_APK_SHA256 $APK" | sha256sum -c --quiet 2>/dev/null; then
echo "warning: $APK is not the upstream v1.3.1 APK this build was made for; carrying on, the symbol check below decides"
fi
# 1. Toolchain. Google ships no arm64 Linux NDK; HomuHomu833's build of r30 fills in on the Frame.
# 1. Toolchain: NDK r29, the one the kit itself was built with and the one the headset links with.
# A newer NDK's libc++ headers emit helpers (std::__hash_memory in r30) that r29's libc++ does
# not have, and the on-device link then fails. Google ships no arm64 Linux NDK; HomuHomu833's
# build of r29 fills in on the Frame.
ARCH=$(uname -m)
if [ -n "${WCF_NDK:-}" ]; then
NDK=$WCF_NDK
elif [ "$ARCH" = aarch64 ]; then
NDK=$TOOLS/ndk/android-ndk-r30
NDK=$TOOLS/ndk/android-ndk-r29
if [ ! -d "$NDK/toolchains/llvm/prebuilt" ]; then
fetch https://github.com/HomuHomu833/android-ndk-custom/releases/download/r30/android-ndk-r30-aarch64-linux-gnu.tar.xz "$TOOLS/dl/ndk-r30-aarch64.tar.xz"
mkdir -p "$TOOLS/ndk" && tar xJf "$TOOLS/dl/ndk-r30-aarch64.tar.xz" -C "$TOOLS/ndk"
fetch https://github.com/HomuHomu833/android-ndk-custom/releases/download/r29/android-ndk-r29-aarch64-linux-gnu.tar.xz "$TOOLS/dl/ndk-r29-aarch64.tar.xz"
mkdir -p "$TOOLS/ndk" && tar xJf "$TOOLS/dl/ndk-r29-aarch64.tar.xz" -C "$TOOLS/ndk"
fi
elif [ "$ARCH" = x86_64 ]; then
NDK=$TOOLS/ndk/android-ndk-r29
@@ -86,7 +89,7 @@ for entry in 007:runtime_4 001:runtime_6; do
"$CXX" "${TARGET[@]}" "${RUNTIME_DEFS[@]}" "${RUNTIME_INC[@]}" "${COMMON[@]}" -include mkw_pch.h \
-c "$OUT/obj/unity_${g}_cxx.cxx" -o "$OUT/kit/objects/${n}_unity_${g}_cxx.cxx.o" &
done
# fmt's format.h uses malloc without including <cstdlib> itself; NDK r30's libc++ no longer does it for it.
# fmt's format.h uses malloc without including <cstdlib> itself; a newer libc++ no longer does it for it.
"$CXX" "${TARGET[@]}" "${DEFS[@]}" "${AURORA_INC[@]}" "${COMMON[@]}" -include cstdlib \
-c "$SRC/aurora-main/lib/gfx/common.cpp" -o "$OUT/obj/common.cpp.o" &
wait
@@ -98,20 +101,26 @@ unzip -q -o "$APK" 'assets/game_kit/objects/007_unity_runtime_4_cxx.cxx.o' 'asse
'assets/game_kit/libs/020_libaurora_gx.a' 'assets/game_kit/include/vr/openxr_input.h' -d "$OUT/upstream-kit"
UK=$OUT/upstream-kit/assets/game_kit
exported() { "$NM" --defined-only "$1" | awk '$2 ~ /[TDBR]/ {print $3}' | sort -u; }
imported() { "$NM" -u "$1" | awk '{print $2}' | sort -u; }
ok=1
for o in 007_unity_runtime_4_cxx.cxx.o 001_unity_runtime_6_cxx.cxx.o; do
if diff <(exported "$UK/objects/$o") <(exported "$OUT/kit/objects/$o") >/dev/null; then
echo "$o: exports the kit's symbols"
# Same exports as the kit's object, and no new C++ imports: a new mangled import means a different
# libc++ than the kit's, which the on-device link cannot satisfy. New plain C imports (bionic) are fine.
check() { # label kit-object new-object
if diff <(exported "$2") <(exported "$3") >/dev/null; then
echo "$1: exports the kit's symbols"
else
echo "$o: exported symbols DIFFER from the kit's (sources out of step with this APK?)"; ok=0
echo "$1: exported symbols DIFFER from the kit's (sources out of step with this APK?)"; ok=0
fi
new_cxx=$(comm -13 <(imported "$2") <(imported "$3") | grep '^_Z' || true)
if [ -n "$new_cxx" ]; then
echo "$1: imports C++ symbols the kit's object does not (wrong NDK/libc++?):"; echo "$new_cxx" | head -5; ok=0
fi
}
for o in 007_unity_runtime_4_cxx.cxx.o 001_unity_runtime_6_cxx.cxx.o; do
check "$o" "$UK/objects/$o" "$OUT/kit/objects/$o"
done
t=$(mktemp -d); (cd "$t" && "$AR" x "$UK/libs/020_libaurora_gx.a" common.cpp.o)
if diff <(exported "$t/common.cpp.o") <(exported "$OUT/obj/common.cpp.o") >/dev/null; then
echo "020_libaurora_gx.a(common.cpp.o): exports the kit's symbols"
else
echo "020_libaurora_gx.a(common.cpp.o): exported symbols DIFFER from the kit's"; ok=0
fi
check "020_libaurora_gx.a(common.cpp.o)" "$t/common.cpp.o" "$OUT/obj/common.cpp.o"
rm -rf "$t"
[ $ok = 1 ] || { echo "not making an APK from objects that do not match the kit"; exit 1; }
cp "$UK/libs/020_libaurora_gx.a" "$OUT/kit/libs/020_libaurora_gx.a"
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
# Collects the logs needed for a bug report into one archive, with personal data masked.
#
# steam-frame/collect-logs.sh [appid]
#
# Without an appid it uses the newest Lepton prefix that has the WiiCompiled app in it.
# Collected: Lepton's launch log, the launch wrapper's log, Config.toml, the newest run's
# console.log, the newest build log, and a summary (SteamOS, APK and kit 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
PKG=org.wiicompiled.quest
STEAM=$HOME/.local/share/Steam
COMPAT=$STEAM/steamapps/compatdata
if [ -n "${1:-}" ]; then
APPID=$1
else
APPID=$(ls -td "$COMPAT"/*/external/Android/data/$PKG 2>/dev/null | head -1 | sed "s|^$COMPAT/||; s|/.*||")
fi
[ -n "$APPID" ] && [ -d "$COMPAT/$APPID" ] || { echo "no WiiCompiled prefix found; pass the appid that install.sh printed"; exit 1; }
DATA=$COMPAT/$APPID/external/Android/data/$PKG/files/WiiCompiledOpenXRVR
STAMP=$(date +%Y%m%d-%H%M%S)
WORK=$(mktemp -d)
RAW=$WORK/raw; OUT=$WORK/wiicompiled-frame-logs-$STAMP
mkdir -p "$RAW" "$OUT"
grab() { [ -f "$1" ] && cp "$1" "$RAW/$2" || true; }
grab "$STEAM/logs/lepton-steamlaunch-$APPID.log" lepton.log
grab "$STEAM/logs/lepton-steamlaunch-$APPID.prev.log" lepton.prev.log
grab /tmp/wiicompiled-launch.log wiicompiled-launch.log
grab "$DATA/Config.toml" Config.toml
RUN=$(ls -td "$DATA"/Logs/*/ 2>/dev/null | head -1 || true)
[ -n "$RUN" ] && grab "$RUN/console.log" console.log
grab "$(ls -t "$DATA"/Logs/build_*.log 2>/dev/null | head -1 || true)" build.log
grab "$DATA/VR-diagnostics.txt" VR-diagnostics.txt
{
echo "collected: $(date -u +%Y-%m-%dT%H:%MZ)"
grep -E '^(VERSION_ID|BUILD_ID)=' /etc/os-release 2>/dev/null | sed 's/^/steamos /' || true
APK=$(ls "$HOME"/Games/WiiCompiledVR-SteamFrame/app/*.apk 2>/dev/null | head -1 || true)
if [ -n "$APK" ]; then
echo "apk sha256: $(sha256sum "$APK" | cut -d' ' -f1)"
echo "apk kit: $(unzip -p "$APK" assets/game_kit/kit.json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["fingerprint"])' 2>/dev/null || echo unknown)"
fi
GAME=$COMPAT/$APPID/internal/$PKG/files/game/base/game.json
[ -f "$GAME" ] && echo "built game kit: $(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("kitFingerprint", ""))' "$GAME" 2>/dev/null)"
[ -f "$RAW/lepton.log" ] && grep -m1 -o 'Lepton v[^ ]* ([^)]*)' "$RAW/lepton.log" || true
[ -f "$RAW/console.log" ] && grep -m1 -c 'Steam Frame button layout active' "$RAW/console.log" | sed 's/^/frame layout active: /' || true
} > "$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=${WCF_LOG_DIR:-$HOME}/wiicompiled-frame-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."
+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).
+30 -5
View File
@@ -14,17 +14,42 @@ LOG=/tmp/wiicompiled-launch.log
SAVED_LD_PRELOAD=$LD_PRELOAD
unset LD_PRELOAD # Steam's overlay preload only spams errors for adb
GAME=
READY= # set once a game built against this APK's kit is installed
REASON="no STEAM_COMPAT_DATA_PATH"
if [ -n "$STEAM_COMPAT_DATA_PATH" ]; then
# Lepton mounts $STEAM_COMPAT_DATA_PATH/external as the container's shared storage and
# internal/<package> as the app's private data.
# internal/<package> as the app's private data. The latter survives an APK update, so a game
# built against another kit can be sitting there: the app calls it stale and will not run it.
CFG=$STEAM_COMPAT_DATA_PATH/external/Android/data/$PKG/files/WiiCompiledOpenXRVR
if [ ! -f "$CFG/Config.toml" ] && [ -f "$HERE/Config.frame.toml" ]; then
mkdir -p "$CFG"
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
GAME=$STEAM_COMPAT_DATA_PATH/internal/$PKG/files/game/base/libmain.so
# 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
REASON="no game built yet"
elif [ -z "$APK" ]; then
REASON="no APK next to this script to compare the kit with"
else
KIT=$(unzip -p "$APK" assets/game_kit/kit.json 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin)["fingerprint"])' 2>/dev/null)
BUILT=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("kitFingerprint", ""))' "$MANIFEST" 2>/dev/null)
if [ -n "$KIT" ] && [ "$KIT" = "$BUILT" ]; then
READY=1
else
REASON="the built game is for another kit (${BUILT:0:12} vs this APK's ${KIT:0:12}); it needs building again"
fi
fi
fi
(
@@ -34,8 +59,8 @@ fi
timeout 5 "$ADB" connect localhost:5555 >/dev/null 2>&1
if timeout 5 "$ADB" -s localhost:5555 shell dumpsys window 2>/dev/null \
| grep -q "mCurrentFocus=.*$PKG/.*LauncherActivity"; then
if [ -n "$GAME" ] && [ ! -f "$GAME" ]; then
echo "$(date +%T) no game built yet ($GAME missing): leaving the launcher up"
if [ -z "$READY" ]; then
echo "$(date +%T) leaving the launcher up: $REASON"
exit 0
fi
if [ -n "$WCF_STAY_IN_LAUNCHER" ]; then