mirror of
https://github.com/simplysanatori/KartFrame.git
synced 2026-10-17 11:01:37 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
261975561d |
No files matched your search
+6
-1
@@ -1,4 +1,4 @@
|
||||
/generated
|
||||
/generated/
|
||||
/Assets/
|
||||
/assets/
|
||||
/PulsarPacks/
|
||||
@@ -87,4 +87,9 @@ output.txt
|
||||
# Local patched launcher build, reproducible from integrations/wheelwizard-vr.patch.
|
||||
/integrations/wheelwizard-work/
|
||||
|
||||
# Steam Frame build (steam-frame/build.sh): toolchain downloads, objects, APKs and the signing key
|
||||
/steam-frame/dl/
|
||||
/steam-frame/out/
|
||||
/steam-frame/release/
|
||||
/steam-frame/signing/
|
||||
__pycache__/
|
||||
@@ -1,254 +1,431 @@
|
||||
# KartFrame
|
||||
# Mario Kart Wii VR on the Steam Frame
|
||||
|
||||
Mario Kart Wii in VR, running natively on the Valve Steam Frame.
|
||||
This is a fork of [heurazy/Wiicompiled_VR-PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS),
|
||||
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.
|
||||
|
||||
KartFrame is a fork of heurazy's [Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS), the Mario Kart Wii VR port. The game in it is heurazy's static translation of the Wii game to C++, drawn by the aurora renderer on Dawn (WebGPU) and shown through OpenXR. KartFrame builds it as a Linux Arm64 program that runs in the Frame's own SteamVR. You do not need Lepton, an APK, adb or a PC.
|
||||
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.
|
||||
|
||||
You need your own Mario Kart Wii disc. No game data is in this repository or its releases.
|
||||
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.
|
||||
|
||||
What it does:
|
||||
**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.
|
||||
|
||||
- Plays Mario Kart Wii in VR on the Steam Frame, in the Frame's SteamVR.
|
||||
- Renders both eyes on every refresh of the 120 Hz display, from your head position at that refresh, at 1728 pixels per eye.
|
||||
- Uses the Steam Frame controllers through SteamVR's own controller profile.
|
||||
- Builds the game on your Frame from your own disc, once, in about 6 minutes.
|
||||
**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.
|
||||
|
||||
All of that has been played on a Frame. One problem is still being worked on, the double image in races; this pre-release carries the mitigation that removes it. See [The double image](#the-double-image-known-issue).
|
||||
## Contents
|
||||
|
||||
## What this adds to Wiicompiled VR PLUS
|
||||
- [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)
|
||||
|
||||
Upstream runs on Windows and on the Meta Quest. On the Frame that would mean Lepton and an Android layer between the game and SteamVR. KartFrame changes the platform side and leaves the game as it is:
|
||||
## What the fork changes
|
||||
|
||||
- A native Linux Arm64 build.
|
||||
- Dawn's Vulkan backend bound to SteamVR's own Vulkan device, through upstream's own Dawn patch.
|
||||
- Eye images handed to SteamVR only after the GPU has finished them.
|
||||
- Both eyes rendered on every refresh from that refresh's head pose, with the game's own VR frame interpolation filling the display rate from the game's 60 frames a second.
|
||||
- Pipelined frame pacing, with the older single stage mode kept as a switch.
|
||||
- The Frame controller layout in code, with no SteamVR binding file.
|
||||
- Simplified models of the Frame's controllers, converted at install from the Frame's own SteamVR files.
|
||||
- The game's 2D screen shown as a flat quad layer in front of you.
|
||||
- A first run that extracts your disc, translates the game and compiles it on the Frame with a bundled compiler, using a kit of prebuilt runtime parts.
|
||||
- A launch wrapper that sets SteamVR's per-app settings (120 Hz, no throttling, no extra prediction, motion smoothing off, 1728 resolution) at every launch and writes a session log folder.
|
||||
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.
|
||||
|
||||
## Status
|
||||
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.
|
||||
|
||||
Version 0.9.0-alpha.1 is a pre-release. It plays, at 120 Hz and 1728 pixels per eye, through twenty headset sessions so far. Version numbers stay below 1.0.0 until the double image (next section) is fixed at its cause rather than worked around.
|
||||
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)).
|
||||
|
||||
Confirmed in the headset: the native build in SteamVR; 119 to 120 sets of eyes a second in races with under 1 % late; the game at its full 60 frames a second; the menus as a steady flat screen; the race HUD on the virtual screen with correct clipping; the Frame controls, hand steering and the controller models; the first run from a disc; the installer, the library entry, the launch wrapper and the session logs.
|
||||
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`)
|
||||
|
||||
Known issues: the Time Trial ghost is flat; full-view 2D effects such as Blooper ink do not cover the whole view; the laser pointer's dot is not visible on the 2D menu screen (the ray is); two and four player races are heavier and not yet measured in the headset. The short list of what is built but not yet confirmed is in [frame/UNTESTED.md](frame/UNTESTED.md).
|
||||
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`)
|
||||
|
||||
## The double image (known issue)
|
||||
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`)
|
||||
|
||||
In races, turning your head could show the world twice, one copy about two refreshes of head motion ahead of the other, alternating every refresh, worse in one eye. Menus were always clean, and so was GalaxyFrame on the same SteamVR.
|
||||
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`)
|
||||
|
||||
Twenty sessions of measurement, each one eliminating something by a direct test, ended with this: SteamVR's compositor on the Frame takes each eye's most recently handed-over image at its own point inside the refresh, the right eye after the left. The game was handing frames over right where those points sit, so from one refresh to the next the two eyes did not always get the same set. A probe that showed SteamVR the game's own recorded race images was clean in every variation, and logging every compositor frame beside the game's own timeline showed the clean launches keeping one side of the pick-up every frame and the doubled ones flipping.
|
||||
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).
|
||||
|
||||
What this build does about it: the launch wrapper runs a small helper that reads SteamVR's compositor timing and publishes its grid, and the game hands every frame over at a fixed point against that grid, just after the compositor's update (`KARTFRAME_END_PHASE=after`, the default). The doubling is gone with that. What remains, and why this is a mitigation rather than a fix: the right eye runs one set ahead of the left, which reads as the right eye being a touch more sensitive to head movement, and the compositor shows a frame twice now and then. The probe hands its frames over at the same point as the old default and is clean, so the point itself is not the cause; the remaining difference is the 4 to 7 ms of GPU work the game submits right after each hand-over on the GPU the compositor also composes on. That is the next experiment. The records of every session are in `frame/perf/`, and the mechanism is written up in the [technical notes](docs/TECHNICAL_NOTES.md).
|
||||
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.
|
||||
|
||||
If you want the old behaviour to compare, put `KARTFRAME_END_PHASE=off` in `session.env` for one launch.
|
||||
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.
|
||||
|
||||
## What has been done so far
|
||||
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.
|
||||
|
||||
The work since the first headset session, in the order it mattered:
|
||||
|
||||
- The game ran at 38 frames a second under gamescope: a window-sync call slept 10 ms every game frame. Fixed; the game runs at its full 60.
|
||||
- The 2D menus moved from projected panels to a quad layer, as upstream does on PC, which ended the flicker and the 5 to 7 ms of GPU the menus cost.
|
||||
- An sRGB sink and a cut of the native render pass: copying the eyes to the swapchain went from 2.3 ms to 0.47 ms, which is what lets races hold 120 Hz at 1728.
|
||||
- Pipelined pacing with the sets of eyes rendered two refreshes ahead, every frame handed over at the same point of the refresh, each set released only after a GPU fence, a retained repeat when a set is late, half rate never by default.
|
||||
- Turnip's sysmem mode by default: 7 to 8 % faster for this draw-bound game.
|
||||
- Thread priority through RealtimeKit and a utilisation clamp, so the game thread and the frame worker stay on the big cores.
|
||||
- The race HUD clipped to its frame on the virtual screen, the thin line beside the map removed, full-view fades only for untextured colour fades.
|
||||
- Input kept through SteamVR's short focus losses; the refresh rate requested only when SteamVR offers it.
|
||||
- The double-image work above, with the tools it needed: a mock OpenXR runtime with exact checks, a probe that replays recorded eye images, per-frame compositor timing, panel recordings and their analysis.
|
||||
The cameras, hand IK, HUD and Retro Rewind support are upstream's work and are unchanged.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A Steam Frame with SteamVR, and about 1 GB free for the toolchain and kit, plus the extracted disc.
|
||||
- Your own clean PAL Mario Kart Wii disc image (RMCP01).
|
||||
- A terminal on the Frame for the one time setup: SSH, or Konsole from desktop mode.
|
||||
- Steam running on the Frame while you install, so the installer can add the library entry.
|
||||
- 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.
|
||||
|
||||
## Install
|
||||
|
||||
1. Get the repository:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/simplysanatori/KartFrame.git
|
||||
cd KartFrame
|
||||
git clone -b steam-frame https://github.com/simplysanatori/Wiicompiled_VR-PLUS-steam-frame.git
|
||||
cd Wiicompiled_VR-PLUS-steam-frame
|
||||
```
|
||||
|
||||
2. Download `kartframe-0.9.0-alpha.1-linux-arm64.tar.gz` and its `.sha256` file from the [Releases](https://github.com/simplysanatori/KartFrame/releases) page. Check the download and install it:
|
||||
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`:
|
||||
|
||||
```sh
|
||||
sha256sum -c kartframe-0.9.0-alpha.1-linux-arm64.tar.gz.sha256
|
||||
frame/install.sh ~/Downloads/kartframe-0.9.0-alpha.1-linux-arm64.tar.gz
|
||||
sha256sum ~/Downloads/WiiCompiled-VR-SteamFrame-*.apk
|
||||
```
|
||||
|
||||
The installer unpacks the program into `~/Games/KartFrame/app`. It adds **KartFrame** to your Steam library as a non-Steam shortcut, or updates the entry if it is there. It converts the Frame's controller models from SteamVR's own files. The script talks to Steam through its local debugging port, which SteamOS has on by default.
|
||||
You can also build the APK yourself ([Building the APK yourself](#building-the-apk-yourself)).
|
||||
The APK contains no game data.
|
||||
|
||||
3. Build the game from your disc, once:
|
||||
3. Run the installer with Steam running. Use the `install.sh` from the same release tag or newer:
|
||||
|
||||
```sh
|
||||
~/Games/KartFrame/app/setup.sh /path/to/MarioKartWii.iso
|
||||
steam-frame/install.sh ~/Downloads/WiiCompiled-VR-SteamFrame-*.apk
|
||||
```
|
||||
|
||||
Setup checks that the disc is RMCP01, extracts it to `~/.local/share/KartFrame/game`, translates the game, compiles it with the bundled compiler and installs the finished program. It took about 6 minutes on my Frame. It can be run again after an interruption and picks up where it stopped.
|
||||
The installer:
|
||||
|
||||
4. Put the headset on and launch **KartFrame** from your Steam library.
|
||||
- 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.
|
||||
|
||||
To update, download the new tarball and run `frame/install.sh` again, then `setup.sh` again if it tells you the kit changed. Your game files, saves and settings stay where they are.
|
||||
Running it again updates the same entry.
|
||||
|
||||
The installer changes nothing outside the folders listed under [Files](#files), the Steam shortcut and the controller models. To remove KartFrame, run `~/Games/KartFrame/app/tools/uninstall.sh`. It keeps your data folder unless you pass `--purge`.
|
||||
## First run: import the disc and build the game
|
||||
|
||||
## First run
|
||||
1. Put the disc image in `~/Documents/` on the Frame. Lepton shows that folder to the app as
|
||||
`/sdcard/Documents`.
|
||||
|
||||
The launch wrapper checks that the installed program was built for the installed kit. If it was not, it stops and writes the reason to the session log; run `setup.sh` again.
|
||||
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.
|
||||
|
||||
On the very first start the game creates its own settings file and a fresh Wii save area. The game makes the Wii licence in-game on first boot.
|
||||
3. In the terminal, import the disc. Replace `<appid>` with the number from `install.sh` and
|
||||
`MarioKart.iso` with your file name:
|
||||
|
||||
If the game cannot find the disc files, the setup did not finish. Run `setup.sh status` to see which step is missing.
|
||||
```sh
|
||||
LEPTON=~/.local/share/Steam/steamapps/common/Lepton/lepton
|
||||
$LEPTON exec steamlaunch-<appid> am start-foreground-service -n org.wiicompiled.quest/.launcher.GameSetupService \
|
||||
-d file:///sdcard/Documents/MarioKart.iso --es task ExtractDisc
|
||||
```
|
||||
|
||||
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 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'
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
||||
The game sees a Wii wheel and pad. The Frame controllers are mapped like this.
|
||||
`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.
|
||||
|
||||
| Steam Frame | In the game |
|
||||
|---|---|
|
||||
| Right trigger, **A** | Accelerate |
|
||||
| Left trigger | Reverse |
|
||||
| **B** | Brake, and back in menus |
|
||||
| **X**, left bumper | Use item |
|
||||
| **Y** | Look back |
|
||||
| Both grips, right bumper | Drift |
|
||||
| Left D-pad | D-pad |
|
||||
| Right **Menu** | Start |
|
||||
| Left **View** | Back. Held for half a second, it opens the VR settings panel. Pressed while the panel is open, it closes it |
|
||||
The Frame's left controller has the D-pad and View button. The right controller has A, B, X, Y
|
||||
and the Menu button.
|
||||
|
||||
Hand steering: in the first-person cockpit the grips hold the wheel, and the right bumper drifts there. Elsewhere the grips drift.
|
||||
| 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
|
||||
|
||||
Hold left View for half a second. The VR settings panel opens. Everything on it is also in `~/.local/share/KartFrame/UserData/Config.toml`, which is seeded from `Config.frame.toml` on the first run. The keys that matter:
|
||||
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".
|
||||
|
||||
| Key | Default | What it does |
|
||||
|---|---|---|
|
||||
| `[vr] render_scale` | 1.0 | Eye size relative to the size SteamVR's resolution setting gives the game (1728 per eye at SteamVR's 100 %) |
|
||||
| `[vr] frame_interpolation_fps` | 1 | 1 renders at the headset's refresh rate, interpolated from the game's 60 frames a second |
|
||||
| `[vr] frame_pacing` | `"auto"` | `auto`, `inline` or `pipelined`. Auto picks pipelined |
|
||||
| `[vr] refresh_rate` | 120 | The refresh rate the game asks for |
|
||||
| `[vr] menu_shader_quality` | 0 | 0 is a plain dark background behind the menus |
|
||||
| `[vr] hand_steering` | true | Grips hold the wheel in the first-person cockpit |
|
||||
| `[vr] hud_virtual_screen` | true | Menus and the race HUD on a virtual screen |
|
||||
| `[vr] hud_distance_meters` | 2.0 | How far away that screen is |
|
||||
| `[vr] hud_width_meters` | 2.4 | How wide it is |
|
||||
| `[video] resolution_multiplier` | 1.0 | Resolution of the flat desktop mirror |
|
||||
| `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. |
|
||||
|
||||
I do not ship a default below 1728 per eye. Lower values of `render_scale` work, but the picture gets soft.
|
||||
`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`.
|
||||
|
||||
Switches for testing go in `~/.local/share/KartFrame/session.env`, one `NAME=value` per line, read at the next launch only:
|
||||
Environment variables for the scripts:
|
||||
|
||||
| Name | What it does |
|
||||
|---|---|
|
||||
| `KARTFRAME_PACING` | `auto`, `inline` or `pipelined`, overriding the config |
|
||||
| `KARTFRAME_REFRESH_HZ` | 90 or 120, the rate asked of SteamVR |
|
||||
| `KARTFRAME_SYSMEM` | 0 turns off the GPU's direct memory rendering mode (`TU_DEBUG=sysmem`); it is on by default |
|
||||
| `KARTFRAME_FDM_LAYER` | 0 disables Valve's foveation layer in the Vulkan chain, to compare |
|
||||
| `KARTFRAME_END_PHASE` | `after` (default), `before`, or `off`: when in the refresh each frame is handed to SteamVR, against the compositor grid the wrapper's helper publishes. `off` hands over at the wake-up, which doubles the image |
|
||||
| `KARTFRAME_END_DELAY_US` | A fixed delay after the wake-up instead of the grid, in microseconds; also the fallback when the grid is missing (4000) |
|
||||
| `KARTFRAME_VRTIMING_FRAMES` | 1 logs every compositor frame to `vrframes.log` in the session folder |
|
||||
| `KARTFRAME_HOME` | The data folder, if it is not `~/.local/share/KartFrame` |
|
||||
| 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). |
|
||||
|
||||
## Performance
|
||||
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.
|
||||
|
||||
Measured on my Frame, from the session logs:
|
||||
## Known problems
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Display | 120 Hz, 1728 x 1728 per eye |
|
||||
| Races | 119 to 120 sets of eyes a second, 0.2 to 1 % late, 2 to 3 % retained repeats |
|
||||
| Menus | 110 to 113 sets a second (the quad layer and the intro screens) |
|
||||
| GPU per set | about 5 ms in races (about 7 with the hand-over timing of this build, since the next set starts before the hand-over), 1.5 to 2.5 ms in menus |
|
||||
| Game thread | 60 frames a second; about 8 % of race frames take 20 to 33 ms (the game's own course loading and item logic); a 144 ms hitch at race start |
|
||||
| First run | about 6 minutes from the disc: extract 1 minute, translate 50 seconds, compile 5 minutes |
|
||||
|
||||
What the build does, so you know what the numbers mean:
|
||||
|
||||
- At 120 Hz there are about 8.3 ms per refresh, and about 7 ms of that is the GPU's for both eyes beside SteamVR's compositor.
|
||||
- Each refresh locates the head once and renders both eyes from that pose, two refreshes ahead of the refresh that shows them.
|
||||
- A set of eyes is handed to SteamVR only after the GPU has finished it, at a fixed point of the refresh against the compositor's own clock. If a set is not finished in time, the previous set is shown again and the miss is counted.
|
||||
- Every second, the game logs one `vr pacing:` line with the refresh rate, the sets rendered, interpolated, late and missed, and GPU and encode times; every 10 seconds an `end phase:` line with the achieved hand-over phase. `report.txt` in each session folder sums them up, and `tools/session-report.py` lays several sessions side by side.
|
||||
|
||||
## Files
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Program, wrapper, kit, toolchain, translator | `~/Games/KartFrame/app` |
|
||||
| Game files, built game, saves, settings | `~/.local/share/KartFrame` (`game/`, `build/`, `bin/`, `UserData/`) |
|
||||
| Controller models | `~/.local/share/KartFrame/UserData/controller_models/frame` |
|
||||
| Session logs | `~/.local/share/KartFrame/sessions/<date-time>/` (`launch.log`, `stdout.log`, `game.log`, `vrtiming.log`, `report.txt`); `sessions/latest` points at the newest |
|
||||
| Downloaded tools | `~/.cache/kartframe` (nodtool, fetched by `setup.sh`) |
|
||||
| One launch's test settings | `~/.local/share/KartFrame/session.env` |
|
||||
|
||||
The game does not write to `~/.local/share/WiiCompiled`.
|
||||
- **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.** Start Steam and run it again.
|
||||
- **The game does not start and `launch.log` says it refuses.** Read the reason in `~/.local/share/KartFrame/sessions/latest/launch.log`. If it names a kit mismatch, run `setup.sh` again.
|
||||
- **`setup.sh` says the disc is not Mario Kart Wii RMCP01.** Only the clean PAL disc is supported.
|
||||
- **SteamVR's per-app settings were not applied.** `launch.log` lists each one and what SteamVR holds after the write. They are only set when the game is started from the Steam library.
|
||||
- **The world doubles when you turn your head in a race.** That is the known issue above. Check `launch.log` says `KARTFRAME_END_PHASE=after` and `game.log` has `end phase: locked to the compositor grid`; if it says it fell back to the fixed delay, the helper did not get SteamVR's timing (it needs SteamVR's `libopenvr_api.so`, which the wrapper finds under `/opt/steamvr`).
|
||||
- **The right eye feels more sensitive to head movement than the left.** The known issue's remainder: the right eye runs one set ahead. Not fixed yet.
|
||||
- **Motion is uneven.** `report.txt` shows late and repeated sets per second. A few percent is normal; more usually means something else is using the GPU (a recording, a stream).
|
||||
- **`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`.
|
||||
|
||||
## Reporting a problem
|
||||
## Logs and reporting a problem
|
||||
|
||||
Open an [issue](https://github.com/simplysanatori/KartFrame/issues) with what you did, what you expected and what happened, and the output of:
|
||||
| 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
|
||||
|
||||
```sh
|
||||
~/Games/KartFrame/app/tools/collect-logs.sh
|
||||
steam-frame/build.sh # downloads upstream's v1.3.1 APK and checks its SHA-256
|
||||
steam-frame/build.sh path/to/WiiCompiled-VR-Quest3-v1.3.1.apk
|
||||
```
|
||||
|
||||
It gathers the newest session's logs into one archive and masks IP and MAC addresses, host names, network names, serial numbers, Steam ids, email addresses and user names in paths. The masking is a best effort. Read the files before you attach them.
|
||||
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.
|
||||
|
||||
## Build from source
|
||||
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.
|
||||
|
||||
See the [technical notes](docs/TECHNICAL_NOTES.md). The patched Dawn is built once on the Frame, then the game and the release kit. `frame/tools/privacy-check.sh` checks the tree, the history and a release tarball for personal data before publishing, and `frame/boundary-check.sh` checks that nothing from the disc is in a release.
|
||||
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.
|
||||
|
||||
## Support
|
||||
The game itself (`libmain.so`) is never built here. The headset builds it from your disc.
|
||||
|
||||
KartFrame exists because of heurazy's [Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS). If you want to support someone, support them first: star and follow it, report what you find there, and send improvements to the game upstream.
|
||||
## Where things are
|
||||
|
||||
If you would like more Steam Frame ports, you can also [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.
|
||||
|
||||
## Credits
|
||||
|
||||
- Built by [simplysanatori](https://github.com/simplysanatori).
|
||||
- [Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS) by heurazy, which this is built on, and heurazy's [Mario Kart Wii VR port](https://github.com/heurazy/mario-kart-wii-VR-port) before it.
|
||||
- [Wiicompiled VR](https://github.com/iChris4/Wiicompiled_VR) by iChris4, the OpenXR port of WiiCompiled that Wiicompiled VR PLUS started from, and the WiiCompiled translator that turns the Wii game into C++.
|
||||
- [aurora](https://github.com/encounter/aurora), the renderer, and [Dawn](https://dawn.googlesource.com/dawn), the WebGPU implementation under it.
|
||||
- [SDL3](https://github.com/libsdl-org/SDL) and the [OpenXR SDK](https://github.com/KhronosGroup/OpenXR-SDK).
|
||||
- [nod and nodtool](https://github.com/encounter/nod) by encounter, which extracts the disc.
|
||||
- [LLVM](https://llvm.org/), the compiler bundled for the first run, and the [.NET runtime](https://github.com/dotnet/runtime), which the translator carries.
|
||||
- Valve, for the Frame controller models. They are converted on your Frame from your own SteamVR files and never redistributed.
|
||||
- [framecorder](https://github.com/simplysanatori/framecorder), my headset recorder, which recorded the panels the double-image work was measured on.
|
||||
- bigmak94's [GalaxyQuest](https://github.com/bigmak94/GalaxyQuest) and the GalaxyFrame work, for what I learned about pacing for SteamVR and for the test tools (`xrmock`, and `xrprobe`, GPLv3).
|
||||
|
||||
[THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) has the full list with licences.
|
||||
|
||||
Mario Kart Wii, Nintendo and their logos are trademarks of Nintendo. This is an unofficial fan project. It is not affiliated with or endorsed by Nintendo, Valve or the upstream author.
|
||||
|
||||
## Licence
|
||||
|
||||
GPLv3, as upstream: see [LICENSE](LICENSE). Third-party parts keep their own licences: see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md) and the `licenses/` folder. The test probe in `frame/tools/xrprobe` is GPLv3 and is not part of the game. No Nintendo game data is included.
|
||||
|
||||
Upstream's own README is kept as [README.upstream.md](README.upstream.md).
|
||||
- **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
|
||||
|
||||
I wanted Mario Kart Wii on my Frame without Lepton, so I started from Wiicompiled VR PLUS and had it built natively for SteamVR: a Linux Arm64 build, Dawn's Vulkan backend on SteamVR's own device, pacing written for the Frame's compositor, the Frame controllers in code, and a first run that builds the game from your own disc on the Frame itself.
|
||||
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.
|
||||
|
||||
This is vibe coded, with manual review throughout. I wrote it with Claude Code, using Anthropic's Fable 5.1, Opus 5.5 and Sonnet 5.5 models running on the Frame itself, in parallel lanes with a mock OpenXR runtime so that almost everything could be proven without putting the headset on. The double image took twenty headset sessions of about a minute each, every one of them a test of one thing, and the records of those are in `frame/perf/`.
|
||||
- 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.
|
||||
|
||||
Developer notes and what was learned about SteamVR on the Frame are in the [technical notes](docs/TECHNICAL_NOTES.md).
|
||||
## Licence and credits
|
||||
|
||||
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 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.
|
||||
@@ -235,45 +235,6 @@ unmodified, with their license texts, in the installer's `licenses/` folder.
|
||||
|
||||
---
|
||||
|
||||
## Native Linux Arm64 build for the Steam Frame (KartFrame)
|
||||
|
||||
The KartFrame release tarball carries a kit, a toolchain and a translator so that the game can be
|
||||
built on the user's own Steam Frame from the user's own disc. Pins and the build of each are in
|
||||
`frame/SETUP.md`; license texts for the kit are in the kit's `licenses/` folder.
|
||||
|
||||
| Component | Version | License | Upstream |
|
||||
| --- | --- | --- | --- |
|
||||
| LLVM clang, lld, llvm-ar, llvm-ranlib (portable toolchain, linux-aarch64) | 22.1.8 | Apache-2.0 WITH LLVM-exception | <https://github.com/llvm/llvm-project> |
|
||||
| CMake (portable toolchain) | 4.3.3 | BSD-3-Clause | <https://cmake.org/> |
|
||||
| Ninja (portable toolchain) | 1.13.2 | Apache-2.0 | <https://ninja-build.org/> |
|
||||
| .NET 8 runtime (inside the self-contained `Translator.Cli`) | 8.0 | MIT | <https://github.com/dotnet/runtime> |
|
||||
| YamlDotNet (inside `Translator.Cli`) | as pinned by the translator | MIT | <https://github.com/aaubry/YamlDotNet> |
|
||||
| nodtool (disc image extraction, fetched on the user's machine at setup, not redistributed) | v2.0.0-alpha.10 | MIT OR Apache-2.0 | <https://github.com/encounter/nod> |
|
||||
| Dawn and Tint, SDL3, OpenXR-SDK with its loader, abseil, zlib, libpng, FreeType, Dear ImGui, {fmt}, Zstandard, SQLite, Tracy (prebuilt static in the kit) | as listed above | as listed above | as listed above |
|
||||
|
||||
The kit also carries a minimal sysroot made unmodified from the packages of the Frame it was built
|
||||
on, used only to compile and link the user's game against, with their license texts in the kit
|
||||
(`sysroot/LICENSES`):
|
||||
|
||||
| Component | License | Upstream |
|
||||
| --- | --- | --- |
|
||||
| glibc headers, crt objects, link-time copies of `libc`, `libm` and the dynamic loader | LGPL-2.1-or-later | <https://www.gnu.org/software/libc/> |
|
||||
| GCC runtime: libstdc++ headers and library, `libgcc`, `libgcc_eh`, `libgcc_s`, `crtbegin` and `crtend` objects | GPL-3.0-or-later WITH GCC-exception (Runtime Library Exception) | <https://gcc.gnu.org/> |
|
||||
| Linux kernel API headers | GPL-2.0 WITH Linux-syscall-note | <https://www.kernel.org/> |
|
||||
|
||||
The built game links the user's own glibc, libstdc++ and libgcc_s dynamically. Complete corresponding
|
||||
source for the glibc and GCC parts is available from the upstream projects at the versions recorded
|
||||
in `sysroot/LICENSES`, and this project will supply it on request for the exact version shipped.
|
||||
|
||||
Test tools in `frame/tools` (`xrmock`, `xrprobe`) are not part of the game. `xrprobe` is GPLv3
|
||||
(`frame/tools/xrprobe/LICENSE`). The release tarball carries only `vrtiming.py` and `session-report.py`.
|
||||
|
||||
Valve's Steam Frame controller models are read from the user's own SteamVR install at install time,
|
||||
simplified and written to the user's data folder. They are never copied into the repository or a
|
||||
release.
|
||||
|
||||
---
|
||||
|
||||
## Development-only dependencies
|
||||
|
||||
Not redistributed in any release artifact.
|
||||
|
||||
@@ -39,10 +39,6 @@ if (AURORA_ENABLE_GX)
|
||||
# of producing unresolved interop references.
|
||||
if (CMAKE_SYSTEM_NAME STREQUAL Windows)
|
||||
target_sources(aurora_core PRIVATE lib/webgpu/d3d12_interop.cpp lib/webgpu/vulkan_win32_interop.cpp)
|
||||
elseif (CMAKE_SYSTEM_NAME STREQUAL Linux)
|
||||
# Desktop Linux OpenXR uses the same shared-device Vulkan bridge as Windows,
|
||||
# with the patched Dawn linked statically instead of loaded from a DLL.
|
||||
target_sources(aurora_core PRIVATE lib/webgpu/vulkan_win32_interop.cpp)
|
||||
endif ()
|
||||
# Android/Vulkan counterpart: the AHardwareBuffer stereo bridge. The file
|
||||
# compiles to C ABI stubs on every other platform so the runtime's OpenXR
|
||||
|
||||
Vendored
-18
@@ -9,24 +9,7 @@ else ()
|
||||
set(_USE_SHARED OFF)
|
||||
endif ()
|
||||
|
||||
# KartFrame: a shipped Linux binary must not depend on libraries that are not part of the stock
|
||||
# SteamOS image (abseil, libpng, freetype and their dependency chains were installed by hand on the
|
||||
# development machine). With this on, the four packages below are built from the pinned sources
|
||||
# below, as static libraries, instead of being found on the system.
|
||||
if (CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
set(_AURORA_VENDOR_DEFAULT ON)
|
||||
else ()
|
||||
set(_AURORA_VENDOR_DEFAULT OFF)
|
||||
endif ()
|
||||
option(AURORA_VENDOR_THIRD_PARTY "Build abseil, zlib, libpng and freetype from source (static) instead of using system copies" ${_AURORA_VENDOR_DEFAULT})
|
||||
if (AURORA_VENDOR_THIRD_PARTY)
|
||||
set(_USE_SHARED OFF)
|
||||
endif ()
|
||||
|
||||
function(aurora_find_package_global)
|
||||
if (AURORA_VENDOR_THIRD_PARTY AND ARGV0 MATCHES "^(absl|ZLIB|PNG|Freetype)$")
|
||||
return()
|
||||
endif ()
|
||||
set(_PREV_FIND_PACKAGE_TARGETS_GLOBAL ${CMAKE_FIND_PACKAGE_TARGETS_GLOBAL})
|
||||
set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL ON)
|
||||
find_package(${ARGV})
|
||||
@@ -188,7 +171,6 @@ if (AURORA_ENABLE_GX)
|
||||
if (NOT TARGET Freetype::Freetype)
|
||||
message(STATUS "aurora: Fetching Freetype")
|
||||
set(FT_DISABLE_BROTLI ON CACHE BOOL "" FORCE)
|
||||
set(FT_DISABLE_BZIP2 ON CACHE BOOL "" FORCE)
|
||||
set(FT_DISABLE_HARFBUZZ ON CACHE BOOL "" FORCE)
|
||||
set(FT_DISABLE_PNG ON CACHE BOOL "" FORCE)
|
||||
FetchContent_Declare(Freetype
|
||||
|
||||
@@ -17,10 +17,6 @@ bool aurora_vulkan_win32_set_targets_with_panel(uint64_t token, const AuroraD3D1
|
||||
const AuroraD3D12StereoTarget* panel);
|
||||
bool aurora_vulkan_win32_cancel(uint64_t token);
|
||||
bool aurora_vulkan_win32_disable();
|
||||
// Only inside the submitted callback, on the frame worker: true when the set being
|
||||
// reported was rendered from a retained game frame (VR frame interpolation, the
|
||||
// projected menu) instead of a newly sealed one.
|
||||
bool aurora_vulkan_win32_submitting_retained();
|
||||
void* aurora_vulkan_win32_lock_queue();
|
||||
void aurora_vulkan_win32_unlock_queue(void* guard);
|
||||
#ifdef __cplusplus
|
||||
|
||||
+39
-765
File diff suppressed because it is too large.
Load diff
@@ -529,27 +529,9 @@ void GXCopyTex(void* dest, GXBool clear) {
|
||||
handle.handle->isEfbCopy = true;
|
||||
handle.handle->lastEfbCopyFrame = currentFrame;
|
||||
}
|
||||
// R2 (lane H): at the internal resolution the minimap's half-scale bake copy (440x440 logical -> 220x220
|
||||
// IA4) carries a faint white stripe (alpha 1/15) in its last two or three logical columns, which the
|
||||
// race HUD shows as a thin vertical line along the map's right edge; trimming the last 4 render pixels
|
||||
// of the sampled source removed it, 3 did not (eye dumps; what in the bake draws it is not known).
|
||||
// Half-scale copies at a fractional scale are sampled without their last 3 logical columns (a 0.7 %
|
||||
// stretch of a 440 px bake). KARTFRAME_COPY_TRIM=0 restores the full source; =<n> trims n columns.
|
||||
static const float copyTrimLogical = [] {
|
||||
const char* env = std::getenv("KARTFRAME_COPY_TRIM");
|
||||
return env != nullptr ? std::max(0.0f, static_cast<float>(std::atof(env))) : 3.0f;
|
||||
}();
|
||||
auto sampleRect = sourceRect.sampleRect;
|
||||
if (copyTrimLogical > 0.f && g_gxState.texCopyHalfScale && !aurora::gx::is_depth_format(texCopyFmt) &&
|
||||
g_gxState.texCopySrc.width > 0) {
|
||||
const float scale = sampleRect.z() / static_cast<float>(g_gxState.texCopySrc.width);
|
||||
if (std::fabs(scale - std::round(scale)) > 0.01f && g_gxState.texCopySrc.width > 8.f * copyTrimLogical) {
|
||||
sampleRect = {sampleRect.x(), sampleRect.y(), sampleRect.z() - copyTrimLogical * scale, sampleRect.w()};
|
||||
}
|
||||
}
|
||||
aurora::gfx::resolve_pass(handle.handle, rect, clearState.clearColor, clearState.clearAlpha, clearState.clearDepth,
|
||||
clearState.clearColorValue, aurora::gx::clear_depth_value(), resolveFmt,
|
||||
&sampleRect, g_gxState.texCopyHalfScale, ©Filter, forceOpaqueAlpha,
|
||||
&sourceRect.sampleRect, g_gxState.texCopyHalfScale, ©Filter, forceOpaqueAlpha,
|
||||
sourceRect.sampleRect.w() / std::max<float>(g_gxState.texCopySrc.height, 1.0f),
|
||||
(g_gxState.copyClamp & GX_CLAMP_TOP) != 0, (g_gxState.copyClamp & GX_CLAMP_BOTTOM) != 0,
|
||||
persistentCopy);
|
||||
|
||||
+86
-630
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,6 @@
|
||||
#include <cstring>
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
@@ -332,10 +331,6 @@ struct StereoReplayFrame {
|
||||
// The immersive window (AuroraStereoFrame::window): each eye is masked to the
|
||||
// 2D layer's screen after its last draw (gfx/window_mask.hpp).
|
||||
bool window = false;
|
||||
// Lane P: the packet these eye transforms came from (AuroraStereoFrame::displayTimeNanos and
|
||||
// frameToken), recorded per eye where the eye uniforms are filled (EyeSceneRecord::pose*).
|
||||
uint64_t displayTimeNanos = 0;
|
||||
uint64_t frameToken = 0;
|
||||
};
|
||||
|
||||
void end_frame(const wgpu::CommandEncoder& cmd);
|
||||
@@ -379,38 +374,6 @@ bool has_late_stereo_replay(const SealedFrame& frame) noexcept;
|
||||
bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, const StereoReplayFrame& stereoFrame,
|
||||
float weight);
|
||||
|
||||
// Lane L: the scene state each eye of the set being encoded actually used, for the frame stamp and the
|
||||
// per-set log line (KARTFRAME_FRAME_STAMP, KARTFRAME_FRAME_LOG). Filled where the eye uniform ranges are
|
||||
// written (prepare_late_stereo_replay at the set's sample time, or the seal's own eye pair) and where the
|
||||
// eye replays bind them (render_stereo_eye). The frame worker's own record; run_stereo_sink takes it.
|
||||
struct EyeSceneRecord {
|
||||
bool valid = false;
|
||||
bool late = false; // filled at the set's sample time (interpolation), else at the seal (current only)
|
||||
bool anchored = false;
|
||||
float weight = 1.f; // the clamped weight the fill used
|
||||
bool continuous = false; // set by the caller: a previous game frame was available
|
||||
uint32_t previousFrame = 0, currentFrame = 0; // set by the caller: the pair's game (logical) frames
|
||||
uint32_t fillOffset = 0, fillSize = 0; // the uniform buffer bytes this fill wrote
|
||||
// Per eye, perspective draws: filled from the previous/current pair (weight < 1), filled with the
|
||||
// current transforms only; drawn with the recorded uniform (no eye range), drawn with an eye range
|
||||
// outside this fill (one some other set or frame wrote).
|
||||
std::array<uint16_t, 2> interpolated{}, current{}, recorded{}, outside{};
|
||||
// Lane P: per eye, the head pose its uniform fill actually wrote: the packet (display time, token) and
|
||||
// the eye transform (viewFromCenter) of the first draw filled for that eye, and how many later draws of
|
||||
// the eye were filled with a different packet or transform.
|
||||
std::array<bool, 2> posed{};
|
||||
std::array<uint64_t, 2> poseTime{}, poseToken{};
|
||||
std::array<Mat3x4<float>, 2> pose{};
|
||||
std::array<Mat4x4<float>, 2> projection{}; // the eye's frustum (StereoReplayEye::projection) of that draw
|
||||
std::array<uint16_t, 2> poseMixed{};
|
||||
// The GPU uniform probe (aurora.cpp UniformProbe): per eye, the first filled range's offset in
|
||||
// g_uniformBuffer and the first bytes the fill wrote there, read back after the set's eyes.
|
||||
std::array<bool, 2> probed{};
|
||||
std::array<uint32_t, 2> probeOffset{}, probeSize{};
|
||||
std::array<std::array<uint8_t, 64>, 2> probeBytes{};
|
||||
};
|
||||
EyeSceneRecord& eye_scene_record() noexcept;
|
||||
|
||||
// Encode a sealed frame. Never touches the producer-visible recording state,
|
||||
// so this may run concurrently with the producer's FIFO drains.
|
||||
// `nativeRenderLastPass` limits the passes that do render work (texture bakes still run for every
|
||||
@@ -511,34 +474,14 @@ enum class GpuTimingCategory : uint8_t {
|
||||
DepthPeek, // the depth snapshot compute pass
|
||||
Snapshot, // presentation snapshot and its ImGui pass
|
||||
Present, // the desktop presentation copy
|
||||
// Segments inside an eye's render passes and around the headset submission, timed with in-pass
|
||||
// timestamps (gpu_timing_mark) where the device has them (KARTFRAME_FPSLOG=2).
|
||||
EyeScene, // perspective (world) draws
|
||||
EyeHud, // 2D draws on the virtual screen
|
||||
EyeFade, // 2D draws across the whole eye (full-view fades, unclaimed 2D)
|
||||
EyeEfbEffect, // 2D draws sampling the game's own EFB copies (bloom, blur and the like)
|
||||
EyeClear, // clear draws
|
||||
EyeOther2D, // 2D draws that are none of the above
|
||||
Cockpit, // the VR cockpit and the immersive window's mask
|
||||
Upload, // the late replay's uniform upload
|
||||
Sink, // copies into the headset's swapchain images
|
||||
HudLayer, // the race's 2D layer rendered once into its own image
|
||||
MenuEnv, // the menus' environment layer
|
||||
Count,
|
||||
};
|
||||
void gpu_timing_set_enabled(bool enabled) noexcept;
|
||||
bool gpu_timing_enabled() noexcept;
|
||||
// Opens the current frame's query slot; a frame whose slot is still being read back is skipped.
|
||||
// kind 0: a game frame's cycle (native render and its eye set), 1: a refresh's eye set.
|
||||
void gpu_timing_begin_frame(uint8_t kind = 0) noexcept;
|
||||
void gpu_timing_begin_frame() noexcept;
|
||||
// Timestamp writes for one pass of the open frame, or nullptr when timing is off or exhausted.
|
||||
const wgpu::PassTimestampWrites* gpu_timing_pass(GpuTimingCategory category) noexcept;
|
||||
// True when the open frame is timed and the device writes timestamps inside passes.
|
||||
bool gpu_timing_marks_enabled() noexcept;
|
||||
// A timestamp starting a segment of `category` inside a timed pass, or on the encoder between passes
|
||||
// (no category: ends the segment). Segments end at the next timestamp of the frame in GPU order.
|
||||
void gpu_timing_mark(const wgpu::RenderPassEncoder& pass, GpuTimingCategory category) noexcept;
|
||||
void gpu_timing_mark(const wgpu::CommandEncoder& encoder, std::optional<GpuTimingCategory> category) noexcept;
|
||||
// Resolves the open frame's queries on `encoder`, which must be the frame's last submission.
|
||||
void gpu_timing_end_frame(wgpu::CommandEncoder& encoder) noexcept;
|
||||
// After that submission: starts the readback of the resolved queries.
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
|
||||
// Game-frame hitch accounting (lane E, KartFrame). Cheap always-on counters: a few relaxed atomic
|
||||
// adds where the work happens; end_frame() (aurora.cpp) logs one "hitch:" line for every game frame
|
||||
// whose interval since the previous end_frame exceeds 20 ms, with the deltas of these counters over
|
||||
// that interval. frame/perf/hitches.py reads the lines. Counters are shared by the game thread, the
|
||||
// frame worker and the pipeline workers, so a delta says what happened in the process during the
|
||||
// slow frame, not necessarily on the game thread.
|
||||
namespace aurora::gfx::hitch {
|
||||
inline std::atomic<uint64_t> g_pipelineWaitNs{0}; // render/worker threads blocked on a queued pipeline
|
||||
inline std::atomic<uint64_t> g_pipelineSyncNs{0}; // pipelines compiled inline (not on a pipeline worker)
|
||||
inline std::atomic<uint64_t> g_pipelineSyncCount{0};
|
||||
inline std::atomic<uint64_t> g_texConvertNs{0}; // CPU texture conversion (GX formats to RGBA8 etc.)
|
||||
inline std::atomic<uint64_t> g_texConvertBytes{0}; // bytes of GX texture data converted
|
||||
inline std::atomic<uint64_t> g_texUploadBytes{0}; // bytes staged for texture uploads
|
||||
|
||||
inline uint64_t now_ns() noexcept {
|
||||
return static_cast<uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count());
|
||||
}
|
||||
|
||||
// Adds the scope's duration to a counter.
|
||||
class ScopedNs {
|
||||
public:
|
||||
explicit ScopedNs(std::atomic<uint64_t>& counter) noexcept : counter_(counter), start_(now_ns()) {}
|
||||
~ScopedNs() { counter_.fetch_add(now_ns() - start_, std::memory_order_relaxed); }
|
||||
ScopedNs(const ScopedNs&) = delete;
|
||||
ScopedNs& operator=(const ScopedNs&) = delete;
|
||||
|
||||
private:
|
||||
std::atomic<uint64_t>& counter_;
|
||||
uint64_t start_;
|
||||
};
|
||||
} // namespace aurora::gfx::hitch
|
||||
@@ -4,7 +4,6 @@
|
||||
#include "../gx/pipeline.hpp"
|
||||
#include "../fs_helper.hpp"
|
||||
#include "../sqlite_utils.hpp"
|
||||
#include "hitch_stats.hpp"
|
||||
#include "../webgpu/gpu.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -506,10 +505,7 @@ static PipelineRef find_pipeline_impl(ShaderType type, const PipelineConfig& con
|
||||
}
|
||||
|
||||
if (syncCreate) {
|
||||
const uint64_t syncStart = hitch::now_ns();
|
||||
const auto pipeline = cb();
|
||||
hitch::g_pipelineSyncNs.fetch_add(hitch::now_ns() - syncStart, std::memory_order_relaxed);
|
||||
hitch::g_pipelineSyncCount.fetch_add(1, std::memory_order_relaxed);
|
||||
{
|
||||
std::scoped_lock guard{g_pipelineMutex};
|
||||
removedPending = remove_pending_pipeline(g_priorityPipelines, hash);
|
||||
@@ -1427,7 +1423,6 @@ static bool wait_pipeline_impl(PipelineRef ref, wgpu::RenderPipeline& pipeline,
|
||||
// A persistent resolve is the last chance to produce its texture, so timing out here corrupts it
|
||||
// permanently. Only a pass marked requireReadyPipelines takes this path.
|
||||
}
|
||||
const hitch::ScopedNs waited(hitch::g_pipelineWaitNs);
|
||||
g_pipelineCv.wait(lock, finished);
|
||||
}
|
||||
const auto it = g_pipelines.find(ref);
|
||||
|
||||
@@ -375,11 +375,6 @@ static wgpu::Sampler g_nearestSampler;
|
||||
static wgpu::Sampler g_linearSampler;
|
||||
static absl::flat_hash_map<GXTexFmt, wgpu::RenderPipeline> g_pipelines;
|
||||
static wgpu::RenderPipeline g_blitPipeline;
|
||||
// The blit's destination is either a render texture (surface format) or a conversion texture
|
||||
// (RGBA8Unorm, e.g. the EFB RAM native readback). A pipeline must match the attachment's format
|
||||
// (VUID-vkCmdDraw-dynamicRenderingUnusedAttachments-08910 when the surface is BGRA8Unorm), so the
|
||||
// RGBA8Unorm destinations get their own pipeline.
|
||||
static wgpu::RenderPipeline g_blitPipelineRgba8;
|
||||
|
||||
static wgpu::RenderPipeline create_pipeline(const ConvPipeline& conv, const std::string_view shaderPreamble,
|
||||
const wgpu::BindGroupLayout& bindGroupLayout) {
|
||||
@@ -496,11 +491,6 @@ void initialize() {
|
||||
g_blitPipeline = create_pipeline(
|
||||
{GX_TF_RGBA8, FragPassthrough, webgpu::g_graphicsConfig.surfaceConfiguration.format, "TexCopyConv Blit"},
|
||||
ShaderPreamble, g_bindGroupLayout);
|
||||
g_blitPipelineRgba8 =
|
||||
webgpu::g_graphicsConfig.surfaceConfiguration.format == wgpu::TextureFormat::RGBA8Unorm
|
||||
? g_blitPipeline
|
||||
: create_pipeline({GX_TF_RGBA8, FragPassthrough, wgpu::TextureFormat::RGBA8Unorm, "TexCopyConv Blit RGBA8"},
|
||||
ShaderPreamble, g_bindGroupLayout);
|
||||
for (const auto& conv : ConvPipelines) {
|
||||
g_pipelines[conv.fmt] = create_pipeline(conv, ShaderPreamble, g_bindGroupLayout);
|
||||
if (conv.outputFormat != to_wgpu(conv.fmt)) {
|
||||
@@ -532,7 +522,6 @@ void initialize() {
|
||||
void shutdown() {
|
||||
g_pipelines.clear();
|
||||
g_blitPipeline = {};
|
||||
g_blitPipelineRgba8 = {};
|
||||
g_bindGroupLayout = {};
|
||||
g_depthBindGroupLayout = {};
|
||||
g_nearestSampler = {};
|
||||
@@ -615,8 +604,6 @@ void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
|
||||
execute(cmd, req, it->second);
|
||||
}
|
||||
|
||||
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
|
||||
execute(cmd, req, req.dst->format == wgpu::TextureFormat::RGBA8Unorm ? g_blitPipelineRgba8 : g_blitPipeline);
|
||||
}
|
||||
void blit(const wgpu::CommandEncoder& cmd, const ConvRequest& req) { execute(cmd, req, g_blitPipeline); }
|
||||
|
||||
} // namespace aurora::gfx::tex_copy_conv
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "texture_convert.hpp"
|
||||
#include "hitch_stats.hpp"
|
||||
|
||||
#include "../internal.hpp"
|
||||
#include "../gx/gx_fmt.hpp"
|
||||
@@ -555,25 +554,7 @@ static ByteBuffer BuildRGBA8FromCMPR(uint32_t width, uint32_t height, uint32_t m
|
||||
return buf;
|
||||
}
|
||||
|
||||
// convert_texture_palette calls convert_texture: only the outermost call is counted.
|
||||
static thread_local int t_convertDepth = 0;
|
||||
struct ConvertTimer {
|
||||
uint64_t start = 0;
|
||||
explicit ConvertTimer(size_t bytes) noexcept {
|
||||
if (t_convertDepth++ == 0) {
|
||||
hitch::g_texConvertBytes.fetch_add(bytes, std::memory_order_relaxed);
|
||||
start = hitch::now_ns();
|
||||
}
|
||||
}
|
||||
~ConvertTimer() {
|
||||
if (--t_convertDepth == 0) {
|
||||
hitch::g_texConvertNs.fetch_add(hitch::now_ns() - start, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ConvertedTexture convert_texture(u32 format, uint32_t width, uint32_t height, uint32_t mips, ArrayRef<uint8_t> data) {
|
||||
const ConvertTimer hitchTimer(data.size());
|
||||
const uint32_t maxMips = max_texture_mip_count(width, height);
|
||||
const uint32_t safeMips = std::clamp(mips, 1u, maxMips);
|
||||
if (safeMips != mips) {
|
||||
@@ -698,7 +679,6 @@ GXTexFmt tlut_texture_format(GXTlutFmt format) noexcept {
|
||||
ConvertedTexture convert_texture_palette(u32 textureFormat, uint32_t width, uint32_t height, uint32_t mips,
|
||||
ArrayRef<uint8_t> textureData, GXTlutFmt tlutFormat, uint16_t tlutEntries,
|
||||
ArrayRef<uint8_t> tlutData) {
|
||||
const ConvertTimer hitchTimer(textureData.size());
|
||||
const auto indices = convert_texture(textureFormat, width, height, mips, textureData);
|
||||
if (indices.data.empty()) {
|
||||
return {};
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
@@ -2516,71 +2515,6 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
|
||||
} else if (prim == GX_POINTS) {
|
||||
instanceCount = vtxCount;
|
||||
}
|
||||
const auto drawScreenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride);
|
||||
// Fades are blended over the picture (the respawn's black filter, wipes, the scene fader). Opaque and
|
||||
// logic-op full-frame quads are not: the race draws such quads every frame (backgrounds, a logic-op
|
||||
// pass), and one shown across the eye blacked it out. Nor is a textured one (lane I, 90 Hz headset
|
||||
// session): a full-frame quad sampling a texture composites a picture (the Time Trial ghost from its
|
||||
// offscreen copy, the player's character), which pinned across the eye from frame space doubled
|
||||
// against the world on every head turn. Only untextured, constant-colour quads (no texgens) are fades.
|
||||
const bool fadeCandidate =
|
||||
g_gxState.colorUpdate && g_gxState.blendMode == GX_BM_BLEND && g_gxState.numTexGens == 0;
|
||||
const auto invisible_blend = [] {
|
||||
const auto& st = g_gxState;
|
||||
if (st.blendMode != GX_BM_BLEND || st.blendFacSrc != GX_BL_SRCALPHA || st.blendFacDst != GX_BL_INVSRCALPHA ||
|
||||
st.depthUpdate || st.numTexGens != 0 || st.numTevStages != 1 || st.vtxDesc[GX_VA_CLR0] != GX_NONE) {
|
||||
return false;
|
||||
}
|
||||
const auto& stage = st.tevStages[0];
|
||||
if (stage.alphaPass.a != GX_CA_ZERO || stage.alphaPass.b != GX_CA_ZERO || stage.alphaPass.c != GX_CA_ZERO ||
|
||||
stage.alphaPass.d != GX_CA_RASA || stage.alphaOp.op != GX_TEV_ADD || stage.alphaOp.bias != GX_TB_ZERO ||
|
||||
stage.alphaOp.outReg != GX_TEVPREV || stage.channelId != GX_COLOR0A0 || stage.tevSwapRas != GX_TEV_SWAP0) {
|
||||
return false;
|
||||
}
|
||||
const auto& alphaChannel = st.colorChannelConfig[GX_ALPHA0];
|
||||
return alphaChannel.matSrc == GX_SRC_REG && !alphaChannel.lightingEnabled &&
|
||||
st.colorChannelState[GX_ALPHA0].matColor[3] < 2.f / 255.f;
|
||||
};
|
||||
const auto quad_signature = [] {
|
||||
const auto& tex = get_texture(GX_TEXMAP0);
|
||||
const uint32_t w = tex.ref ? std::min<uint32_t>(tex.texObj.width(), 2047) : 0;
|
||||
const uint32_t h = tex.ref ? std::min<uint32_t>(tex.texObj.height(), 2047) : 0;
|
||||
return uint64_t(g_gxState.blendMode & 3) | (uint64_t(g_gxState.blendOp & 15) << 2) |
|
||||
(uint64_t(g_gxState.numTexGens & 15) << 6) | (uint64_t(w) << 10) | (uint64_t(h) << 21) |
|
||||
(uint64_t(tex.ref && tex.ref->isEfbCopy) << 32);
|
||||
};
|
||||
// Large 2D rectangles in VR (lane H: which full-frame fades the game draws), a few distinct kinds a run.
|
||||
if (drawScreenRect && stereo) {
|
||||
const auto& vp = g_gxState.renderViewport;
|
||||
if (drawScreenRect->width * drawScreenRect->height >= 0.5f * vp.width * vp.height) {
|
||||
static std::mutex loggedMutex;
|
||||
static std::vector<uint64_t> logged;
|
||||
const auto& c = g_gxState.colorChannelState[0].matColor;
|
||||
const uint64_t key = (uint64_t(g_gxState.blendMode) << 0) | (uint64_t(g_gxState.colorUpdate) << 4) |
|
||||
(uint64_t(g_gxState.alphaUpdate) << 5) | (uint64_t(g_gxState.depthUpdate) << 6) |
|
||||
(uint64_t(g_gxState.depthCompare) << 7) | (uint64_t(g_gxState.numTevStages) << 8) |
|
||||
(uint64_t(g_gxState.numTexGens) << 16) | (uint64_t(drawScreenRect->width) << 24) |
|
||||
(uint64_t(drawScreenRect->height) << 40) ^
|
||||
(uint64_t(g_gxState.colorChannelState[GX_ALPHA0].matColor[3] * 255.f) << 56);
|
||||
std::lock_guard lock(loggedMutex);
|
||||
if (logged.size() < 200 && std::find(logged.begin(), logged.end(), key) == logged.end()) {
|
||||
logged.push_back(key);
|
||||
Log.info("VR large 2D rectangle: {:.0f}x{:.0f} at ({:.0f}, {:.0f}) of a {:.0f}x{:.0f} viewport; blend {} colour "
|
||||
"{} alpha {} depth update {} compare {} func {}; {} TEV stages, {} texgens; material colour "
|
||||
"{:.2f} {:.2f} {:.2f} {:.2f}; blend factors {}/{}; TEV0 alpha {} {} {} {} ka {} chan {}; alpha channel src {} "
|
||||
"light {} {:.2f}; vertex colour {}; invisible {}",
|
||||
drawScreenRect->width, drawScreenRect->height, drawScreenRect->left, drawScreenRect->top, vp.width,
|
||||
vp.height, int(g_gxState.blendMode), g_gxState.colorUpdate, g_gxState.alphaUpdate,
|
||||
g_gxState.depthUpdate, g_gxState.depthCompare, int(g_gxState.depthFunc), g_gxState.numTevStages,
|
||||
g_gxState.numTexGens, c[0], c[1], c[2], c[3], int(g_gxState.blendFacSrc), int(g_gxState.blendFacDst),
|
||||
int(g_gxState.tevStages[0].alphaPass.a), int(g_gxState.tevStages[0].alphaPass.b),
|
||||
int(g_gxState.tevStages[0].alphaPass.c), int(g_gxState.tevStages[0].alphaPass.d),
|
||||
int(g_gxState.tevStages[0].kaSel), int(g_gxState.tevStages[0].channelId),
|
||||
int(g_gxState.colorChannelConfig[GX_ALPHA0].matSrc), g_gxState.colorChannelConfig[GX_ALPHA0].lightingEnabled,
|
||||
g_gxState.colorChannelState[GX_ALPHA0].matColor[3], int(g_gxState.vtxDesc[GX_VA_CLR0]), invisible_blend());
|
||||
}
|
||||
}
|
||||
}
|
||||
gfx::push_draw_command(DrawData{
|
||||
.pipeline = pipeline,
|
||||
.exactScreenDepthPipeline = stereo && screen ? pipelineState.screenRef : 0,
|
||||
@@ -2598,10 +2532,7 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
|
||||
.instanceCount = instanceCount,
|
||||
.bindGroups = bindGroups,
|
||||
.dstAlpha = pipelineState.dstAlpha,
|
||||
.screenRect = drawScreenRect,
|
||||
.fadeCandidate = fadeCandidate,
|
||||
.invisible = stereo && invisible_blend(),
|
||||
.quadSignature = drawScreenRect ? quad_signature() : uint64_t{0},
|
||||
.screenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride),
|
||||
});
|
||||
// What the next draw must match to be allowed to fold into this one.
|
||||
nativeWheelLastDrawCommand = gfx::get_last_draw_command<DrawData>();
|
||||
|
||||
@@ -70,20 +70,6 @@ constexpr u32 MaxPnMtx = (GX_PNMTX9 / 3) + 1;
|
||||
constexpr u32 MaxPostexMtx = MaxPnMtx + MaxTexMtx;
|
||||
constexpr u32 MaxIndexAttr = 12; // VA_POS -> VA_TEX7
|
||||
constexpr u32 MaxUniformSize = 3840;
|
||||
// Virtual-screen (exactScreenDepth) variants read a clip block after the draw's own uniform: three rows
|
||||
// mapping an eye pixel to homogeneous frame NDC on the 2D layer's screen, and the rectangle (the draw's
|
||||
// recorded scissor within the frame) its fragments must fall in. 0: the uniform has no room.
|
||||
// Takes ShaderInfo::structSize, the WGSL struct's end, NOT uniformSize: that one is padded to the
|
||||
// 64-byte offset alignment, and a block written there was read by the shader 16 to 48 bytes too
|
||||
// early (padding), which discarded nearly every HUD draw on the Frame (second headset session).
|
||||
// ScreenClipMarker in the w row's unused fourth component tells the shader the block is real; without
|
||||
// it the shader does not clip (a layout slip then shows the HUD unclipped instead of hiding it).
|
||||
constexpr u32 ScreenClipBytes = 64;
|
||||
constexpr float ScreenClipMarker = 31337.0f;
|
||||
constexpr u32 screen_clip_offset(u32 uniformSize) noexcept {
|
||||
const u32 offset = (uniformSize + 15u) & ~15u;
|
||||
return offset + ScreenClipBytes <= MaxUniformSize ? offset : 0u;
|
||||
}
|
||||
|
||||
extern wgpu::BindGroup g_emptyTextureBindGroup;
|
||||
|
||||
@@ -771,9 +757,6 @@ struct ShaderInfo {
|
||||
std::bitset<MaxIndTexMtxs> usedIndTexMtxs;
|
||||
UniformMatrixLayout matrixLayout;
|
||||
u32 uniformSize = 0;
|
||||
// The WGSL Uniform struct's own size, before uniformSize is padded to the dynamic-offset
|
||||
// alignment: where a virtual-screen variant's clip block fields start (screen_clip_offset).
|
||||
u32 structSize = 0;
|
||||
bool usesFog : 1 = false;
|
||||
bool lightingEnabled : 1 = false;
|
||||
u8 lineMode : 2 = 0;
|
||||
|
||||
@@ -30,15 +30,6 @@ struct DrawData {
|
||||
// Recorded before merging so VR can omit desktop split masks without
|
||||
// modifying GX.
|
||||
std::optional<gfx::stereo_replay::SubviewRect> screenRect;
|
||||
// Writes colour, blended or opaque (not a logic-op or depth-only quad): a candidate for a full-frame
|
||||
// fade shown across the whole eye.
|
||||
bool fadeCandidate = false;
|
||||
// Leaves the picture unchanged: blended with the source alpha, untextured, one TEV stage passing a
|
||||
// material alpha under 2/255 through, no depth write (an idle screen filter). The eye replay skips it.
|
||||
bool invisible = false;
|
||||
// Diagnostics (KARTFRAME_LOG_2D_QUADS): blend | logic << 2 | texgens << 6 | tex0 width << 10 | height << 21 |
|
||||
// tex0 is an EFB copy << 32.
|
||||
uint64_t quadSignature = 0;
|
||||
};
|
||||
|
||||
constexpr uint32_t GXPipelineConfigVersion = 22;
|
||||
|
||||
@@ -1694,26 +1694,6 @@ wgpu::ShaderModule build_shader(const ShaderConfig& config) noexcept {
|
||||
" return out;";
|
||||
}
|
||||
|
||||
if (config.exactScreenDepth && screen_clip_offset(info.structSize) != 0) {
|
||||
// A reprojected 2D draw uses the whole eye viewport and scissor, so the frame edge and the game's
|
||||
// scissor no longer clip it (Mario Kart's item box sliding up out of the frame stayed visible
|
||||
// above the screen). The clip block (gx.hpp, written per eye by gfx/common.cpp) maps the pixel
|
||||
// back to frame NDC; outside the recorded rectangle the fragment is dropped. Checked last, after
|
||||
// every texture sample, so it cannot affect uniformity.
|
||||
uniBufAttrs +=
|
||||
"\n screen_clip_u: vec4f,"
|
||||
"\n screen_clip_v: vec4f,"
|
||||
"\n screen_clip_w: vec4f,"
|
||||
"\n screen_clip_rect: vec4f,";
|
||||
fragmentFn +=
|
||||
"\n let screen_clip_p = vec3f(in.pos.xy, 1.0);"
|
||||
"\n let screen_clip_h = vec3f(dot(ubuf.screen_clip_u.xyz, screen_clip_p), "
|
||||
"dot(ubuf.screen_clip_v.xyz, screen_clip_p), dot(ubuf.screen_clip_w.xyz, screen_clip_p));"
|
||||
"\n let screen_clip_uv = screen_clip_h.xy / max(screen_clip_h.z, 1e-20);"
|
||||
"\n if (ubuf.screen_clip_w.w == 31337.0 && (screen_clip_h.z <= 0.0 || "
|
||||
"any(screen_clip_uv < ubuf.screen_clip_rect.xy) || any(screen_clip_uv > ubuf.screen_clip_rect.zw))) { discard; }";
|
||||
}
|
||||
|
||||
const auto shaderSource = fmt::format(R"""(
|
||||
fn bswap32(v: u32, le: bool) -> u32 {{
|
||||
if (le) {{
|
||||
|
||||
@@ -503,7 +503,6 @@ ShaderInfo build_shader_info(const ShaderConfig& config) noexcept {
|
||||
}
|
||||
info.uniformSize += info.sampledTexCoords.count() * sizeof(Vec4<float>);
|
||||
info.uniformSize += info.sampledTextures.count() * sizeof(Vec4<float>);
|
||||
info.structSize = info.uniformSize;
|
||||
info.uniformSize = gfx::align_uniform(info.uniformSize);
|
||||
if (info.uniformSize > MaxUniformSize) {
|
||||
Log.fatal("Uniform size exceeds maximum: {} > {}", info.uniformSize, MaxUniformSize);
|
||||
@@ -770,7 +769,6 @@ UniformRanges build_uniform(const ShaderInfo& info, u32 vtxStart, const BindGrou
|
||||
.perspective = perspective,
|
||||
.indexedMatrices = info.indexAttr.test(GX_VA_PNMTXIDX),
|
||||
.nativeEfbEffect = !perspective && samplesRecentEfbCopy && (samplesReducedEfbCopy || blends),
|
||||
.uniformSize = info.structSize,
|
||||
};
|
||||
|
||||
if (!perspective || !frame_interpolation_active()) {
|
||||
|
||||
@@ -21,9 +21,6 @@ struct UniformReplayLayout {
|
||||
float worldHudDepth = 0.0f;
|
||||
float worldHudAnchorX = 0.0f;
|
||||
float worldHudAnchorY = 0.0f;
|
||||
// ShaderInfo::structSize (the WGSL struct's end, before the range is padded to the offset alignment):
|
||||
// where a virtual-screen variant's clip block starts (gx::screen_clip_offset).
|
||||
uint32_t uniformSize = 0;
|
||||
};
|
||||
|
||||
struct UniformRanges {
|
||||
|
||||
@@ -16,37 +16,11 @@ struct EyeImage {
|
||||
wgpu::TextureFormat format = wgpu::TextureFormat::Undefined;
|
||||
};
|
||||
|
||||
// Lane L: the scene state one eye's draws used (see gfx::EyeSceneRecord), for the frame stamp.
|
||||
struct EyeScene {
|
||||
bool valid = false; // an immersive replay whose eye uniforms were filled for this set
|
||||
bool late = false; // filled at the set's sample time (interpolation), else at the seal
|
||||
bool anchored = false;
|
||||
bool continuous = false; // a previous game frame was available
|
||||
float weight = 1.f;
|
||||
uint32_t previousFrame = 0, currentFrame = 0; // game (logical) frames of the transform pair
|
||||
uint32_t set = 0; // sets encoded so far
|
||||
uint16_t interpolated = 0, current = 0, recorded = 0, outside = 0;
|
||||
// Lane P: the head pose this eye's uniform fill wrote (gfx::EyeSceneRecord::pose*): the packet's
|
||||
// display time (steady ns, 0 none) and token, the eye's orientation recovered from the transform written
|
||||
// (x, y, z, w; the inverse of the view rotation), and draws filled with another packet or transform.
|
||||
uint64_t poseTime = 0;
|
||||
uint64_t poseToken = 0;
|
||||
std::array<float, 4> poseOrientation{0.f, 0.f, 0.f, 1.f};
|
||||
// The tangents of the eye frustum that fill used: left, right, down, up.
|
||||
std::array<float, 4> poseTangents{};
|
||||
uint16_t poseMixed = 0;
|
||||
};
|
||||
|
||||
struct SinkFrame {
|
||||
uint64_t frameToken = 0;
|
||||
uint32_t logicalFrame = 0;
|
||||
AuroraStereoFrameMode mode = AURORA_STEREO_FRAME_IMMERSIVE_REPLAY;
|
||||
// Rendered from the retained game frame (interpolation, projected menu), not a new one.
|
||||
bool retained = false;
|
||||
std::array<EyeImage, AURORA_STEREO_EYE_COUNT> eyes{};
|
||||
// The packet's display time (steady clock ns): the time its eye poses were located for.
|
||||
uint64_t displayTimeNanos = 0;
|
||||
std::array<EyeScene, AURORA_STEREO_EYE_COUNT> scene{};
|
||||
};
|
||||
|
||||
// Runs synchronously on the frame worker after both eye replays have been
|
||||
@@ -66,16 +40,4 @@ using SubmitCallback = void (*)(const SinkFrame& frame, void* userdata) noexcept
|
||||
void set_sink(SinkCallback callback, SubmitCallback submitted, void* userdata) noexcept;
|
||||
inline void set_sink(SinkCallback callback, void* userdata) noexcept { set_sink(callback, nullptr, userdata); }
|
||||
|
||||
// Optional, for a sink that can take the eyes as render targets (the Vulkan OpenXR bridge): the
|
||||
// swapchain images acquired for `frameToken`, wrapped as textures of `format` and `width` x `height`,
|
||||
// so the eyes render straight into them and the sink only stamps them instead of copying 2 x 12 MB a
|
||||
// set (lane I: 1.3 ms of GPU a set at 1728). False when the set cannot take them (no pending set
|
||||
// for that token, other sizes or formats); the eyes then render into Aurora's own targets and the sink
|
||||
// copies as before. Once it returned true the set can no longer be canceled: the caller must hand
|
||||
// the same textures to the sink (SinkFrame::eyes) for this token.
|
||||
using DirectTargetsCallback = bool (*)(uint64_t frameToken, wgpu::TextureFormat format, uint32_t width,
|
||||
uint32_t height, std::array<wgpu::Texture, AURORA_STEREO_EYE_COUNT>& out,
|
||||
void* userdata) noexcept;
|
||||
void set_direct_targets(DirectTargetsCallback callback, void* userdata) noexcept;
|
||||
|
||||
} // namespace aurora::stereo
|
||||
@@ -3,8 +3,6 @@
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace aurora::stereo {
|
||||
// The current scene belongs to the VI presentation boundary. Delaying scene
|
||||
// motion by one guest interval lets every display sample fall between known
|
||||
@@ -16,38 +14,4 @@ inline float interpolation_weight(uint64_t displayTime, uint64_t boundary, uint6
|
||||
return 0.0f;
|
||||
return static_cast<float>(std::min(static_cast<double>(displayTime - boundary) / static_cast<double>(interval), 1.0));
|
||||
}
|
||||
|
||||
// interpolation_weight assumes the display time falls inside the current frame's interval. On a
|
||||
// headset it does not: a set is located for a display time one or two refreshes ahead (more with
|
||||
// pipelined pacing), so display minus boundary lay beyond one interval for almost every set and the
|
||||
// weight stuck at 1: each game frame was shown on two refreshes at 120 Hz and moving scenery doubled.
|
||||
// SceneClock samples the scene at the display time minus a delay it learns: over a game interval the
|
||||
// sets' (display - boundary) sweep one interval, so their running mean minus half an interval puts
|
||||
// every sample between the previous and the current frame. A slow mean (about 2 s at 120 Hz) keeps
|
||||
// the delay steady, so the scene advances uniformly; a jump (a new display lead, a different game
|
||||
// rate) restarts it. One instance per caller thread.
|
||||
class SceneClock {
|
||||
public:
|
||||
float weight(uint64_t displayTime, uint64_t boundary, uint64_t interval) noexcept {
|
||||
if (displayTime == 0 || boundary == 0 || interval == 0)
|
||||
return 1.0f;
|
||||
const double x = static_cast<double>(displayTime) - static_cast<double>(boundary);
|
||||
const double period = static_cast<double>(interval);
|
||||
if (!m_valid || std::abs(x - m_mean) > 2.0 * period || std::abs(period - m_interval) > 0.25 * m_interval) {
|
||||
m_valid = true;
|
||||
m_mean = x;
|
||||
m_interval = period;
|
||||
} else {
|
||||
m_mean += (x - m_mean) * kRate;
|
||||
}
|
||||
return static_cast<float>(std::clamp(0.5 + (x - m_mean) / period, 0.0, 1.0));
|
||||
}
|
||||
void reset() noexcept { m_valid = false; }
|
||||
|
||||
private:
|
||||
static constexpr double kRate = 1.0 / 256.0;
|
||||
bool m_valid = false;
|
||||
double m_mean = 0.0;
|
||||
double m_interval = 0.0;
|
||||
};
|
||||
} // namespace aurora::stereo
|
||||
+10
-78
@@ -30,16 +30,9 @@ std::atomic<int> requestedQuality{3};
|
||||
std::atomic<float> panelAspect{16.f/9.f};
|
||||
int activeQuality=-1;
|
||||
V point(const float* m,V p) { return {m[0]*p[0]+m[1]*p[1]+m[2]*p[2]+m[3],m[4]*p[0]+m[5]*p[1]+m[6]*p[2]+m[7],m[8]*p[0]+m[9]*p[1]+m[10]*p[2]+m[11]}; }
|
||||
// The menus' environment layer (the Frame): one eye's room around the screen drawn into its part of a
|
||||
// packed two-eye target. The screen itself is a separate quad layer, so its area stays transparent.
|
||||
struct Environment { wgpu::TextureView view; uint32_t x=0,width=0,height=0; bool clear=false; };
|
||||
void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,const AuroraStereoFrame& frame,
|
||||
const AuroraVRUiGuide& layout,uint32_t eye,const Environment* env=nullptr) {
|
||||
const auto& output=g_stereoEyeTargets[env?1:eye].output();
|
||||
const wgpu::TextureView targetView=env?env->view:output.view;
|
||||
const auto setViewport=[&](const wgpu::RenderPassEncoder& pass) {
|
||||
if(env) pass.SetViewport(float(env->x),0,float(env->width),float(env->height),0,1);
|
||||
};
|
||||
const AuroraVRUiGuide& layout,uint32_t eye) {
|
||||
const auto& output=g_stereoEyeTargets[eye].output();
|
||||
panelAspect.store(float(source.size.width)/std::max(1u,source.size.height),std::memory_order_relaxed);
|
||||
// Apply changes between stereo pairs, so both eyes use the same quality.
|
||||
const int quality=(eye==0 || activeQuality<0)?requestedQuality.load(std::memory_order_relaxed):activeQuality;
|
||||
@@ -65,7 +58,6 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
|
||||
return vec4f(tanh(light.xyz+vec3f(0.1*length(light)))*0.65,1);
|
||||
}
|
||||
@fragment fn fs(i:O)->@location(0) vec4f {
|
||||
if(i.t > 2.5) { return vec4f(0.0); }
|
||||
if(i.t < -1.5) {
|
||||
return dielectric(normalize(i.c),i.origin,-i.t-2.0);
|
||||
}
|
||||
@@ -109,7 +101,6 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
|
||||
const wgpu::RenderPassColorAttachment ca{.view=view,.loadOp=load,.storeOp=wgpu::StoreOp::Store};
|
||||
const wgpu::RenderPassDescriptor rd{.label="VR menu environment",.colorAttachmentCount=1,.colorAttachments=&ca};
|
||||
auto pass=encoder.BeginRenderPass(&rd);pass.SetPipeline(pipeline);pass.SetBindGroup(0,binding);
|
||||
if(view.Get()==targetView.Get()) setViewport(pass);
|
||||
pass.SetVertexBuffer(0,buffer);pass.Draw(count);pass.End();
|
||||
};
|
||||
static const auto start=std::chrono::steady_clock::now();
|
||||
@@ -126,13 +117,13 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
|
||||
-(m[1]*m[3]+m[5]*m[7]+m[9]*m[11])/6,
|
||||
-(m[2]*m[3]+m[6]*m[7]+m[10]*m[11])/6}};
|
||||
}
|
||||
if(quality && !env) {
|
||||
if(quality) {
|
||||
drawVertices(panoramaView,source.bindGroup,sky,3,wgpu::LoadOp::Clear);
|
||||
for(auto& v:sky) v.textured=-1;
|
||||
drawVertices(targetView,panoramaBinding,sky,3,wgpu::LoadOp::Clear);
|
||||
drawVertices(output.view,panoramaBinding,sky,3,wgpu::LoadOp::Clear);
|
||||
} else {
|
||||
for(auto& v:sky) { v.textured=0;v.color[0]=.012f;v.color[1]=.018f;v.color[2]=.027f; }
|
||||
drawVertices(targetView,source.bindGroup,sky,3,!env || env->clear?wgpu::LoadOp::Clear:wgpu::LoadOp::Load);
|
||||
drawVertices(output.view,source.bindGroup,sky,3,wgpu::LoadOp::Clear);
|
||||
}
|
||||
std::vector<Vertex> vertices;
|
||||
bool workshop=false;
|
||||
@@ -153,11 +144,7 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
|
||||
};
|
||||
const float width=std::max(.25f,frame.ui.width),height=width*float(source.size.height)/std::max(1u,source.size.width);
|
||||
const auto panelSlice=[&](float top,float bottom) { quad({0,(.5f-(top+bottom)/2)*height,-frame.ui.distance},width,(bottom-top)*height,0,top,1,bottom); };
|
||||
if(env) {
|
||||
// The screen's own quad layer shows the picture: leave its area transparent black.
|
||||
quad({0,0,-frame.ui.distance},width,height,0,0,1,1);
|
||||
for(size_t i=vertices.size()-6;i<vertices.size();++i) vertices[i].textured=3;
|
||||
} else if(!layout.active) panelSlice(0,1);
|
||||
if(!layout.active) panelSlice(0,1);
|
||||
else {
|
||||
panelSlice(0,layout.headerEnd);panelSlice(layout.footerStart,1);
|
||||
}
|
||||
@@ -187,7 +174,6 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
|
||||
// perspective and ordering; no scene depth exists behind the tutorial.
|
||||
struct Triangle { std::array<Vertex,3> v;float depth;uint32_t material; };
|
||||
std::vector<Triangle> triangles;
|
||||
triangles.reserve(9216);
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
for(auto& [id,material]:controllerTextures) if(!material.pixels.empty()) {
|
||||
@@ -225,22 +211,11 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort small keys, not the triangles themselves: the controller models are ~9000
|
||||
// triangles per eye and moving them dominated the frame worker at 120 Hz. Back to front
|
||||
// in 5 mm slices, by material within a slice: a strict depth order switched material
|
||||
// (a draw and a bind group each) almost every triangle.
|
||||
struct Key { int32_t slice; uint32_t material; uint32_t index; };
|
||||
std::vector<Key> order(triangles.size());
|
||||
for(uint32_t i=0;i<order.size();++i)
|
||||
order[i]={static_cast<int32_t>(std::floor(triangles[i].depth*(1.f/3.f)/.005f)),triangles[i].material,i};
|
||||
std::sort(order.begin(),order.end(),[](const Key& a,const Key& b){
|
||||
return a.slice!=b.slice?a.slice>b.slice:a.material<b.material;});
|
||||
std::sort(triangles.begin(),triangles.end(),[](const auto& a,const auto& b){return a.depth>b.depth;});
|
||||
materialRanges.push_back({0,static_cast<uint32_t>(vertices.size()),source.bindGroup});
|
||||
vertices.reserve(vertices.size()+triangles.size()*3+512);
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
for(const auto& key:order) {
|
||||
const auto& t=triangles[key.index];
|
||||
for(const auto& t:triangles) {
|
||||
const auto found=controllerTextures.find(t.material);
|
||||
auto binding=found!=controllerTextures.end() && found->second.binding?found->second.binding:source.bindGroup;
|
||||
if(materialRanges.back().binding.Get()==binding.Get()) materialRanges.back().count+=3;
|
||||
@@ -280,55 +255,12 @@ void render(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,co
|
||||
}
|
||||
const wgpu::BufferDescriptor bd{.label="Anchored VR UI vertices",.usage=wgpu::BufferUsage::Vertex|wgpu::BufferUsage::CopyDst,.size=std::max(size_t(4),vertices.size()*sizeof(Vertex))};
|
||||
auto buffer=g_device.CreateBuffer(&bd);if(!vertices.empty()) g_queue.WriteBuffer(buffer,0,vertices.data(),vertices.size()*sizeof(Vertex));
|
||||
const wgpu::RenderPassColorAttachment ca{.view=targetView,.loadOp=wgpu::LoadOp::Load,.storeOp=wgpu::StoreOp::Store};
|
||||
const wgpu::RenderPassColorAttachment ca{.view=output.view,.loadOp=wgpu::LoadOp::Load,.storeOp=wgpu::StoreOp::Store};
|
||||
const wgpu::RenderPassDescriptor rd{.label="Anchored VR UI stereo",.colorAttachmentCount=1,.colorAttachments=&ca};
|
||||
auto pass=encoder.BeginRenderPass(&rd);pass.SetPipeline(pipeline);setViewport(pass);pass.SetVertexBuffer(0,buffer);
|
||||
auto pass=encoder.BeginRenderPass(&rd);pass.SetPipeline(pipeline);pass.SetVertexBuffer(0,buffer);
|
||||
for(const auto& range:materialRanges) if(range.count) {
|
||||
pass.SetBindGroup(0,range.binding);pass.Draw(range.count,1,range.first);
|
||||
}
|
||||
pass.End();
|
||||
}
|
||||
// The pointer's target marks drawn into the menus' screen image itself (eye target 0, the screen quad's
|
||||
// picture): a mark on the quad's own surface is visible and stereo-correct whatever order the runtime
|
||||
// composites the layers in (SteamVR on the Frame hid the environment layer's mark under the quad, H4.md).
|
||||
// Call after render() built the pipeline for this format; `source` is the picture placed in the image.
|
||||
void screen_marks(wgpu::CommandEncoder& encoder,const webgpu::PresentSource& source,const AuroraStereoFrame& frame) {
|
||||
const auto& output=g_stereoEyeTargets[0].output();
|
||||
if(!pipeline || format!=output.format || !source.bindGroup || !source.size.width || !source.size.height) return;
|
||||
const auto vp=webgpu::calculate_present_viewport(output.size.width,output.size.height,source.size.width,source.size.height);
|
||||
const float width=std::max(.25f,frame.ui.width),height=width*float(source.size.height)/float(source.size.width);
|
||||
std::vector<Vertex> vertices;
|
||||
const auto at=[&](float x,float y,V color) {
|
||||
const float px=vp.left+(x/width+.5f)*vp.width,py=vp.top+(.5f-y/height)*vp.height;
|
||||
return Vertex{{px/float(output.size.width)*2-1,1-py/float(output.size.height)*2,0,1},{0,0},{color[0],color[1],color[2]},0};
|
||||
};
|
||||
for(int hand=0;hand<2;++hand) if(frame.ui.pointerTracked[hand]) {
|
||||
const auto* ray=frame.ui.pointerRay[hand];
|
||||
if(ray[5]>=-.001f) continue;
|
||||
const float distance=(-frame.ui.distance-ray[2])/ray[5];
|
||||
if(distance<=0 || distance>10) continue;
|
||||
const float x=ray[0]+ray[3]*distance,y=ray[1]+ray[4]*distance;
|
||||
if(std::abs(x)>width*.5f || std::abs(y)>height*.5f) continue;
|
||||
const V color=hand?V{.96f,.74f,.31f}:V{.15f,.88f,.82f};
|
||||
// A coloured ring with a dark edge and a white centre: readable on bright and dark menus alike.
|
||||
const auto disc=[&](float radius,V inner,V outer) {
|
||||
for(int segment=0;segment<20;++segment) {
|
||||
const float angle=segment*6.2831853f/20,next=(segment+1)*6.2831853f/20;
|
||||
vertices.push_back(at(x,y,inner));
|
||||
vertices.push_back(at(x+radius*std::cos(angle),y+radius*std::sin(angle),outer));
|
||||
vertices.push_back(at(x+radius*std::cos(next),y+radius*std::sin(next),outer));
|
||||
}
|
||||
};
|
||||
disc(.018f,{.02f,.02f,.02f},{.02f,.02f,.02f});
|
||||
disc(.015f,color,color);
|
||||
disc(.006f,{1,1,1},{1,1,1});
|
||||
}
|
||||
if(vertices.empty()) return;
|
||||
const wgpu::BufferDescriptor bd{.label="VR menu pointer marks",.usage=wgpu::BufferUsage::Vertex|wgpu::BufferUsage::CopyDst,.size=vertices.size()*sizeof(Vertex)};
|
||||
auto buffer=g_device.CreateBuffer(&bd);g_queue.WriteBuffer(buffer,0,vertices.data(),vertices.size()*sizeof(Vertex));
|
||||
const wgpu::RenderPassColorAttachment ca{.view=output.view,.loadOp=wgpu::LoadOp::Load,.storeOp=wgpu::StoreOp::Store};
|
||||
const wgpu::RenderPassDescriptor rd{.label="VR menu pointer marks",.colorAttachmentCount=1,.colorAttachments=&ca};
|
||||
auto pass=encoder.BeginRenderPass(&rd);pass.SetPipeline(pipeline);pass.SetBindGroup(0,source.bindGroup);
|
||||
pass.SetVertexBuffer(0,buffer);pass.Draw(static_cast<uint32_t>(vertices.size()));pass.End();
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,6 @@ static wgpu::AdapterInfo g_adapterInfo;
|
||||
static wgpu::SurfaceCapabilities g_surfaceCapabilities;
|
||||
bool g_bcTexturesSupported;
|
||||
bool g_timestampQueriesSupported = false;
|
||||
bool g_timestampInsidePassesSupported = false;
|
||||
// Written by Dawn's device-loss callback and consumed at ordered frame boundaries. Keep the
|
||||
// callback free of logging, allocation, teardown and renderer state mutation.
|
||||
static std::atomic_bool g_deviceLost{false};
|
||||
@@ -540,32 +539,6 @@ static wgpu::BackendType to_wgpu_backend(AuroraBackend backend) {
|
||||
}
|
||||
}
|
||||
|
||||
bool headless() noexcept {
|
||||
static const bool value = [] {
|
||||
const char* env = std::getenv("KARTFRAME_HEADLESS");
|
||||
return env != nullptr && *env != '\0' && *env != '0';
|
||||
}();
|
||||
return value;
|
||||
}
|
||||
|
||||
wgpu::Texture headless_surface_texture() {
|
||||
static wgpu::Texture texture;
|
||||
static uint32_t width = 0, height = 0;
|
||||
const auto& cfg = g_graphicsConfig.surfaceConfiguration;
|
||||
if (!texture || width != cfg.width || height != cfg.height) {
|
||||
const wgpu::TextureDescriptor descriptor{
|
||||
.label = "Headless surface",
|
||||
.usage = cfg.usage,
|
||||
.size = {cfg.width, cfg.height, 1},
|
||||
.format = cfg.format,
|
||||
};
|
||||
texture = g_device.CreateTexture(&descriptor);
|
||||
width = cfg.width;
|
||||
height = cfg.height;
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
static bool create_surface() {
|
||||
SDL_Window* window = window::get_sdl_window();
|
||||
if (window == nullptr) {
|
||||
@@ -754,14 +727,6 @@ bool initialize(AuroraBackend auroraBackend) {
|
||||
g_timestampQueriesSupported = true;
|
||||
requiredFeatures.push_back(feature);
|
||||
}
|
||||
// The per-segment GPU profile (KARTFRAME_FPSLOG=2) times parts of a render pass; asked for only then.
|
||||
if (feature == wgpu::FeatureName::ChromiumExperimentalTimestampQueryInsidePasses) {
|
||||
const char* env = std::getenv("KARTFRAME_FPSLOG");
|
||||
if (env != nullptr && env[0] == '2') {
|
||||
g_timestampInsidePassesSupported = true;
|
||||
requiredFeatures.push_back(feature);
|
||||
}
|
||||
}
|
||||
// The presenter calls device and queue methods while the frame worker encodes, which Dawn only
|
||||
// supports with this feature; without it the two race inside the device's dynamic uploader.
|
||||
if (feature == wgpu::FeatureName::ImplicitDeviceSynchronization) {
|
||||
@@ -1125,9 +1090,7 @@ void resize_swapchain(uint32_t width, uint32_t height, uint32_t native_width, ui
|
||||
g_graphicsConfig.surfaceConfiguration.height = native_height;
|
||||
auto surfaceConfiguration = g_graphicsConfig.surfaceConfiguration;
|
||||
surfaceConfiguration.device = g_device;
|
||||
if (!headless()) {
|
||||
g_surface.Configure(&surfaceConfiguration);
|
||||
}
|
||||
g_surface.Configure(&surfaceConfiguration);
|
||||
if (!sizeChanged) {
|
||||
// Forced reconfigure at an unchanged size (present-mode change or recreated surface). The
|
||||
// offscreen targets are not swapchain images, so reallocating them would only stall the frame.
|
||||
|
||||
@@ -63,15 +63,8 @@ extern wgpu::Instance g_instance;
|
||||
extern bool g_bcTexturesSupported;
|
||||
// The device was created with TimestampQuery, so passes may carry timestamp writes (gfx::gpu_timing_*).
|
||||
extern bool g_timestampQueriesSupported;
|
||||
// Timestamps inside passes and on the encoder (Dawn experimental feature; KARTFRAME_FPSLOG=2 only).
|
||||
extern bool g_timestampInsidePassesSupported;
|
||||
|
||||
bool initialize(AuroraBackend backend);
|
||||
// KARTFRAME_HEADLESS=1: development runs without a presentable swapchain. The window and surface
|
||||
// still exist (the adapter is chosen against it) but the swapchain is never configured; the desktop
|
||||
// view renders into an offscreen texture that is never shown.
|
||||
bool headless() noexcept;
|
||||
wgpu::Texture headless_surface_texture();
|
||||
void shutdown();
|
||||
// Device-loss callbacks may run on a Dawn-managed thread and only publish loss state; frame
|
||||
// threads call this at ordered boundaries to turn it into the one user-visible fatal error.
|
||||
|
||||
@@ -4,145 +4,16 @@
|
||||
#include "../stereo.hpp"
|
||||
#include "../stereo_overlay.hpp"
|
||||
#include "gpu.hpp"
|
||||
#if (defined(_WIN32) || (defined(__linux__) && !defined(__ANDROID__))) && defined(WEBGPU_DAWN) && defined(DAWN_ENABLE_BACKEND_VULKAN)
|
||||
#if defined(_WIN32)
|
||||
#if defined(_WIN32) && defined(WEBGPU_DAWN) && defined(DAWN_ENABLE_BACKEND_VULKAN)
|
||||
#include <windows.h>
|
||||
#else
|
||||
// The patched Dawn is linked statically on Linux; its Aurora Vulkan ABI is resolved at link time.
|
||||
extern "C" {
|
||||
uint32_t AuroraDawnVulkanVersion();
|
||||
int AuroraDawnVulkanConfigure(const AuroraDawnVulkanHooks* hooks);
|
||||
int AuroraDawnVulkanGetHandles(void* handle, AuroraDawnVulkanHandles* out);
|
||||
void* AuroraDawnVulkanWrap(void* handle, const void* desc, uint64_t image);
|
||||
int AuroraDawnVulkanRelease(void* handle, void* const* textures, uint32_t count);
|
||||
void* AuroraDawnVulkanLock(void* handle);
|
||||
void AuroraDawnVulkanUnlock(void* guard);
|
||||
int AuroraDawnVulkanDrain(void* handle);
|
||||
}
|
||||
#endif
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
namespace aurora::vulkan_win32 {
|
||||
namespace {
|
||||
Module Log("aurora::vulkan_win32");
|
||||
// KARTFRAME_FRAME_STAMP=1 (tests only): each eye image gets a 64x15 pixel block in its top left
|
||||
// corner naming the display time its poses were located for and the eye, written after the eye
|
||||
// copy. The mock runtime (MOCK_XR_STAMP) reads it back from the image a projection layer submits
|
||||
// and compares it with the locate the layer's poses came from. Layout, one pixel a cell, white = 1:
|
||||
// row 0 the 64 bits of the display time (steady clock ns, bit 0 first), row 1 their complement,
|
||||
// row 2: cell 0 the eye (1 = right), cell 1 its complement, cells 2-5 the marker 1 0 1 0.
|
||||
// Lane L, the scene state this eye's draws used (stereo::EyeScene), rows 3 and 5 two 64-bit words, rows 4
|
||||
// and 6 their complements:
|
||||
// row 3: bits 0-15 weight x 65535, 16-31 previous game frame (low 16 bits), 32-47 current game frame,
|
||||
// 48 valid, 49 late (filled at the set's sample time), 50 anchored, 51 continuous, 56-63 set (low 8);
|
||||
// row 5: perspective draws filled from the pair (bits 0-15), filled current-only (16-31), drawn with the
|
||||
// recorded uniform (32-47), drawn with a range outside this set's fill (48-63).
|
||||
// Lane P, the head pose this eye's uniform fill wrote (stereo::EyeScene::pose*), rows 7, 9 and 11 three
|
||||
// 64-bit words, rows 8, 10 and 12 their complements:
|
||||
// row 7: the display time (steady ns) of the packet whose eye transform the fill wrote (0: none);
|
||||
// row 9: the eye's orientation recovered from that transform, x, y, z, w as signed 16-bit (x 32767);
|
||||
// row 11: bits 0-15 draws of this eye filled with another packet or transform, 16-47 the packet's token
|
||||
// (low 32 bits), 48 a pose was recorded;
|
||||
// row 13: the tangents of that fill's eye frustum, left, right, down, up as signed 16-bit (x 8192); row 14
|
||||
// its complement.
|
||||
// Lane P: one pixel a cell (the block is 64x15 pixels; it was 4x4-pixel cells, 256x28, before): the mock
|
||||
// reads the image raw, and the smaller block keeps the test stamp's cost out of the frame's timing.
|
||||
constexpr uint32_t kStampCell = 1;
|
||||
constexpr uint32_t kStampRows = 15;
|
||||
std::array<uint64_t, 2> SceneWords(const stereo::EyeScene& scene) {
|
||||
if (!scene.valid) return {0, 0};
|
||||
const uint64_t weight = static_cast<uint64_t>(std::lround(std::clamp(scene.weight, 0.f, 1.f) * 65535.f));
|
||||
const uint64_t a = weight | (static_cast<uint64_t>(scene.previousFrame & 0xffffu) << 16) |
|
||||
(static_cast<uint64_t>(scene.currentFrame & 0xffffu) << 32) | (1ull << 48) |
|
||||
(scene.late ? 1ull << 49 : 0) | (scene.anchored ? 1ull << 50 : 0) |
|
||||
(scene.continuous ? 1ull << 51 : 0) | (static_cast<uint64_t>(scene.set & 0xffu) << 56);
|
||||
const uint64_t b = static_cast<uint64_t>(scene.interpolated) | (static_cast<uint64_t>(scene.current) << 16) |
|
||||
(static_cast<uint64_t>(scene.recorded) << 32) | (static_cast<uint64_t>(scene.outside) << 48);
|
||||
return {a, b};
|
||||
}
|
||||
std::array<uint64_t, 4> PoseWords(const stereo::EyeScene& scene) {
|
||||
if (!scene.valid || scene.poseTime == 0) return {0, 0, 0, 0};
|
||||
uint64_t q = 0;
|
||||
for (uint32_t k = 0; k < 4; ++k) {
|
||||
const auto v = static_cast<int16_t>(std::lround(std::clamp(scene.poseOrientation[k], -1.f, 1.f) * 32767.f));
|
||||
q |= static_cast<uint64_t>(static_cast<uint16_t>(v)) << (16 * k);
|
||||
}
|
||||
const uint64_t e = static_cast<uint64_t>(scene.poseMixed) | ((scene.poseToken & 0xffffffffull) << 16) | (1ull << 48);
|
||||
uint64_t t = 0;
|
||||
for (uint32_t k = 0; k < 4; ++k) {
|
||||
const auto v = static_cast<int16_t>(std::lround(std::clamp(scene.poseTangents[k], -3.99f, 3.99f) * 8192.f));
|
||||
t |= static_cast<uint64_t>(static_cast<uint16_t>(v)) << (16 * k);
|
||||
}
|
||||
return {scene.poseTime, q, e, t};
|
||||
}
|
||||
bool StampEnabled() {
|
||||
static const bool enabled = [] {
|
||||
const char* env = std::getenv("KARTFRAME_FRAME_STAMP");
|
||||
return env != nullptr && env[0] == '1';
|
||||
}();
|
||||
return enabled;
|
||||
}
|
||||
// The whole block is built on the CPU and written into a small texture per eye (queue.WriteTexture runs
|
||||
// before this set's submission and after the last one's, so reusing it per eye is safe), then copied into
|
||||
// the eye image with one copy. One pixel a bit (lane P; 4x4 cells before): 64x15 pixels, 3.8 KB an eye.
|
||||
void WriteStamp(wgpu::CommandEncoder& encoder, const wgpu::Texture& target, wgpu::TextureFormat format, uint64_t value,
|
||||
uint32_t eye, const stereo::EyeScene& scene) {
|
||||
constexpr uint32_t kWidth = 64 * kStampCell, kHeight = kStampRows * kStampCell;
|
||||
static std::array<wgpu::Texture, 2> blocks;
|
||||
static std::array<wgpu::TextureFormat, 2> blockFormats{};
|
||||
const uint32_t slot = eye == 1 ? 1 : 0;
|
||||
const wgpu::Extent3D size{kWidth, kHeight, 1};
|
||||
if (!blocks[slot] || blockFormats[slot] != format) {
|
||||
const wgpu::TextureDescriptor descriptor{.label = "Frame stamp block",
|
||||
.usage = wgpu::TextureUsage::CopySrc | wgpu::TextureUsage::CopyDst,
|
||||
.dimension = wgpu::TextureDimension::e2D,
|
||||
.size = size,
|
||||
.format = format,
|
||||
.mipLevelCount = 1,
|
||||
.sampleCount = 1};
|
||||
blocks[slot] = webgpu::g_device.CreateTexture(&descriptor);
|
||||
blockFormats[slot] = format;
|
||||
}
|
||||
std::array<uint8_t, size_t{kWidth} * kHeight * 4> pixels;
|
||||
const auto cell = [&](uint32_t column, uint32_t row, bool one) {
|
||||
for (uint32_t y = 0; y < kStampCell; ++y)
|
||||
for (uint32_t x = 0; x < kStampCell; ++x) {
|
||||
auto* p = &pixels[((row * kStampCell + y) * kWidth + column * kStampCell + x) * 4];
|
||||
p[0] = p[1] = p[2] = one ? 255 : 0;
|
||||
p[3] = 255;
|
||||
}
|
||||
};
|
||||
const auto word = [&](uint32_t row, uint64_t bits) {
|
||||
for (uint32_t bit = 0; bit < 64; ++bit) {
|
||||
const bool one = ((bits >> bit) & 1u) != 0;
|
||||
cell(bit, row, one);
|
||||
cell(bit, row + 1, !one);
|
||||
}
|
||||
};
|
||||
word(0, value);
|
||||
for (uint32_t column = 0; column < 64; ++column)
|
||||
cell(column, 2, column == 0 ? eye == 1 : column == 1 ? eye != 1 : column < 6 ? ((column - 2) & 1u) == 0 : false);
|
||||
const auto words = SceneWords(scene);
|
||||
word(3, words[0]);
|
||||
word(5, words[1]);
|
||||
const auto pose = PoseWords(scene);
|
||||
for (uint32_t k = 0; k < pose.size(); ++k) word(7 + 2 * k, pose[k]);
|
||||
const wgpu::TexelCopyTextureInfo block{.texture = blocks[slot]};
|
||||
const wgpu::TexelCopyBufferLayout layout{.bytesPerRow = kWidth * 4, .rowsPerImage = kHeight};
|
||||
webgpu::g_queue.WriteTexture(&block, pixels.data(), pixels.size(), &layout, &size);
|
||||
const wgpu::TexelCopyTextureInfo destination{.texture = target};
|
||||
encoder.CopyTextureToTexture(&block, &destination, &size);
|
||||
}
|
||||
// Set on the frame worker around the submitted callback (see aurora_vulkan_win32_submitting_retained).
|
||||
thread_local bool submittingRetained = false;
|
||||
struct Api {
|
||||
AuroraDawnVulkanConfigureFn configure = nullptr;
|
||||
AuroraDawnVulkanHandlesFn handles = nullptr;
|
||||
@@ -152,7 +23,6 @@ struct Api {
|
||||
AuroraDawnVulkanUnlockFn unlock = nullptr;
|
||||
AuroraDawnVulkanDrainFn drain = nullptr;
|
||||
bool Load() {
|
||||
#if defined(_WIN32)
|
||||
HMODULE module = GetModuleHandleW(L"webgpu_dawn.dll");
|
||||
if (!module) return false;
|
||||
auto version = reinterpret_cast<AuroraDawnVulkanVersionFn>(GetProcAddress(module, "AuroraDawnVulkanVersion"));
|
||||
@@ -167,17 +37,6 @@ struct Api {
|
||||
LOAD(drain, AuroraDawnVulkanDrainFn, "AuroraDawnVulkanDrain");
|
||||
#undef LOAD
|
||||
return true;
|
||||
#else
|
||||
if (AuroraDawnVulkanVersion() != AURORA_DAWN_VULKAN_ABI) return false;
|
||||
configure = &AuroraDawnVulkanConfigure;
|
||||
handles = &AuroraDawnVulkanGetHandles;
|
||||
wrap = &AuroraDawnVulkanWrap;
|
||||
release = &AuroraDawnVulkanRelease;
|
||||
lock = &AuroraDawnVulkanLock;
|
||||
unlock = &AuroraDawnVulkanUnlock;
|
||||
drain = &AuroraDawnVulkanDrain;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
} api;
|
||||
int64_t VkFormat(wgpu::TextureFormat format) {
|
||||
@@ -209,8 +68,6 @@ public:
|
||||
uint32_t images = 0;
|
||||
bool panel = false;
|
||||
bool encoded = false;
|
||||
// The eyes render straight into this set's images (DirectTargets): it can no longer be canceled.
|
||||
bool claimed = false;
|
||||
AuroraD3D12StereoSubmittedCallback callback;
|
||||
void* userdata;
|
||||
Bridge(AuroraD3D12StereoSubmittedCallback cb, void* data) : callback(cb), userdata(data) {}
|
||||
@@ -232,140 +89,9 @@ public:
|
||||
}
|
||||
bool Cancel(uint64_t wanted) {
|
||||
std::unique_lock guard(mutex, std::try_to_lock);
|
||||
if (!guard.owns_lock() || encoded || claimed || !token || token != wanted) return false;
|
||||
if (!guard.owns_lock() || encoded || !token || token != wanted) return false;
|
||||
token = 0; return true;
|
||||
}
|
||||
// How the eyes reach the swapchain images (lane I). Default srgb: a full-screen draw that decodes the
|
||||
// eye's sRGB-encoded bytes and writes them through an sRGB view of the sRGB image (the same bytes; the
|
||||
// stamps read back exactly). The image then needs no MUTABLE_FORMAT, so the driver may compress it
|
||||
// (Turnip: UBWC), and the eyes' way into it cost 0.47 ms a set instead of the copy's 2.3 ms (mock, GP
|
||||
// race, compositor running; RACE120.md). KARTFRAME_SINK=copy is the old texture copy into a UNORM view
|
||||
// of a MUTABLE_FORMAT image, =blit the same through a full-screen draw (the backend asks for
|
||||
// MUTABLE_FORMAT for both). Not for a UNORM swapchain (no sRGB format offered): that takes the copy.
|
||||
enum class Sink { Copy, Blit, Srgb };
|
||||
static Sink SinkMode() {
|
||||
static const Sink mode = [] {
|
||||
const char* env = std::getenv("KARTFRAME_SINK");
|
||||
const std::string_view v = env != nullptr ? env : "";
|
||||
return v == "blit" ? Sink::Blit : v == "copy" ? Sink::Copy : Sink::Srgb;
|
||||
}();
|
||||
return mode;
|
||||
}
|
||||
static bool SinkBlit() { return SinkMode() != Sink::Copy; }
|
||||
// The sRGB format of an sRGB swapchain image (VkFormat 43 or 50), for the draw that writes it.
|
||||
static wgpu::TextureFormat TargetSrgb(int64_t vk) {
|
||||
return vk == 43 ? wgpu::TextureFormat::RGBA8UnormSrgb : vk == 50 ? wgpu::TextureFormat::BGRA8UnormSrgb
|
||||
: wgpu::TextureFormat::Undefined;
|
||||
}
|
||||
static wgpu::TextureFormat SrgbOf(wgpu::TextureFormat f) {
|
||||
return f == wgpu::TextureFormat::BGRA8Unorm ? wgpu::TextureFormat::BGRA8UnormSrgb
|
||||
: f == wgpu::TextureFormat::RGBA8Unorm ? wgpu::TextureFormat::RGBA8UnormSrgb
|
||||
: f;
|
||||
}
|
||||
wgpu::RenderPipeline srgbPipeline;
|
||||
wgpu::TextureFormat srgbPipelineFormat = wgpu::TextureFormat::Undefined;
|
||||
const wgpu::RenderPipeline& SrgbPipeline(wgpu::TextureFormat format) {
|
||||
if (srgbPipeline && srgbPipelineFormat == format) return srgbPipeline;
|
||||
wgpu::ShaderSourceWGSL code{};
|
||||
code.code = R"(
|
||||
@group(0) @binding(0) var s: sampler;
|
||||
@group(0) @binding(1) var t: texture_2d<f32>;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
let p = array(vec2f(-1.0, 1.0), vec2f(-1.0, -3.0), vec2f(3.0, 1.0));
|
||||
return vec4f(p[i], 0.0, 1.0);
|
||||
}
|
||||
fn decode(c: f32) -> f32 {
|
||||
return select(pow((c + 0.055) / 1.055, 2.4), c / 12.92, c <= 0.04045);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
|
||||
let c = textureLoad(t, vec2i(p.xy), 0);
|
||||
return vec4f(decode(c.r), decode(c.g), decode(c.b), 1.0);
|
||||
}
|
||||
)";
|
||||
wgpu::ShaderModuleDescriptor md{};
|
||||
md.nextInChain = &code;
|
||||
const auto module = webgpu::g_device.CreateShaderModule(&md);
|
||||
const auto layout = webgpu::g_CopyPipeline.GetBindGroupLayout(0);
|
||||
const wgpu::PipelineLayoutDescriptor ld{.bindGroupLayoutCount = 1, .bindGroupLayouts = &layout};
|
||||
const wgpu::ColorTargetState target{.format = format};
|
||||
const wgpu::FragmentState fs{.module = module, .entryPoint = "fs", .targetCount = 1, .targets = &target};
|
||||
wgpu::RenderPipelineDescriptor pd{};
|
||||
pd.layout = webgpu::g_device.CreatePipelineLayout(&ld);
|
||||
pd.vertex = {.module = module, .entryPoint = "vs"};
|
||||
pd.fragment = &fs;
|
||||
srgbPipeline = webgpu::g_device.CreateRenderPipeline(&pd);
|
||||
srgbPipelineFormat = format;
|
||||
return srgbPipeline;
|
||||
}
|
||||
std::vector<std::pair<WGPUTextureView, wgpu::BindGroup>> blitSources;
|
||||
std::vector<std::pair<WGPUTexture, wgpu::TextureView>> blitTargets;
|
||||
wgpu::Sampler blitSampler;
|
||||
void Blit(wgpu::CommandEncoder& encoder, const wgpu::TextureView& source, const wgpu::Texture& target) {
|
||||
if (!blitSampler) {
|
||||
const wgpu::SamplerDescriptor sd{.magFilter = wgpu::FilterMode::Nearest, .minFilter = wgpu::FilterMode::Nearest};
|
||||
blitSampler = webgpu::g_device.CreateSampler(&sd);
|
||||
}
|
||||
auto src = std::find_if(blitSources.begin(), blitSources.end(), [&](const auto& e) { return e.first == source.Get(); });
|
||||
if (src == blitSources.end()) {
|
||||
if (blitSources.size() >= 8) blitSources.erase(blitSources.begin());
|
||||
blitSources.emplace_back(source.Get(), webgpu::create_copy_bind_group(source, blitSampler));
|
||||
src = blitSources.end() - 1;
|
||||
}
|
||||
auto dst = std::find_if(blitTargets.begin(), blitTargets.end(), [&](const auto& e) { return e.first == target.Get(); });
|
||||
if (dst == blitTargets.end()) {
|
||||
if (blitTargets.size() >= 16) blitTargets.erase(blitTargets.begin());
|
||||
blitTargets.emplace_back(target.Get(), target.CreateView());
|
||||
dst = blitTargets.end() - 1;
|
||||
}
|
||||
const wgpu::RenderPassColorAttachment ca{.view = dst->second, .loadOp = wgpu::LoadOp::Clear,
|
||||
.storeOp = wgpu::StoreOp::Store};
|
||||
const wgpu::RenderPassDescriptor rd{.label = "VR eye to swapchain", .colorAttachmentCount = 1, .colorAttachments = &ca};
|
||||
auto pass = encoder.BeginRenderPass(&rd);
|
||||
pass.SetPipeline(SinkMode() == Sink::Srgb ? SrgbPipeline(target.GetFormat()) : webgpu::g_CopyPipeline);
|
||||
pass.SetBindGroup(0, src->second, 0, nullptr);
|
||||
pass.Draw(3);
|
||||
pass.End();
|
||||
}
|
||||
// The wrapped texture of target i as `format`, imported on first use (mutex held).
|
||||
wgpu::Texture* ImportTarget(uint32_t i, wgpu::TextureFormat format) {
|
||||
const auto& target = targets[i];
|
||||
const uint64_t image = reinterpret_cast<uintptr_t>(target.resource);
|
||||
auto it = std::find_if(imports.begin(), imports.end(), [&](const Import& entry) { return entry.image == image; });
|
||||
if (it != imports.end() && (it->width != target.width || it->height != target.height || it->format != format)) {
|
||||
// The runtime reuses VkImage handles across swapchain recreation, and Aurora's eye format can
|
||||
// change with the surface: re-wrap.
|
||||
imports.erase(it);
|
||||
it = imports.end();
|
||||
}
|
||||
if (it == imports.end()) {
|
||||
wgpu::TextureDescriptor descriptor;
|
||||
descriptor.usage = wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::RenderAttachment;
|
||||
descriptor.size = {target.width, target.height, 1};
|
||||
descriptor.format = format;
|
||||
void* wrapped = api.wrap(webgpu::g_device.Get(), &descriptor, image);
|
||||
if (!wrapped) return nullptr;
|
||||
imports.push_back({image, target.width, target.height, format,
|
||||
wgpu::Texture::Acquire(static_cast<WGPUTexture>(wrapped))});
|
||||
it = imports.end() - 1;
|
||||
}
|
||||
return &it->texture;
|
||||
}
|
||||
bool DirectTargets(uint64_t wanted, wgpu::TextureFormat format, uint32_t width, uint32_t height,
|
||||
std::array<wgpu::Texture, AURORA_STEREO_EYE_COUNT>& out) {
|
||||
std::lock_guard guard(mutex);
|
||||
if (!token || token != wanted || encoded || count != AURORA_STEREO_EYE_COUNT || SinkMode() == Sink::Srgb) return false;
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
if (targets[i].width != width || targets[i].height != height ||
|
||||
!CopyCompatible(VkFormat(format), targets[i].dxgiFormat)) return false;
|
||||
}
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
auto* texture = ImportTarget(i, format);
|
||||
if (!texture) return false;
|
||||
out[i] = *texture;
|
||||
}
|
||||
claimed = true;
|
||||
return true;
|
||||
}
|
||||
bool Encode(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) {
|
||||
std::lock_guard guard(mutex);
|
||||
if (!token || encoded || frame.frameToken != token) return false;
|
||||
@@ -377,36 +103,37 @@ public:
|
||||
for (uint32_t i = 0; i < images; ++i) {
|
||||
const auto& eye = sources[i];
|
||||
const auto& target = targets[i];
|
||||
if (!eye.texture || eye.size.width != target.width || eye.size.height != target.height) return false;
|
||||
// Lane O: an eye swapchain in the other channel order (R8G8B8A8_SRGB against Aurora's BGRA, as
|
||||
// GalaxyFrame's) can only be written by the sRGB draw, which writes any order; that includes the
|
||||
// menus' screen image (the left image of a pair), which the copy wrote before.
|
||||
const bool swizzle = !CopyCompatible(VkFormat(eye.format), target.dxgiFormat);
|
||||
const bool srgbTarget = TargetSrgb(target.dxgiFormat) != wgpu::TextureFormat::Undefined;
|
||||
const bool srgbEye = SinkMode() == Sink::Srgb && srgbTarget && SrgbOf(eye.format) != eye.format &&
|
||||
((i < count && frame.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY) || swizzle);
|
||||
if (swizzle && !(srgbEye && eye.view != nullptr && *eye.view)) return false;
|
||||
auto* texture = ImportTarget(i, srgbEye ? TargetSrgb(target.dxgiFormat) : eye.format);
|
||||
if (!texture) return false;
|
||||
active[i] = *texture;
|
||||
if (!eye.texture || eye.size.width != target.width || eye.size.height != target.height ||
|
||||
!CopyCompatible(VkFormat(eye.format), target.dxgiFormat)) return false;
|
||||
const uint64_t image = reinterpret_cast<uintptr_t>(target.resource);
|
||||
auto it = std::find_if(imports.begin(), imports.end(), [&](const Import& entry) { return entry.image == image; });
|
||||
if (it == imports.end()) {
|
||||
wgpu::TextureDescriptor descriptor;
|
||||
descriptor.usage = wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::RenderAttachment;
|
||||
descriptor.size = {target.width, target.height, 1};
|
||||
descriptor.format = eye.format;
|
||||
void* wrapped = api.wrap(webgpu::g_device.Get(), &descriptor, image);
|
||||
if (!wrapped) return false;
|
||||
imports.push_back({image, target.width, target.height, eye.format,
|
||||
wgpu::Texture::Acquire(static_cast<WGPUTexture>(wrapped))});
|
||||
it = imports.end() - 1;
|
||||
}
|
||||
if (it->width != target.width || it->height != target.height || it->format != eye.format) {
|
||||
// The runtime reuses VkImage handles across swapchain recreation, and
|
||||
// Aurora's eye format can change with the surface. Re-wrap rather than
|
||||
// rejecting every future frame for this image.
|
||||
imports.erase(it);
|
||||
--i;
|
||||
continue;
|
||||
}
|
||||
active[i] = it->texture;
|
||||
}
|
||||
for (uint32_t i = 0; i < images; ++i) {
|
||||
wgpu::TexelCopyTextureInfo source, destination;
|
||||
source.texture = *sources[i].texture;
|
||||
destination.texture = active[i];
|
||||
wgpu::Extent3D size{targets[i].width, targets[i].height, 1};
|
||||
// An eye rendered straight into its swapchain image (DirectTargets) needs no copy.
|
||||
if (source.texture.Get() == destination.texture.Get()) {
|
||||
} else if (SinkBlit() && sources[i].view != nullptr && *sources[i].view &&
|
||||
(SinkMode() == Sink::Blit || active[i].GetFormat() != sources[i].format)) {
|
||||
Blit(encoder, *sources[i].view, active[i]);
|
||||
} else {
|
||||
encoder.CopyTextureToTexture(&source, &destination, &size);
|
||||
}
|
||||
if (StampEnabled() && frame.mode == AURORA_STEREO_FRAME_IMMERSIVE_REPLAY && i < count && count == 2 &&
|
||||
targets[i].width >= 64 * kStampCell &&
|
||||
targets[i].height >= kStampRows * kStampCell)
|
||||
WriteStamp(encoder, active[i], active[i].GetFormat(), frame.displayTimeNanos, i, frame.scene[i]);
|
||||
encoder.CopyTextureToTexture(&source, &destination, &size);
|
||||
}
|
||||
encoded = true;
|
||||
return true;
|
||||
@@ -420,10 +147,8 @@ public:
|
||||
// flush them under its device guard, then allow the XR thread to release.
|
||||
const bool success = api.release(webgpu::g_device.Get(), textures.data(), images) != 0;
|
||||
const auto completed = token;
|
||||
token = 0; encoded = false; claimed = false;
|
||||
submittingRetained = frame.retained;
|
||||
token = 0; encoded = false;
|
||||
callback(completed, success, userdata);
|
||||
submittingRetained = false;
|
||||
}
|
||||
};
|
||||
std::unique_ptr<Bridge> bridge;
|
||||
@@ -447,11 +172,6 @@ bool aurora_vulkan_win32_enable(AuroraD3D12StereoSubmittedCallback cb, void* dat
|
||||
[](wgpu::CommandEncoder& encoder, const aurora::stereo::SinkFrame& frame, void* self) noexcept {
|
||||
return static_cast<Bridge*>(self)->Encode(encoder, frame);
|
||||
}, [](const aurora::stereo::SinkFrame& frame, void* self) noexcept { static_cast<Bridge*>(self)->Submitted(frame); }, bridge.get());
|
||||
aurora::stereo::set_direct_targets(
|
||||
[](uint64_t token, wgpu::TextureFormat format, uint32_t width, uint32_t height,
|
||||
std::array<wgpu::Texture, AURORA_STEREO_EYE_COUNT>& out, void* self) noexcept {
|
||||
return static_cast<Bridge*>(self)->DirectTargets(token, format, width, height, out);
|
||||
}, bridge.get());
|
||||
return true;
|
||||
}
|
||||
bool aurora_vulkan_win32_set_targets(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count) {
|
||||
@@ -471,12 +191,10 @@ bool aurora_vulkan_win32_disable() {
|
||||
using namespace aurora::vulkan_win32;
|
||||
if (!bridge) return true;
|
||||
aurora::stereo::set_sink(nullptr, nullptr, nullptr);
|
||||
aurora::stereo::set_direct_targets(nullptr, nullptr);
|
||||
if (!api.drain(aurora::webgpu::g_device.Get())) { bridge.release(); return false; }
|
||||
bridge.reset();
|
||||
return true;
|
||||
}
|
||||
bool aurora_vulkan_win32_submitting_retained() { return aurora::vulkan_win32::submittingRetained; }
|
||||
void* aurora_vulkan_win32_lock_queue() {
|
||||
return aurora::vulkan_win32::api.lock(aurora::webgpu::g_device.Get());
|
||||
}
|
||||
@@ -497,7 +215,6 @@ bool aurora_vulkan_win32_set_targets_with_panel(uint64_t, const AuroraD3D12Stere
|
||||
const AuroraD3D12StereoTarget*) { return false; }
|
||||
bool aurora_vulkan_win32_cancel(uint64_t) { return false; }
|
||||
bool aurora_vulkan_win32_disable() { return true; }
|
||||
bool aurora_vulkan_win32_submitting_retained() { return false; }
|
||||
void* aurora_vulkan_win32_lock_queue() { return nullptr; }
|
||||
void aurora_vulkan_win32_unlock_queue(void*) {}
|
||||
#endif
|
||||
@@ -33,7 +33,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
@@ -54,12 +53,6 @@ AuroraWindowSize g_windowSize;
|
||||
std::vector<AuroraEvent> g_events;
|
||||
std::atomic_bool g_backgrounded = false;
|
||||
std::atomic_bool g_nativeResizePending = false;
|
||||
// SDL_SyncWindow is for after a window request; pump_events used to call it every game frame. On X11,
|
||||
// when the window's geometry differs from what SDL expects (Xvfb without a window manager, or a WM that
|
||||
// sizes the window itself, as gamescope does) and nothing is pending, it ends in SDL_Delay(10): 10 ms of
|
||||
// every game frame on the game thread, which took the demo race from 60 to 39 game frames a second in
|
||||
// headless mock runs. Now it runs once after each window request (KARTFRAME_SYNC_WINDOW=1: every frame).
|
||||
std::atomic_bool g_windowSyncWanted = true;
|
||||
std::atomic<AuroraDisplayMode> g_displayMode{AURORA_DISPLAY_MODE_WINDOWED};
|
||||
#if defined(SDL_PLATFORM_ANDROID)
|
||||
// SDL's own activity mutex (Android_LockActivityMutex) is internal and not
|
||||
@@ -485,7 +478,6 @@ void destroy_window() {
|
||||
void show_window() {
|
||||
if (g_window != nullptr) {
|
||||
TRY_WARN(SDL_ShowWindow(g_window), "Failed to show window: {}", SDL_GetError());
|
||||
g_windowSyncWanted.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,11 +594,7 @@ void pump_events() noexcept {
|
||||
// runtime calls on the scheduler's own stack (KartPad found this on device).
|
||||
return;
|
||||
#else
|
||||
static const bool syncEveryFrame = [] {
|
||||
const char* env = std::getenv("KARTFRAME_SYNC_WINDOW");
|
||||
return env != nullptr && env[0] == '1';
|
||||
}();
|
||||
if (g_window != nullptr && (g_windowSyncWanted.exchange(false, std::memory_order_acq_rel) || syncEveryFrame)) {
|
||||
if (g_window != nullptr) {
|
||||
SDL_SyncWindow(g_window);
|
||||
}
|
||||
SDL_PumpEvents();
|
||||
@@ -738,7 +726,6 @@ void set_display_mode(AuroraDisplayMode mode) {
|
||||
}
|
||||
g_displayMode.store(mode, std::memory_order_release);
|
||||
g_nativeResizePending.store(true, std::memory_order_release);
|
||||
g_windowSyncWanted.store(true, std::memory_order_release);
|
||||
TRY_WARN(push_custom_event(CustomEvent::FutureResize), "Failed to queue fullscreen resize event: {}",
|
||||
SDL_GetError());
|
||||
}
|
||||
@@ -748,18 +735,15 @@ AuroraDisplayMode get_display_mode() { return g_displayMode.load(std::memory_ord
|
||||
void set_window_size(uint32_t width, uint32_t height) {
|
||||
TRY_WARN(SDL_RestoreWindow(g_window), "Failed to un-maximize window: {}", SDL_GetError());
|
||||
TRY_WARN(SDL_SetWindowSize(g_window, width, height), "Failed to set window size: {}", SDL_GetError());
|
||||
g_windowSyncWanted.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
void set_window_position(uint32_t x, uint32_t y) {
|
||||
TRY_WARN(SDL_SetWindowPosition(g_window, x, y), "Failed to set window position: {}", SDL_GetError());
|
||||
g_windowSyncWanted.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
void center_window() {
|
||||
TRY_WARN(SDL_SetWindowPosition(g_window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED),
|
||||
"Failed to center window: {}", SDL_GetError());
|
||||
g_windowSyncWanted.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
void sync_frame_buffer_size() noexcept {
|
||||
|
||||
@@ -60,32 +60,3 @@ TEST(StereoInterpolation, MissingTimingAndStallsDoNotExtrapolate) {
|
||||
EXPECT_FLOAT_EQ(interpolation_weight(105, 100, 10), 0.5);
|
||||
EXPECT_FLOAT_EQ(interpolation_weight(500, 100, 10), 1);
|
||||
}
|
||||
|
||||
// A headset set's display time lies one to three refreshes after the newest game frame's boundary;
|
||||
// the scene clock must still interpolate (not stick at 1) and advance uniformly.
|
||||
TEST(StereoInterpolation, SceneClockInterpolatesAheadOfTheBoundary) {
|
||||
constexpr uint64_t interval = 16'666'667;
|
||||
for (uint64_t hz : {90u, 120u}) {
|
||||
for (uint64_t lead : {16'666'667ull, 25'000'000ull, 33'000'000ull}) {
|
||||
aurora::stereo::SceneClock clock;
|
||||
double previousPosition = 0;
|
||||
uint32_t between = 0, samples = 0;
|
||||
for (uint64_t sample = 1; sample <= 20 * hz; ++sample) {
|
||||
const uint64_t render = 1'000'000'000 + sample * 1'000'000'000 / hz;
|
||||
const uint64_t display = render + lead;
|
||||
// The newest frame at render time: n, sealed in its own interval, due at the next retrace.
|
||||
const uint64_t n = (render - 1'000'000'000) / interval;
|
||||
const uint64_t boundary = 1'000'000'000 + (n + 1) * interval;
|
||||
const float weight = clock.weight(display, boundary, interval);
|
||||
const double position = static_cast<double>(n) - 1.0 + weight;
|
||||
if (sample > 4 * hz) { // after the mean settled
|
||||
++samples;
|
||||
between += weight > 0.f && weight < 1.f;
|
||||
EXPECT_NEAR(position - previousPosition, 1'000'000'000.0 / hz / interval, 0.02);
|
||||
}
|
||||
previousPosition = position;
|
||||
}
|
||||
EXPECT_GT(between, samples * 9 / 10) << hz << " Hz, lead " << lead;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.0-alpha.1 (October 2026), pre-release
|
||||
|
||||
The first public build of the native port. Everything below has been played on a Steam Frame unless marked
|
||||
otherwise. One known issue remains open, the double image in races (see the README); this build carries
|
||||
the mitigation that removes it.
|
||||
|
||||
### Confirmed in the headset
|
||||
|
||||
- Runs natively on the Frame in SteamVR at 120 Hz, 1728 pixels per eye, both eyes rendered on every refresh: races hold 119-120 sets a second with under 1 % late.
|
||||
- The game itself runs at its full 60 frames a second (the first session's "half speed" was a window-sync call sleeping 10 ms every game frame under gamescope; fixed).
|
||||
- Menus and the intro screens as a flat quad layer in front of you, sharp and steady; the race HUD on the virtual screen with correct clipping; the thin line beside the map removed.
|
||||
- The Frame controller layout, hand steering with the grips, the simplified Frame controller models in the hands.
|
||||
- First run from your own disc in about 6 minutes: extract, translate, compile with the bundled compiler, link against the kit.
|
||||
- The installer, the Steam library entry, the launch wrapper with SteamVR's per-app settings and the session logs.
|
||||
|
||||
### Rendering and pacing work
|
||||
|
||||
- Dawn's Vulkan backend on SteamVR's own device; eye images released only after a GPU fence (timeline semaphore under Dawn's lock).
|
||||
- Pipelined pacing: each set of eyes rendered two refreshes ahead, every frame handed over at a fixed phase against SteamVR's compositor (the double-image mitigation, `KARTFRAME_END_PHASE`), released only when finished; retained repeat when late; half-rate never by default.
|
||||
- An sRGB sink and a cut of the native render pass: eyes to swapchain 2.3 ms -> 0.47 ms.
|
||||
- Turnip's sysmem mode by default (7-8 % faster for this draw-bound game).
|
||||
- Thread priority (RealtimeKit) and a utilisation clamp so the game thread and the frame worker stay on the big cores.
|
||||
- Input kept through SteamVR's short focus losses; refresh rate requested only when offered.
|
||||
|
||||
### Tools (for developers)
|
||||
|
||||
- A mock OpenXR runtime (`frame/tools/xrmock`) with exact checks: finished releases, pose stamps, end times, layer lists, per-eye probes, image dumps.
|
||||
- `xrprobe --replay`: SteamVR shown the game's own recorded eye images through a minimal loop.
|
||||
- Compositor timing per frame (`tools/vrtiming.py`), `frame/tools/phase.py`, panel recordings and their analysis.
|
||||
|
||||
### Known issues
|
||||
|
||||
- The double image in races: see the README. This build removes it by timing the hand-over; the right eye still runs one set ahead of the left, which reads as extra sensitivity to head movement.
|
||||
- The Time Trial ghost is flat. Full-view 2D effects (Blooper ink) do not cover the whole view. The laser pointer's dot is not visible on the 2D menu screen.
|
||||
|
||||
## 1.0.0 (planned)
|
||||
|
||||
The items of the 1.0.0 plan that are not yet confirmed are listed in [frame/UNTESTED.md](../frame/UNTESTED.md).
|
||||
@@ -1,39 +0,0 @@
|
||||
# KartFrame 0.9.0-alpha.1 (pre-release)
|
||||
|
||||
Mario Kart Wii in VR, running natively on the Valve Steam Frame. KartFrame is a fork of heurazy's Wiicompiled VR PLUS with a native Linux Arm64 build, Vulkan on SteamVR's own device, and frame pacing written for SteamVR on the Frame. No Lepton, no APK, no PC.
|
||||
|
||||
This is a pre-release. It plays, at 120 Hz and 1728 pixels per eye, and has been through twenty headset sessions, but one problem is still being worked on (below), and a few smaller things are listed as known issues. Version numbers stay below 1.0.0 until that is done.
|
||||
|
||||
You need your own PAL Mario Kart Wii disc image. No game data is in this repository or its releases.
|
||||
|
||||
## What you get
|
||||
|
||||
- The game built on your Frame from your own disc, once, in about 6 minutes.
|
||||
- A Steam library entry that starts it in SteamVR with the right per-app settings every time.
|
||||
- Both eyes rendered on every refresh of the 120 Hz display from that refresh's head pose; races hold 119-120 sets a second with under 1 % late.
|
||||
- The 2D menus on a flat, steady screen in front of you; the race HUD on the virtual screen.
|
||||
- The Frame controllers, with the grips holding the wheel in the cockpit view.
|
||||
|
||||
## The known issue: the double image
|
||||
|
||||
In races, turning your head could show the world twice, one copy a little ahead of the other, worse in one eye. Twenty sessions of measurement found the mechanism: SteamVR's compositor on the Frame takes each eye's newest handed-over image at its own point inside the refresh, the right eye after the left, and a hand-over landing near those points made consecutive refreshes disagree. This build hands every frame over at a fixed point against the compositor's own clock (`KARTFRAME_END_PHASE=after`, the launch wrapper's default), which removes the doubling. What remains: the right eye runs one set ahead of the left, which reads as the right eye being a touch more sensitive to head movement, and the compositor now and then shows a frame twice. The README explains it in full; the cause behind the cause is still being chased.
|
||||
|
||||
## Other known issues
|
||||
|
||||
- The Time Trial ghost is drawn flat.
|
||||
- Full-view 2D effects (Blooper ink, the black of a respawn) do not cover the whole view.
|
||||
- The laser pointer's dot is not visible on the 2D menu screen; the ray is.
|
||||
- Two and four player races are heavier and not yet measured in the headset.
|
||||
|
||||
## Download
|
||||
|
||||
- `kartframe-0.9.0-alpha.1-linux-arm64.tar.gz`
|
||||
- `kartframe-0.9.0-alpha.1-linux-arm64.tar.gz.sha256`
|
||||
|
||||
## Install
|
||||
|
||||
Clone the repository, run `frame/install.sh` with the tarball as its argument, then `~/Games/KartFrame/app/setup.sh` with your disc image. The [README](https://github.com/simplysanatori/KartFrame#install) has the steps. Updating is the same two commands; your game files, saves and settings stay where they are.
|
||||
|
||||
## Reporting problems
|
||||
|
||||
Open an issue with what you did, what you expected and what happened, and the archive from `~/Games/KartFrame/app/tools/collect-logs.sh`. It masks addresses, names and ids before anything leaves your Frame.
|
||||
@@ -1,204 +0,0 @@
|
||||
# KartFrame: technical notes
|
||||
|
||||
KartFrame is a fork of [Wiicompiled VR PLUS](https://github.com/heurazy/Wiicompiled_VR-PLUS) by heurazy. In that project the Wii game is statically translated to C++, drawn by the aurora renderer on Dawn (WebGPU) and shown through OpenXR. KartFrame builds the same game as a Linux Arm64 program that runs in the Frame's own SteamVR, without Lepton, an APK or adb. This document is for version 1.0.0.
|
||||
|
||||
Install, first run, controls, settings and how to report problems are in the [README](../README.md). This one says how the port works, how to build it, and what I learned about SteamVR on the Frame. Where a claim needs a verdict from the headset it carries `[to confirm in the headset]`, and the claim is listed in [frame/UNTESTED.md](../frame/UNTESTED.md).
|
||||
|
||||
Not affiliated with Valve, Nintendo or the upstream author.
|
||||
|
||||
## AI disclosure
|
||||
|
||||
I did not write this code by hand. The port, its scripts and these notes were written with Anthropic's Claude (Fable 5.1, Opus 5.5 and Sonnet 5.5, through Claude Code), running on my Steam Frame. I chose what to attempt, supplied the disc, reviewed the code and the scripts as they were written, tested in the headset and reported what I saw. Claude read the logs and changed the code. The upstream author has not reviewed this port. Read the code before trusting it.
|
||||
|
||||
## Status
|
||||
|
||||
0.9.0-alpha.1, pre-release. The build has been through twenty headset sessions. Confirmed: native
|
||||
Linux Arm64 on SteamVR at 120 Hz and 1728 per eye, 119-120 sets a second in races with under 1 % late,
|
||||
the game at its full 60 fps, quad-layer menus, the HUD on the virtual screen, the Frame controls and
|
||||
models, the first run from a disc in about 6 minutes. Open: the double image (below) is removed by a
|
||||
timing mitigation, not yet by a fix of its cause; the Time Trial ghost is flat; full-view 2D effects and
|
||||
the pointer dot on the menu screen are pending. The per-session records are in `frame/perf/`.
|
||||
|
||||
## How it works
|
||||
|
||||
### Platform and process
|
||||
|
||||
The build is a plain Linux Arm64 executable, `kartframe`, linked against glibc and libstdc++ only. The libraries that SteamOS does not ship in its base image (abseil, zlib, libpng, FreeType) are built static from pinned sources (`AURORA_VENDOR_THIRD_PARTY`, on by default on Linux). SDL3 is built from source and loads its audio and video drivers at run time. The game takes no command line arguments.
|
||||
|
||||
The upstream desktop code is kept. The Linux changes are small: the OpenXR path is on by default, the shared device Vulkan bridge that upstream wrote for the PC is used on Linux, the executable directory comes from `/proc/self/exe`, and the blob assembly of the translated data is written in ELF syntax.
|
||||
|
||||
### Install layout
|
||||
|
||||
`KARTFRAME_HOME` (default `~/.local/share/KartFrame`) holds:
|
||||
|
||||
- `portable.txt`, an empty marker that makes the game use `KARTFRAME_HOME/UserData` as its root instead of `~/.local/share/WiiCompiled`.
|
||||
- `UserData/`: the game's `Config.toml`, `NAND/`, `Cache/`, `Logs/`, and `controller_models/frame`.
|
||||
- `game/`: the extracted disc (`main.dol`, `StaticR.rel`, `DATA/`).
|
||||
- `build/`: the translation and the objects made by setup.
|
||||
- `bin/`: `kartframe`, its resources (`dsp_coef.bin`, `initial_pipeline_cache.db`, `wii_bootstrap/`, `resources/`) and `kartframe.fingerprint`.
|
||||
- `sessions/<date-time>/`: the wrapper's logs.
|
||||
|
||||
The program directory, `~/Games/KartFrame/app`, holds the wrapper, the tools and, from a release, the kit, the toolchain, the translator and `setup.sh`.
|
||||
|
||||
### Data pipeline
|
||||
|
||||
Nothing derived from the disc is committed or released. A release carries:
|
||||
|
||||
- a **kit**: the prebuilt runtime (aurora, patched Dawn, SDL3, OpenXR loader and the static third party libraries), the compile flags and link recipe read from the real build graph, a minimal sysroot made from the build Frame's own packages (glibc and libstdc++ headers, kernel headers, crt files and libgcc), and a manifest with a sha256 per file and a fingerprint;
|
||||
- the **translator**, a self contained single file publish of upstream's `Translator.Cli` for linux-arm64;
|
||||
- a **portable toolchain** made by upstream's own `prepare-portable-tools.sh`: LLVM 22 (clang, lld, llvm-ar), CMake and Ninja.
|
||||
|
||||
`setup.sh <iso>` then runs four steps, once, resumable:
|
||||
|
||||
1. **Extract.** nodtool (fetched once and checked against a pinned sha256) reads the game id, requires RMCP01, checks `main.dol` and `StaticR.rel` against the sha256 pins in `projects/mkwii/recomp.yml`, and extracts to `game/`.
|
||||
2. **Translate.** `translate-recursive`, `emit-base-manifest`, `generate-data-init --target-os linux` and `emit-build-shards`, the same sequence as upstream's `local-build.sh`.
|
||||
3. **Build.** The translated shards are compiled with the kit's flags and the bundled compiler, three at a time (about 2.5 GB of memory each), and linked against the kit with lld.
|
||||
4. **Install.** The binary and its resources go to `bin/`, with the kit fingerprint beside it, and `Config.toml` is seeded from `Config.frame.toml` if missing.
|
||||
|
||||
The whole run took about 6 minutes on my Frame. The wrapper compares the installed kit's fingerprint with the one recorded beside the binary and refuses to start if they differ. `frame/boundary-check.sh` checks a directory or tarball for anything disc derived, and files over 1 MB that `frame/kit-allowlist.txt` does not allow. Details for maintainers are in [frame/SETUP.md](../frame/SETUP.md).
|
||||
|
||||
### OpenXR session and renderer
|
||||
|
||||
Dawn's Vulkan backend is patched with upstream's own patch set at upstream's pinned revision (Aurora Dawn Vulkan ABI 1). With it the OpenXR runtime creates Dawn's `VkInstance` and `VkDevice` through `xrCreateVulkanInstanceKHR` and `xrCreateVulkanDeviceKHR`, borrowed swapchain images are wrapped as Dawn textures, and the eyes are rendered straight into the acquired images with no extra copy. The patched Dawn is built once on the Frame as a static monolithic library for linux-aarch64 (`frame/build-dawn.sh`), and aurora links it as a package.
|
||||
|
||||
I did not change the patch. What the Frame needs on top, I did in the bridge (`openxr_vulkan_win32.cpp`, which upstream wrote for the PC and which Linux now uses):
|
||||
|
||||
- The release barrier submission that follows aurora's submit is followed by an empty queue submission that signals a timeline semaphore, under Dawn's device lock. The pacing thread waits for it before it releases the swapchain image.
|
||||
- Single level swapchains, array size 1, an sRGB sibling format with mutable format.
|
||||
- The Vulkan instance is kept at 1.2 or lower.
|
||||
- Dawn's device lock is held around exactly `xrBeginFrame`, `xrEndFrame`, `xrAcquireSwapchainImage` and `xrReleaseSwapchainImage`, because SteamVR's client submits on the one queue inside those calls.
|
||||
- Two sets of eye swapchains, so that two frames can be in flight.
|
||||
|
||||
### Frame loop
|
||||
|
||||
Every refresh (8.33 ms at 120 Hz):
|
||||
|
||||
1. `xrWaitFrame` and `xrBeginFrame`.
|
||||
2. One `xrLocateViews`. Both eyes, the HUD screen, the settings panel and the pointer use those poses and no others.
|
||||
3. The pose packet goes to aurora's frame worker, which interpolates the retained game frame to that refresh's display time, renders both eyes into the acquired set and submits on Dawn's queue.
|
||||
4. In **inline** mode the pacing thread waits for the GPU to finish that set, releases it and ends the frame with the same poses. In **pipelined** mode the set stays acquired into the next refresh: the previous set is waited for, released and ended with its own poses, whose display time is this refresh's by construction. The price is one refresh of latency; the gain is CPU and GPU work that overlap.
|
||||
5. If a set is not finished by the deadline, the previous layer is shown again with its own poses, and the miss is counted. An unfinished image is never released.
|
||||
|
||||
`[vr] frame_pacing` is `auto`, `inline` or `pipelined`; `auto` is pipelined, because inline did not fit encode plus GPU of both eyes into one refresh in the mock measurements. `KARTFRAME_PACING` overrides it. An optional fallback to new eyes every second refresh exists (`KARTFRAME_HALF_RATE`). Its default is being settled after the first headset session. [to confirm in the headset]
|
||||
|
||||
The game's clock is converted to `XrTime` with `XR_KHR_convert_timespec_time`, which SteamVR offers. The first frame's `CLOCK_MONOTONIC` anchor is kept as a fallback. Input keeps its state through a loss of focus shorter than a second, because SteamVR takes focus for a few milliseconds now and then.
|
||||
|
||||
Once a second the game logs one line: `vr pacing: t= hz= mode= ticks= rendered= interpolated= late= missed= repeats= gpu_ms= encode_ms=`. `frame/tools/session-report.py` reads it together with `vrtiming.log` and writes `report.txt` in the session folder.
|
||||
|
||||
### Interpolation
|
||||
|
||||
The game's own VR frame interpolation (aurora's stereo replay with matched previous and current transform uniforms, up to three interpolated frames) with `[vr] frame_interpolation_fps = 1`. It renders on every compositor tick, so a 120 Hz display gets 120 stereo presentations a second from the game's 60 frames a second, each interpolated to that tick's display time and drawn from that tick's head pose. Frame synthesis (`XR_EXT_frame_synthesis`) and `XR_FB_space_warp` are accepted by SteamVR on the Frame and ignored, so they are not used.
|
||||
|
||||
### SteamVR per-app settings
|
||||
|
||||
SteamVR drops its per-app settings now and then, so the wrapper sets them at every launch through `vrcmd` and reads them back from `steamvr.vrsettings`, logging each difference:
|
||||
|
||||
```
|
||||
preferredRefreshRate=120 framesToThrottle=0 additionalFramesToPredict=0 motionSmoothingOverride=2 resolutionOverride=1728
|
||||
```
|
||||
|
||||
They only apply to a launch from the Steam library, where `SteamAppId` is set. 90 Hz felt wrong for a 60 frame a second game. [to confirm in the headset] The wrapper also sets `TU_DEBUG=sysmem` unless `KARTFRAME_SYSMEM=0`: on this GPU it was 7 to 8 percent faster for a draw bound game in the mock measurements. Valve's implicit layer `XR_APILAYER_VALVE_fdm_injection` sits in the Vulkan chain; `KARTFRAME_FDM_LAYER=0` sets `DISABLE_VULKAN_FDM_INJECTION_LAYER=1` so the two can be compared. Eye tracked foveation saved almost nothing and is not used.
|
||||
|
||||
### Resolution
|
||||
|
||||
1728 pixels per eye is SteamVR's 100 percent on the Frame, and it is the floor for the shipped default (`render_scale = 1.0`). I measure lower values for the record but do not ship them.
|
||||
|
||||
### Controls
|
||||
|
||||
The layout is in code, in `runtime/include/vr/frame_controls.h`, as pure functions that a table test checks without OpenXR. The runtime enables `XR_VALVE_frame_controller_interaction`, suggests bindings for `/interaction_profiles/valve/frame_controller`, and latches the layout when `xrGetCurrentInteractionProfile` reports that profile. There is no SteamVR binding file. The controls table in the README is the layout as coded. [to confirm in the headset]
|
||||
|
||||
The View button has a small state machine: a tap goes back on release, a half second hold opens the VR settings panel, and a press while the panel is open closes it without also going back.
|
||||
|
||||
### Controller models
|
||||
|
||||
SteamVR's own Frame controller models have about 93,000 vertices a hand, and animating them on the CPU every frame made menus slow in earlier tests. `frame/frame-controller-models.py` therefore reads them from SteamVR's `frame_controller` driver on the user's Frame at install, simplifies them to about 4,000 triangles a hand, bakes the ambient occlusion into the base colour texture and writes the game's own model format into `UserData/controller_models/frame`. Valve's files are only read locally, never copied into the repository or a release. If the converted models are missing, the game draws its procedural controllers. [to confirm in the headset]
|
||||
|
||||
### Menus and the HUD
|
||||
|
||||
The game's 2D screen (menus, intro screens, the race HUD) is shown as a flat quad layer in front of the player instead of being drawn into the eyes, placed by `hud_distance_meters` and `hud_width_meters`. This is being finished. [to confirm in the headset]
|
||||
|
||||
### Packaging
|
||||
|
||||
`frame/release.sh` builds `kartframe-1.0.0-linux-arm64.tar.gz` from a kit directory (`frame/make-kit.sh`), the toolchain tarball directory, the published translator, the `frame/` scripts, `Config.frame.toml`, the controller model converter, the Steam shortcut helper, the two runtime tools (`vrtiming.py`, `session-report.py`), the README, the docs and the licences. It then runs `frame/boundary-check.sh` and `frame/tools/privacy-check.sh` on the tarball and writes its sha256.
|
||||
|
||||
## First headset session
|
||||
|
||||
The first session on a development build showed judder, half rate flapping and flickering menus. In more detail, from the logs and what I saw:
|
||||
|
||||
- Motion looked like 60 frames a second on a 120 Hz display, with ghosting when I turned my head.
|
||||
- The 2D screen flickered together with the VR menu.
|
||||
- The controller guide looked bad.
|
||||
- The compositor itself was healthy: it reused almost no frames and its own time was small.
|
||||
- The menus were drawn into the eyes with a projected surface. The intro and controller guide screens, together with the controllers, cost much more GPU time than the race path I had measured in the mock, and the fallback to every second refresh switched on and off repeatedly. A large share of the layers were repeats of an earlier set.
|
||||
- The game asked for 90 Hz through `XR_FB_display_refresh_rate`, the only rate the runtime offers there, before the per-app setting gave 120 Hz.
|
||||
|
||||
Fixes made or in progress:
|
||||
|
||||
- Menus move to a flat quad layer. [to confirm in the headset]
|
||||
- The every second refresh fallback is no longer taken as a default. [to confirm in the headset]
|
||||
- The game stops asking for a refresh rate that the runtime does not offer. [to confirm in the headset]
|
||||
- `TU_DEBUG=sysmem` is checked against translucent overlays. [to confirm in the headset]
|
||||
- The item box (the roulette) is drawn in the wrong place; reprojected 2D draws are being clipped to their frame rectangle. [to confirm in the headset]
|
||||
|
||||
## The double image: what twenty sessions found
|
||||
|
||||
The symptom: in races, on head turns, the image sat on two positions alternating every refresh, worse
|
||||
in one eye, clean in the menus (a quad layer) and clean in GalaxyFrame on the same SteamVR.
|
||||
|
||||
Eliminated, each by a direct test (the records are `frame/perf/session-h*/README.md` and `LANE_*.md`):
|
||||
pacing and late sets, retained repeats, end times, pose stamps, per-eye scene state, interpolation
|
||||
evenness, eye poses and FOV, swapchain format and usage, pair counts 2 to 8, a quad beside the projection,
|
||||
GalaxyFrame's first-write barrier, the prediction distance of the poses (fresh poses for the exact display
|
||||
time did not help), the rendered images themselves (240 consecutive sets dumped from the mock: no jump),
|
||||
the desktop window's presents, the release ordering against the GPU (a strict mock check), the size of the
|
||||
GPU work, the refresh rate, and the thread priority boost.
|
||||
|
||||
What SteamVR itself showed: the game's own recorded race images, handed over by a minimal probe loop
|
||||
(`frame/tools/xrprobe --replay`) in six variations including the game's own order, were clean.
|
||||
|
||||
The mechanism, from logging every compositor frame beside the game's own timeline (`tools/vrtiming.py`
|
||||
frame mode, `frame/tools/phase.py`): the compositor's update runs at about vsync + 3.0 to + 5.3 ms and
|
||||
takes each eye's newest released image when it reaches that eye; the game handed its frame over near those
|
||||
points, and whether a hand-over fell before or after an eye's pick-up changed from refresh to refresh. A
|
||||
launch whose hand-over kept one side every frame was clean; one whose compositor updates started early in
|
||||
episodes was doubled. Ending each frame at a fixed phase against the published grid (`KARTFRAME_END_PHASE`,
|
||||
lane X) keeps one side and removes the doubling. It leaves the right eye one set ahead of the left and the
|
||||
compositor reusing a frame now and then, so it is a mitigation. The probe ends its frames at the same phase
|
||||
as the doubled default and is clean, so the phase is not the cause; the remaining difference is the 4-7 ms
|
||||
of GPU work the game submits right after each hand-over on the GPU the compositor composes on. That is the
|
||||
next experiment (the probe with synthetic GPU load; lane Y, unfinished).
|
||||
|
||||
## Building
|
||||
|
||||
Needs an Arm64 Linux machine with a compiler, CMake and Ninja (the Frame needs them installed; the release does not). The steps for developers, in short, are in [frame/DEV.md](../frame/DEV.md):
|
||||
|
||||
1. `frame/build-dawn.sh`, once: the patched static Dawn goes to `~/.cache/kartframe/dawn/<revision>-<patch hash>/`. The first build took about 12 minutes here.
|
||||
2. Translate the game from your own disc (`frame/setup.sh translate`) to get a `generated` tree, then configure and build `runtime/` in Release mode with `-DMKW_DAWN_PACKAGE_DIR` pointing at that Dawn. The binary is `build-linux/kartframe`.
|
||||
3. `frame/install.sh --dev` copies a build into the data folder and adds a separate shortcut, for testing.
|
||||
4. `frame/make-kit.sh <dir>` makes the kit, `frame/toolchain.sh` the toolchain, `frame/publish-translator.sh <dir>` the translator, and `frame/release.sh` the tarball.
|
||||
|
||||
Games are run only against the mock runtime during development: `frame/tools/mockrun.sh` uses a private Xvfb, a dummy audio driver and aborts if the mock runtime file is missing.
|
||||
|
||||
## SteamVR on the Frame: settled facts
|
||||
|
||||
These came from probes and runs on the Frame, and I build on them:
|
||||
|
||||
- SteamVR shows a swapchain image the moment it is released, finished or not, in Vulkan too. Release only after a fence or timeline wait shows the GPU is done. It costs no latency.
|
||||
- One waited, unreleased image per swapchain. Pipelining two frames needs two sets of eye swapchains.
|
||||
- SteamVR allocates only mip 0 of a Vulkan swapchain, so swapchains have a single level.
|
||||
- Locate the views once per refresh and render both eyes from exactly those poses; release and end the frame with those same poses. A right eye rendered a refresh later got random placement error.
|
||||
- The Vulkan instance must be 1.0 to 1.2; 1.3 features come as extensions. There is one queue, and the SteamVR client submits on it inside the four calls above.
|
||||
- `XR_EXT_frame_synthesis` and `XR_FB_space_warp` are accepted and ignored. Depth submission works.
|
||||
- SteamVR takes focus for a few milliseconds now and then. That is not a pause.
|
||||
- The runtime offers `XR_KHR_vulkan_enable2`, `XR_KHR_convert_timespec_time`, `XR_FB_display_refresh_rate`, `XR_KHR_composition_layer_depth`, `XR_EXT_eye_gaze_interaction` and `XR_VALVE_frame_controller_interaction`.
|
||||
- At 120 Hz the GPU budget for both eyes including the composite is about 7.1 ms; SteamVR's compositor takes about 0.8 ms.
|
||||
|
||||
The probes and the mock runtime come from my GalaxyFrame work with bigmak94's GalaxyQuest (`xrmock`, and `xrprobe`, which is GPLv3 and not part of the game).
|
||||
|
||||
## How this was made
|
||||
|
||||
See "How this was made" in the [README](../README.md).
|
||||
|
||||
## Licence
|
||||
|
||||
GPLv3, as upstream. Third party parts keep their licences: see [THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md).
|
||||
@@ -1,36 +0,0 @@
|
||||
# KartFrame defaults for the Steam Frame, seeded into Config.toml on the first run (setup.sh and the
|
||||
# launch wrapper copy this file only when there is no Config.toml). The in-game VR settings panel
|
||||
# edits Config.toml in place afterwards; this file is never read again.
|
||||
[paths]
|
||||
# The extracted game's DATA folder (the one holding sys/fst.bin and files/). The placeholder is
|
||||
# replaced with the real path when the file is seeded. TODO(lane B): confirm the placeholder name.
|
||||
dvd_root = "@DVD_ROOT@"
|
||||
|
||||
[video]
|
||||
widescreen = true
|
||||
resolution_multiplier = 1.0
|
||||
|
||||
[vr]
|
||||
enabled = true
|
||||
required = false
|
||||
# 1.0 renders the eyes at the size SteamVR's resolutionOverride (1728, set by the launch wrapper) asks for.
|
||||
render_scale = 1.000000
|
||||
# 1 = render at the headset's refresh rate (120 Hz), interpolated from the game's 60 fps.
|
||||
frame_interpolation_fps = 1
|
||||
frame_pacing = "auto"
|
||||
refresh_rate = 120
|
||||
# Grips hold the wheel in the first-person cockpit (the right bumper drifts there); elsewhere they drift.
|
||||
hand_steering = true
|
||||
# 0 = plain dark background behind the menus (the raymarched one is far too heavy).
|
||||
menu_shader_quality = 0
|
||||
hand_hud = false
|
||||
# The virtual screen for menus and the race HUD, as the upstream defaults.
|
||||
hud_virtual_screen = true
|
||||
hud_distance_meters = 2.0
|
||||
hud_width_meters = 2.4
|
||||
foveation = "off"
|
||||
passthrough = false
|
||||
adaptive_resolution = false
|
||||
|
||||
[diagnostics]
|
||||
openxr_logging = false
|
||||
-242
@@ -1,242 +0,0 @@
|
||||
# KartFrame development notes (lane 0)
|
||||
|
||||
## Build
|
||||
|
||||
frame/build-dawn.sh # once: patched static Dawn into ~/.cache/kartframe/dawn/<rev>-<patch hash>/
|
||||
~/Games/KartFrame/kfbuild.sh <worktree> WiiCompiled # the game, binary build-linux/kartframe
|
||||
KF_CONFIGURE_ONLY=1 ~/Games/KartFrame/kfbuild.sh <worktree> # configure only (no lock)
|
||||
|
||||
`kfbuild.sh` takes the shared build lock, uses ccache (~/.cache/ccache-kf), links the worktree's
|
||||
`generated` to the development translation (`~/Games/KartFrame/data/generated-dev`, never committed),
|
||||
and passes the newest Dawn package (or `KARTFRAME_DAWN_DIR`) as `-DMKW_DAWN_PACKAGE_DIR`. The project
|
||||
only accepts `CMAKE_BUILD_TYPE=Release`. The translated shards are the heavy part;
|
||||
`MKW_TRANSLATED_COMPILE_JOBS` (default 3 in kfbuild) caps how many compile at once.
|
||||
|
||||
The CMake wiring for Linux: OpenXR on by default, pinned Vulkan headers and the pinned static OpenXR
|
||||
loader (vendored jsoncpp) are fetched, the patched Dawn package is linked statically (its
|
||||
`AuroraDawnVulkan*` ABI resolves at link time), SDL3 is built from source (audio: ALSA, JACK, PipeWire,
|
||||
PulseAudio loaded at run time, plus dummy; video: Wayland, X11, KMS/DRM, offscreen, dummy). The shard
|
||||
manifest and the blob assembly of a translation made elsewhere are relocated at configure time.
|
||||
|
||||
## Where the runtime looks for things
|
||||
|
||||
Executable `kartframe` (target `WiiCompiled`). Its resources (`dsp_coef.bin`, `initial_pipeline_cache.db`,
|
||||
`wii_bootstrap/`, `resources/`) are copied next to it in the build directory and are found next to the
|
||||
executable.
|
||||
|
||||
User state, from `runtime_config.h`:
|
||||
|
||||
- Normal install: `$XDG_DATA_HOME/WiiCompiled` (default `~/.local/share/WiiCompiled`), holding
|
||||
`Config.toml`, `NAND/`, `Cache/`, `Logs/`.
|
||||
- Portable: a file `portable.txt` in the executable's directory or up to a few parents makes
|
||||
`<that dir>/UserData` the root instead.
|
||||
- `Config.toml` is created with defaults on first start. The game files are not found automatically:
|
||||
`[paths] dvd_root` must name the extracted DATA directory (the one holding `sys/fst.bin` and
|
||||
`files/`); a relative value is relative to the Config.toml. `[paths] nand_root` is optional; when it
|
||||
is absent the managed NAND is `<state dir>/NAND` (the licence is made in-game on first boot).
|
||||
- `[vr] enabled = true, required = false` (defaults) try OpenXR and fall back to the desktop renderer.
|
||||
The OpenXR loader reads `~/.config/openxr/1/active_runtime.json` (SteamVR) unless `XR_RUNTIME_JSON`
|
||||
is set. `[vr] force_steamvr` does nothing on Linux.
|
||||
- The program takes no command line arguments.
|
||||
|
||||
## Tools and runs
|
||||
|
||||
`frame/tools/mockrun.sh [--flat] [--seconds N]` runs the game against the mock runtime (xrmock) or flat,
|
||||
headless: a private Xvfb for the window (Dawn needs a window surface to pick the adapter),
|
||||
`SDL_AUDIO_DRIVER=dummy`, and `KARTFRAME_HEADLESS=1`, which makes aurora skip configuring and
|
||||
presenting the desktop swapchain (it renders into an offscreen texture and logs a frame count). The
|
||||
scratch user state is `~/.cache/kf-mockrun/data/WiiCompiled`. It aborts when the mock runtime file is
|
||||
missing or empty and when a game is already running. Never run the game with the real runtime from an
|
||||
agent. Known facts: Valve's implicit OpenXR layer `XrApiLayer_VALVE_fdm_injection` is loaded in front of
|
||||
the mock; the mock offers no `XR_KHR_convert_timespec_time` yet, so the game logs "no clock
|
||||
conversion"; abseil, zlib, libpng and freetype are built static from pinned sources (AURORA_VENDOR_THIRD_PARTY); the binary needs only glibc, libstdc++ and libgcc_s.
|
||||
|
||||
## Pacing knobs (lane A)
|
||||
Config `[vr] frame_pacing = auto|inline|pipelined` (auto = pipelined, measured) and `refresh_rate`
|
||||
(120; requested in-app only when SteamVR offers exactly that rate, else the wrapper's per-app key
|
||||
decides). Menus are a quad layer fixed in the room (menu_shader_quality 0), the first-run guide is
|
||||
projected. Env: KARTFRAME_PACING overrides the mode; KARTFRAME_REFRESH_HZ the rate;
|
||||
KARTFRAME_PACING_MARGIN_US (1000) is kept free before the deadline for release + xrEndFrame;
|
||||
every refresh always by default (a late set repeats the retained layer, which SteamVR reprojects;
|
||||
half rate is 60 Hz content shown twice, the judder H1 rejected); KARTFRAME_HALF_RATE=1 always every
|
||||
second refresh (measurements), =auto drops after more than 5 % late over 30 s and stays there for
|
||||
the session; KARTFRAME_PACING_DEBUG=1 per-set timelines;
|
||||
KARTFRAME_NO_GPU_WAIT=1 negative test only. KARTFRAME_RELEASE=idle (lane U, headset A/B) drains Dawn's
|
||||
queue (vkQueueWaitIdle under the queue guard) right before each set's releases and its xrEndFrame, so
|
||||
SteamVR's client submissions there never wait behind our next set's GPU work; logs `release idle
|
||||
drain` every 10 s (frame/perf/LANE_U.md). The wrapper sets TU_DEBUG=sysmem unless
|
||||
KARTFRAME_SYSMEM=0 (the race holds 120 Hz at 1728 only with it). Log line per second:
|
||||
`vr pacing: t= hz= mode= ticks= rendered= interpolated= late= missed= repeats= gpu_ms=p50/p99 encode_ms=p50/p99`
|
||||
(format in runtime/include/vr/openxr_diagnostics.h); session-report.py reads it with vrtiming.log.
|
||||
Mock: MOCK_XR_BUSY_MS (exact released-unfinished check; lane U: plus the strict check, bytes at the
|
||||
release against the next acquire, and `release queue scN` lines: how long a submission made inside
|
||||
xrReleaseSwapchainImage waits behind the app's queued GPU work, per swapchain and frame parity), MOCK_XR_FOCUS_BLIP=<ms>[@<s>],
|
||||
MOCK_XR_PROFILE, MOCK_XR_EYE, MOCK_XR_TIME_OFFSET_S, MOCK_XR_DUMP=<dir> (released images as PPM at
|
||||
MOCK_XR_DUMP_AT seconds); mockrun seeds welcome/tutorial done so the demo race starts by itself
|
||||
(KF_MOCKRUN_INTRO=1 shows them). KARTFRAME_FPSLOG=1 logs the game frame rate every 5 s;
|
||||
KARTFRAME_SYNC_WINDOW=1 restores aurora's per-frame SDL_SyncWindow (10 ms a game frame on X11);
|
||||
KARTFRAME_HUD_CLIP=0 stops clipping the race's 2D layer to the frame and the game's scissor.
|
||||
|
||||
## Lane G switches
|
||||
KARTFRAME_SCENE_CLOCK=0 restores the old interpolation rule (scene sampled at the display time;
|
||||
with pipelined pacing that pinned the weight to the newest frame). KARTFRAME_END_TIME=cycle ends
|
||||
layers at the cycle's predicted time instead of their poses' locate time. KARTFRAME_MENU_ENV=over
|
||||
puts the menu environment layer (controllers, lasers) over the screen quad; =0 turns it off.
|
||||
KARTFRAME_FRAME_STAMP=1 (tests) stamps each eye image with its locate time. Mock: MOCK_XR_STAMP,
|
||||
MOCK_XR_PREDICT_JITTER, MOCK_XR_FOV (default Frame-like asymmetric), MOCK_XR_HANDS, MOCK_XR_PRESS
|
||||
(scripted presses; frame/perf/hudrace.sh drives a real Grand Prix race). Log lines: "vr display
|
||||
times" and "VR interpolation weights" every 10 s, eye fields of view once. Details in frame/perf/H2.md.
|
||||
|
||||
## Lane H switches
|
||||
KARTFRAME_END_TIME=locate (lane G's rule; default now: each frame's own predicted time).
|
||||
KARTFRAME_THREADPRIO=high|nice|off (game, pacing, frame worker, GX and GPU watcher threads at up to
|
||||
nice -15 through RealtimeKit), KARTFRAME_UCLAMP=0 (no utilization clamp; by default the game thread
|
||||
runs on the big core). KARTFRAME_FULL_VIEW_FADES=0 keeps full-frame fades on the screen (default:
|
||||
across the whole eye; about 0.3 ms GPU a set). KARTFRAME_COPY_TRIM=0 keeps the minimap copy's last
|
||||
columns (default trims them: removes the thin line by the map). KARTFRAME_MENU_ENV defaults to
|
||||
over (controllers and laser over the screen quad, alpha blended); =under is lane G's order.
|
||||
KARTFRAME_LOG_2D_QUADS logs large 2D rectangles (to find the Blooper ink later). Mock: MOCK_XR_LEAD
|
||||
(display lead in refreshes, default 3 like SteamVR), MOCK_XR_DUMP_STEP, MOCK_XR_DUMP_ALPHA,
|
||||
MOCK_XR_HANDS=high. Details in frame/perf/H3.md.
|
||||
|
||||
## Lane I switches
|
||||
KARTFRAME_SINK=srgb|copy|blit: how eyes reach the swapchain (default srgb: an sRGB-decoding draw
|
||||
through an sRGB view, so the swapchain needs no MUTABLE_FORMAT and the driver can compress it; copy is
|
||||
the old path). KARTFRAME_DIRECT_EYES=0|1 forces rendering straight into the swapchain images (by default
|
||||
measured per session; never applies with the srgb sink). KARTFRAME_MONO_FULL=1 and
|
||||
KARTFRAME_NATIVE_CLIP=0 restore the full native race render (by default it stops after the last copy
|
||||
the eyes sample and is clipped to it; the desktop window is black during races). KARTFRAME_FPSLOG=2
|
||||
logs a per-segment GPU profile (frame/perf/gpuprof.py tabulates it). Mock: MOCK_XR_MUTABLE=0.
|
||||
Details and the profile in frame/perf/RACE120.md; A/B pairs with frame/perf/abgp.sh.
|
||||
|
||||
## Lane J switches
|
||||
KARTFRAME_EYE_PAIRS=2..8 (lane J set 6; back to 2 for the old loop, 3 for sets ahead): eye swapchain pairs the per-refresh sets rotate through; a pair is
|
||||
acquired again only after the frames that showed it are well past SteamVR's composition (the sixth
|
||||
session's recording showed one eye at a time re-showing an image 3 to 4 refreshes old with two pairs).
|
||||
KARTFRAME_FRAME_LOG=1 writes one XRF line per frame (wake-up, end after wake-up, pair, image per eye,
|
||||
display step). Mock: MOCK_XR_COMPOSITE_MS and MOCK_XR_COMPOSITE_SELECT=spec|lazyrel|lazyacq model when
|
||||
SteamVR picks each eye's image. frame/perf/panels/analyse.sh SESSION_DIR analyses a KARTFRAME_RECORD=raw
|
||||
recording per eye (stale and held refreshes, eyes out of step) in about 4 minutes; see frame/perf/PANELS.md.
|
||||
The wrapper takes one-launch settings from KARTFRAME_HOME/session.queue/*.env in name order when no
|
||||
session.env is waiting.
|
||||
|
||||
## Lane K switches
|
||||
The default `pipelined` loop renders each set two refreshes ahead and ends every frame at SteamVR's
|
||||
wake-up with the newest finished set (else the retained layer), never waiting inside the cycle.
|
||||
KARTFRAME_PIPELINED=wait restores the old one-set-at-a-time loop. KARTFRAME_EYE_PAIRS defaults to 3 with
|
||||
sets ahead (3 to 8) and 2 with wait. KARTFRAME_AHEAD_START_US delays each set's start after the wake-up.
|
||||
KARTFRAME_END_DELAY_US (test only, default 0, max 8000) sleeps that long after the wake-up before the frame is ended (same
|
||||
set; the next set starts at the wake-up, before the sleep); the XRF e= field and end_late= then show the lateness by design.
|
||||
The pacing line ends with dropped= (finished sets released unshown) and end_late= (ends more than 1 ms
|
||||
after the wake-up). Mock: MOCK_XR_LATE_END_MS (1.5) models late ends; KF_MOCKRUN_END_GATE=<percent>
|
||||
fails a run with more late ends. Details: frame/perf/H7.md.
|
||||
|
||||
## Lane L and M switches
|
||||
KARTFRAME_FRAME_LOG=1 now also writes an XRS line per set (the scene state each eye's draws used:
|
||||
weight, game-frame pair, draw counts; the mock compares the eyes) and an XRE line per locate (each
|
||||
eye's offset from the head and its change since the last locate), plus a per-second "vr eyes" summary.
|
||||
KARTFRAME_EYE_OFFSETS=located|fixed (default located): fixed renders and submits each eye at the head
|
||||
pose with the median eye-to-head offset and field of view of the first 120 focused locates, so
|
||||
per-refresh eye motion from the runtime cannot reach the images. KARTFRAME_AHEAD_REFRESHES=1|2
|
||||
(default 2): locate sets one refresh ahead (sets then do not fit a refresh in races: about half late).
|
||||
Session plan: frame/perf/session-h9/README.md.
|
||||
|
||||
## Lane O switches (the hand-over GalaxyFrame's way)
|
||||
KARTFRAME_XR_FORMAT=rgba|bgra (default rgba: eye swapchains VkFormat 43, the sRGB draw swizzles),
|
||||
KARTFRAME_XR_USAGE=galaxy|kart (default galaxy: COLOR_ATTACHMENT | SAMPLED), KARTFRAME_END_LOCATE=1|0
|
||||
(default 1: one more locate for the frame's own time before ending it, race frames), and
|
||||
KARTFRAME_XR_EXTENSIONS=galaxy (GalaxyFrame's extension list: drops hand steering, other headsets'
|
||||
profiles and the clock conversion; default stays KartFrame's). Mock: MOCK_XR_TRACE_DETAIL=1.
|
||||
Comparison and remaining candidates in frame/perf/H9.md.
|
||||
|
||||
## Lane P instrumentation
|
||||
With KARTFRAME_FRAME_LOG=1 the XRS line carries XRP fields (per eye: the packet used, the pose
|
||||
written, the frustum, draws filled from another packet; same=1 when the eyes agree), "VR eye poses
|
||||
(10 s)" and "VR eye uniform probe (10 s)" summaries (KARTFRAME_UNIFORM_PROBE=1 for the probe alone);
|
||||
the stamp is a 64x15 one-pixel-per-bit block; the mock reads back and decodes before its xrWaitFrame
|
||||
sleep so end-after-wake-up is not inflated. In the mock both eyes always used the same packet and pose.
|
||||
|
||||
## Lane Q switches
|
||||
KARTFRAME_RACE_QUAD=1|cycle|0 (default 1: a transparent 16x16 quad listed after every projection-only
|
||||
layer; cycle also acquires and releases one of its images every frame), KARTFRAME_FIRST_WRITE_BARRIER=
|
||||
all|dawn (default all: GalaxyFrame's ALL_COMMANDS / MEMORY_WRITE to MEMORY_READ|WRITE barrier on every
|
||||
image of a set before its writes, submitted by the bridge). XRF lines end in n=<layers>; xrmock prints
|
||||
"layer lists per xrEndFrame". Plan: frame/perf/session-h11/README.md.
|
||||
|
||||
## Lane R switches (the pose check, and the two levers)
|
||||
GalaxyFrame's pose check (notePoses) runs at every xrEndFrame with located views: lane O's end locate
|
||||
(one xrLocateViews for the frame's own display time, in the layer's space, after the release) is now
|
||||
that check's locate, made in race and menu frames alike (lane O: race only); KARTFRAME_END_LOCATE=0
|
||||
still leaves it out. Every 5 s, and when the scene changes, in GalaxyFrame's words plus the scene and
|
||||
the locate's own cost: `vr: views of N sets: M for the display time they went out at (off X ms avg, Y
|
||||
max); against a locate then: left eye A mm B deg avg, C mm D deg max; right eye ...; eye distance
|
||||
E-F mm; race|menus, S s; that locate G ms avg H max` (frames that show a new set, each once, as
|
||||
GalaxyFrame counts them). `vr: poses predicted ahead (N sets, S s, race|menus): display time minus the
|
||||
set's locate p50 X ms, max Y` (every set, at its start; sets ahead: two refreshes plus SteamVR's lead).
|
||||
XRF lines end in aL=/aR= (degrees between each eye's rendered pose and the check's locate, every frame;
|
||||
-1 not checked). session-report.py shows both per scene. KARTFRAME_AHEAD_REFRESHES=1 now starts the
|
||||
next set at the wake-up before the frame is ended (GalaxyFrame's order) without waiting for Aurora
|
||||
(a set still being encoded: the start waits after the end as before), with 4 eye pairs by default.
|
||||
KARTFRAME_SUBMIT_POSE=fresh|rendered (default rendered): fresh submits race frames that show a new set
|
||||
with the check's poses (the images stay as rendered; repeats of the retained layer keep their own).
|
||||
Mock: MOCK_XR_STAMP=fresh checks such layers (poses from a locate for their own display time; each
|
||||
image against the locate of its own stamp: "fresh-pose stamps"). Plan: frame/perf/session-h12/README.md.
|
||||
|
||||
## Lane S switches (mock only)
|
||||
MOCK_XR_SWAY=<deg>,<period s>[,<from s>] sizes the head's sway (default 17.19,2) and holds the head still
|
||||
until <from>. MOCK_XR_PRESS=<path>@<start>/<every>/<hold s>/<until s> holds a press longer (1000/1000:
|
||||
held from <start> on, e.g. A to drive) and stops it at <until>. MOCK_XR_DUMP_QUEUE=<slots> dumps eye
|
||||
images without stalling (blitted down to 1/MOCK_XR_DUMP_STEP, written when the dumps pause for 0.3 s);
|
||||
every MOCK_XR_DUMP writes <dir>/index.txt (file, swapchain, frame, and with MOCK_XR_STAMP the eye, the
|
||||
display time and the scene words). frame/perf/mockdump/dumprace.sh NAME records 240-set windows of a race
|
||||
(head still, standing with the sway, driving); frame/perf/mockdump/zigzag.py DIR applies H7's zigzag
|
||||
rule, a betweenness test and a lagdiff per eye (ZZ_INJECT=2: positive control, ZZ_TRACK=1: positions).
|
||||
Results: frame/perf/LANE_S.md; next: frame/perf/session-h13/README.md.
|
||||
|
||||
## Lane T (session 13: the recorded race images through xrprobe)
|
||||
`frame/tools/xrprobe/xrprobe.c --replay DIR` plays a dumprace.sh recording's eye images at their recorded
|
||||
poses and fov (one set per refresh by display time; `--replay-window standing|driving`, `--replay-offset`,
|
||||
`--replay-pairs 3` KartFrame's sets ahead, `--replay-quad` the race quad, `--replay-hold 2`). Build:
|
||||
`cmake -S frame/tools/xrprobe -B ~/.cache/kf-xrprobe -G Ninja -DCMAKE_C_COMPILER=clang` (OpenXR headers from
|
||||
GalaxyFrame's tree, or -DOPENXR_INCLUDE=...), then the build under the KartFrame lock. `replay-case.sh N`
|
||||
runs case N (1..6); `frame/perf/session-h13/queue.sh BINARY RECORDING` queues the six as KartFrame launches
|
||||
(KARTFRAME_EXE in a session.queue file, recorded). Headless: `PROBE_MOCK=1 PROBE_SET=replay run-probes.sh`
|
||||
(the mock runtime, detailed trace per case), then `frame/perf/session-h13/replaycheck.py DIR`.
|
||||
|
||||
## Lane W (the compositor's phase beside the app's timeline)
|
||||
KARTFRAME_VRTIMING_FRAMES=1 (wrapper session.env) makes frame/tools/vrtiming.py also write vrframes.log in the
|
||||
session folder: one F line per SteamVR compositor frame (index, m_flSystemTimeInSeconds, presents, mispresented,
|
||||
dropped, reprojection flags, then waitgetposes / posesready / frameready / update start / update end / render
|
||||
start in ms after that frame's system time, compositor cpu and gpu ms), a P line per poll (CLOCK_MONOTONIC,
|
||||
_RAW, BOOTTIME read around IVRSystem::GetTimeSinceLastVsync, its counter, the newest frame), G lines for frames
|
||||
missed between polls (polls every 0.25 s, 128 frames kept by SteamVR: none expected). About 45 MB an hour.
|
||||
XRF lines now end in m=<wake-up, CLOCK_MONOTONIC ms> r=<release after the wake-up, ms, -1 none> t=<predicted
|
||||
display time, CLOCK_MONOTONIC ms via XR_KHR_convert_timespec_time>; the end is m + e. One "XRF clock:" line
|
||||
says how t= was converted. `python3 -I frame/tools/phase.py SESSION_DIR...` prints, per launch, (A) SteamVR's
|
||||
own app timings against its vsync reference (frame ready, poses ready, the margin to the compositor's update
|
||||
start) and (B) the app's wake-up, end, release and display time minus the nearest compositor anchor (vsync,
|
||||
update start, render start), with histograms in 0.25 ms bins and a drift table (--every S, --all, --hist all).
|
||||
Headless check of the logger: a stub libopenvr_api.so via KARTFRAME_STEAMVR_DIR (frame/perf/LANE_W.md).
|
||||
The lock design and its risks: frame/perf/LANE_W.md; next session: frame/perf/session-h19/README.md.
|
||||
|
||||
## Lane X (the end locked to the compositor's grid, test only)
|
||||
KARTFRAME_END_PHASE=after[:us]|before[:us] (default off; in the wrapper's session.env, which also reaches
|
||||
frame/tools/vrtiming.py) times each frame's end against SteamVR's compositor instead of the wake-up: the helper polls
|
||||
every 0.1 s and publishes the newest vsync (CLOCK_MONOTONIC), the period and the update window's start p5/p50 and end
|
||||
p50/p95 as a 64-byte record (atomic rename) at KARTFRAME_PHASE_FILE (default $XDG_RUNTIME_DIR/kartframe-phase.bin);
|
||||
the pacing thread re-reads it at most every 50 ms (about 1 us a read) and ends at vsync + update end p95 + us (after,
|
||||
300) or vsync + update start p50 - us (before, 1500), bounded by the cycle's deadline, keeping lane V's order (next
|
||||
set started at the wake-up, 4 pairs). No fresh grid (missing, older than 0.5 s, period 1 % off): KARTFRAME_END_DELAY_US,
|
||||
or 4000 us. Log lines "end phase:" (mode, every switch, the achieved phase every 10 s). phase.py view C classifies
|
||||
every end against its refresh's update window (before / inside / after, SteamVR's presents per class) and view D
|
||||
prints the game's lines. Mock: mockrun.sh runs the helper against frame/tools/openvr-stub.c (STUB_UPDATE_MS,
|
||||
KF_MOCKRUN_STUB_UPDATE_MS, KF_MOCKRUN_PHASE_SECONDS) with MOCK_XR_ALIGN_US. Analysis: frame/perf/LANE_X.md; next
|
||||
session: frame/perf/session-h20/README.md.
|
||||
|
||||
## Wrapper recorder (KARTFRAME_RECORD)
|
||||
The recorder starts 8 s after the game and only if the game is still alive then; it runs in its own
|
||||
session (setsid) and the wrapper stops it on every exit path (game exit, Steam's TERM/INT/HUP): SIGINT
|
||||
and up to 30 s for the file, then TERM, then KILL, then a pkill on anything writing into the session's
|
||||
panels.mkv. The log says "recorder stopped" or "recorder was never started"; a leftover
|
||||
panels.mkv.part is logged with its size and left for the owner. frame/test-wrapper-recorder.sh checks
|
||||
the four paths with a fake game and a fake framecorder (about 45 s).
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
# Game kit, first-run setup and toolchain: developer notes
|
||||
|
||||
Nothing disc-derived is ever committed, put in the kit, or released. What ships is a **kit**; the
|
||||
game itself is built on the user's Frame from the user's own disc. Timings are from the Frame
|
||||
(5 CPUs, 15 GB, page cache warm).
|
||||
|
||||
## What a user runs
|
||||
|
||||
`frame/setup.sh <iso>` (the app dir's `setup.sh`), once, idempotent and resumable. It needs only the
|
||||
stock system tools (bash, coreutils, awk, sed, grep, sha256sum, curl for nodtool) plus what ships
|
||||
with the release: `kit/`, `toolchain/`, `translator/`. No system compiler, cmake, ninja or python is used.
|
||||
|
||||
| Step | What | Time (scratch run, 5 CPUs) |
|
||||
| --- | --- | --- |
|
||||
| verify kit | `kit.json` fingerprint recomputed, every kit file checked against `kit.sha256` | under 1 s |
|
||||
| extract | nodtool checks RMCP01 and the `main.dol` / `StaticR.rel` pins, extracts to `$KARTFRAME_HOME/game` | 2 s warm (a minute or two cold) |
|
||||
| translate | translator -> `$KARTFRAME_HOME/build/{generated,build}` | 49 s |
|
||||
| build | `kitbuild.cmake` writes `build/kit-build/build.ninja`; the toolchain's ninja compiles 72 translated + 17 product shards, 2 data sources, 1 assembly (3 parallel) and links `kartframe` | 305 s (PCH 2x, shards, link 14 s) |
|
||||
| install | `bin/kartframe`, resources beside it, `kartframe.fingerprint`, `kartframe.build.txt`, `portable.txt`, `UserData/Config.toml` if missing | under 1 s |
|
||||
| **total** | | **356 s** (about 6 minutes; the 20 minute budget has room for slower disks and a cold extract) |
|
||||
|
||||
A second run does nothing (0.3 s). Deleting objects or the binary and rerunning rebuilds only those
|
||||
(ninja); the relinked binary is byte-identical (sha256 equal), the build is reproducible.
|
||||
`setup.sh status` shows what is done. `--force` redoes everything.
|
||||
|
||||
Layout (`LANE_RULES.md` "Install layout"): `$KARTFRAME_HOME` (default `~/.local/share/KartFrame`)
|
||||
holds `portable.txt`, `UserData/`, `game/`, `build/`, `bin/`. `portable.txt` is found from the
|
||||
executable's directory (`bin/`) one level up, so the game's `ApplicationDataDirectory()` is
|
||||
`$KARTFRAME_HOME/UserData` (Config.toml, NAND, Cache, Logs; checked in the acceptance run: the
|
||||
game wrote there and `~/.local/share/WiiCompiled` was not created). `Config.toml` is seeded only if
|
||||
missing: from `Config.frame.toml` (token `@DVD_ROOT@` -> `$KARTFRAME_HOME/game/DATA`), else just
|
||||
`[paths] dvd_root`.
|
||||
|
||||
Environment: `KARTFRAME_HOME`, `KARTFRAME_KIT` (default `<app>/kit`), `KARTFRAME_TOOLCHAIN`
|
||||
(default `<app>/toolchain`, then the cache, then `toolchain.sh` makes it), `KARTFRAME_TRANSLATOR`,
|
||||
`KARTFRAME_CACHE`, `KARTFRAME_JOBS` (default 3, fewer if RAM is short: about 2.5 GB per compile),
|
||||
`KARTFRAME_THREADS`.
|
||||
|
||||
The launch wrapper compares `<app>/kit/kit.json` `"fingerprint"` with `bin/kartframe.fingerprint`
|
||||
(plain fingerprint string) and refuses to start on a mismatch; rerunning setup rebuilds against the
|
||||
new kit (the translation is redone too, because it depends on the kit's runtime sources).
|
||||
|
||||
## The kit (`frame/make-kit.sh <dir>` then `frame/export-kit.py`, maintainer only)
|
||||
|
||||
`make-kit.sh` configures `build-kit` with the portable toolchain, builds the target
|
||||
`kartframe_kit_probe` (WiiCompiled without any translated code, `runtime/cmake/PublicProducts.cmake`),
|
||||
and runs `export-kit.py`, which reads everything from the real build graph (`ninja -t commands`,
|
||||
`-t compdb`) instead of hand-written flags:
|
||||
|
||||
```
|
||||
kit/kit.json compile flags per source kind (translated shards, product/registration shards,
|
||||
runtime data sources, blob assembly), source slots (which translator list fills
|
||||
which slot), link recipe with {kit}/{sysroot}/{workspace}, fingerprint
|
||||
kit/kitbuild.cmake the first-run build: writes build.ninja from kit.json + the translator's shard lists
|
||||
kit/objects, libs prebuilt runtime objects; aurora, Dawn (patched, static), SDL3, OpenXR loader,
|
||||
abseil, zlib, libpng, freetype, fmt, imgui, zstd, sqlite, tracy ...: all static
|
||||
kit/include runtime/include (everything the translated code includes: only this, checked)
|
||||
kit/sysroot see below
|
||||
kit/workspace projects/mkwii/{recomp.yml,MAP.txt} and runtime/src: the translator reads them
|
||||
(native overrides), so the translation always matches the kit's runtime
|
||||
kit/resources dsp_coef.bin, initial_pipeline_cache.db, wii_bootstrap/, resources/quest_touch_plus/:
|
||||
copied beside the executable (taken from the product's own POST_BUILD copies)
|
||||
kit/licenses repo and third-party licence texts
|
||||
kit/kit.sha256 sha256 of every other file; fingerprint = sha256(sha256(kit.json without its
|
||||
fingerprint line) + sha256(kit.sha256))
|
||||
```
|
||||
|
||||
Resources, none disc-derived: `dsp_coef.bin` (4 KiB DSP coefficient ROM, already in the repo, hash
|
||||
pinned in CMake), `initial_pipeline_cache.db` (shader recipe database), `wii_bootstrap/` (first-run
|
||||
WiiConnect24 stubs), Touch Plus models (MIT, WebXR input profiles).
|
||||
|
||||
`export-kit.py` fails when a link input is outside the build or the Dawn package, is not a static
|
||||
archive, or a `-l` library is outside the glibc stub set; it then compiles one sample of every
|
||||
source kind with exactly the recorded flags and the kit's sysroot and fails if a header outside the
|
||||
kit, sysroot, compiler resource dir or the sample's own directory is read.
|
||||
`export-kit.py` needs python3 (maintainer machine only). Compile decisions worth knowing:
|
||||
`-mcpu=native` is kept as in the build (all Frames are the same SoC; it is resolved on the user's
|
||||
machine); PCHs of `mkw_pch.h` are made at setup (a PCH only works with the compiler that wrote it);
|
||||
the translated slot links as `--start-lib ... --end-lib`, like the Quest kit.
|
||||
|
||||
Sizes: kit 184 MiB unpacked (libs 74, objects 18, resources 40, sysroot 55), 23.8 MiB as `.tar.xz`;
|
||||
toolchain 581 MiB unpacked, 113 MiB as `.tar.xz`; sysroot 55 MiB (2306 files, included in the kit).
|
||||
|
||||
## Self-contained runtime: DT_NEEDED of `kartframe`
|
||||
|
||||
Rule: packages installed after the OS base image (`filesystem`, `glibc` and most others: 2026-09-15;
|
||||
the owner's hand-installed dev packages: 2026-10-07) are not stock SteamOS. `pacman -Qo` / `-Qi`:
|
||||
|
||||
| DT_NEEDED (dev build, lane 0) | Package | Installed | Decision |
|
||||
| --- | --- | --- | --- |
|
||||
| libpng16.so.16 | libpng 1.6.43 | 10-07, explicit | static: aurora's pinned libpng 1.6.58 from source |
|
||||
| libabsl_*.so.2508 (38 libs) | abseil-cpp 20250814 | 10-07 | static: aurora's pinned abseil 20240722.0 from source |
|
||||
| libfreetype.so.6 | freetype2 2.13.2 | 10-07 | static: pinned freetype 2.14.3, harfbuzz / brotli / bzip2 / png off |
|
||||
| (via freetype) libharfbuzz, libgraphite2 | harfbuzz 8.4.0, graphite | 10-07 | gone with the static freetype |
|
||||
| (via freetype) libglib-2.0, libpcre2-8, libbrotli*, libbz2 | glib2, pcre2, brotli, bzip2 | 09-15 | gone (stock anyway) |
|
||||
| libz.so.1 | zlib 1.3.1 | 09-15, stock | static anyway (libpng needs it; zlib 1.3.2 pinned source) |
|
||||
| libm.so.6, libc.so.6, ld-linux-aarch64.so.1 | glibc 2.39 | 09-15, stock | dynamic |
|
||||
| libstdc++.so.6, libgcc_s.so.1 | gcc-libs 15.1.1 | 09-15, stock | dynamic |
|
||||
|
||||
The switch is `AURORA_VENDOR_THIRD_PARTY` (aurora-main/extern/CMakeLists.txt), on by default on
|
||||
Linux. Result, `readelf -d` / `ldd` of the binary built by `setup.sh`: `libm`, `libstdc++`,
|
||||
`libgcc_s`, `libc`, `ld-linux-aarch64` only. Libraries the program loads at run time with `dlopen`
|
||||
(SDL3's ALSA / PulseAudio / PipeWire / JACK / X11 / Wayland / DRM drivers, libdbus for the music
|
||||
attenuation, libvulkan through Dawn) are not link dependencies; their package dates here are
|
||||
10-07 (`alsa-lib`, `libx11`, `wayland`, `vulkan-headers` ...), which looks like owner installs or
|
||||
upgrades. They are optional for SDL (it falls back) and Valve's own SteamVR needs libvulkan, X11 or
|
||||
Wayland; not verified on a second Frame.
|
||||
|
||||
## Toolchain and sysroot
|
||||
|
||||
Toolchain: upstream's `Launcher/prepare-portable-tools.sh --arch aarch64` through `toolchain.sh`
|
||||
(patched LLVM 22.1.8: clang, clang++, ld.lld, llvm-ar, llvm-ranlib; CMake 4.3.3; Ninja 1.13.2; all
|
||||
sha256-pinned there). Fetched once (about 1.8 GB of downloads, 75 s) or shipped as
|
||||
`toolchain-aarch64.tar.xz`.
|
||||
|
||||
The portable LLVM has no sysroot. With no `--sysroot` it reads the host's `/usr/include`, its libstdc++
|
||||
headers (`/usr/include/c++/15.1.1`), `crt*.o`, libgcc and libs. Same package/date method:
|
||||
|
||||
| Used by the first-run build | Package | Installed | Stock? |
|
||||
| --- | --- | --- | --- |
|
||||
| glibc headers, `Scrt1.o crti.o crtn.o`, `libc.so`, `libm.so`, `libc_nonshared.a` | glibc 2.39 | 09-15 | yes |
|
||||
| `libstdc++.so`, `libgcc_s.so` | gcc-libs 15.1.1 | 09-15 | yes |
|
||||
| libstdc++ headers `/usr/include/c++/15.1.1` | gcc 15.1.1 | 10-07 | **no** (dev package) |
|
||||
| `crtbeginS.o crtendS.o libgcc.a libgcc_eh.a` | gcc 15.1.1 | 10-07 | **no** |
|
||||
| kernel headers (`linux/`, `asm/`, ...) | linux-api-headers 6.18 | 10-07 | **no** (dated after the image) |
|
||||
|
||||
So the kit carries a minimal sysroot (`frame/make-sysroot.sh`, 55 MiB, 2306 files) built from this
|
||||
Frame's own packages, unmodified: glibc headers and crt objects, libc/libm/libstdc++/libgcc_s and
|
||||
the dynamic loader (link-time copies, the binary still loads the user's own), libstdc++ headers,
|
||||
kernel headers, `libgcc.a`, `libgcc_eh.a`, `crtbegin*/crtend*`. Licences are in
|
||||
`sysroot/LICENSES` (glibc LGPL-2.1+, GCC GPL-3 with the Runtime Library Exception, kernel headers
|
||||
GPL-2 with the syscall note). Every compile and the link use `--sysroot={sysroot}`; checked: the
|
||||
include search list and `-H` output of a shard compile, and `-Wl,-t` of the link, name only files
|
||||
under the kit sysroot, the toolchain's own resource dir (`stddef.h` and friends), and the kit.
|
||||
Objects of the kit itself were built without `--sysroot` (SDL3 needs the host's X11/Wayland/ALSA
|
||||
headers at configure time); the sysroot is a copy of the same files, so they are what it would produce.
|
||||
|
||||
## Extraction, translation (unchanged from phase 1)
|
||||
|
||||
- Disc: PAL `RMCP01`; `setup.sh` reads the game id with `nodtool info`, then checks `main.dol` and
|
||||
`StaticR.rel` against the sha256 pins in `recomp.yml`. nodtool `v2.0.0-alpha.10` (the version
|
||||
upstream pins), linux-aarch64 asset, sha256 `b0d94617ed2393334845669cc1f74f1e4fce0d6acb24e8be28c479689a1b907b`,
|
||||
cached in `~/.cache/kartframe/nodtool/<version>/`.
|
||||
- Extracted layout (as upstream's `DiscTool.cs`): `main.dol`, `StaticR.rel` (translator inputs),
|
||||
`DATA/` (the runtime's `[paths] dvd_root`), `.complete` (written last).
|
||||
- Translator: `frame/publish-translator.sh <outdir>` (maintainer, once, .NET 8 SDK in
|
||||
`~/.local/opt/dotnet`) makes a 34 MiB self-contained single-file `Translator.Cli`.
|
||||
- `setup.sh translate` is the base-product sequence of `Launcher/local-build.sh`: `translate-recursive`,
|
||||
`emit-base-manifest`, `generate-data-init --target-os linux`, `emit-build-shards`. `<outdir>` is a
|
||||
workspace root (`projects`, `Assets`, `runtime` are links; here `projects` and `runtime` point at the
|
||||
kit's `workspace/`, in a source tree at the repo). Compared with the Windows installer's translation,
|
||||
`generated/` is byte-identical except the blob assembly's section directive and absolute paths.
|
||||
- Boundary check: `frame/boundary-check.sh <dir|tarball> [allowlist]` fails on disc-derived file types,
|
||||
translator output names and directories, and files over 1 MB that `frame/kit-allowlist.txt` (globs)
|
||||
does not allow. `frame/tools/privacy-check.sh <dir|tarball>` needs these entries in
|
||||
`frame/tools/privacy-allow.txt` for the kit's third-party headers and licence texts (false positives:
|
||||
author addresses in glibc and kernel headers, example IPs): `^(kit/)?sysroot/usr/include/`,
|
||||
`^(kit/)?licenses/third-party/`, `^(kit/)?workspace/runtime/src/hle/net/network_socket\.cpp$`, each with
|
||||
text regex `.*`. With them: 0 findings on the kit dir and the tarball.
|
||||
@@ -1,21 +0,0 @@
|
||||
# Built but not confirmed in the headset
|
||||
|
||||
One line per claim. When the owner has tested one, delete its line here and complete the text in README.md
|
||||
and the release notes. Twenty headset sessions have confirmed most of the build; what is left:
|
||||
|
||||
## Rendering
|
||||
|
||||
- The item box (roulette) drawing in the right place during a race (the clip fix is in; the demo race has no HUD).
|
||||
- Full-view 2D effects (Blooper ink, the black of a respawn) across the whole view. Deferred.
|
||||
- The Time Trial ghost drawn in 3D (it is flat today, a known issue).
|
||||
- Two and four player races at 120 Hz (heavier; measured in the mock only: 2p 113 sets/s, 6 % late).
|
||||
|
||||
## Screen and menus
|
||||
|
||||
- The laser pointer's dot on the 2D menu screen (the ray shows, the dot on the screen does not yet).
|
||||
- hud_distance_meters and hud_width_meters away from their defaults.
|
||||
|
||||
## Install and first run
|
||||
|
||||
- setup.sh from the release tarball on a clean Frame, start to finish, by someone other than the owner.
|
||||
- uninstall.sh on a real install.
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fail if a directory or tarball holds anything derived from the game disc.
|
||||
#
|
||||
# boundary-check.sh <dir|.tar|.tar.gz|.tgz|.tar.xz> [allowlist-file]
|
||||
#
|
||||
# The allowlist holds one shell glob per line (relative path as listed in the tree/tarball, without
|
||||
# any leading "./" or top-level folder; * also matches "/") for files over 1 MB that are allowed
|
||||
# (the kit's libraries). frame/kit-allowlist.txt is the kit's. Default: that file when it exists.
|
||||
set -euo pipefail
|
||||
|
||||
[[ $# -ge 1 ]] || { echo "usage: boundary-check.sh <dir|tarball> [allowlist]" >&2; exit 2; }
|
||||
input=$1 allow=${2:-$(dirname "${BASH_SOURCE[0]}")/kit-allowlist.txt}
|
||||
[[ -f $allow ]] || allow=/dev/null
|
||||
work=
|
||||
if [[ -d $input ]]; then
|
||||
root=$input
|
||||
else
|
||||
work=$(mktemp -d "${TMPDIR:-$HOME/.cache}/kf-boundary.XXXXXX")
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
tar -xf "$input" -C "$work"
|
||||
root=$work
|
||||
fi
|
||||
|
||||
# A path (or the path without its top-level folder, for tarballs) matching a glob of the allowlist.
|
||||
allowed() {
|
||||
local rel=$1 pat
|
||||
while IFS= read -r pat; do
|
||||
[[ -z $pat || $pat == \#* ]] && continue
|
||||
# shellcheck disable=SC2053
|
||||
[[ $rel == $pat || ${rel#*/} == $pat ]] && return 0
|
||||
done < "$allow"
|
||||
return 1
|
||||
}
|
||||
|
||||
bad=0
|
||||
flag() { echo "boundary-check: $1: $2" >&2; bad=1; }
|
||||
|
||||
while IFS= read -r -d '' f; do
|
||||
rel=${f#"$root"/}
|
||||
base=${rel##*/}; lower=${base,,}
|
||||
case $lower in
|
||||
*.dol|*.rel|*.szs|*.brres|*.brstm|*.thp|*.arc|*.u8|*.iso|*.wbfs|*.rvz|*.ciso|*.wia|*.bin.lz|rksys.dat|game.json|*game*.json)
|
||||
flag "disc-derived file type" "$rel" ;;
|
||||
data_sections_init*|base_translation_*|mkwii_base_manifest*|*_translation_sources.bin|translation-provenance*)
|
||||
flag "translator output" "$rel" ;;
|
||||
esac
|
||||
# Anything under generated/ or functions/, or a translated shard.
|
||||
if [[ /$rel == */generated/* || /$rel == */functions/* || /$rel == */build_shards/* ]]; then
|
||||
flag "translator output directory" "$rel"
|
||||
fi
|
||||
size=$(stat -c %s "$f")
|
||||
if (( size > 1048576 )) && ! allowed "$rel"; then
|
||||
flag "over 1 MB and not allowlisted" "$rel"
|
||||
fi
|
||||
done < <(find "$root" \( -type f -o -type l \) -print0)
|
||||
|
||||
if (( bad )); then echo "boundary-check: FAILED" >&2; exit 1; fi
|
||||
echo "boundary-check: ok ($input)"
|
||||
@@ -1,133 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Builds Aurora's patched Dawn for linux-aarch64 (static, monolithic, Vulkan + Null) and installs it
|
||||
# as a package that aurora's Dawn provider consumes (AURORA_DAWN_PACKAGE_URL = package directory).
|
||||
#
|
||||
# frame/build-dawn.sh [--force]
|
||||
#
|
||||
# Same pinned Dawn revision, patches and CMake flags as android/Build-QuestDawn.ps1, minus the Android
|
||||
# ones. The package lands in $KARTFRAME_CACHE/dawn/<rev>-<patch hash 12>/ (default
|
||||
# ~/.cache/kartframe) with aurora-dawn.json, and is reused when the inputs match. Heavy: holds the
|
||||
# shared build lock (~/Games/KartFrame/.build.lock when present), -j4, nice 10.
|
||||
set -euo pipefail
|
||||
|
||||
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO=$(cd "$HERE/.." && pwd)
|
||||
CACHE=${KARTFRAME_CACHE:-$HOME/.cache/kartframe}
|
||||
JOBS=${JOBS:-4}
|
||||
FORCE=0; [ "${1:-}" = "--force" ] && FORCE=1
|
||||
|
||||
REVISION=13abc3bc8ea2d3c2050f9e77a12d012108ceee24
|
||||
ARCHIVE_SHA256=713bea5b92d4f6c5175752fd7cbf1c3c5ce36598ff5dd98685d8a1216614ebba
|
||||
PATCH_DIR=$REPO/aurora-main/patches/dawn
|
||||
|
||||
FLAGS=(
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
|
||||
-DDAWN_FETCH_DEPENDENCIES=ON
|
||||
-DDAWN_BUILD_MONOLITHIC_LIBRARY=STATIC
|
||||
-DBUILD_SHARED_LIBS=OFF
|
||||
-DDAWN_ENABLE_INSTALL=ON
|
||||
-DDAWN_BUILD_SAMPLES=OFF
|
||||
-DDAWN_BUILD_TESTS=OFF
|
||||
-DDAWN_BUILD_BENCHMARKS=OFF
|
||||
-DDAWN_USE_GLFW=OFF
|
||||
-DDAWN_BUILD_PROTOBUF=OFF
|
||||
-DTINT_BUILD_TESTS=OFF
|
||||
-DTINT_BUILD_CMD_TOOLS=OFF
|
||||
-DTINT_BUILD_IR_BINARY=OFF
|
||||
-DDAWN_ENABLE_VULKAN=ON
|
||||
-DDAWN_ENABLE_NULL=ON
|
||||
-DDAWN_ENABLE_DESKTOP_GL=OFF
|
||||
-DDAWN_ENABLE_OPENGLES=OFF
|
||||
# The flat mirror window needs a surface: keep the window system backends (headers are in SteamOS).
|
||||
-DDAWN_USE_X11=ON
|
||||
-DDAWN_USE_WAYLAND=ON
|
||||
)
|
||||
|
||||
# Patch hash, as Build-QuestDawn.ps1: file name + LF-normalised text of every patch input.
|
||||
PATCH_HASH=$(python3 -I - "$PATCH_DIR" "$REPO" <<'PY'
|
||||
import hashlib, sys
|
||||
patch, repo = sys.argv[1], sys.argv[2]
|
||||
files = [patch + "/apply.py", patch + "/aurora_vulkan_hooks.h", patch + "/aurora_vulkan_interop.inc",
|
||||
patch + "/aurora_fdm.h", patch + "/aurora_fdm.inc",
|
||||
repo + "/aurora-main/include/aurora/dawn_vulkan_abi.h",
|
||||
repo + "/aurora-main/include/aurora/dawn_fdm_abi.h"]
|
||||
parts = []
|
||||
for f in files:
|
||||
text = open(f, encoding="utf-8", newline="").read().replace("\r\n", "\n")
|
||||
parts.append(f.rsplit("/", 1)[1] + "\n" + text)
|
||||
print(hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest())
|
||||
PY
|
||||
)
|
||||
CC_ID=$(clang --version | head -1)
|
||||
CACHE_KEY=$(printf '%s' "$REVISION|$ARCHIVE_SHA256|$PATCH_HASH|$CC_ID|${FLAGS[*]}" | sha256sum | cut -d' ' -f1)
|
||||
PACKAGE=$CACHE/dawn/$REVISION-${PATCH_HASH:0:12}
|
||||
LIB=$PACKAGE/lib/libwebgpu_dawn.a
|
||||
|
||||
if [ "$FORCE" = 0 ] && [ -f "$PACKAGE/aurora-dawn.json" ] && [ -f "$LIB" ] &&
|
||||
grep -q "\"CacheKey\": \"$CACHE_KEY\"" "$PACKAGE/aurora-dawn.json"; then
|
||||
echo "Patched Dawn is up to date: $PACKAGE"; exit 0
|
||||
fi
|
||||
|
||||
LOCK=${KARTFRAME_BUILD_LOCK:-$HOME/Games/KartFrame/.build.lock}
|
||||
mkdir -p "$(dirname "$LOCK")"
|
||||
exec 9>"$LOCK"; echo "build-dawn: waiting for the build lock"; flock 9; echo "build-dawn: building"
|
||||
|
||||
WORK=$CACHE/dawn-work
|
||||
mkdir -p "$WORK"
|
||||
ARCHIVE=$WORK/dawn-source.tar.gz
|
||||
if [ ! -f "$ARCHIVE" ]; then
|
||||
curl -fL --retry 3 -o "$ARCHIVE.part" "https://github.com/google/dawn/archive/$REVISION.tar.gz"
|
||||
mv "$ARCHIVE.part" "$ARCHIVE"
|
||||
fi
|
||||
echo "$ARCHIVE_SHA256 $ARCHIVE" | sha256sum -c - || { echo "Dawn archive does not match the pinned SHA-256" >&2; exit 1; }
|
||||
|
||||
# Patches go on pristine sources: src/ is extracted again when the patches changed; third_party/
|
||||
# (filled by DAWN_FETCH_DEPENDENCIES) is kept.
|
||||
SRC=$WORK/dawn-$REVISION
|
||||
MARKER=$SRC/aurora-patches.sha256
|
||||
if [ ! -f "$MARKER" ] || [ "$(cat "$MARKER")" != "$PATCH_HASH" ]; then
|
||||
if [ -d "$SRC" ]; then
|
||||
rm -rf "$SRC/src"
|
||||
python3 -I -c 'import sys,tarfile
|
||||
a,d=sys.argv[1:3]
|
||||
with tarfile.open(a) as t:
|
||||
m=[x for x in t.getmembers() if x.name.split("/",2)[1:2]==["src"]]
|
||||
t.extractall(d,members=m,filter="data")' "$ARCHIVE" "$WORK"
|
||||
else
|
||||
python3 -I -c 'import sys,tarfile
|
||||
a,d=sys.argv[1:3]
|
||||
with tarfile.open(a) as t: t.extractall(d,filter="data")' "$ARCHIVE" "$WORK"
|
||||
fi
|
||||
python3 -I "$PATCH_DIR/apply.py" "$SRC"
|
||||
echo -n "$PATCH_HASH" > "$MARKER"
|
||||
fi
|
||||
|
||||
BUILD=$WORK/build-$CACHE_KEY
|
||||
export CCACHE_BASEDIR=$HOME CCACHE_DIR=${CCACHE_DIR:-$HOME/.cache/ccache-kf}
|
||||
L=(); command -v ccache >/dev/null && L=(-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache)
|
||||
cmake -S "$SRC" -B "$BUILD" -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
|
||||
-DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld -DCMAKE_SHARED_LINKER_FLAGS=-fuse-ld=lld \
|
||||
-DPython3_EXECUTABLE="$(command -v python3)" "${L[@]}" "${FLAGS[@]}" -DCMAKE_INSTALL_PREFIX="$PACKAGE"
|
||||
rm -rf "$PACKAGE"
|
||||
nice -n 10 cmake --build "$BUILD" --parallel "$JOBS"
|
||||
cmake --install "$BUILD"
|
||||
[ -f "$PACKAGE/lib/libwebgpu_dawn.a" ] || { echo "Dawn archive missing" >&2; exit 1; }
|
||||
command -v llvm-strip >/dev/null && llvm-strip --strip-debug "$PACKAGE/lib/libwebgpu_dawn.a"
|
||||
|
||||
LIB_SHA=$(sha256sum "$PACKAGE/lib/libwebgpu_dawn.a" | cut -d' ' -f1)
|
||||
cat > "$PACKAGE/aurora-dawn.json" <<JSON
|
||||
{
|
||||
"SourceRevision": "$REVISION",
|
||||
"SourceSha256": "$ARCHIVE_SHA256",
|
||||
"PatchSha256": "$PATCH_HASH",
|
||||
"AuroraVulkanAbi": 1,
|
||||
"AuroraFdmAbi": 1,
|
||||
"Target": "linux-aarch64",
|
||||
"Compiler": "$CC_ID",
|
||||
"Flags": "${FLAGS[*]}",
|
||||
"CacheKey": "$CACHE_KEY",
|
||||
"ArchiveSha256": "$LIB_SHA"
|
||||
}
|
||||
JSON
|
||||
echo "Patched Dawn ready: $PACKAGE"
|
||||
@@ -1,424 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export the KartFrame game kit from a configured and built build dir.
|
||||
|
||||
The kit is everything the first-run build links except the translated game, plus the recipe that
|
||||
compiles and links the player's own translation against it (modelled on upstream's Quest kit,
|
||||
android/QuestGameKit.psm1). Nothing in it is derived from the disc. Compile flags and the link
|
||||
line are read back out of the real CMake/ninja build graph (`ninja -t commands`, `-t compdb`), so
|
||||
they cannot drift from a normal build.
|
||||
|
||||
export-kit.py --build <build dir with kartframe_kit_probe built> --sysroot <make-sysroot.sh dir>
|
||||
--out <kit dir>
|
||||
|
||||
Placeholders in kit.json: {kit} the kit directory, {sysroot} the kit's sysroot, {workspace} the
|
||||
directory that holds the translator's generated/ tree. frame/make-kit.sh drives this.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = 1
|
||||
ALLOWED_SYSTEM_LIBS = {"-ldl", "-lrt", "-lm", "-lpthread", "-lutil", "-pthread"} # glibc stubs, stock
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def die(msg):
|
||||
sys.exit(f"export-kit: error: {msg}")
|
||||
|
||||
|
||||
def run(cmd, **kw):
|
||||
return subprocess.run(cmd, check=True, text=True, capture_output=True, **kw).stdout
|
||||
|
||||
|
||||
def sha256_file(p):
|
||||
h = hashlib.sha256()
|
||||
with open(p, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def cache_var(build, name):
|
||||
for line in (build / "CMakeCache.txt").read_text().splitlines():
|
||||
if line.startswith(name + ":"):
|
||||
return line.split("=", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def ninja_commands(build, target):
|
||||
return run(["ninja", "-C", str(build), "-t", "commands", target]).splitlines()
|
||||
|
||||
|
||||
# ---- compile flags -------------------------------------------------------------------------
|
||||
|
||||
def reduce_flags(tokens, repo, kit_include_src, dropped):
|
||||
"""Compile command tokens (compiler first) -> (kit flags, uses_pch)."""
|
||||
i = 1
|
||||
if tokens[0].endswith("ccache"):
|
||||
i = 2
|
||||
flags, pch = [], False
|
||||
toks = tokens
|
||||
while i < len(toks):
|
||||
a = toks[i]
|
||||
if a in ("-o", "-c", "-MT", "-MF"):
|
||||
i += 2
|
||||
continue
|
||||
if a in ("-MD", "-MMD", "-Winvalid-pch") or a == "-g":
|
||||
i += 1
|
||||
continue
|
||||
if a == "-isystem":
|
||||
dropped.add(toks[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if a == "-Xclang":
|
||||
if toks[i + 1] == "-include-pch":
|
||||
pch = True
|
||||
i += 4
|
||||
continue
|
||||
if toks[i + 1] == "-include":
|
||||
i += 4
|
||||
continue
|
||||
flags.append(a)
|
||||
i += 1
|
||||
continue
|
||||
if a.startswith("-I"):
|
||||
path = os.path.normpath(a[2:])
|
||||
if path == kit_include_src:
|
||||
flags.append("-I{kit}/include")
|
||||
elif path == str(repo):
|
||||
flags.append("-I{workspace}")
|
||||
else:
|
||||
dropped.add(path)
|
||||
i += 1
|
||||
continue
|
||||
if a.startswith("--sysroot="):
|
||||
i += 1
|
||||
continue
|
||||
if not a.startswith("-") and (a.endswith(".cpp") or a.endswith(".S") or a.endswith(".c")):
|
||||
i += 1 # the source itself
|
||||
continue
|
||||
flags.append(a)
|
||||
i += 1
|
||||
return flags, pch
|
||||
|
||||
|
||||
def compile_kinds(build, repo):
|
||||
db = [e for e in json.loads(run(["ninja", "-C", str(build), "-t", "compdb"]))
|
||||
if e["file"].endswith((".cpp", ".S"))]
|
||||
patterns = {
|
||||
"translated": lambda f: "/build_shards/base_common/" in f or "/build_shards/base_sensitive/" in f,
|
||||
"product": lambda f: "/build_shards/base_registration/" in f or "/build_shards/base_dispatch/" in f,
|
||||
"runtime": lambda f: f.endswith("/data_sections_init.cpp") or f.endswith("/guest_symbol_table.cpp"),
|
||||
"asm": lambda f: f.endswith("data_sections_init_blobs_elf.S"),
|
||||
}
|
||||
kit_include_src = os.path.normpath(str(repo / "runtime/include"))
|
||||
out = {}
|
||||
for kind, match in patterns.items():
|
||||
entries = [e for e in db if match(e["file"])]
|
||||
if not entries:
|
||||
if kind == "translated":
|
||||
die("compile database has no translated shard; configure against a translation")
|
||||
die(f"compile database has no {kind} source")
|
||||
reduced = []
|
||||
for e in entries:
|
||||
dropped = set()
|
||||
flags, pch = reduce_flags(shlex.split(e["command"]), repo, kit_include_src, dropped)
|
||||
reduced.append((tuple(flags), pch, dropped, e))
|
||||
first = reduced[0]
|
||||
for r in reduced[1:]:
|
||||
if (r[0], r[1]) != (first[0], first[1]):
|
||||
die(f"{kind} sources do not share one flag set ({first[3]['file']} vs {r[3]['file']})")
|
||||
out[kind] = {"flags": list(first[0]), "pch": first[1], "sample": first[3]["file"],
|
||||
"compiler": shlex.split(first[3]["command"])[0 if not first[3]["command"].startswith("ccache") else 1],
|
||||
"dropped": sorted(first[2]), "count": len(entries)}
|
||||
return out
|
||||
|
||||
|
||||
# ---- link recipe ---------------------------------------------------------------------------
|
||||
|
||||
def link_recipe(build, out, dawn_dir):
|
||||
lines = [l for l in ninja_commands(build, "kartframe_kit_probe") if " -o kartframe_kit_probe" in l]
|
||||
if len(lines) != 1:
|
||||
die("could not find the kartframe_kit_probe link command (build the target first)")
|
||||
line = lines[0]
|
||||
line = line.split(" && ", 1)[1] if line.startswith(": && ") else line
|
||||
line = line.split(" && ")[0]
|
||||
toks = shlex.split(line)
|
||||
toks = toks[1:] # compiler
|
||||
flags, i = [], 0
|
||||
while i < len(toks) and not toks[i].endswith(".o"):
|
||||
t = toks[i]
|
||||
if t == "-Xlinker" and toks[i + 1].startswith("--dependency-file"):
|
||||
i += 2
|
||||
continue
|
||||
if t == "-Wl,--unresolved-symbols=ignore-all":
|
||||
i += 1
|
||||
continue
|
||||
flags.append(t)
|
||||
i += 1
|
||||
flags.insert(0, "--sysroot={sysroot}")
|
||||
|
||||
copied = {}
|
||||
counter = [0]
|
||||
|
||||
def vendor(path, sub):
|
||||
p = Path(path)
|
||||
if not p.is_absolute():
|
||||
p = build / p
|
||||
p = p.resolve()
|
||||
if p in copied:
|
||||
return copied[p]
|
||||
inside = str(p).startswith(str(build.resolve()) + "/") or (dawn_dir and str(p).startswith(str(dawn_dir) + "/"))
|
||||
if not inside:
|
||||
die(f"link input outside the build and the Dawn package (not self-contained): {p}")
|
||||
if p.suffix not in (".a", ".o"):
|
||||
die(f"link input is not a static archive or object: {p}")
|
||||
counter[0] += 1
|
||||
name = f"{counter[0]:03d}_{p.name}"
|
||||
dst = out / sub / name
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(p, dst)
|
||||
copied[p] = f"{{kit}}/{sub}/{name}"
|
||||
return copied[p]
|
||||
|
||||
objects, libs = [], []
|
||||
skip = False
|
||||
j = i
|
||||
while j < len(toks):
|
||||
t = toks[j]
|
||||
if t == "-o":
|
||||
j += 2
|
||||
continue
|
||||
if t.endswith(".o"):
|
||||
objects.append(vendor(t, "objects"))
|
||||
elif t.startswith("-l") or t == "-pthread":
|
||||
if t not in ALLOWED_SYSTEM_LIBS:
|
||||
die(f"link names a system library outside the stock set: {t}")
|
||||
libs.append(t)
|
||||
elif t.startswith("-"):
|
||||
libs.append(t)
|
||||
else:
|
||||
libs.append(vendor(t, "libs"))
|
||||
j += 1
|
||||
# Game slots: runtime-generated objects and product shards sit with the runtime objects, the
|
||||
# translated shards as an archive-like group before the libraries they call into.
|
||||
inputs = objects[:-1] + ["{game:runtime}", objects[-1], "{game:product}", "{game:translated}"] + libs
|
||||
return flags, inputs
|
||||
|
||||
|
||||
# ---- resources ------------------------------------------------------------------------------
|
||||
|
||||
def collect_resources(build, out):
|
||||
"""The files the game wants beside its executable: the POST_BUILD copies of the real product."""
|
||||
resdir = out / "resources"
|
||||
resdir.mkdir(parents=True, exist_ok=True)
|
||||
pat = re.compile(r"cmake -E (copy_directory|copy_if_different)\s+(\S+)\s+(\S+)\s*(?:&&|$)")
|
||||
seen = {}
|
||||
for line in ninja_commands(build, "WiiCompiled"):
|
||||
for kind, src, dst in pat.findall(line):
|
||||
dst = os.path.normpath(dst)
|
||||
if not dst.startswith(str(build.resolve()) + "/"):
|
||||
continue
|
||||
rel = os.path.relpath(dst, build.resolve())
|
||||
if rel.startswith("..") or rel == "kartframe":
|
||||
continue
|
||||
seen[rel] = (kind, src)
|
||||
if not seen:
|
||||
die("no POST_BUILD resource copies found for WiiCompiled")
|
||||
for rel, (kind, src) in sorted(seen.items()):
|
||||
d = resdir / rel
|
||||
if kind == "copy_directory":
|
||||
shutil.copytree(src, d, dirs_exist_ok=True, symlinks=True)
|
||||
else:
|
||||
d.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, d)
|
||||
return sorted(seen)
|
||||
|
||||
|
||||
def collect_licences(build, repo, out):
|
||||
lic = out / "licenses"
|
||||
lic.mkdir(parents=True, exist_ok=True)
|
||||
for name in ("LICENSE", "THIRD-PARTY-NOTICES.md"):
|
||||
if (repo / name).exists():
|
||||
shutil.copy2(repo / name, lic / name)
|
||||
if (repo / "licenses").is_dir():
|
||||
shutil.copytree(repo / "licenses", lic / "repo-licenses", dirs_exist_ok=True)
|
||||
pat = re.compile(r"^(LICEN[CS]E|COPYING|COPYRIGHT|NOTICE|UNLICENSE|FTL|GPLv2)", re.I)
|
||||
for src in sorted((build / "_deps").glob("*-src")):
|
||||
dst = lic / "third-party" / src.name[:-4]
|
||||
for cand in list(src.glob("*")) + list(src.glob("docs/*")) + list(src.glob("LICENSES/*")):
|
||||
if cand.is_file() and pat.match(cand.name):
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(cand, dst / cand.name)
|
||||
|
||||
|
||||
# ---- fingerprint ----------------------------------------------------------------------------
|
||||
|
||||
def manifest(out):
|
||||
"""sha256 lines for every kit file except kit.json and kit.sha256 itself (sorted by path)."""
|
||||
lines = []
|
||||
for p in sorted(out.rglob("*"), key=lambda x: str(x.relative_to(out)).encode()):
|
||||
rel = str(p.relative_to(out))
|
||||
if rel in ("kit.json", "kit.sha256"):
|
||||
continue
|
||||
if p.is_symlink():
|
||||
lines.append(f"{hashlib.sha256(os.readlink(p).encode()).hexdigest()} {rel} -> {os.readlink(p)}")
|
||||
elif p.is_file():
|
||||
lines.append(f"{sha256_file(p)} {rel}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def fingerprint(kit_json_text, manifest_text):
|
||||
"""sha256 over kit.json without its fingerprint line, then the file manifest. setup.sh
|
||||
recomputes it with grep/sha256sum, so keep the two in step."""
|
||||
body = "".join(l for l in kit_json_text.splitlines(True) if not l.startswith(' "fingerprint"'))
|
||||
h = hashlib.sha256()
|
||||
h.update(hashlib.sha256(body.encode()).hexdigest().encode() + b"\n")
|
||||
h.update(hashlib.sha256(manifest_text.encode()).hexdigest().encode() + b"\n")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
# ---- validation -----------------------------------------------------------------------------
|
||||
|
||||
def validate(kinds, out, repo, sysroot_rel="sysroot"):
|
||||
"""Compile one sample of every kind with exactly the flags the kit records. Fails if a flag set
|
||||
needs a header the kit does not carry, and if anything outside the kit, the sysroot, the
|
||||
workspace or the compiler's resource directory is read."""
|
||||
sysroot = out / sysroot_rel
|
||||
for kind, k in kinds.items():
|
||||
flags = [f.replace("{kit}", str(out)).replace("{workspace}", str(repo)) for f in k["flags"]]
|
||||
flags = ["--sysroot=" + str(sysroot)] + flags
|
||||
if k["pch"] or kind == "runtime":
|
||||
flags += ["-include", "mkw_pch.h"]
|
||||
comp = k["compiler"]
|
||||
if kind == "asm":
|
||||
cmd = [comp] + flags + ["-c", k["sample"], "-o", os.devnull]
|
||||
else:
|
||||
cmd = [comp] + flags + ["-H", "-fsyntax-only", k["sample"]]
|
||||
r = subprocess.run(cmd, text=True, capture_output=True)
|
||||
if r.returncode != 0:
|
||||
die(f"kit flags for '{kind}' do not compile {k['sample']}:\n{r.stderr[-1500:]}")
|
||||
if kind == "asm":
|
||||
continue
|
||||
resource = run([comp, "-print-resource-dir"]).strip()
|
||||
bad = set()
|
||||
for line in r.stderr.splitlines():
|
||||
m = re.match(r"^\.+ (\S+)$", line)
|
||||
if not m:
|
||||
continue
|
||||
h = os.path.realpath(m.group(1))
|
||||
ok = (str(out) in h or h.startswith(os.path.realpath(sysroot)) or h.startswith(resource)
|
||||
or h.startswith(str(repo)) or h.startswith(os.path.realpath(str(repo)))
|
||||
or h.startswith(os.path.realpath(os.path.dirname(k["sample"])) + "/"))
|
||||
if not ok:
|
||||
bad.add(h)
|
||||
if bad:
|
||||
die(f"'{kind}' compile reads files outside the kit: {sorted(bad)[:5]}")
|
||||
print(f"export-kit: validated {kind}: {Path(k['sample']).name}")
|
||||
|
||||
|
||||
# ---- main -----------------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--build", required=True)
|
||||
ap.add_argument("--sysroot", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
ap.add_argument("--no-validate", action="store_true")
|
||||
a = ap.parse_args()
|
||||
build = Path(a.build).resolve()
|
||||
out = Path(a.out).resolve()
|
||||
sysroot = Path(a.sysroot).resolve()
|
||||
home = cache_var(build, "CMAKE_HOME_DIRECTORY")
|
||||
if not home:
|
||||
die("not a configured CMake build dir")
|
||||
repo = Path(home).resolve().parent
|
||||
dawn = cache_var(build, "MKW_DAWN_PACKAGE_DIR")
|
||||
dawn_dir = Path(dawn).resolve() if dawn else None
|
||||
if not (build / "kartframe_kit_probe").exists():
|
||||
die("kartframe_kit_probe is not built (ninja kartframe_kit_probe)")
|
||||
|
||||
if out.exists():
|
||||
shutil.rmtree(out)
|
||||
out.mkdir(parents=True)
|
||||
|
||||
kinds = compile_kinds(build, repo)
|
||||
flags, inputs = link_recipe(build, out, dawn_dir)
|
||||
shutil.copytree(repo / "runtime/include", out / "include", symlinks=True)
|
||||
shutil.copytree(sysroot, out / "sysroot", symlinks=True)
|
||||
resources = collect_resources(build, out)
|
||||
collect_licences(build, repo, out)
|
||||
# What the translator reads besides the disc: the project description and symbol map, and the
|
||||
# runtime sources it scans for native overrides. The translation must match the runtime the kit
|
||||
# was built from, so the kit carries them.
|
||||
ws = out / "workspace"
|
||||
(ws / "projects/mkwii").mkdir(parents=True)
|
||||
for f in ("recomp.yml", "MAP.txt"):
|
||||
shutil.copy2(repo / "projects/mkwii" / f, ws / "projects/mkwii" / f)
|
||||
shutil.copytree(repo / "runtime/src", ws / "runtime/src", symlinks=True)
|
||||
shutil.copy2(HERE / "kitbuild.cmake", out / "kitbuild.cmake")
|
||||
clang_version = run([kinds["translated"]["compiler"], "--version"]).splitlines()[0]
|
||||
|
||||
if not a.no_validate:
|
||||
validate(kinds, out, repo)
|
||||
|
||||
tree = hashlib.sha256()
|
||||
for p in sorted((out / "include").rglob("*"), key=lambda x: str(x).encode()):
|
||||
if p.is_file():
|
||||
tree.update(str(p.relative_to(out / "include")).encode() + b" " + sha256_file(p).encode() + b"\n")
|
||||
|
||||
def kind_json(k, extra=None):
|
||||
d = {"flags": k["flags"] + (["-Wa,--noexecstack"] if extra == "asm" else []), "pch": k["pch"]}
|
||||
return d
|
||||
|
||||
kit = {
|
||||
"schema": SCHEMA,
|
||||
"target": "aarch64-unknown-linux-gnu",
|
||||
"compiler": clang_version,
|
||||
"runtimeIncludeFingerprint": tree.hexdigest(),
|
||||
"sysroot": "{kit}/sysroot",
|
||||
"workspace": "{kit}/workspace",
|
||||
"compile": {
|
||||
"translated": kind_json(kinds["translated"]),
|
||||
"product": kind_json(kinds["product"]),
|
||||
"runtime": kind_json(kinds["runtime"]),
|
||||
"asm": kind_json(kinds["asm"], "asm"),
|
||||
},
|
||||
"sources": {
|
||||
"runtime": ["{workspace}/generated/data_sections_init.cpp",
|
||||
"{workspace}/generated/guest_symbol_table.cpp",
|
||||
"{workspace}/generated/data_sections_init_blobs.S"],
|
||||
"product": ["@MKW_BASE_REGISTRATION_SOURCES"],
|
||||
"translated": ["@MKW_BASE_COMMON_SHARDS", "@MKW_BASE_PORTABLE_SENSITIVE_SHARDS"],
|
||||
},
|
||||
"link": {"output": "kartframe", "flags": flags, "inputs": inputs},
|
||||
"resources": resources,
|
||||
}
|
||||
# Report what the reduction dropped, so a surprise shows up in the log.
|
||||
for kind, k in kinds.items():
|
||||
print(f"export-kit: {kind}: {len(k['flags'])} flags, {k['count']} sources in the build, "
|
||||
f"pch={k['pch']}, dropped {len(k['dropped'])} include paths")
|
||||
|
||||
man = manifest(out)
|
||||
(out / "kit.sha256").write_text(man)
|
||||
text = json.dumps(kit, indent=2).rstrip("\n")
|
||||
assert text.endswith("}")
|
||||
# the fingerprint is the last top-level key, on a line of its own
|
||||
text = text[:-1].rstrip() + ',\n "fingerprint": "PLACEHOLDER"\n}\n'
|
||||
fp = fingerprint(text, man)
|
||||
text = text.replace("PLACEHOLDER", fp)
|
||||
assert fingerprint(text, man) == fp
|
||||
(out / "kit.json").write_text(text)
|
||||
size = sum(p.stat().st_size for p in out.rglob("*") if p.is_file() and not p.is_symlink())
|
||||
print(f"export-kit: kit {out} ({size / 1048576:.0f} MiB) fingerprint {fp}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Installs KartFrame on the Steam Frame: the program into ~/Games/KartFrame/app, the launch wrapper
|
||||
# and tools beside it, and a non-Steam shortcut in Steam's library (through Steam's own client API,
|
||||
# so Steam must be running; no editing of shortcuts.vdf behind its back).
|
||||
#
|
||||
# frame/install.sh [options] [kartframe-*-linux-arm64.tar.gz | BUILD_DIR]
|
||||
# frame/install.sh --dev [BUILD_DIR] a separate "KartFrame (dev)" shortcut that runs the build
|
||||
# build's binary into KARTFRAME_HOME/bin (default <repo>/build-linux)
|
||||
#
|
||||
# --name NAME shortcut name (default "KartFrame", with --dev "KartFrame (dev)")
|
||||
# --art DIR optional library art: p.png (portrait), wide.png, hero.png, logo.png, icon.png
|
||||
# --no-models do not convert the Frame controller models
|
||||
# -h, --help
|
||||
#
|
||||
# Without a source: the newest release tarball in ./release, else <repo>/build-linux.
|
||||
# A BUILD_DIR is a CMake build directory: kartframe plus the resources next to it.
|
||||
#
|
||||
# Environment:
|
||||
# KARTFRAME_APP=DIR where the program goes (default ~/Games/KartFrame/app; dev: app-dev)
|
||||
# KARTFRAME_HOME=DIR data folder (default ~/.local/share/KartFrame); only created here
|
||||
# KARTFRAME_SKIP_STEAM=1 no Steam shortcut at all (Steam need not be running)
|
||||
# KARTFRAME_STEAM_DIR=DIR edit the shortcuts.vdf files of this (copied) Steam folder offline
|
||||
# instead of the live one: DIR/userdata/*/config/shortcuts.vdf, grid art
|
||||
# beside it. Used for dry runs; nothing of the real Steam is touched.
|
||||
set -e
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
REPO=$(cd "$HERE/.." && pwd)
|
||||
DEV=0; NAME=; ART=; MODELS=1; SRC=
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--dev) DEV=1 ;;
|
||||
--name) NAME=$2; shift ;;
|
||||
--art) ART=$2; shift ;;
|
||||
--no-models) MODELS=0 ;;
|
||||
-h | --help) sed -n '2,/^set -e/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
|
||||
-*) echo "unknown option $1" >&2; exit 2 ;;
|
||||
*) SRC=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [ "$DEV" = 1 ]; then
|
||||
NAME=${NAME:-KartFrame (dev)}
|
||||
APP=${KARTFRAME_APP:-$HOME/Games/KartFrame/app-dev}
|
||||
BUILD=$(cd "${SRC:-$REPO/build-linux}" 2> /dev/null && pwd) || { echo "no build tree at ${SRC:-$REPO/build-linux}" >&2; exit 1; }
|
||||
[ -x "$BUILD/kartframe" ] || { echo "no kartframe in $BUILD (build with kfbuild.sh first)" >&2; exit 1; }
|
||||
else
|
||||
NAME=${NAME:-KartFrame}
|
||||
APP=${KARTFRAME_APP:-$HOME/Games/KartFrame/app}
|
||||
[ -n "$SRC" ] || SRC=$(ls "$REPO"/release/kartframe-*-linux-arm64.tar.gz 2> /dev/null | sort -V | tail -1)
|
||||
[ -n "$SRC" ] || { [ -x "$REPO/build-linux/kartframe" ] && SRC=$REPO/build-linux; }
|
||||
[ -n "$SRC" ] && [ -e "$SRC" ] || { echo "usage: $0 [--dev] kartframe-*-linux-arm64.tar.gz | BUILD_DIR" >&2; exit 1; }
|
||||
fi
|
||||
KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame}
|
||||
EXE=$KFHOME/bin/kartframe
|
||||
case $NAME in *\'* | *\\*) echo "the shortcut name cannot hold a quote or backslash" >&2; exit 2 ;; esac
|
||||
STEAM=${KARTFRAME_STEAM_DIR:-$HOME/.local/share/Steam}
|
||||
|
||||
if [ -z "$KARTFRAME_SKIP_STEAM" ] && [ -z "$KARTFRAME_STEAM_DIR" ]; then
|
||||
curl -s --max-time 3 127.0.0.1:8080/json > /dev/null || {
|
||||
echo "Steam's debugging port is not reachable. Make sure Steam is running (on SteamOS it is on by default)." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Layout (LANE_RULES.md "Install layout"): the app dir holds the wrapper, tools and, from a release
|
||||
# tarball, kit/ toolchain/ translator/ setup.sh. The game's own files live in $KFHOME: bin/kartframe
|
||||
# plus its resources, portable.txt, UserData/. A release install does not make bin/ (setup.sh builds
|
||||
# it from the user's disc); --dev and a build-tree install copy the build's binary there.
|
||||
mkdir -p "$APP/tools" "$KFHOME/bin"
|
||||
: > "$KFHOME/portable.txt"
|
||||
copy_build() { # BUILD_DIR: the binary and the resources the game wants beside its executable
|
||||
local B=$1 r
|
||||
[ -x "$B/kartframe" ] || { echo "no kartframe in $B" >&2; exit 1; }
|
||||
cp "$B/kartframe" "$KFHOME/bin/kartframe.new" && mv "$KFHOME/bin/kartframe.new" "$KFHOME/bin/kartframe" # the game may be running
|
||||
for r in dsp_coef.bin initial_pipeline_cache.db wii_bootstrap resources kartframe.fingerprint; do
|
||||
[ -e "$B/$r" ] && { rm -rf "${KFHOME:?}/bin/$r"; cp -r "$B/$r" "$KFHOME/bin/$r"; }
|
||||
done
|
||||
return 0
|
||||
}
|
||||
if [ "$DEV" = 1 ]; then
|
||||
echo "dev install: copying the build in $BUILD to $KFHOME/bin (run again after a rebuild)"
|
||||
copy_build "$BUILD"
|
||||
elif [ -d "$SRC" ]; then
|
||||
echo "installing the build in $SRC"
|
||||
copy_build "$SRC"
|
||||
else
|
||||
echo "installing $SRC into $APP"
|
||||
TOPS=$(tar -tzf "$SRC" | sed 's|^\./||; s|/.*||' | sort -u | wc -l)
|
||||
STRIP=0; [ "$TOPS" = 1 ] && [ -z "$(tar -tzf "$SRC" | sed 's|^\./||' | grep -m1 '^kartframe.sh$')" ] && STRIP=1
|
||||
tar -xzf "$SRC" -C "$APP" --strip-components=$STRIP
|
||||
fi
|
||||
# Seed the game's Config.toml if there is none.
|
||||
mkdir -p "$KFHOME/UserData"
|
||||
[ -f "$KFHOME/UserData/Config.toml" ] || sed "s|@DVD_ROOT@|$KFHOME/game/DATA|" "$HERE/Config.frame.toml" > "$KFHOME/UserData/Config.toml"
|
||||
cp "$HERE/kartframe.sh" "$APP/kartframe.sh"
|
||||
cp "$HERE/Config.frame.toml" "$APP/Config.frame.toml"
|
||||
for t in vrtiming.py session-report.py; do cp "$HERE/tools/$t" "$APP/tools/$t"; done
|
||||
cp "$HERE/steam-cef.py" "$APP/tools/steam-cef.py"
|
||||
chmod +x "$APP/kartframe.sh" "$APP/tools/steam-cef.py"
|
||||
for f in collect-logs.sh uninstall.sh; do cp "$HERE/$f" "$APP/tools/$f" 2> /dev/null && chmod +x "$APP/tools/$f"; done
|
||||
|
||||
# The Frame's own controller models, converted from this Frame's SteamVR (lane C's script, if present).
|
||||
if [ "$MODELS" = 1 ] && [ -f "$HERE/frame-controller-models.py" ]; then
|
||||
python3 "$HERE/frame-controller-models.py" "$KFHOME/UserData/controller_models/frame" ||
|
||||
echo "warning: could not convert SteamVR's Frame controller models"
|
||||
fi
|
||||
|
||||
APPID=none
|
||||
LAUNCH="\"$APP/kartframe.sh\" %command%"
|
||||
if [ -n "$KARTFRAME_SKIP_STEAM" ]; then
|
||||
echo "KARTFRAME_SKIP_STEAM set: no Steam shortcut"
|
||||
elif [ -n "$KARTFRAME_STEAM_DIR" ]; then
|
||||
# Offline: every userdata/*/config/shortcuts.vdf of the (copied) Steam folder.
|
||||
for CFG in "$STEAM"/userdata/*/config; do
|
||||
[ -d "$CFG" ] || continue
|
||||
APPID=$(python3 "$HERE/steam-cef.py" --vdf "$CFG/shortcuts.vdf" --name "$NAME" --exe "\"$EXE\"" \
|
||||
--startdir "\"$KFHOME/bin\"" --opts "$LAUNCH")
|
||||
echo "shortcut '$NAME' written to $CFG/shortcuts.vdf (appid $APPID)"
|
||||
GRID=$CFG/grid
|
||||
done
|
||||
[ "$APPID" != none ] || echo "warning: no userdata/*/config in $STEAM; no shortcut written"
|
||||
else
|
||||
CEF="python3 $HERE/steam-cef.py"
|
||||
EXISTING=$($CEF "(() => { for (const a of (appStore.allApps || [])) { if (a.display_name === '$NAME' && a.app_type === 1073741824) return a.appid; } return 0; })()" 2> /dev/null || echo 0)
|
||||
if [ "$EXISTING" != "0" ] && [ -n "$EXISTING" ]; then
|
||||
APPID=$EXISTING
|
||||
echo "Steam already has a '$NAME' shortcut (appid $APPID): updating it"
|
||||
$CEF "(() => { SteamClient.Apps.SetShortcutExe($APPID, '\"$EXE\"'); SteamClient.Apps.SetShortcutStartDir($APPID, '\"$KFHOME/bin\"'); return 1; })()" > /dev/null
|
||||
else
|
||||
APPID=$($CEF "(async () => { const id = await SteamClient.Apps.AddShortcut('$NAME', '$EXE', '$KFHOME/bin', ''); SteamClient.Apps.SetShortcutName(id, '$NAME'); return id; })()")
|
||||
echo "added the '$NAME' shortcut (appid $APPID)"
|
||||
fi
|
||||
$CEF "(() => { SteamClient.Apps.SetShortcutLaunchOptions($APPID, '$LAUNCH'); return 1; })()" > /dev/null
|
||||
GRID=$(ls -d "$STEAM"/userdata/*/config 2> /dev/null | head -1)/grid
|
||||
fi
|
||||
|
||||
# Optional library art; Steam finds it by the shortcut's appid.
|
||||
if [ -n "$ART" ] && [ "$APPID" != none ] && [ -n "$GRID" ]; then
|
||||
mkdir -p "$GRID"
|
||||
for kind in p:p wide: hero:_hero logo:_logo icon:_icon; do
|
||||
f=$ART/${kind%%:*}.png
|
||||
[ -f "$f" ] && cp "$f" "$GRID/$APPID${kind#*:}.png" && echo "art: $GRID/$APPID${kind#*:}.png"
|
||||
done
|
||||
fi
|
||||
|
||||
cat << MSG
|
||||
|
||||
Installed in $APP.
|
||||
$([ "$APPID" != none ] && echo "Steam library entry \"$NAME\" (appid $APPID) runs $EXE through $APP/kartframe.sh.")
|
||||
Game data: frame/setup.sh <your Mario Kart Wii disc image> (once)
|
||||
Then launch it from the library with SteamVR running.
|
||||
Logs: $KFHOME/sessions/latest/ (launch.log, game.log, vrtiming.log, report.txt)
|
||||
MSG
|
||||
@@ -1,233 +0,0 @@
|
||||
#!/bin/bash
|
||||
# KartFrame launch wrapper, run by the Steam shortcut ("<this script> %command%"). There is no screen
|
||||
# for messages in VR: everything goes to the session log, and a refusal is a non-zero exit.
|
||||
#
|
||||
# Every launch: re-applies SteamVR's per-app settings (it drops them now and then), starts the
|
||||
# compositor timing logger, runs the game, then writes a session folder
|
||||
# $KARTFRAME_HOME/sessions/<date-time>/ with launch.log, stdout.log, game.log, vrtiming.log, report.txt.
|
||||
# $KARTFRAME_HOME/sessions/latest points at the newest one.
|
||||
#
|
||||
# Environment (all optional; session.env below is the way to set them from the Steam library):
|
||||
# KARTFRAME_HOME data folder, default ~/.local/share/KartFrame (game/, build/, sessions/)
|
||||
# KARTFRAME_REFRESH_HZ refresh rate asked of SteamVR, 90 or 120 (default 120)
|
||||
# KARTFRAME_EYE_PX per-eye resolution override asked of SteamVR (default 1728; tests only, 1728 is the floor)
|
||||
# KARTFRAME_FDM_LAYER=0 sets DISABLE_VULKAN_FDM_INJECTION_LAYER=1 (A/B: Valve's FDM layer off)
|
||||
# KARTFRAME_SYSMEM=0 leaves Turnip in tiled mode; default 1 sets TU_DEBUG=sysmem (measured: the
|
||||
# race holds 120 Hz at 1728 only with it)
|
||||
# KARTFRAME_PACING passed through to the game (auto | inline | pipelined)
|
||||
# KARTFRAME_END_PHASE when in the refresh each frame is handed to SteamVR (default "after": just
|
||||
# after the compositor's update, timed from the grid tools/vrtiming.py
|
||||
# publishes; "off" = the wake-up, which doubles the image, see README)
|
||||
# KARTFRAME_VRTIMING=0 no compositor timing log
|
||||
# KARTFRAME_SESSION_ENV_ONCE=1 (inside session.env) delete session.env after this launch
|
||||
# KARTFRAME_KEEP_SESSIONS number of session folders kept (default 30)
|
||||
# Test hooks: KARTFRAME_VRCMD, KARTFRAME_STEAMVR_DIR, KARTFRAME_VRSETTINGS, KARTFRAME_VRTIMING_PY,
|
||||
# KARTFRAME_REPORT_PY, KARTFRAME_EXE.
|
||||
HERE=$(dirname "$(readlink -f "$0")")
|
||||
KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame}
|
||||
STAMP=$(date +%Y%m%d-%H%M%S)
|
||||
SESSION=$KFHOME/sessions/$STAMP
|
||||
mkdir -p "$SESSION" || exit 1
|
||||
ln -sfn "$STAMP" "$KFHOME/sessions/latest"
|
||||
exec > "$SESSION/launch.log" 2>&1
|
||||
log() { echo "$(date +%T) $*"; }
|
||||
log "KartFrame wrapper, session $STAMP, steam appid '${SteamAppId:-unset}'"
|
||||
|
||||
# One launch's test environment: VAR=value lines (KARTFRAME_*, DISABLE_VULKAN_FDM_INJECTION_LAYER, ...).
|
||||
# A queue of one-launch test settings (KFHOME/session.queue/*.env, taken in name order): when no
|
||||
# session.env is waiting, the next queued file becomes this launch's session.env.
|
||||
if [ ! -f "$KFHOME/session.env" ] && [ -d "$KFHOME/session.queue" ]; then
|
||||
NEXT=$(ls "$KFHOME/session.queue"/*.env 2> /dev/null | head -1)
|
||||
[ -n "$NEXT" ] && { mv "$NEXT" "$KFHOME/session.env"; log "session.queue: using $(basename "$NEXT")"; }
|
||||
fi
|
||||
if [ -f "$KFHOME/session.env" ]; then
|
||||
log "session.env:"; cat "$KFHOME/session.env"; cp "$KFHOME/session.env" "$SESSION/session.env"
|
||||
set -a; . "$KFHOME/session.env"; set +a
|
||||
[ "$KARTFRAME_SESSION_ENV_ONCE" = 1 ] && { rm -f "$KFHOME/session.env"; log "session.env deleted (KARTFRAME_SESSION_ENV_ONCE)"; }
|
||||
fi
|
||||
[ "$KARTFRAME_FDM_LAYER" = 0 ] && { export DISABLE_VULKAN_FDM_INJECTION_LAYER=1; log "A/B: Valve FDM layer disabled"; }
|
||||
[ "${KARTFRAME_SYSMEM:-1}" = 1 ] && { export TU_DEBUG=sysmem; log "TU_DEBUG=sysmem (KARTFRAME_SYSMEM=0 turns it off)"; }
|
||||
[ -n "$KARTFRAME_PACING" ] && { export KARTFRAME_PACING; log "KARTFRAME_PACING=$KARTFRAME_PACING"; }
|
||||
EP=${KARTFRAME_END_PHASE:-after}
|
||||
if [ "$EP" = off ] || [ "$EP" = 0 ]; then unset KARTFRAME_END_PHASE; log "KARTFRAME_END_PHASE=off: frames handed over at the wake-up"; else export KARTFRAME_END_PHASE=$EP; log "KARTFRAME_END_PHASE=$EP"; fi
|
||||
|
||||
# The game: Steam's %command% (the binary the shortcut names), else the installed one.
|
||||
EXE=${KARTFRAME_EXE:-${1:-$KFHOME/bin/kartframe}}
|
||||
[ $# -gt 0 ] && shift
|
||||
[ -x "$EXE" ] || { log "REFUSING TO START: no game binary at $EXE (run frame/setup.sh)"; exit 1; }
|
||||
|
||||
# Kit check: the installed binary must have been built against the kit that is installed now.
|
||||
# Installed kit: <app>/kit/kit.json "fingerprint"; the built game's record: $KFHOME/bin/kartframe.fingerprint.
|
||||
# kit_fingerprint FILE prints the "fingerprint" of a kit.json, or the first line of a plain file.
|
||||
kit_fingerprint() {
|
||||
python3 -I -c '
|
||||
import json, sys
|
||||
t = open(sys.argv[1]).read()
|
||||
try:
|
||||
print(json.loads(t)["fingerprint"])
|
||||
except Exception:
|
||||
print(t.split("\n")[0].strip())' "$1" 2> /dev/null
|
||||
}
|
||||
kit_check() { # prints the reason and returns 1 when the binary and the kit disagree
|
||||
local kit= built=
|
||||
[ -f "$HERE/kit/kit.json" ] && kit=$HERE/kit/kit.json
|
||||
[ -f "$KFHOME/bin/kartframe.fingerprint" ] && built=$KFHOME/bin/kartframe.fingerprint
|
||||
[ -n "$kit" ] || { log "kit check: no kit fingerprint installed (development build), skipped"; return 0; }
|
||||
[ -n "$built" ] || { echo "no record of the kit this binary was built against; run frame/setup.sh again"; return 1; }
|
||||
local k b
|
||||
k=$(kit_fingerprint "$kit"); b=$(kit_fingerprint "$built")
|
||||
[ -n "$k" ] && [ "$k" = "$b" ] && { log "kit check: ok (${k:0:12})"; return 0; }
|
||||
echo "the game was built for another kit (${b:0:12} vs installed ${k:0:12}); run frame/setup.sh again"; return 1
|
||||
}
|
||||
if ! why=$(kit_check); then log "REFUSING TO START: $why"; exit 78; fi
|
||||
[ -n "$why" ] && echo "$why"
|
||||
|
||||
# Portable mode: portable.txt in KFHOME (a parent of bin/) makes the game's root $KFHOME/UserData.
|
||||
STATE=$KFHOME/UserData
|
||||
mkdir -p "$STATE"; [ -f "$KFHOME/portable.txt" ] || : > "$KFHOME/portable.txt"
|
||||
if [ ! -f "$STATE/Config.toml" ] && [ -f "$HERE/Config.frame.toml" ]; then
|
||||
sed "s|@DVD_ROOT@|$KFHOME/game/DATA|" "$HERE/Config.frame.toml" > "$STATE/Config.toml" &&
|
||||
log "seeded $STATE/Config.toml"
|
||||
fi
|
||||
|
||||
# SteamVR's per-app settings, every launch. SteamAppId is set by Steam for a library launch.
|
||||
HZ=${KARTFRAME_REFRESH_HZ:-120} # TODO(lane A): follow Config's refresh choice if one is added
|
||||
case "$HZ" in 90 | 120) ;; *) log "KARTFRAME_REFRESH_HZ=$HZ is not 90 or 120, using 120"; HZ=120 ;; esac
|
||||
VRCMD=${KARTFRAME_VRCMD:-$(command -v vrcmd || echo /opt/steamvr/bin/linuxarm64/vrcmd)}
|
||||
VRSETTINGS=${KARTFRAME_VRSETTINGS:-$HOME/.config/openvr/config/steamvr.vrsettings}
|
||||
STEAMVR_DIR=${KARTFRAME_STEAMVR_DIR:-/opt/steamvr/bin/linuxarm64}
|
||||
SETTINGS="preferredRefreshRate=$HZ framesToThrottle=0 additionalFramesToPredict=0 motionSmoothingOverride=2 resolutionOverride=${KARTFRAME_EYE_PX:-1728}"
|
||||
if [ -z "$SteamAppId" ]; then
|
||||
log "no SteamAppId: SteamVR per-app settings NOT applied (not a Steam library launch)"
|
||||
elif [ ! -x "$VRCMD" ]; then
|
||||
log "no vrcmd at $VRCMD: SteamVR per-app settings NOT applied"
|
||||
else
|
||||
for kv in $SETTINGS; do
|
||||
"$VRCMD" --set-settings-int "steam.app.$SteamAppId.${kv%=*}" "${kv#*=}" > /dev/null 2>&1 ||
|
||||
log "WARNING: vrcmd could not set ${kv%=*} (is SteamVR running?)"
|
||||
done
|
||||
sleep 0.3 # SteamVR writes its settings file shortly after a change
|
||||
# Read back from SteamVR's settings file and log every difference.
|
||||
python3 -I - "$VRSETTINGS" "steam.app.$SteamAppId" $SETTINGS << 'PY' || log "settings read-back failed"
|
||||
import json, sys, time
|
||||
path, section, pairs = sys.argv[1], sys.argv[2], sys.argv[3:]
|
||||
try:
|
||||
got = json.load(open(path)).get(section, {})
|
||||
except (OSError, ValueError) as e:
|
||||
print(f"{time.strftime('%T')} read-back: cannot read {path} ({e.__class__.__name__}); settings unverified")
|
||||
sys.exit(0)
|
||||
bad = 0
|
||||
for p in pairs:
|
||||
k, v = p.split("=")
|
||||
if str(got.get(k)) != v:
|
||||
bad += 1
|
||||
print(f"{time.strftime('%T')} MISMATCH {section}.{k}: wanted {v}, SteamVR has {got.get(k)}")
|
||||
print(f"{time.strftime('%T')} read-back: {len(pairs) - bad} of {len(pairs)} per-app settings as asked")
|
||||
PY
|
||||
fi
|
||||
|
||||
# Compositor timing for the whole session; it cannot stop the game from starting.
|
||||
VRT_PID=
|
||||
VRT=${KARTFRAME_VRTIMING_PY:-$HERE/tools/vrtiming.py}
|
||||
if [ "${KARTFRAME_VRTIMING:-1}" != 0 ] && [ -f "$VRT" ] && [ -f "$STEAMVR_DIR/libopenvr_api.so" ]; then
|
||||
env -u LD_LIBRARY_PATH python3 -I "$VRT" 86400 "$SESSION/vrtiming.log" > /dev/null 2>&1 &
|
||||
VRT_PID=$!
|
||||
else
|
||||
log "no vrtiming.log (disabled, or vrtiming.py / libopenvr_api.so missing)"
|
||||
fi
|
||||
|
||||
# Not exec: the wrapper stays to stop the logger and write the report. A signal for the wrapper
|
||||
# (Steam stopping the app) goes on to the game.
|
||||
# The headset is in use: development builds and headless GPU runs on this machine wait while this
|
||||
# file exists (KartFrame's build script does; other projects can check the same file).
|
||||
HEADSET_MARK=${XDG_CACHE_HOME:-$HOME/.cache}/headset-in-use
|
||||
log "starting $EXE"
|
||||
touch "$SESSION/.start"
|
||||
"$EXE" "$@" > "$SESSION/stdout.log" 2>&1 &
|
||||
GAME_PID=$!
|
||||
echo "kartframe $GAME_PID" > "$HEADSET_MARK" 2> /dev/null
|
||||
# KARTFRAME_RECORD=raw records both panels as scanned out (framecorder; about 1.6 ms of GPU a frame)
|
||||
# into the session folder, for questions the logs cannot settle (per-eye ghosting, flicker).
|
||||
# The recorder starts 8 s in and only if the game is still there then (it once started after a game
|
||||
# that had died at 3 s and recorded the headset for hours). It runs in its own session (setsid), so
|
||||
# stopping it can reach everything it started (the framecorder-grab helper too). .rec-started marks
|
||||
# that it really began.
|
||||
REC_PID=
|
||||
# Steam starts games with a short PATH (no ~/.local/bin), so look for the recorder by its full path too.
|
||||
FRAMECORDER=$(command -v framecorder 2> /dev/null || ls "$HOME/.local/bin/framecorder" 2> /dev/null)
|
||||
if [ -n "$KARTFRAME_RECORD" ] && [ -z "$FRAMECORDER" ]; then log "RECORDING NOT POSSIBLE: framecorder not found"; fi
|
||||
if [ -n "$KARTFRAME_RECORD" ] && [ -n "$FRAMECORDER" ]; then
|
||||
rm -f "$SESSION/.rec-started"
|
||||
setsid bash -c 'for ((i = 0; i < 80; i++)); do kill -0 "$1" 2> /dev/null || exit 0; sleep 0.1; done
|
||||
touch "$2/.rec-started"; shift 2; exec "$@"' _ "$GAME_PID" "$SESSION" \
|
||||
"$FRAMECORDER" --view "${KARTFRAME_RECORD}" --fps "${KARTFRAME_RECORD_FPS:-120}" --qp 18 "$SESSION/panels.mkv" \
|
||||
> "$SESSION/framecorder.log" 2>&1 &
|
||||
REC_PID=$!
|
||||
log "recording the panels (framecorder --view $KARTFRAME_RECORD) into panels.mkv"
|
||||
fi
|
||||
# Anything of the recorder still alive (a finished process that nobody has waited for does not count).
|
||||
rec_alive() {
|
||||
ps -o stat= --sid "$REC_PGID" 2> /dev/null | grep -q '^[^Z]' ||
|
||||
pgrep -f "framecorder .* $SESSION/panels.mkv" > /dev/null
|
||||
}
|
||||
# Stops the recorder in every case: not started yet (the waiting subshell is killed and never starts it),
|
||||
# running (SIGINT to finish the file, then a bounded wait, then TERM and KILL). Runs once.
|
||||
stop_recorder() {
|
||||
[ -n "$REC_PID" ] || return 0
|
||||
REC_PGID=$REC_PID; REC_PID=
|
||||
local n how=stopped
|
||||
if [ -f "$SESSION/.rec-started" ]; then
|
||||
kill -INT "$REC_PGID" 2> /dev/null
|
||||
for ((n = 0; n < 300; n++)); do rec_alive || break; sleep 0.1; done # up to 30 s to finish the file
|
||||
if rec_alive; then
|
||||
how="stopped after TERM (SIGINT did not finish it)"
|
||||
kill -TERM -- "-$REC_PGID" 2> /dev/null; pkill -TERM -s "$REC_PGID" 2> /dev/null
|
||||
pkill -TERM -f "framecorder .* $SESSION/panels.mkv" 2> /dev/null
|
||||
for ((n = 0; n < 50; n++)); do rec_alive || break; sleep 0.1; done
|
||||
fi
|
||||
if rec_alive; then
|
||||
how="killed (SIGINT and TERM did not stop it)"
|
||||
kill -KILL -- "-$REC_PGID" 2> /dev/null; pkill -KILL -s "$REC_PGID" 2> /dev/null
|
||||
pkill -KILL -f "framecorder .* $SESSION/panels.mkv" 2> /dev/null
|
||||
sleep 0.5
|
||||
fi
|
||||
wait "$REC_PGID" 2> /dev/null
|
||||
rec_alive && log "WARNING: a recorder process is still alive for $SESSION/panels.mkv"
|
||||
log "recorder $how"
|
||||
else
|
||||
kill -TERM "$REC_PGID" 2> /dev/null; kill -TERM -- "-$REC_PGID" 2> /dev/null
|
||||
pkill -TERM -s "$REC_PGID" 2> /dev/null
|
||||
wait "$REC_PGID" 2> /dev/null
|
||||
log "recorder was never started (the game was gone before the 8 s were up)"
|
||||
fi
|
||||
# A leftover part file is the owner's to judge: logged, never deleted.
|
||||
if [ -f "$SESSION/panels.mkv.part" ] && ! rec_alive; then
|
||||
log "panels.mkv.part left in the session folder, $(stat -c %s "$SESSION/panels.mkv.part") bytes, no recorder alive"
|
||||
fi
|
||||
}
|
||||
trap 'kill -TERM "$GAME_PID" 2> /dev/null; stop_recorder' TERM INT HUP
|
||||
rc=0
|
||||
while kill -0 "$GAME_PID" 2> /dev/null; do
|
||||
wait "$GAME_PID"; rc=$?
|
||||
done
|
||||
trap - TERM INT HUP
|
||||
rm -f "$HEADSET_MARK"
|
||||
stop_recorder
|
||||
log "game exited with status $rc"
|
||||
[ -n "$VRT_PID" ] && { kill "$VRT_PID" 2> /dev/null; wait "$VRT_PID" 2> /dev/null; }
|
||||
|
||||
# The game's own log: the newest console.log of this session, else what it printed.
|
||||
GL=$(find "$STATE/Logs" -name console.log -newer "$SESSION/.start" 2> /dev/null | xargs -r ls -t 2> /dev/null | head -1)
|
||||
if [ -n "$GL" ]; then cp "$GL" "$SESSION/game.log"; else log "no console.log from this run; the report reads stdout.log"; cp "$SESSION/stdout.log" "$SESSION/game.log"; fi
|
||||
rm -f "$SESSION/.start"
|
||||
REPORT=${KARTFRAME_REPORT_PY:-$HERE/tools/session-report.py}
|
||||
if [ -f "$REPORT" ]; then
|
||||
set -- "$SESSION/game.log"; [ -s "$SESSION/vrtiming.log" ] && set -- "$@" "$SESSION/vrtiming.log"
|
||||
python3 -I "$REPORT" "$@" > "$SESSION/report.txt" 2>&1 || log "session-report exited non-zero"
|
||||
else
|
||||
log "no session-report.py at $REPORT"
|
||||
fi
|
||||
# Keep the newest sessions only.
|
||||
KEEP=${KARTFRAME_KEEP_SESSIONS:-30}
|
||||
ls -1d "$KFHOME"/sessions/2* 2> /dev/null | head -n -"$KEEP" | xargs -r rm -rf
|
||||
exit $rc
|
||||
@@ -1,7 +0,0 @@
|
||||
# Files over 1 MB the kit may contain (globs, see boundary-check.sh). Prebuilt runtime objects and
|
||||
# archives, the sysroot's libraries, and the converted controller models are not disc-derived.
|
||||
objects/*
|
||||
libs/*
|
||||
sysroot/usr/lib/*
|
||||
sysroot/usr/lib/gcc/*
|
||||
resources/*
|
||||
@@ -1,232 +0,0 @@
|
||||
# First-run build of the translated game against the kit: writes <BUILD>/build.ninja from kit.json
|
||||
# and the translator's shard lists. Run with the toolchain's own cmake (no other tool is needed):
|
||||
#
|
||||
# cmake -DKIT=<kit> -DWORKSPACE=<dir holding generated/> -DBUILD=<build dir> -DTOOLCHAIN=<root>
|
||||
# [-DJOBS=3] -P kitbuild.cmake
|
||||
# <toolchain>/bin/ninja -C <build> -j<jobs> -> <build>/kartframe
|
||||
#
|
||||
# kit.json says how each kind of source is compiled and how everything links. Placeholders in it:
|
||||
# {kit}, {sysroot} (the kit's own sysroot, so host headers are never read), {workspace}. The
|
||||
# shard lists come from generated/build_shards/shards.cmake (the translator's own manifest).
|
||||
cmake_minimum_required(VERSION 3.25)
|
||||
|
||||
foreach(v KIT WORKSPACE BUILD TOOLCHAIN)
|
||||
if(NOT DEFINED ${v})
|
||||
message(FATAL_ERROR "kitbuild.cmake: -D${v}= is required")
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT DEFINED JOBS)
|
||||
set(JOBS 3)
|
||||
endif()
|
||||
get_filename_component(KIT "${KIT}" ABSOLUTE)
|
||||
get_filename_component(WORKSPACE "${WORKSPACE}" REALPATH)
|
||||
get_filename_component(BUILD "${BUILD}" ABSOLUTE)
|
||||
file(MAKE_DIRECTORY "${BUILD}")
|
||||
|
||||
file(READ "${KIT}/kit.json" KJ)
|
||||
string(JSON KIT_SCHEMA GET "${KJ}" schema)
|
||||
if(NOT KIT_SCHEMA EQUAL 1)
|
||||
message(FATAL_ERROR "kit.json schema ${KIT_SCHEMA} is not supported by this kitbuild.cmake")
|
||||
endif()
|
||||
|
||||
function(kit_sub out text)
|
||||
string(REPLACE "{kit}" "${KIT}" t "${text}")
|
||||
string(REPLACE "{sysroot}" "${KIT}/sysroot" t "${t}")
|
||||
string(REPLACE "{workspace}" "${WORKSPACE}" t "${t}")
|
||||
set(${out} "${t}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# One argument for a POSIX shell, with ninja's own `$` escaping on top.
|
||||
function(sh_quote out text)
|
||||
if(text MATCHES "^[A-Za-z0-9_@%+=:,./-]+$")
|
||||
set(q "${text}")
|
||||
else()
|
||||
string(REPLACE "'" "'\\''" q "${text}")
|
||||
set(q "'${q}'")
|
||||
endif()
|
||||
string(REPLACE "$" "$$" q "${q}")
|
||||
set(${out} "${q}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(json_args out) # <out> <json path...>: a JSON string array as a shell-quoted, substituted string
|
||||
string(JSON n LENGTH "${KJ}" ${ARGN})
|
||||
set(res "")
|
||||
math(EXPR last "${n} - 1")
|
||||
if(n GREATER 0)
|
||||
foreach(i RANGE ${last})
|
||||
string(JSON a GET "${KJ}" ${ARGN} ${i})
|
||||
kit_sub(a "${a}")
|
||||
sh_quote(a "${a}")
|
||||
string(APPEND res " ${a}")
|
||||
endforeach()
|
||||
endif()
|
||||
set(${out} "${res}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# ---- the translator's shard lists, relocated to where this translation actually is ---------------
|
||||
set(manifest "${WORKSPACE}/generated/build_shards/shards.cmake")
|
||||
if(NOT EXISTS "${manifest}")
|
||||
message(FATAL_ERROR "no translation at ${WORKSPACE}/generated (run setup.sh translate first)")
|
||||
endif()
|
||||
file(READ "${manifest}" manifest_text)
|
||||
if(manifest_text MATCHES "set\\(MKW_TRANSLATED_SHARD_ROOT \"([^\"]+)\"\\)")
|
||||
set(recorded_root "${CMAKE_MATCH_1}")
|
||||
get_filename_component(real_root "${WORKSPACE}/generated/build_shards" REALPATH)
|
||||
if(NOT recorded_root STREQUAL real_root)
|
||||
string(REPLACE "${recorded_root}" "${real_root}" manifest_text "${manifest_text}")
|
||||
endif()
|
||||
endif()
|
||||
file(WRITE "${BUILD}/shards_relocated.cmake" "${manifest_text}")
|
||||
include("${BUILD}/shards_relocated.cmake")
|
||||
|
||||
# ---- the data blob assembly: ELF section syntax and this machine's blob paths (as runtime/cmake/PublicProducts.cmake)
|
||||
set(blob_in "${WORKSPACE}/generated/data_sections_init_blobs.S")
|
||||
set(blob_out "${BUILD}/data_sections_init_blobs_elf.S")
|
||||
if(EXISTS "${blob_in}")
|
||||
file(READ "${blob_in}" blob)
|
||||
string(REPLACE ".section .rdata,\"dr\"" ".section .rodata,\"a\",@progbits" blob "${blob}")
|
||||
string(REGEX REPLACE "\\.incbin \"[^\"]*/data_sections_init_blobs/"
|
||||
".incbin \"${WORKSPACE}/generated/data_sections_init_blobs/" blob "${blob}")
|
||||
if(NOT blob MATCHES "\\.note\\.GNU-stack")
|
||||
string(APPEND blob "\n.section .note.GNU-stack,\"\",@progbits\n")
|
||||
endif()
|
||||
file(WRITE "${blob_out}" "${blob}")
|
||||
endif()
|
||||
|
||||
# ---- sources per slot --------------------------------------------------------------------------
|
||||
function(slot_sources out slot)
|
||||
string(JSON n LENGTH "${KJ}" sources ${slot})
|
||||
set(res "")
|
||||
math(EXPR last "${n} - 1")
|
||||
foreach(i RANGE ${last})
|
||||
string(JSON entry GET "${KJ}" sources ${slot} ${i})
|
||||
if(entry MATCHES "^@(.+)$")
|
||||
list(APPEND res ${${CMAKE_MATCH_1}})
|
||||
else()
|
||||
kit_sub(entry "${entry}")
|
||||
if(EXISTS "${entry}")
|
||||
list(APPEND res "${entry}")
|
||||
else()
|
||||
message(FATAL_ERROR "kit source missing: ${entry}")
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
set(${out} "${res}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
slot_sources(SRC_RUNTIME runtime)
|
||||
slot_sources(SRC_PRODUCT product)
|
||||
slot_sources(SRC_TRANSLATED translated)
|
||||
list(LENGTH SRC_PRODUCT n_product)
|
||||
list(LENGTH SRC_TRANSLATED n_translated)
|
||||
if(n_translated EQUAL 0 OR n_product EQUAL 0)
|
||||
message(FATAL_ERROR "the shard lists are empty (${manifest})")
|
||||
endif()
|
||||
|
||||
# ---- build.ninja ---------------------------------------------------------------------------------
|
||||
set(cxx "${TOOLCHAIN}/bin/clang++")
|
||||
set(cc "${TOOLCHAIN}/bin/clang")
|
||||
set(ninja "# generated by kitbuild.cmake from ${KIT}/kit.json; do not edit\nninja_required_version = 1.10\n")
|
||||
string(APPEND ninja "pool heavy\n depth = ${JOBS}\n\n")
|
||||
|
||||
# Kinds with at least this many sources get a precompiled mkw_pch.h of their own (it is only valid for
|
||||
# the compiler and flags that wrote it, so it is made here, never shipped).
|
||||
set(PCH_MIN_SOURCES 8)
|
||||
|
||||
set(all_objects "")
|
||||
function(add_kind kind sources compiler)
|
||||
json_args(flags compile ${kind} flags)
|
||||
string(JSON want_pch GET "${KJ}" compile ${kind} pch)
|
||||
list(LENGTH sources count)
|
||||
set(extra "")
|
||||
set(pch_dep "")
|
||||
set(out "")
|
||||
if(want_pch AND count GREATER_EQUAL PCH_MIN_SOURCES)
|
||||
set(pch "${BUILD}/pch/${kind}.pch")
|
||||
string(APPEND out "rule pch_${kind}\n command = ${compiler} ${flags} -x c++-header ${KIT}/include/mkw_pch.h -o $out\n description = PCH ${kind}\n pool = heavy\n")
|
||||
string(APPEND out "build ${pch}: pch_${kind} ${KIT}/include/mkw_pch.h\n\n")
|
||||
set(extra "-include-pch ${pch}")
|
||||
set(pch_dep "| ${pch}")
|
||||
elseif(want_pch OR kind STREQUAL "runtime")
|
||||
set(extra "-include mkw_pch.h")
|
||||
endif()
|
||||
string(APPEND out "rule cc_${kind}\n command = ${compiler} ${flags} ${extra} -MD -MF $out.d -o $out -c $in\n description = ${kind} $in\n depfile = $out.d\n deps = gcc\n pool = heavy\n\n")
|
||||
set(objs "")
|
||||
set(idx 0)
|
||||
foreach(src IN LISTS sources)
|
||||
get_filename_component(base "${src}" NAME)
|
||||
set(obj "${BUILD}/obj/${kind}/${idx}_${base}.o")
|
||||
string(APPEND out "build ${obj}: cc_${kind} ${src} ${pch_dep}\n")
|
||||
list(APPEND objs "${obj}")
|
||||
math(EXPR idx "${idx} + 1")
|
||||
endforeach()
|
||||
set(KIND_OBJECTS_${kind} "${objs}" PARENT_SCOPE)
|
||||
set(NINJA_${kind} "${out}\n" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
# asm sources are the blob object only; the .cpp runtime sources are the generated data files
|
||||
set(runtime_cpp "")
|
||||
set(runtime_asm "")
|
||||
foreach(s IN LISTS SRC_RUNTIME)
|
||||
if(s MATCHES "\\.S$")
|
||||
list(APPEND runtime_asm "${blob_out}") # the rewritten copy made above
|
||||
else()
|
||||
list(APPEND runtime_cpp "${s}")
|
||||
endif()
|
||||
endforeach()
|
||||
add_kind(runtime "${runtime_cpp}" "${cxx}")
|
||||
add_kind(product "${SRC_PRODUCT}" "${cxx}")
|
||||
add_kind(translated "${SRC_TRANSLATED}" "${cxx}")
|
||||
add_kind(asm "${runtime_asm}" "${cc}")
|
||||
foreach(k runtime product translated asm)
|
||||
string(APPEND ninja "${NINJA_${k}}")
|
||||
endforeach()
|
||||
|
||||
# link: kit objects and libraries in the recorded order, the game's objects at their slots
|
||||
json_args(link_flags link flags)
|
||||
string(JSON n_inputs LENGTH "${KJ}" link inputs)
|
||||
math(EXPR last "${n_inputs} - 1")
|
||||
set(rsp "")
|
||||
set(kit_files "")
|
||||
foreach(i RANGE ${last})
|
||||
string(JSON in GET "${KJ}" link inputs ${i})
|
||||
if(in STREQUAL "{game:runtime}")
|
||||
foreach(o ${KIND_OBJECTS_runtime} ${KIND_OBJECTS_asm})
|
||||
string(APPEND rsp "${o}\n")
|
||||
endforeach()
|
||||
set(game_objs ${KIND_OBJECTS_runtime} ${KIND_OBJECTS_asm})
|
||||
elseif(in STREQUAL "{game:product}")
|
||||
foreach(o ${KIND_OBJECTS_product})
|
||||
string(APPEND rsp "${o}\n")
|
||||
endforeach()
|
||||
elseif(in STREQUAL "{game:translated}")
|
||||
string(APPEND rsp "-Wl,--start-lib\n")
|
||||
foreach(o ${KIND_OBJECTS_translated})
|
||||
string(APPEND rsp "${o}\n")
|
||||
endforeach()
|
||||
string(APPEND rsp "-Wl,--end-lib\n")
|
||||
else()
|
||||
kit_sub(in "${in}")
|
||||
string(APPEND rsp "${in}\n")
|
||||
if(in MATCHES "^/")
|
||||
list(APPEND kit_files "${in}")
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
# flags go through the response file too (one per line is fine for clang)
|
||||
string(JSON n_flags LENGTH "${KJ}" link flags)
|
||||
set(flag_lines "")
|
||||
math(EXPR lastf "${n_flags} - 1")
|
||||
foreach(i RANGE ${lastf})
|
||||
string(JSON f GET "${KJ}" link flags ${i})
|
||||
kit_sub(f "${f}")
|
||||
string(APPEND flag_lines "${f}\n")
|
||||
endforeach()
|
||||
string(JSON out_name GET "${KJ}" link output)
|
||||
file(WRITE "${BUILD}/link.rsp" "${flag_lines}-o\n${BUILD}/${out_name}\n${rsp}")
|
||||
set(all_game ${KIND_OBJECTS_runtime} ${KIND_OBJECTS_asm} ${KIND_OBJECTS_product} ${KIND_OBJECTS_translated})
|
||||
string(REPLACE ";" " " all_game_s "${all_game}")
|
||||
string(REPLACE ";" " " kit_files_s "${kit_files}")
|
||||
string(APPEND ninja "rule link\n command = ${cxx} @${BUILD}/link.rsp\n description = LINK ${out_name}\n pool = console\n\n")
|
||||
string(APPEND ninja "build ${BUILD}/${out_name}: link ${all_game_s} | ${kit_files_s} ${BUILD}/link.rsp\n\ndefault ${BUILD}/${out_name}\n")
|
||||
file(WRITE "${BUILD}/build.ninja" "${ninja}")
|
||||
message(STATUS "kitbuild: ${n_translated} translated + ${n_product} product shards, pool ${JOBS}, ${BUILD}/build.ninja")
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Maintainer step: build the game kit (everything the first-run build links except the translated
|
||||
# game) with the SAME portable toolchain and sysroot the user's machine will use, and export it.
|
||||
#
|
||||
# make-kit.sh <kit-outdir>
|
||||
#
|
||||
# Environment: KARTFRAME_DAWN_DIR (patched Dawn package; default the newest pinned one),
|
||||
# KF_KIT_BUILD (build dir, default <repo>/build-kit), KF_SYSROOT_CACHE (default
|
||||
# ~/.cache/kartframe/sysroot), JOBS (default 4). Needs a development translation at <repo>/generated
|
||||
# (its shard lists only supply compile flags; none of it ends up in the kit). Takes the shared
|
||||
# build lock (~/Games/KartFrame/.build.lock) for the compile.
|
||||
set -euo pipefail
|
||||
|
||||
here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
repo=$(cd "$here/.." && pwd)
|
||||
[[ $# -eq 1 ]] || { echo "usage: make-kit.sh <kit-outdir>" >&2; exit 2; }
|
||||
out=$1
|
||||
build=${KF_KIT_BUILD:-$repo/build-kit}
|
||||
sysroot=${KF_SYSROOT_CACHE:-$HOME/.cache/kartframe/sysroot}
|
||||
dawn=${KARTFRAME_DAWN_DIR:-$(ls -dt "$HOME"/.cache/kartframe/dawn/*/ 2>/dev/null | head -1)}
|
||||
dawn=${dawn%/}
|
||||
[[ -d $dawn/lib ]] || { echo "no patched Dawn package (frame/build-dawn.sh)" >&2; exit 1; }
|
||||
[[ -e $repo/generated/build_shards/shards.cmake ]] || { echo "no translation at $repo/generated (flags only; see frame/DEV.md)" >&2; exit 1; }
|
||||
|
||||
tc=$("$here/toolchain.sh")
|
||||
"$here/make-sysroot.sh" "$sysroot"
|
||||
|
||||
if [[ ! -f $build/build.ninja ]]; then
|
||||
# No --sysroot here: SDL3 and friends need the host's X11/Wayland/ALSA headers. The sysroot is a copy
|
||||
# of the host's glibc/libstdc++/kernel headers and crt files (make-sysroot.sh), so the objects built
|
||||
# here are the ones the sysroot would have produced; the first-run build compiles and links with it.
|
||||
PATH=$tc/bin:$PATH cmake -S "$repo/runtime" -B "$build" -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_C_COMPILER="$tc/bin/clang" -DCMAKE_CXX_COMPILER="$tc/bin/clang++" \
|
||||
-DCMAKE_AR="$tc/bin/llvm-ar" -DCMAKE_RANLIB="$tc/bin/llvm-ranlib" \
|
||||
-DCMAKE_MAKE_PROGRAM="$tc/bin/ninja" \
|
||||
-DCMAKE_EXE_LINKER_FLAGS="-fuse-ld=lld" \
|
||||
-DMKW_DAWN_PACKAGE_DIR="$dawn" -DMKW_BUILD_TESTS=OFF
|
||||
fi
|
||||
exec 9>"$HOME/Games/KartFrame/.build.lock"
|
||||
echo "make-kit: waiting for the build lock"; flock 9
|
||||
"$tc/bin/ninja" -C "$build" -j"${JOBS:-4}" kartframe_kit_probe
|
||||
"$here/export-kit.py" --build "$build" --sysroot "$sysroot" --out "$out"
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Maintainer step: build the minimal compile/link sysroot that goes into the kit, from the files
|
||||
# of this machine's own packages (glibc headers and crt objects, libstdc++ headers, libgcc and the
|
||||
# crtbegin/crtend objects, the kernel headers glibc includes). The portable LLVM has no sysroot of
|
||||
# its own; without this the first-run build would compile against whatever /usr/include a user's
|
||||
# machine happens to have.
|
||||
#
|
||||
# make-sysroot.sh <destdir> creates <destdir> (replaced if present), prints its size
|
||||
#
|
||||
# Layout: <dest>/usr/include, <dest>/usr/lib (+ gcc/<triple>/<ver>), <dest>/lib -> usr/lib, and
|
||||
# <dest>/LICENSES with the origin packages and licence texts. Use it as `--sysroot=<dest>`.
|
||||
set -euo pipefail
|
||||
|
||||
[[ $# -eq 1 ]] || { echo "usage: make-sysroot.sh <destdir>" >&2; exit 2; }
|
||||
dest=$1
|
||||
command -v pacman >/dev/null || { echo "make-sysroot.sh needs pacman (it copies this Frame's packages)" >&2; exit 1; }
|
||||
rm -rf "$dest"; mkdir -p "$dest/usr/include" "$dest/usr/lib" "$dest/LICENSES"
|
||||
dest=$(cd "$dest" && pwd)
|
||||
ln -s usr/lib "$dest/lib"
|
||||
|
||||
# Every regular file or symlink the package owns under the given prefix, copied with its path.
|
||||
copy_pkg() { # <pkg> <regex on the absolute path>
|
||||
pacman -Ql "$1" | awk '{print $2}' | grep -E "$2" | grep -v '/$' | while IFS= read -r f; do
|
||||
[[ -f $f || -L $f ]] && cp -a --parents "$f" "$dest"
|
||||
done
|
||||
}
|
||||
|
||||
gccver=$(pacman -Q gcc | awk '{print $2}' | sed 's/+.*//')
|
||||
triple=aarch64-unknown-linux-gnu
|
||||
copy_pkg glibc '^/usr/include/'
|
||||
copy_pkg linux-api-headers '^/usr/include/'
|
||||
copy_pkg gcc "^/usr/include/c\\+\\+/$gccver/"
|
||||
copy_pkg glibc '^/usr/lib/(crt1|crti|crtn|Scrt1)\.o$'
|
||||
copy_pkg glibc '^/usr/lib/(libc\.so|libc\.so\.6|libc_nonshared\.a|libm\.so|libm\.so\.6|libmvec\.so|libmvec\.so\.1|ld-linux-aarch64\.so\.1|libdl\.a|libdl\.so\.2|librt\.a|librt\.so\.1|libpthread\.a|libpthread\.so\.0|libutil\.a|libutil\.so\.1)$'
|
||||
copy_pkg gcc-libs '^/usr/lib/(libstdc\+\+\.so[.0-9]*|libgcc_s\.so(\.1)?)$'
|
||||
copy_pkg gcc "^/usr/lib/gcc/$triple/$gccver/(crtbegin|crtbeginS|crtbeginT|crtend|crtendS|libgcc|libgcc_eh)\\.(o|a)$"
|
||||
[[ -f $dest/usr/lib/gcc/$triple/$gccver/crtbeginS.o ]] || { echo "gcc crt objects missing" >&2; exit 1; }
|
||||
[[ -f $dest/usr/include/c++/$gccver/vector && -f $dest/usr/include/stdio.h && -f $dest/usr/include/linux/types.h ]] \
|
||||
|| { echo "headers missing" >&2; exit 1; }
|
||||
|
||||
# Provenance and licences.
|
||||
{
|
||||
echo "Minimal sysroot for building KartFrame's translated game, copied from these packages of the"
|
||||
echo "SteamOS image it was made on (files only; nothing was modified):"
|
||||
echo
|
||||
for p in glibc gcc gcc-libs linux-api-headers; do
|
||||
pacman -Qi "$p" | awk -F' *: *' -v p="$p" '/^Name/{n=$2} /^Version/{v=$2} /^Licenses/{l=$2} END{printf " %-20s %-40s %s\n", n, v, l}'
|
||||
done
|
||||
echo
|
||||
echo "glibc: LGPL-2.1-or-later (GPL-2.0-or-later for some files), https://www.gnu.org/software/libc/"
|
||||
echo "gcc/libstdc++/libgcc: GPL-3.0-or-later with the GCC Runtime Library Exception, https://gcc.gnu.org/"
|
||||
echo "linux-api-headers: GPL-2.0 with the Linux syscall note, https://www.kernel.org/"
|
||||
echo "Object files linked into the game (crt*.o, libgcc, libc_nonshared) fall under the exceptions above."
|
||||
echo "Source for all of them is available from the upstream projects named here."
|
||||
} > "$dest/LICENSES/README.txt"
|
||||
for d in /usr/share/licenses/gcc /usr/share/licenses/gcc-libs; do
|
||||
[[ -d $d ]] && cp -a "$d/." "$dest/LICENSES/" 2>/dev/null || true
|
||||
done
|
||||
for l in GPL2 GPL3 LGPL2.1 FDL1.3; do
|
||||
[[ -f /usr/share/licenses/common/$l/license.txt ]] && cp -a "/usr/share/licenses/common/$l/license.txt" "$dest/LICENSES/$l.txt" || true
|
||||
done
|
||||
echo "sysroot: $dest ($(du -sh "$dest" | cut -f1), $(find "$dest" -type f | wc -l) files)"
|
||||
@@ -1,70 +0,0 @@
|
||||
# H1 (2026-10-13 03:07, first headset session of the native build): what happened
|
||||
|
||||
Session: `$KARTFRAME_HOME/sessions/20261013-030749` (game.log, vrtiming.log, report.txt), build `native`
|
||||
at eb8192d: eyes 1728x1728, display 120 Hz, pipelined pacing, TU_DEBUG=sysmem, projected menus,
|
||||
the old half-rate rule. Owner: "jittery, felt like half speed, ghosting when turning my head; the 2D
|
||||
screen with the VR menu on was flickering; the controls guide looked bad".
|
||||
|
||||
## Timeline
|
||||
|
||||
Seconds are the game's pacing seconds (`vr pacing: t=`). Game frames/s from the game's own frame
|
||||
counter in the `[mkw-vr]` lines (scene-frame) between two marks. GPU is the median of the per-second
|
||||
p50 and the worst per-second p99, ms per eye set.
|
||||
|
||||
| t (s) | what | sets/s | late | repeats | half-rate s | GPU p50 / worst p99 | encode p50 | game frames/s |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1-49 | boot, menus (projected), seated-space init at 37 | 102.6 | 0.9 % | 14.6 % | 10 | 2.06 / 7.76 | 2.86 | about 46 (boot included) |
|
||||
| 49-67 | race, restarted at 54 (same scene) | 75.4 | 7.6 % | 37.4 % | 12 | 6.54 / 10.97 | 2.59 | 32 to 37 |
|
||||
| 67-100 | race scene on the virtual screen (pause), focus blip at 83 | 105.4 | 0.0 % | 12.6 % | 8 | 2.12 / 8.02 | 2.84 | 16 (paused) |
|
||||
| 100-244 | race | 68.4 | 4.7 % | 43.2 % | 115 | 5.76 / 10.14 | 2.58 | 38.5 |
|
||||
| 244-248 | VR settings panel open (chord) | 60.0 | 0.0 % | 50.1 % | 4 | 4.30 / 8.82 | 2.88 | 25 |
|
||||
| 248-254 | race to the end | 75.2 | 2.1 % | 37.6 % | 6 | 6.91 / 11.38 | 2.72 | - |
|
||||
|
||||
The compositor was healthy throughout (vrtiming: 120 frames/s, app frames reused 0.3 %, compositor
|
||||
GPU 0.8 ms). The game asked for 90 Hz (`display refresh rates: 90; requested 90 Hz`, the only rate
|
||||
SteamVR offered), the display went to 90, then the per-app key put it back to 120.
|
||||
|
||||
Correction to the first reading: the session was not 5 s of race and 4 minutes of menus. The race
|
||||
restarted at t=54 ("Scene Restart") and no new "entered RaceScene" was logged, so session-report.py
|
||||
counted 168 s of racing as menus. The seconds at GPU 5 to 7 ms a set were the race (presentation
|
||||
immersive-race, scene 2); real menu seconds cost 2.1 ms a set, as in the mock. session-report.py now
|
||||
also uses the presentation lines.
|
||||
|
||||
## Causes (confirmed headless where possible)
|
||||
|
||||
1. **Half speed: the game ran at 38 game frames a second in the race.** aurora called
|
||||
SDL_SyncWindow on every game frame. On X11, when the window's geometry differs from what SDL
|
||||
expects (gamescope sizes the window itself; Xvfb has no window manager) and nothing is pending, it
|
||||
ends in SDL_Delay(10): 10 ms of every game frame on the game thread. Menus (6 ms of game logic) still
|
||||
made 60; the race (13 to 17 ms) dropped to 38, and Mario Kart's logic is frame-locked, so the race
|
||||
ran at 63 % speed. Mock A/B on one build (1728, sysmem, pipelined, demo race): old behaviour
|
||||
(`KARTFRAME_SYNC_WINDOW=1`) 38.1 game fps, 72.9 sets/s, 3.7 % late; fixed 59.8
|
||||
game fps, 120.6 sets/s, 0.0 % late, GPU 4.70 / 5.35 ms a set (old 6.13 / 8.17). Commit 57526ee.
|
||||
2. **Judder and head-turn ghosting: half rate.** The old rule dropped to every second refresh at
|
||||
13 late of 240 and came back after 10 s; it flapped 16 down / 15 up, 33 % of all ticks were
|
||||
repeats, and 115 of the 143 race seconds ran at half rate (60 Hz content shown twice). Half rate
|
||||
is now off by default (late sets repeat the retained layer, which SteamVR reprojects); commit
|
||||
b240caf.
|
||||
3. **Race GPU 5.8 to 6.9 ms a set** (p99 to 11), above the mock's 4.7 / 5.35 with the fix. Part of
|
||||
that may be cause 1 (the mock's old-behaviour run also showed 6.1 / 8.2), part SteamVR's own GPU
|
||||
work (0.8 ms) and the headset's clocks. Unconfirmed until the next session.
|
||||
4. **Flicker of the 2D screen.** In 67-100 (race scene on the virtual screen) the immersive
|
||||
display-copy source region alternated every game frame between the display copy (893x456) and the
|
||||
full-EFB fallback (939x528): 1100 log lines in 33 s. The 2D content is scaled from that region, so
|
||||
alternate frames were 5 % wider and 16 % taller. Menus are now a quad layer (commit b240caf); the
|
||||
fallback frequency is logged every 10 s now ("Immersive display-copy source: N frames with a
|
||||
display copy, M with the full-EFB fallback"). Whether the race HUD alternates the same way on the
|
||||
headset is to be read from the next session's log.
|
||||
5. **Projected menus.** Not the GPU cost (2.1 ms a set, intro included: mock with the welcome and
|
||||
controller guide shown, sysmem 2.10 / 2.87, tiled 2.06 / 2.70), but every menu set was rendered for
|
||||
both eyes and encoded at 2.9 ms. Menus are now a quad fixed in the room, cropped to the picture
|
||||
(1728x972 of the 1728 square), as GalaxyFrame; the first-run guide stays projected.
|
||||
|
||||
## What the owner should see in the next session
|
||||
|
||||
- The race at full speed (60 game frames a second), every refresh at 120 Hz, no switching to half
|
||||
rate (`mode=pipelined` in every `vr pacing` line, no "every second refresh" lines).
|
||||
- Menus: a flat screen in front, fixed in the room, no black bands, the VR panel beside it.
|
||||
- No 90 Hz request: "120 Hz not offered, requested none" in the log.
|
||||
- If the race GPU stays above 6.5 ms a set, late sets show as repeats (reprojected by SteamVR);
|
||||
the per-second lines and the display-copy fallback count tell which.
|
||||
@@ -1,69 +0,0 @@
|
||||
# H2 (2026-10-13 11:12, second headset session): what the owner saw, and lane G's fixes
|
||||
|
||||
Session `$KARTFRAME_HOME/sessions/20261013-111257`, build `native` 0441316: 120 Hz, pipelined, 119.7
|
||||
sets/s, 0.4 % late, 0.7 % repeats, compositor reused 0.2 %, GPU 3.5 ms menus / 5.9 ms race a set.
|
||||
The owner: (1) "when moving my head every image was doubled" (head still: fine), (2) "the HUD wasn't
|
||||
present and would flicker in only", (3) "the pointer and controller models didn't render when it was a
|
||||
2D screen". All runs below: mock runtime, real Turnip GPU, 1728 per eye, 120 Hz, TU_DEBUG=sysmem.
|
||||
|
||||
## 1. Doubled images
|
||||
|
||||
- Image/pose pairing is clean. New exact check: with `MOCK_XR_STAMP=1` the game stamps every eye image
|
||||
with the display time its set was located for (and the eye); the mock reads the stamp back from the
|
||||
image each projection layer submits and compares it with the locate of the layer's poses. Pipelined
|
||||
and inline, with and without 2 ms prediction jitter, menus and race: 10 064 to 14 318 eye images a
|
||||
run, every one rendered for the locate of its submitted poses, none with the other eye's stamp.
|
||||
- Display times: on SteamVR a pipelined set is located for "this prediction + one period", which need
|
||||
not be the next cycle's prediction. Layers are now ended with the display time their poses were
|
||||
located for (kept increasing; a repeated layer keeps the cycle's time); `KARTFRAME_END_TIME=cycle`
|
||||
is the old way. Mock with `MOCK_XR_PREDICT_JITTER=2`: old 0 of 7155 fresh layers carried their
|
||||
poses' time, new 7157 of 7159. Whether SteamVR's predictions jitter is in the next session's log.
|
||||
- The cause found: VR interpolation did not interpolate. A set's display time lies 2 to 3 refreshes
|
||||
after the newest game frame's boundary, and the weight `(display - boundary) / interval` was clamped
|
||||
to 1: in the pipelined race 98 % of sets showed the newest game frame whole (mock: 2 to 54 of ~1180
|
||||
sets per 10 s between two frames), so each game frame stayed on two refreshes, 60 Hz scenery on a
|
||||
120 Hz panel. Anything the eye follows (the road and walls streaming past when the head turns) shows
|
||||
twice. Head still and looking ahead, little moves across the view. `stereo::SceneClock` samples the
|
||||
scene at the display time minus a delay it learns (running mean of display minus boundary, minus
|
||||
half an interval): mock race now 80 to 94 % of sets between two frames, motion uniform (unit check:
|
||||
step error under 0.002 frames at 90 and 120 Hz, leads 16.7 to 33 ms). `KARTFRAME_SCENE_CLOCK=0` is
|
||||
the old rule. Cost: none (one subtraction a set); the scene is shown about half a game frame later
|
||||
than the old rule intended (and earlier than what it actually did, a whole frame held twice).
|
||||
|
||||
## 2. HUD missing or flickering
|
||||
|
||||
- Not the FOVs: the headset's "screen clip self-check -2.00000" is the check's "not run yet" value
|
||||
(menus, first race seconds); in the race it read 0.00000 on the real asymmetric FOVs too.
|
||||
- Cause: lane E's clip block was written at `screen_clip_offset(uniformSize)`, but `uniformSize` is
|
||||
padded to the 64-byte dynamic-offset alignment while the shader's clip fields follow the WGSL
|
||||
struct's end. The shader read padding (stale bytes), discarded nearly every 2D draw on the virtual
|
||||
screen, and let one through now and then on leftover bytes.
|
||||
- Found headless: `MOCK_XR_PRESS` scripted A presses take the mock into a real Grand Prix race (Luigi
|
||||
Circuit). With the owner's settings (hud_virtual_screen = true, hand_hud = false) the eyes had no HUD
|
||||
with the clip on or off (`KARTFRAME_HUD_CLIP=0` only blanks the CPU block); hud_virtual_screen =
|
||||
false showed it; removing the shader clip brought it back.
|
||||
- Fix: `ShaderInfo::structSize` places the block on both sides; the block carries a marker without
|
||||
which the shader does not clip (a future layout slip shows the HUD unclipped, never hides it).
|
||||
`frame/perf/hudrace.sh NAME` runs the scripted race and writes eye PNGs: TIME, LAP, position and map
|
||||
are on the screen in both eyes (mock FOVs now asymmetric, -52/44/48/-52 deg, self-check 0.00000).
|
||||
- `KARTFRAME_HUD_CLIP=0` is no longer needed as a wrapper default.
|
||||
|
||||
## 3. Controllers and pointer in menus
|
||||
|
||||
A menu set now also renders the room around the screen into a packed two-eye image (3456x1728): plain
|
||||
background, the controller models, the laser pointers with their marks, the screen's area transparent.
|
||||
It goes out as one projection layer with the set's poses under the screen's quad (plus the panel
|
||||
quad). `KARTFRAME_MENU_ENV=over` puts it over the quad, alpha-blended (controllers in front of the
|
||||
screen stay in front, if SteamVR keeps the layer order); `=0` turns it off. Mock, warm caches, menus
|
||||
30-90 s: 120.5 sets/s, 0 late, GPU 2.60 / 2.99 ms a set (without: 1.25 / 5.89), encode 0.52 ms.
|
||||
|
||||
## What the next session should show (game.log)
|
||||
|
||||
- `OpenXR eye fields of view (deg, left right up down): ...` once: the Frame's real FOVs (for the mock).
|
||||
- `vr display times (10 s): predicted step minus period ...`: SteamVR's prediction jitter and display
|
||||
lead; `located for another time than their cycle's` counts sets the new end time changed.
|
||||
- `VR interpolation weights (10 s)`: most sets "between" in the race.
|
||||
- `OpenXR menu environment layer ready (3456x1728, under the screen)`.
|
||||
- Owner: no doubling when turning the head in a race; HUD on the screen in front, steady (item box
|
||||
sliding up out of the frame clipped); controllers and laser in menus; whether the controllers
|
||||
disappear behind the screen when held in front of it (then try `KARTFRAME_MENU_ENV=over`).
|
||||
@@ -1,99 +0,0 @@
|
||||
# H3 (2026-10-13 13:22, third headset session): what happened, and lane H's fixes
|
||||
|
||||
Session `$KARTFRAME_HOME/sessions/20261013-132235` (game.log, vrtiming.log, report.txt), build `native`
|
||||
ff9b277 (lane G's fixes). Owner: no doubled images; "the frame rate felt really bad, flickering, it
|
||||
hurt, kind of nauseous"; "half speed at the start, every second of game time took two, better later";
|
||||
HUD practically fixed. Requests: (R1) full-screen 2D effects (Blooper ink, the black of a respawn)
|
||||
across the whole view; (R2) remove a thin transparent vertical line right of the map. Later: the menu
|
||||
laser's part over the screen and its target mark were hidden.
|
||||
|
||||
Session: 3.1 min, race 137 s at 90.8 sets/s, 24.2 % late, GPU 6.68 ms/set p50 (H2: 5.9), encode 2.59;
|
||||
SteamVR: app frames reused 4.4 % (H2 0.2), mispresented 1284 (H2 116).
|
||||
|
||||
## Causes
|
||||
|
||||
1. **Half speed at the race start: a PrimeFrame build.** Its ninja log: 1172 jobs written 13:23:25 to
|
||||
13:23:44 (mostly cache hits); the game started 13:22:46, the race at t=41. Slow game frames
|
||||
(`hitch:` lines) per 10 s: 192 and 285 in t=40-60, at 21.5 / 15.7 ms of game CPU and 4.0 / 3.7 ms
|
||||
run-queue wait each; afterwards 33 to 164 per 10 s at ~14 ms game CPU and 0.7 ms run-queue. The
|
||||
build is paused by the headset-in-use marker now (orchestrator).
|
||||
2. **Flicker: 20 to 36 % of race sets late, the whole race.** GPU p50 6.3 to 7.3 ms a set, p99 9 to
|
||||
10, against ~7.5 ms of GPU per 8.33 ms refresh left beside SteamVR's compositor: a late set repeats
|
||||
the retained layer and the scene stalls a refresh. Lane G's end-time rule made it worse: every
|
||||
layer was ended with its poses' locate time, so each late set went out with a display time one
|
||||
refresh older than its cycle's prediction (SteamVR counts such frames as mispresented; 1284 here).
|
||||
3. **"Located for another time than their cycle's ~1180 of ~1190" was not an off-by-one.** SteamVR's
|
||||
predictions jitter by +-0.02 ms; a pipelined set is located for "this prediction + one period", so
|
||||
nearly every set differed from its showing cycle's prediction by microseconds. Only the late sets
|
||||
(the -8.3 ms minimum) were a refresh off. The display lead was 25.06 ms in menus and 27.18 in the
|
||||
race (3 refreshes; the mock had 2).
|
||||
4. **GPU per set 6.7 ms vs H2's 5.9 is not lane G's cost.** Same mock race (Grand Prix, HUD on the
|
||||
screen, real FOVs, 3-refresh lead): H2's build 4.92 ms, H3's build 5.05, H3's with
|
||||
KARTFRAME_SCENE_CLOCK=0 5.03. The headset difference is not reproduced headless; candidates are the
|
||||
SoC's state in a third session of the day (CPU 85 to 90 C under load here) and SteamVR. Unconfirmed.
|
||||
|
||||
## Fixes (branch lane/H)
|
||||
|
||||
| commit | what |
|
||||
| --- | --- |
|
||||
| d46788d | Every xrEndFrame carries its cycle's predicted display time (KARTFRAME_END_TIME=locate: lane G's rule). Display-time line counts late sets apart from jitter. Mock: the Frame's per-eye FOVs, 3-refresh lead (MOCK_XR_LEAD), 0.02 ms jitter, an ERROR for any other end time, MOCK_XR_DUMP_STEP. |
|
||||
| 673408a | Game thread, pacing thread, Aurora's frame worker, GX thread and GPU watcher at nice -15 through RealtimeKit (else RLIMIT_NICE; KARTFRAME_THREADPRIO=high/nice/off). run.sh holds PrimeFrame's lock too; perf/bgload.sh. |
|
||||
| 4b87fcf | uclamp min 1024 for the game thread, 800 for the frame worker (KARTFRAME_UCLAMP=0 off). |
|
||||
| 0409ac2 | xrmock: unique head poses; the pose check had matched a "twin" locate (false stamp errors). |
|
||||
| f127ea4 | R1: blended full-frame 2D rectangles drawn after the scene go across the whole eye (KARTFRAME_FULL_VIEW_FADES=0 off). |
|
||||
| 798a4a5 | R2: half-scale copies at a fractional scale leave out their last 3 logical columns (KARTFRAME_COPY_TRIM=0 off). |
|
||||
| 3175a78 | Menus: environment layer over the screen quad by default (KARTFRAME_MENU_ENV=under: old order). |
|
||||
| 96bb810 | Eye replay skips blended screen filters with alpha under 2/255 (the race's every-frame full-frame quad is not one of them: R1's cost stays). |
|
||||
|
||||
## Numbers (mock, real Turnip GPU, 1728/eye, 120 Hz, sysmem, pipelined, Grand Prix race with HUD, both build locks)
|
||||
|
||||
| run | race sets/s | late | GPU p50 | game fps | slow frames / 74 s |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| H3 build (lane G), mock with real lead/FOV | 119.7 | 0.05 % | 5.05 | 58.4 | 423 |
|
||||
| lane H, nice only (B1) | 119.3 | 0.20 % | 5.30 | 59.4 | 244 |
|
||||
| lane H, nice + uclamp (H6 / J3) | 119.3 / 119.6 | 0.17 / 0.15 % | 5.33 / 5.40 | 60.0 | 85 / 111 |
|
||||
| same, KARTFRAME_FULL_VIEW_FADES=0 (J2) | 114.9 | 0.39 % | 5.02 | 60.0 | 152 |
|
||||
| final build 96bb810, shipping defaults (Z1) | 119.3 | 0.14 % | 5.34 | 60.0 | 89 |
|
||||
| same with bgload.sh 4 jobs at nice 5 (Z2) | 108.5 | 8.87 % | 5.66 | 44.5 | 1820 |
|
||||
|
||||
Unloaded, without vs with the thread changes (stamp runs): priority off 58.8 fps / 286 slow frames,
|
||||
nice only 58.2 / 376, nice + uclamp 60.0 / 75. R1 costs ~0.3 ms of GPU a set in the race (a blended
|
||||
full-frame quad is drawn every frame and now fills both eyes).
|
||||
|
||||
Background load (`frame/perf/bgload.sh`: clang compiles, run beside the race):
|
||||
|
||||
| load | priority off | nice -15 | nice -15 + uclamp |
|
||||
| --- | --- | --- | --- |
|
||||
| 4 jobs at nice 5 | 45.5 fps, 8.8 % late | 46.7 fps, 6.8 % | 45.9 / 44.7 fps, 7.5 / 9.2 % |
|
||||
| 5 jobs at nice 0 | 38.8 fps, 11.3 % | 39.5 fps, 8.8 % | 39.0 fps, 10.3 % |
|
||||
|
||||
The game does not hold 60 under a compile load whatever its priority: slow frames keep ~15 ms of game
|
||||
CPU and only 1.3 to 2.9 ms of run-queue wait, so the loss is shared cores, caches and memory, not
|
||||
scheduling. Pausing other builds while the headset is in use is the fix that matters.
|
||||
|
||||
Checks: pipelined and inline, exact release check (MOCK_XR_BUSY_MS): 23722 / 18208 releases, 0
|
||||
unfinished; stamps 17818 / 18246 eye images, 0 mismatches; end times 0 off; 0 errors. ctest 32/32.
|
||||
|
||||
## R1, R2, menu pointer (eye dumps, ~/.cache/kf-laneH/evidence)
|
||||
|
||||
- R1: race start fade-in: before, a black rectangle on the virtual screen; now the whole eye darkens.
|
||||
The Blooper ink was not reproduced headless (the player is last in the scripted race and is never
|
||||
inked); the ink is particle effects ("rk_gessoA*" in Race/Common.szs RKRace.breff). If it is drawn as
|
||||
blended full-frame rectangles it is covered; if as smaller particles it still sits on the screen: the
|
||||
"VR large 2D rectangle" lines of the next session will tell.
|
||||
- R2: the line is in the minimap texture: its bake copy (440x440 logical -> 220x220 IA4, half scale)
|
||||
holds a faint white stripe (alpha 1/15) in the last two or three logical columns; trimming 4 render
|
||||
pixels of the copy source removed it, 2 or 3 did not, clamping the sampler did not. What in the bake
|
||||
draws it is not known.
|
||||
- Menus: rays reach their marks on the screen; a controller raised in front of the screen is drawn over
|
||||
it (composited from MOCK_XR_DUMP_ALPHA dumps by a scratch script, both layer orders).
|
||||
|
||||
## What the owner should see in the next session
|
||||
|
||||
- Race at full speed, `thread priority:` lines with nice -15 and uclamp, no PrimeFrame build.
|
||||
- `vr display times`: "a refresh or more earlier, shown late: N" small; SteamVR mispresented near H2's.
|
||||
- `vr pacing` late per second: if still 20 % or more in the race with GPU above 6.5 ms, the GPU budget
|
||||
is the problem (try KARTFRAME_FULL_VIEW_FADES=0 to win ~0.3 ms, then the 90 Hz fallback).
|
||||
- A respawn: the black covers the whole view. The ink: whole view or still on the screen (report).
|
||||
- No vertical line right of the map; the map otherwise unchanged.
|
||||
- Menus: the laser ray and its mark visible on the screen; controllers in front of the screen.
|
||||
@@ -1,38 +0,0 @@
|
||||
# H4 (2026-10-13 18:57, fourth headset session, 90 Hz): lane I's first two fixes
|
||||
|
||||
Session `$KARTFRAME_HOME/sessions/20261013-185740`, build `native` 2fe42c4 at 90 Hz: pacing clean (89.8
|
||||
sets/s, 0.2 % late, compositor reused 0.1 %). Owner: doubling of the character (and the Time Trial ghost
|
||||
"like a PNG") when turning the head; the laser ray still not visible on the 2D menu screen.
|
||||
|
||||
## 1. Doubling on head turns: lane H's full-view fades took textured composites
|
||||
|
||||
Lane H's R1 (f127ea4) drew every blended full-frame 2D rectangle after the scene across the whole eye,
|
||||
mapped from frame space, i.e. pinned to the head. Untextured ones are fades (black, white flashes,
|
||||
wipes); textured ones composite a picture of the scene (the ghost from its offscreen copy, the player's
|
||||
character). Pinned to the head, such a picture doubles against the world on every head turn. The
|
||||
session's log has the textured, depth-tested full-frame rectangle (blend 1, depth func LESS, 2 TEV
|
||||
stages, 1 texgen) that R1 made full-view, one a frame in the Time Trial.
|
||||
|
||||
Fix (lane/I-fixes 6a9632b, lane/I 65e6fce): a full-view fade needs no texgens. Textured full-frame quads
|
||||
take their pre-lane-H path (the virtual screen, or the framebuffer-effect path when they sample a fresh
|
||||
EFB copy). `Full-frame 2D draw class: blend B logic L texgens N -> <path>` names each class once. Mock
|
||||
(demo race and a scripted Grand Prix, head sway +-0.3 rad): only "blend 1 logic 0/15 texgens 0" go
|
||||
across the eye (one a frame: a black quad blended by destination alpha, per eye pixel, so it cannot
|
||||
double); "blend 1 logic 15/3 texgens 1", "texgens 2" are textured and now stay on the screen. Not
|
||||
reproduced: a real ghost (no scripted route to a Time Trial). A textured composite of an offscreen
|
||||
render was never stereo-correct (before lane H either): it is flat on the screen. Making the ghost
|
||||
3D needs its own draws replayed per eye (follow-up).
|
||||
|
||||
## 2. Laser on the menu screen: SteamVR ignores our layer order
|
||||
|
||||
With "OpenXR menu environment layer ready (3456x1728, over the screen)" the ray and its mark, drawn in
|
||||
the environment projection layer over the screen's quad, still did not show on the screen in the
|
||||
headset, while the mock (which draws layers in submission order) showed them. So SteamVR on the Frame
|
||||
evidently composites the projection layer before (under) the quad layer whatever order xrEndFrame lists
|
||||
them in. Nothing may depend on the order of a projection layer against a quad layer there.
|
||||
|
||||
Fix (lane/I): the pointer's target marks (a dark-rimmed ring in the hand's colour with a white centre,
|
||||
both hands when both aim at the screen) are drawn into the screen quad's own image, on its surface:
|
||||
visible and stereo-correct under any layer order. The environment layer goes under the screen quad by
|
||||
default again (KARTFRAME_MENU_ENV=over: lane H's order). The ray now stops at the screen's edge where
|
||||
the quad covers it. Mock dump (menus, MOCK_XR_HANDS=1, t=35 s): both marks on the screen image.
|
||||
@@ -1,208 +0,0 @@
|
||||
# H7 (lane K): the seventh headset session, the zigzag on head turns, and sets rendered ahead
|
||||
|
||||
Sessions `$KARTFRAME_HOME/sessions/20261014-031224` (launch 1, `KARTFRAME_EYE_PAIRS=2`) and `20261014-031408`
|
||||
(launch 2, 6 pairs), both recorded (`KARTFRAME_RECORD=raw`) with one XRF line per frame
|
||||
(`KARTFRAME_FRAME_LOG=1`). Owner: both "bad, doubled on head turns", the second maybe worse. Panel
|
||||
analysis: `frame/perf/panels/analyse.sh`. Scripts for this page: `$HOME/.cache/kf-laneK/h7/` (not kept in
|
||||
the repo; `h7.py` the table, `xcurve.py` the alignment, `track.py` the feature check).
|
||||
|
||||
## 1. Does the zigzag follow the end time? No.
|
||||
|
||||
**Alignment, to the refresh.** A refresh with no frame (XRF wake-up step > 1) shows black on both panels.
|
||||
Panel refresh n (round(pts x 120)) shows the frame of wake-up refresh r = n + K, counted from the first XRF
|
||||
line by summed wake-up steps (no drift). Launch 1: K = 458; black-vs-missing correlation by K: 456 0.33,
|
||||
457 0.62, **458 1.00**, 459 0.62, 460 0.31 (the gaps are 3 refreshes wide), two anchors 44 s apart (r
|
||||
932-934 -> panels 474-476, r 6160-6162 -> 5702-5704), and the retained repeat r 6163 (f=0) lands on panel
|
||||
5705, pixel-identical to 5701 (r 6159), as a repeat must. Launch 2: K = 455 +-1 (fewer, messier gaps).
|
||||
In time: video t + 3.8 s = XRF w; video t + about 4.5 s = `vr pacing` t (not the 5.9 s of the sixth
|
||||
session: the recorder starts at a different point after the game each time; take K from black refreshes).
|
||||
Generic signals (e, f=0, late) correlate with no panel measure at any offset (|r| < 0.2, flat).
|
||||
|
||||
**The zigzag is real.** Launch 1, right eye, refreshes 3839-3847: three textured 48x48 patches tracked at
|
||||
full resolution: +3.9, -11.3, +4.0, -11.0 px (480-px units) per refresh against motion.txt's +3.3, -11.0,
|
||||
+3.6, -10.9. Every second refresh sits about 7.5 px off a smooth -3.5 px/refresh track: an image placed
|
||||
two refreshes of motion away, then back. Not pixel-identical to an earlier one (stale: 0.2 %).
|
||||
|
||||
Race, moving refreshes (n-1, n, n+1 captured back to back, median shift over n-2..n+3 at least 2 px);
|
||||
zigzag refresh: |pos(n) - mean(pos(n+-1))| >= 1 px and >= 0.75 refresh of motion; e, f, p of the frame
|
||||
shown on that refresh:
|
||||
|
||||
| launch | eye | e < 1 ms | e 1-2 | e 2-4 | e 4-6 | e > 6 | f=1 | f=0 repeat | p=0 / p=1 (launch 2: p0..p5) | menus |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1 (2 pairs) | L | 31 % (547, 1.8 px) | 33 % (18) | 33 % (6) | 1 of 1 | 3 of 4 | 31 % (572) | 2 of 2 | 27 / 36 % | 4 % (184) |
|
||||
| 1 (2 pairs) | R | **84 % (477, 6.1 px)** | 78 % (23) | 5 of 5 | 1 of 1 | 0 of 4 | 83 % (506) | 0 of 2 | 84 / 82 % | 6 % (224) |
|
||||
| 2 (6 pairs) | L | 45 % (29) | 28 % (138) | 27 % (146) | 36 % (67) | 22 % (96) | 30 % (381) | 19 % (48) | 17-56 % | (5 refreshes) |
|
||||
| 2 (6 pairs) | R | 56 % (52) | 69 % (130) | 71 % (106) | 69 % (58) | 72 % (86) | 68 % (350) | 68 % (47) | 55-82 % | (7 refreshes) |
|
||||
|
||||
(n moving refreshes in brackets; mean |zigzag| where given.)
|
||||
|
||||
- **e: no.** In launch 1's race 78 % of frames ended within 1 ms of the wake-up (e p50 0.11 ms) and the
|
||||
right eye zigzagged on 84 % of those refreshes. Neither eye's rate rises with e in launch 2 (e p50
|
||||
2.2 ms). e of the frame shown, of the frame before or after, the change of e, and e straddling 1, 2 or 4
|
||||
ms each explain under 4 % of the variance.
|
||||
- **f: no.** Launch 2 right eye: 68 % on fresh frames, 68 % on repeats, 71 % right after a repeat.
|
||||
- **p: no refresh-level effect.** Both pairs alike in launch 1; where a repeat flips pair against refresh
|
||||
parity the zigzag phase followed the pair 4 times of 7. The pair count changes the shape: with 2 pairs
|
||||
clean +-2-refresh jumps with period 2, with 6 pairs irregular jumps of 1 to 8 refreshes (only 8 to 11 % at
|
||||
a pair's own reuse distance).
|
||||
- **What does predict it: the eye and race against menus.** Same frames, same e, f, p: right 83 %, left
|
||||
32 %. Menus (98.6 % of ends under 1 ms): 4 to 6 %, the noise floor; but the menus are a quad layer that
|
||||
SteamVR places in the room itself, so they cannot show a projection-layer misplacement either way.
|
||||
|
||||
So the premise of this lane (frames ended late after the wake-up are shown inconsistently and that is the
|
||||
doubling) does not hold for this session: launch 1 was already ended at the wake-up for most of its race.
|
||||
The sawtooth is real (launch 2: e p50 2.2 ms, p90 7.5 ms; launch 1 in its worse seconds), and sections 2-4
|
||||
remove it, but on this evidence the doubling has another cause, specific to the eye, present with any end
|
||||
time and any pair count. Candidates not tested here: what SteamVR does with the right eye's layer (its pose
|
||||
or image) when it composes; a per-eye difference in our own submission; the race's scene motion (a game
|
||||
frame is two refreshes, the size of the jump) against the head's.
|
||||
|
||||
## 2. The fix for the sawtooth: sets rendered ahead (the new default of `pipelined`)
|
||||
|
||||
**Why the old loop drifted.** It had one set in flight: the set for refresh k+1 was located and published
|
||||
only after frame k was ended, and frame k was ended when its own set finished on the GPU (waiting up to
|
||||
the deadline, 1 ms before the next wake-up). A set costs encode (about 2.5 ms; behind a game frame's
|
||||
encode on the frame worker when one is running) plus GPU (about 5 ms p50 in the worse race seconds,
|
||||
queued behind the game frame's own GPU work): more than a refresh. So each frame ended later after its
|
||||
wake-up by the excess (e(k+1) = e(k) + cost - 8.33 ms; the race's +1.7 ms a refresh), until a set missed
|
||||
the deadline, the cycle repeated the retained layer, and the next set, started at once, ended near the
|
||||
wake-up again: the 0 to 10 ms sawtooth with a repeat every 4 to 5 refreshes. Not the frame worker
|
||||
interleaving as such, but the serial chain: the start of a set waited for the end of the last.
|
||||
|
||||
**The new cycle** (`openxr_integration.cpp` AheadCycle, backend `*AheadSet*` in `openxr_vulkan_win32.cpp`):
|
||||
|
||||
1. xrWaitFrame, xrBeginFrame.
|
||||
2. Among the sets due by this refresh, take the newest whose GPU work is finished (a non-blocking poll of
|
||||
the timeline semaphore); end the frame at once with it (its own poses, this cycle's display time),
|
||||
else with the retained layer. Never wait inside the cycle. A set due now and not finished counts late;
|
||||
if it finishes later it is shown on a later refresh unless a newer set is ready first, then it is
|
||||
released unshown (`dropped=`).
|
||||
3. Start the set for the display time two refreshes ahead (this cycle's predicted time + 2 periods),
|
||||
located now for that time. Aurora takes one set at a time, so the previous set must have reached
|
||||
Aurora's queue.Submit first (it has, normally: it was published a refresh ago; the wait is bounded by
|
||||
this cycle's deadline, else no set starts this refresh).
|
||||
|
||||
So each set has the rest of its starting refresh and all of the next (about 16 ms) for encode plus GPU,
|
||||
one set is on the GPU while the next is encoded, and the end of a frame no longer depends on any set's
|
||||
timing: it is the wake-up plus xrBeginFrame, the poll, two releases and xrEndFrame. With the race's
|
||||
measured costs (encode 2.5, GPU 5 p50, 11 p99 including queueing) a set finishes 3 to 9 ms before its
|
||||
wake-up; only a set over about 15 ms total misses its refresh. Pose prediction is one refresh longer
|
||||
than before (located about 42 ms before display instead of 33; SteamVR's own lead is 25 of those). The
|
||||
scene clock (interpolation) learns the new lead by itself. `KARTFRAME_AHEAD_START_US` (default 0)
|
||||
delays each start after the wake-up for less prediction at the cost of margin.
|
||||
|
||||
**Three eye pairs.** Two sets in flight plus the pair on display. A set never writes the displayed pair,
|
||||
so a set that is overtaken can be released unshown: a release makes that image its swapchain's newest,
|
||||
which for the displayed pair would change what the retained layer shows. With two pairs the second set
|
||||
would have to use the displayed pair. Pairs are reused first in, first out: with three, a pair is written
|
||||
again right after it left the display (as the old two-pair alternation did); `KARTFRAME_EYE_PAIRS=4..8`
|
||||
lets each rest longer. The old loop's default goes back to 2 (six were worse on the headset). The menu
|
||||
environment image travels with its set the same way (three of those too, made as needed).
|
||||
|
||||
**Switches.** `KARTFRAME_PIPELINED=wait` restores the old loop (with its own pair default, 2).
|
||||
`KARTFRAME_PACING=inline` is unchanged. Sets ahead need the timeline semaphore (polled); without it the
|
||||
backend keeps the old loop and the log says so. Log: "OpenXR pipelined pacing: sets rendered two
|
||||
refreshes ahead ..." and "OpenXR eye swapchain pairs: 3 (sets rendered ahead ...)"; the pacing line's mode
|
||||
reads `pipelined` (new) or `pipelined-wait` (old) and ends in `dropped=<n> end_late=<n>` (finished sets
|
||||
released unshown; xrEndFrame calls more than 1 ms after the wake-up). session-report.py shows both.
|
||||
|
||||
## 3. The mock scores it
|
||||
|
||||
`xrmock` now logs every xrEndFrame against the runtime's own wake-up (the refresh grid, so a late
|
||||
xrWaitFrame call cannot hide lateness) and prints at the end of the session:
|
||||
"xrmock: end after wake-up (ms, from the runtime's wake-up): N frames, p50, p99, max; histogram <0.5,
|
||||
0.5-1, 1-1.5, 1.5-2, 2-4, 4-6, 6-8.3, more; later than 1 ms K (P %)". `KF_MOCKRUN_END_GATE=<percent>` makes
|
||||
mockrun FAIL above that share (2 separates the two loops below: old 3.0 to 55 %, new 0.4 to 1.0 %).
|
||||
It also prints a late-end model ("xrmock: late-end model ..."): a frame ended more than
|
||||
`MOCK_XR_LATE_END_MS` (default 1.5) after its wake-up is taken for the refresh after its display time
|
||||
(PrimeFrame's H2 reading of SteamVR), each vsync shows the newest frame taken for it, and "zigzag" counts
|
||||
vsyncs whose pose step differs from the last by 2 or more (a 0 next to a 2). It is a model, not a measure:
|
||||
section 1 shows the headset's zigzag does not follow the end time, and the model makes no claim about it.
|
||||
lane J's compositor models (spec, lazyrel, lazyacq) are unchanged.
|
||||
|
||||
## 4. Measured: A/B on the scripted Grand Prix, SteamVR's compositor running
|
||||
|
||||
`frame/perf/abgp.sh`, interleaved A B A B, 90 s each, `MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1
|
||||
KARTFRAME_FRAME_LOG=1`; A = `KARTFRAME_PIPELINED=wait KARTFRAME_EYE_PAIRS=2`, B = the new default (3 pairs).
|
||||
At render scale 1.0 (the mock's own load) and 1.25 (measurement only: puts the mock's old loop at the
|
||||
headset's lateness). Per second, pacing lines after t=5; end ms = median of the seconds' p50 / p99 (worst
|
||||
second's p99); "> 1 ms" = xrEndFrame calls more than 1 ms after the wake-up, share of ticks.
|
||||
|
||||
| run | part | s | sets/s | late | end ms p50 / p99 (worst) | > 1 ms | dropped | GPU ms p50 / p99 | encode p50 | GPU MHz, max C |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1.0 A1 | race | 52 | 120.2 | 0.11 % | 0.16 / 0.69 (15.93) | 6.1 % | 0 | 4.68 / 5.06 | 2.68 | 802, 76 |
|
||||
| 1.0 B1 | race | 49 | 120.5 | 0.03 % | 0.16 / 0.64 (0.95) | 0.1 % | 0 | 4.53 / 4.93 | 2.27 | 797, 74 |
|
||||
| 1.0 A2 | race | 60 | 120.2 | 0.06 % | 0.15 / 0.62 (10.61) | 2.5 % | 0 | 4.54 / 4.96 | 2.59 | 803, 76 |
|
||||
| 1.0 B2 | race | 57 | 120.4 | 0.03 % | 0.14 / 0.55 (1.06) | 0.1 % | 0 | 4.62 / 4.99 | 2.29 | 803, 79 |
|
||||
| 1.25 A1 | race | 60 | **110.9** | **7.80 %** | **3.98 / 7.83** (16.98) | **78.7 %** | 0 | 6.17 / 6.64 | 2.53 | 802, 81 |
|
||||
| 1.25 B1 | race | 57 | **120.4** | **0.03 %** | **0.19 / 0.47** (0.97) | **0.1 %** | 0 | 6.29 / 6.77 | 2.50 | 803, 80 |
|
||||
| 1.25 A2 | race | 60 | 112.3 | 6.69 % | 3.89 / 7.72 (8.44) | 77.4 % | 0 | 6.11 / 6.56 | 2.48 | 803, 79 |
|
||||
| 1.25 B2 | race | 57 | 120.3 | 0.06 % | 0.18 / 0.45 (1.44) | 0.1 % | 0 | 6.32 / 6.77 | 2.54 | 801, 94 |
|
||||
| 1.0 A1 / B1 | menus | 32 / 35 | 119.5 / 120.2 | 0.18 / 0.07 % | 0.16 / 0.71 / 0.11 / 0.44 | 1.0 / 0.1 % | 0 / 1 | 2.36 / 2.33 | 0.48 / 0.54 | |
|
||||
| 1.25 A2 / B2 | menus | 24 / 27 | 120.5 / 120.7 | 0 / 0 % | 0.14 / 0.68 / 0.11 / 0.37 | 0.2 / 0.0 % | 0 / 0 | 3.66 / 3.77 | 0.55 / 0.52 | |
|
||||
|
||||
The mock's whole-session view (xrmock, every frame from the runtime's wake-up):
|
||||
|
||||
| run | end p50 / p99 / max ms | later than 1 ms | late-end model zigzag | lazyrel own poses L / R, eyes from different sets |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1.0 A1 / A2 | 0.34 / 6.15 / 17.3, 0.29 / 3.10 / 22.3 | 5.5 %, 3.0 % | 0.3 %, 0.2 % | |
|
||||
| 1.0 B1 / B2 | 0.28 / 0.89 / 14.7, 0.27 / 0.85 / 13.3 | 0.6 %, 0.4 % | 0.1 %, 0.0 % | |
|
||||
| 1.25 A1 / A2 | 1.67 / 8.08 / 41.8, 1.58 / 7.97 / 13.5 | 55.4 %, 55.4 % | 4.0 %, 3.6 % | A1: 41 / 22 %, 1357 of 7143 |
|
||||
| 1.25 B1 / B2 | 0.36 / 1.00 / 34.2, 0.34 / 0.89 / 25.9 | 1.0 %, 0.7 % | 0.0 %, 0.1 % | B1: 100 / 100 %, 0 |
|
||||
|
||||
- The sawtooth is gone: at the headset-like load the old loop ended 77 to 79 % of race frames more than
|
||||
1 ms after the wake-up (p50 3.9 ms, p99 7.8), the new one 0.1 % (p50 0.19, p99 0.47). The few later ends
|
||||
(max 25 to 34 ms) fall outside the race's seconds (its worst second's p99 was 0.97 to 1.44 ms): loading
|
||||
and scene changes.
|
||||
- And it holds the rate: at 1.25 the old loop showed 111 to 112 new sets a second (6.7 to 7.8 % late), the
|
||||
new one 120.3 to 120.4 (0.03 to 0.06 % late), on the same GPU time per set (6.2 to 6.3 ms p50): the
|
||||
overlap of one set's encode with the last one's GPU work is worth about 9 sets a second there.
|
||||
- Menus unchanged (0.1 to 0.2 ms p50). GPU clock the same in every run (797 to 803 MHz average); B2's
|
||||
94 C peak is the SoC's hottest zone late in the series, its numbers match B1's.
|
||||
- Checks, every run: 0 errors, 0 of 20356 to 21482 releases changed after the GPU passed them, stamped eye
|
||||
images all rendered for their submitted poses' locate (11786 of 11790 to 14296 of 14302; the 4 to 12
|
||||
others carry no stamp, as in the old loop), every xrEndFrame with its own predicted display time, lane
|
||||
J's spec model 100 % own poses. Under the lazyrel model the new loop is consistent (100 %, no split
|
||||
eyes), the old two-pair loop at 1.25 was not (41 / 22 %, 19 % split). ctest 32 of 32.
|
||||
- Negative control: `KARTFRAME_NO_GPU_WAIT=1` still fails the exact release check (4 of 9528 releases
|
||||
changed, 40 s in the menus); few, because a set rendered ahead is nearly always finished two refreshes
|
||||
later anyway.
|
||||
- Not measured: the real headset (SteamVR's own lead and composition), the new loop with
|
||||
`KARTFRAME_AHEAD_START_US` above 0, and with the settings panel's own layer (that layer is off in the
|
||||
game; its swapchains travel with the sets like the menu environment's).
|
||||
|
||||
## 5. Next headset session (two recorded launches, under a minute each)
|
||||
|
||||
Install `frame/perf/session-h8/01-old-ending.env` and `02-ahead.env` into `$KARTFRAME_HOME/session.queue/`
|
||||
(the wrapper takes them in name order, one per launch). Both record the panels and log every frame.
|
||||
|
||||
Each launch: menus, then straight into a race (Time Trial is easiest). **During the countdown, or with the
|
||||
kart braked to a standstill**, look at a track-side edge and turn the head slowly left and right for about
|
||||
five seconds; then drive and do the same for ten seconds; hold still for five. Say for each launch:
|
||||
doubled or not, which eye, and whether it was doubled while the kart stood still too.
|
||||
|
||||
1. `01-old-ending.env`: `KARTFRAME_PIPELINED=wait KARTFRAME_EYE_PAIRS=2` (the seventh session's launch 1).
|
||||
Expected: as before, doubled on turns. Logs: "OpenXR pipelined pacing: one set at a time ...", pacing
|
||||
lines `mode=pipelined-wait`, `end_ms` p99 several ms in heavier race seconds, `end_late` some per cent
|
||||
of ticks; XRF `e` a sawtooth in those seconds.
|
||||
2. `02-ahead.env`: `KARTFRAME_EYE_PAIRS=3`, the new default. Logs: "OpenXR pipelined pacing: sets
|
||||
rendered two refreshes ahead ...", "OpenXR eye swapchain pairs: 3 (sets rendered ahead ...)", pacing
|
||||
lines `mode=pipelined`, `end_ms` p50 about 0.2 and p99 under 1.5 ms in every race second, `end_late`
|
||||
under 1 % of ticks, `late` and `repeats` near 0, `dropped` near 0; XRF `e` under 1.5 ms on nearly every
|
||||
line, `l=0.00` on fresh frames, `p` cycling 0 1 2. session-report.py: "xrEndFrame later than 1 ms after
|
||||
wake-up" under 1 % (launch 1 several %).
|
||||
|
||||
Panel analysis for both (`frame/perf/panels/analyse.sh <session>`, then the zigzag table of section 1;
|
||||
align with the black refreshes, not a fixed offset). What it decides:
|
||||
|
||||
- Launch 2 clean, launch 1 doubled: ending at the wake-up was the fix after all (section 1 then
|
||||
under-measured it); keep the default.
|
||||
- Both doubled, zigzag in launch 2 at launch 1's rate (expected from section 1): the end time is not the
|
||||
cause; keep the new default anyway (no cost, no sawtooth, cleaner in the mock's lazy models) and look
|
||||
at the cause by the standstill part:
|
||||
- doubled with the kart standing still (head motion only): the eye images' placement (the poses SteamVR
|
||||
uses for the right eye, or our submission per eye); next, log both eyes' submitted poses per frame and
|
||||
compare the right eye's zigzag with its pose steps;
|
||||
- clean standing still, doubled only while driving: scene time (a game frame is exactly the two
|
||||
refreshes of the jump); next, the interpolation weights per set (`VR interpolation weights`) against
|
||||
the zigzag, and `KARTFRAME_SCENE_CLOCK=0` as an A/B.
|
||||
@@ -1,197 +0,0 @@
|
||||
# H9 (lane O): what KartFrame hands SteamVR, against GalaxyFrame
|
||||
|
||||
GalaxyFrame (native Vulkan, 120 Hz, both eyes from one locate every refresh, two eye swapchain sets,
|
||||
images released after a fence) was clean on the same headset on 2026-10-14 (30 s of head turns), while
|
||||
KartFrame doubles on head turns in every one of nine sessions. So SteamVR and the Frame are fine; this page
|
||||
lists every difference in what the two apps hand SteamVR, as SteamVR logged it (section 1), as the mock
|
||||
runtime sees the calls (section 2) and in the Vulkan hand-over (section 3), what was switched to
|
||||
GalaxyFrame's way (section 4) and the next session (section 5). Scratch: `$HOME/.cache/kf-laneO/h9/`.
|
||||
|
||||
## 1. What SteamVR logged (xrclient_*.txt, vrcompositor.txt)
|
||||
|
||||
Sources: `~/.local/share/Steam/logs/xrclient_kartframe.txt` (11:25 session, 8 pairs) and `.previous`
|
||||
(11:23, 6 pairs), `xrclient_galaxyframe.txt` (11:38, the clean session) and `.previous` (06:03), and
|
||||
`vrcompositor.txt` around each app's connection. Every log line was normalised (time stamps, handles and
|
||||
numbers out) and the message types of the two apps compared (`norm.py`, `scenes.py` in the scratch dir).
|
||||
Identical in both: the client start-up, the Vulkan renderer of the client ("Supports dmabuf formats +
|
||||
modifiers? - Yes!", "Enable advanced image processing of scene textures", "max texture dimensions
|
||||
3458x3458"), the session states, the LOCAL space ("xrLocateViews: Switching to TrackingUniverseSeated
|
||||
because XR_REFERENCE_SPACE_TYPE_LOCAL continued to be requested"), the projection bounds (full image, one
|
||||
image per eye), 120 Hz granted the same way, "WaitForPendingPresent: failed to wait for present" once at
|
||||
connection (both), no warning from SteamVR about either app's frames.
|
||||
|
||||
| | KartFrame | GalaxyFrame |
|
||||
| --- | --- | --- |
|
||||
| extensions requested | XR_KHR_vulkan_enable2, **XR_KHR_convert_timespec_time**, XR_VALVE_frame_controller_interaction, XR_FB_display_refresh_rate, XR_HTC_vive_cosmos/focus3_controller_interaction, XR_EXT_hand_tracking, ... | **XR_KHR_opengl_es_enable, XR_MNDX_egl_enable**, XR_KHR_vulkan_enable2, XR_VALVE_frame_controller_interaction, XR_FB_display_refresh_rate, XR_FB_space_warp, XR_META_recommended_layer_resolution, XR_META_performance_metrics, XR_EXT_eye_gaze_interaction |
|
||||
| client's Vulkan debug utils | "enabled" (Dawn's instance enables VK_EXT_debug_utils) | "disabled, missing entrypoints" |
|
||||
| eye swapchains | **VkFormat 50 (B8G8R8A8_SRGB)**, 1728x1728, sampleCount 1, array 1, mip 1, 3 images each; 6 to 16 eye swapchains (3 to 8 pairs) | **VkFormat 43 (R8G8B8A8_SRGB)**, 1555x1555 (its resolution 0.9), sampleCount 1, array 1, mip 1, 3 images each; 4 eye swapchains (2 sets) |
|
||||
| usage flags (from the mock, SteamVR does not log them) | **COLOR_ATTACHMENT, TRANSFER_DST** | **COLOR_ATTACHMENT, SAMPLED** |
|
||||
| remote image memory | 12001280 bytes for 1728x1728x4 = 11943936 + 57344, memory type 0 | 10088448 for 1555x1555: 1600x1568x4 + 53248, memory type 0 |
|
||||
| other swapchains | menu environment 3456x1728 (format 50; 11:23 only, 11:25 "xrCreateSwapchain failed (-10)" with 8 pairs), settings panel when open | UI quads 1600x900, 880x1300, 2048x1152 (2), 1280x940, 32x512, 64x64, 128x128, format 43 |
|
||||
| layers in the race | one projection layer | one projection layer plus quad layers (HUD, pointer) |
|
||||
| client's per-frame log | `Scene[eye:image]` for both eyes every frame, every frame a new image | the same |
|
||||
| per-eye image index | pairs 0-2 left = right; pairs 3-7 left one ahead (the menus' quad uses only the left image of a pair) | always equal |
|
||||
| compositor at exit (last two sessions) | 8007 / 7553 presents, 10 / 13 dropped, 45 / 32 reprojected; "ApplicationTime GPU 1.09 ms" | 13001 presents, 18 dropped, 74 reprojected; "ApplicationTime GPU 4.46 ms" |
|
||||
| shared images | 6 "VRMsg_CompositorNewSharedImage" (the menu quad as an overlay, "openxr_quadlayer_0") | none in the session (its quads are not overlays) |
|
||||
|
||||
Reading it:
|
||||
- The two real hand-over differences SteamVR shows are the **pixel format** (BGRA against RGBA: the
|
||||
client imports both as dmabufs with modifiers, both compressed, judging by the metadata after each image,
|
||||
but the compositor samples a B8G8R8A8 image on another path than R8G8B8A8 for all we know) and the
|
||||
**usage** (TRANSFER_DST against SAMPLED; it can change the modifier the client picks). The size
|
||||
(1728 against 1555) and the swapchain count are differences too.
|
||||
- "ApplicationTime GPU" is SteamVR's time between its own GPU marks in xrBeginFrame and xrEndFrame:
|
||||
GalaxyFrame submits its eye work between them, KartFrame's frame worker submits it at other times. Not a
|
||||
fault, but SteamVR sees a 1 ms app.
|
||||
- Nothing in either log names a per-eye problem; SteamVR's counters are as clean for KartFrame as for
|
||||
GalaxyFrame (0.4 to 0.6 % reprojected).
|
||||
|
||||
## 2. The OpenXR calls per frame (both apps against the same mock)
|
||||
|
||||
`xrmock` has a detail trace now (`MOCK_XR_TRACE_DETAIL=1`): every line ends in "@ms after the frame's
|
||||
scheduled wake-up", plus the reference spaces with their poses, every xrLocateViews (space, display time
|
||||
against the frame's predicted one), xrWaitSwapchainImage's timeout, and per xrEndFrame the blend mode,
|
||||
display time, next chains, and per layer its space, flags, rects, array indices, fields of view and the
|
||||
locate its poses came from. GalaxyFrame's installed binary ran against KartFrame's mock in a scratch home
|
||||
(`gfmock.sh`: `GALAXYFRAME_HOME` scratch, the game data linked read-only, `PETARI_GFX=vk`, 30 s,
|
||||
`TU_DEBUG=sysmem`); KartFrame the scripted Grand Prix (`frame/perf/hudrace.sh`, 90 s). Steady frames
|
||||
(GalaxyFrame f2500, KartFrame race f9000, baseline build 3337d22):
|
||||
|
||||
| | KartFrame (baseline) | GalaxyFrame |
|
||||
| --- | --- | --- |
|
||||
| order in a refresh | wait @0.07, begin @0.18-0.33, release L, R, **xrEndFrame @0.29-0.53**, then locate for **+2 refreshes**, acquire + wait L, R (Aurora renders the set on its frame worker) | wait @0.08, begin @0.21, **locate for +1 refresh**, acquire + wait L, R, record and submit both eyes, release the previous set L, R @0.48-0.76, release 3 UI quads, **locate for this frame's own display time** @0.82-0.86, acquire UI quads, **xrEndFrame @0.91-1.01** |
|
||||
| last xrLocateViews before xrEndFrame | the one for the set two refreshes ahead (made the refresh before) | one for the frame's own display time, right before the end |
|
||||
| reference spaces | VIEW, LOCAL, and a recentred LOCAL (pose 0, 1.6, 0 and a yaw) for locates and layers | the same three (LOCAL twice, VIEW, then the recentred LOCAL) |
|
||||
| poses submitted | the set's own locate, for the end's display time (+-0.003 refreshes of SteamVR-like jitter) | the same |
|
||||
| xrWaitSwapchainImage | infinite timeout | infinite timeout |
|
||||
| xrEndFrame | blend OPAQUE, display time = the frame's predicted time, no next chain; one projection layer, flags 0, recentred LOCAL, each view its own swapchain, rect 0,0 1728x1728, array 0, no next chain (no depth) | the same, 1555x1555; plus three quad layers (flags BLEND_TEXTURE_SOURCE_ALPHA) |
|
||||
| ends after the wake-up (mock) | p50 0.47 ms, 1.6 % over 1 ms | p50 0.96 ms, 42.6 % over 1 ms |
|
||||
| swapchains | 3 eye pairs (6), 2 environment (3456x1728), usage 0x11; per pair left and right image indices out of step (the menus' screen uses only the left image) | 4 eye swapchains, 8 UI, usage 0x21; indices in step |
|
||||
| extensions (mock) | vulkan_enable2, convert_timespec_time, frame_controller, display_refresh_rate, performance_settings | opengl_es_enable, MNDX_egl_enable, vulkan_enable2, frame_controller, display_refresh_rate, performance_settings, eye_gaze_interaction (the mock offers no META/space_warp) |
|
||||
|
||||
Nothing GalaxyFrame submits is missing from KartFrame's projection layer (no depth info, no flags, no
|
||||
composition settings: GalaxyFrame's layer settings and SpaceWarp are off on Vulkan). The differences are
|
||||
the order (GalaxyFrame acquires and records the next set before it releases and ends; KartFrame releases
|
||||
and ends first), the extra locate for the frame's own time before the end, the lead of the set's locate
|
||||
(+1 against +2 refreshes; KartFrame's old one-set loop, `KARTFRAME_PIPELINED=wait`, located for +1 like
|
||||
GalaxyFrame and doubled the same, H7 launch 1), when in the refresh the end comes (KartFrame earlier) and
|
||||
the quad layers beside the eyes.
|
||||
|
||||
## 3. The Vulkan hand-over
|
||||
|
||||
GalaxyFrame: `platform/src/xr/xr_gfx_vk.cpp`, `gx/vk_renderer.cpp` (transitionImage, Barriers). KartFrame:
|
||||
`openxr_vulkan_win32.cpp`, `aurora-main/lib/webgpu/vulkan_win32_interop.cpp` (Encode, Submitted), the Dawn
|
||||
patch (`AuroraDawnVulkanWrap`, `AuroraDawnVulkanRelease`, Dawn's `TextureVk.cpp` barrier rules).
|
||||
|
||||
| | KartFrame | GalaxyFrame |
|
||||
| --- | --- | --- |
|
||||
| format, views | **B8G8R8A8_SRGB**, one sRGB view, no MUTABLE_FORMAT (sRGB sink) | **R8G8B8A8_SRGB**, one sRGB view, no MUTABLE_FORMAT |
|
||||
| usage | **COLOR_ATTACHMENT, TRANSFER_DST** | **COLOR_ATTACHMENT, SAMPLED** |
|
||||
| how the eye gets in | a Dawn render pass (LOAD_OP_CLEAR, STORE) with a full-screen triangle that loads Aurora's eye texture and writes it through the sRGB view | its own dynamic-rendering passes composite the eye straight into the image |
|
||||
| barrier at the first write after xrWaitSwapchainImage | Dawn's tracked state: COLOR_ATTACHMENT_OPTIMAL to COLOR_ATTACHMENT_OPTIMAL, src COLOR_ATTACHMENT_OUTPUT / COLOR_ATTACHMENT_READ+WRITE, dst the same, queue families IGNORED | `assumeImageLayout`: src **ALL_COMMANDS / MEMORY_WRITE**, dst COLOR_ATTACHMENT_OUTPUT / COLOR_ATTACHMENT_READ+WRITE, old layout COLOR_ATTACHMENT_OPTIMAL (UNDEFINED where the pass discards), queue families IGNORED |
|
||||
| barrier before the release | COLOR_ATTACHMENT_OPTIMAL to COLOR_ATTACHMENT_OPTIMAL, COLOR_ATTACHMENT_OUTPUT, COLOR_ATTACHMENT_READ+WRITE to the same, IGNORED (appended by `AuroraDawnVulkanRelease`, flushed in its own submit) | none when the image is already COLOR_ATTACHMENT_OPTIMAL after the pass (else a transition to it), IGNORED |
|
||||
| ownership transfer to EXTERNAL / FOREIGN | none | none |
|
||||
| GPU finished before the release | timeline semaphore signalled after the set, waited before every release (mock: 0 of 21182 released images changed afterwards) | timeline value per submit, waited before the release (and re-checked in it) |
|
||||
| command buffers | one Dawn command buffer for both eyes (and the test stamp), one more submit for the release barriers | one command buffer for both eyes |
|
||||
| GPU writes after the release barrier | none: the stamp (tests only) and the sink draw are in the set's encoder before it; the release submit holds only barriers | none |
|
||||
| reads of swapchain images | never | none for the eyes in play (SAMPLED is there for UI mips and debug shots) |
|
||||
| an image on display rewritten | no: a set never writes the pair on display (lane K); mock spec and lazyrel models 100 % own poses | no: two sets, the one on display is not acquired again until the next is released |
|
||||
| queue | one queue; SteamVR's client submits on it inside our begin, end, acquire and release calls, under Dawn's device guard | one queue, one thread |
|
||||
| memory (SteamVR) | 1728x1728x4 + 56 KiB (compressed: UBWC metadata) | 1600x1568x4 + 52 KiB (compressed) |
|
||||
|
||||
Memory visibility for the compositor (a compressed image read stale by a foreign reader): both apps give
|
||||
SteamVR the same guarantee, the image finished on the GPU and in COLOR_ATTACHMENT_OPTIMAL, without a queue
|
||||
family release to EXTERNAL and without a flush of their own; SteamVR's client does the hand-off on our
|
||||
queue after our release. If that left a compressed image partly stale, GalaxyFrame would show it too, and
|
||||
a stale image would show old pixels: lane N's search (below) finds the doubled refreshes are not pixel
|
||||
matches of older (or newer) images. KartFrame's release barrier is the stronger of the two; GalaxyFrame's
|
||||
first-write barrier is (it waits for ALL_COMMANDS and MEMORY_WRITE, so it orders after whatever SteamVR's
|
||||
client recorded at the acquire). Making Dawn do that needs a new Dawn entry point (a rebuild of Dawn);
|
||||
not done here, it is the next candidate if the format does not decide it (section 5).
|
||||
|
||||
## 4. What changed (switches, default GalaxyFrame's way)
|
||||
|
||||
- `KARTFRAME_XR_FORMAT=rgba|bgra` (default rgba): the eye pairs are R8G8B8A8_SRGB (VkFormat 43) whenever
|
||||
the sRGB sink is on and SteamVR offers 43 (it does). Aurora still renders BGRA; the sink's draw writes
|
||||
any channel order, so it costs nothing. The menus' screen image (the left image of a pair) went in by a
|
||||
copy before; with RGBA it takes the same draw. The menu environment and settings panel swapchains keep
|
||||
Aurora's BGRA (they take copies). `bgra` is the old format; the copy and blit sinks keep BGRA.
|
||||
- `KARTFRAME_XR_USAGE=galaxy|kart` (default galaxy): eye swapchains COLOR_ATTACHMENT | SAMPLED (0x21), with
|
||||
TRANSFER_DST only where a copy can still reach an eye image (BGRA eyes, the test stamp, the copy sinks);
|
||||
`kart` is the old COLOR_ATTACHMENT | TRANSFER_DST (0x11).
|
||||
- `KARTFRAME_END_LOCATE=1|0` (default 1): one xrLocateViews for the frame's own display time between the
|
||||
set's release and xrEndFrame, as GalaxyFrame's last call before its end (result unused; eye-set frames
|
||||
only, not the menus; through the runtime's dispatch). About 0.01 ms.
|
||||
- `KARTFRAME_XR_EXTENSIONS=galaxy` (default KartFrame's list): exactly GalaxyFrame's extension request
|
||||
(its GLES binding extensions beside Vulkan's, XR_FB_space_warp, XR_META_recommended_layer_resolution,
|
||||
XR_META_performance_metrics, XR_EXT_eye_gaze_interaction; no XR_KHR_convert_timespec_time, no other
|
||||
controllers' profiles, no hand tracking). Not the default: it drops hand steering and the other
|
||||
headsets' controller profiles. Without the clock extension, display times read XrTime as
|
||||
CLOCK_MONOTONIC (SteamVR's clock); that choice is now made once at the first frame (a stalled frame
|
||||
switched it to the anchored fallback before, which the mock's stamp check caught in a dump stall).
|
||||
- Log line: "OpenXR Vulkan swapchains ready: VkFormat 50 (Aurora 44), eye swapchains VkFormat 43 usage
|
||||
0x21 (KARTFRAME_XR_FORMAT=rgba, KARTFRAME_XR_USAGE=galaxy), end locate on (KARTFRAME_END_LOCATE), ...";
|
||||
"OpenXR extensions asked for: ...".
|
||||
- Not switched: the order (acquire and record the next set before the release and end: sets ahead keep
|
||||
three pairs in rotation and would need a fourth), the +1 lead (tested: the old loop), the eye size (1555
|
||||
is GalaxyFrame's resolution 0.9; 1728 is the floor here), the quad layers (none in KartFrame's race),
|
||||
the first-write barrier (Dawn).
|
||||
|
||||
Mock checks (scripted Grand Prix, 90 s, `MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KARTFRAME_FRAME_LOG=1
|
||||
KF_MOCKRUN_END_GATE=2`, SteamVR's compositor running):
|
||||
|
||||
| run | errors | releases changed after the GPU | stamps for their poses' locate | end times off | ends > 1 ms after wake-up | gate |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| baseline 3337d22 | 0 | 0 of 21182 | 13292 of 13298 (6 unstamped) | 0 of 10649 | 1.6 % | ok |
|
||||
| new defaults (rgba, galaxy usage, end locate) | 0 | 0 of 21182 | 13296 of 13304 (8 unstamped) | 0 of 10653 | 1.8 % | ok |
|
||||
| launch 1 (defaults + galaxy extensions) | 0 | 0 of 21228 | 13336 of 13338 (2 unstamped) | 0 of 10673 | 0.9 % | ok |
|
||||
|
||||
Race seconds alike (end p50 0.18 ms in both, late sets 14 of about 3970). Validation layer
|
||||
(`VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`, defaults, 100 s, menus and race, usage 0x21): 0 messages,
|
||||
0 mock errors. ctest 32 of 32. Eye images (dumps at 65 and 75 s) identical in colour to the baseline's;
|
||||
the race position number's colour changes from run to run in every lane's dumps (game state), not with
|
||||
the format.
|
||||
|
||||
## Lane N's future-lag analysis (uncommitted, `$HOME/.cache/kf-laneN/`): verdict
|
||||
|
||||
Lane N asked whether the doubled refreshes are images newer than the frame's poses (SteamVR reading an eye
|
||||
after a newer release) as well as older ones. On the headset recordings (`cross-*.txt`, `cadence-*.txt`:
|
||||
best match of each refresh among the 8 before and the 8 after, per eye, at rest, slow and turning) the
|
||||
matches are symmetric in past and future (for example 20261014-111622 right eye turning: -7 17.4 %, -5
|
||||
15.0 %, +5 10.8 %, +7 12.8 %), and pixel-exact matches with another refresh are rare and as often future
|
||||
as past (0 to 4.7 %, right eye at rest 9 past / 3 future). In the mock, a lazy compositor shows newer
|
||||
images only if it reads the eyes after the vsync (3 pairs: 96 to 97 % newer when read 0 to 5 ms after;
|
||||
none when read before), and with 6 pairs almost never; yet 6 and 8 pairs were worse on the headset. So the
|
||||
doubled image is neither an older nor a newer swapchain image: the content is right for its set and placed
|
||||
off, which is what H7 measured (not pixel-identical, two refreshes of head motion). That points at how
|
||||
SteamVR takes our layer, not at which image it reads; the hand-over differences above are what is left.
|
||||
|
||||
One older data point: the third session (H3, 20261013-132235, lane G's build) is the only KartFrame
|
||||
session the owner called free of doubling. It ended every layer with its poses' locate time (lane G's
|
||||
`KARTFRAME_END_TIME=locate`) and had 24 % late sets; its frame rate "felt really bad", which may have
|
||||
hidden the doubling. The format, usage, order and extensions were as in every other session.
|
||||
|
||||
## 5. Next headset session (session 10)
|
||||
|
||||
Files: `frame/perf/session-h10/01-galaxy-way.env`, `02-format-alone.env`, `README.md`. Install 01 into
|
||||
`$KARTFRAME_HOME/session.queue/` (and 02 only as the README says). Each records the panels and logs every
|
||||
frame. Build: this lane's branch installed with `install --dev`.
|
||||
|
||||
Expectations, launch 1 (everything GalaxyFrame's way):
|
||||
- game.log: "OpenXR extensions asked for: GalaxyFrame's list (KARTFRAME_XR_EXTENSIONS=galaxy)";
|
||||
"OpenXR display times: no conversion offered; CLOCK_MONOTONIC fallback" and "OpenXR: no clock
|
||||
conversion; XrTime - CLOCK_MONOTONIC = <about 25000> us, reading XrTime as CLOCK_MONOTONIC"; "OpenXR
|
||||
Vulkan swapchains ready: VkFormat 50 (Aurora 44), eye swapchains VkFormat 43 usage 0x21
|
||||
(KARTFRAME_XR_FORMAT=rgba, KARTFRAME_XR_USAGE=galaxy), end locate on"; pacing as session 9 (`mode=pipelined`,
|
||||
end_ms p50 about 0.2, late near 0).
|
||||
- `~/.local/share/Steam/logs/xrclient_kartframe.txt`: the extension line starts "XR_KHR_vulkan_enable2,
|
||||
XR_KHR_opengl_es_enable, XR_MNDX_egl_enable, ..."; "Created remote Vulkan image ..., dimensions 1728 x
|
||||
1728, format 43" for the eye swapchains and "format: 43" after their CreateSwapchain (the 3456x1728
|
||||
menu environment stays format 50); "Scene[...] [1728x1728]" every frame as before.
|
||||
- Colours as before (a channel swap would show red skies at once; the mock's dumps are right).
|
||||
- What it decides: clean (no doubling on head turns, standing or driving, either eye) means the hand-over
|
||||
was the cause: then launch 2 (format alone) says whether the format is all of it. Still doubled means
|
||||
none of these four differences: the next candidates are the order of the calls (acquire and render the
|
||||
next set before releasing and ending), GalaxyFrame's first-write barrier (src ALL_COMMANDS, a Dawn
|
||||
change), the quad layers beside the eyes, and the eye size (a measurement at render_scale 0.9).
|
||||
- Panel analysis as before (`frame/perf/panels/analyse.sh <session>`, the zigzag table of H7 section 1).
|
||||
@@ -1,63 +0,0 @@
|
||||
# Lane S: is the race's double image already in the eye images KartFrame renders? No.
|
||||
|
||||
## Method
|
||||
|
||||
Scripted mock race (`frame/perf/mockdump/dumprace.sh`, wrapping hudrace.sh/run.sh, mockrun ok in every run):
|
||||
Moo Moo Meadows, the head still until 51 s, then a 25 deg / 1.5 s yaw sway (`MOCK_XR_SWAY`, about 2 px a
|
||||
refresh at 432 px across, up to 3.5); the kart stands until A is held from 56 s (`MOCK_XR_PRESS` hold). Five
|
||||
windows of 240 consecutive sets (both eyes, every release) were dumped without stalling the app
|
||||
(`MOCK_XR_DUMP_QUEUE`: blit to 432 px, written when the dumps pause; end after wake-up p99 1.1 ms during the
|
||||
windows; 0-1 refreshes without a set per window, 12 in the first). Each image's stamp (eye, display time, scene words) is in
|
||||
index.txt. Phases checked on the images: 48.5 s kart standing after GO, 52.5 s standing, 58.5 s leaving the
|
||||
start line, 62.5 and 66.5 s driving.
|
||||
|
||||
`frame/perf/mockdump/zigzag.py`, per eye and window:
|
||||
- **view**: global shift from column projections of the upper image (motion.py's fit), H7's zigzag rule as in
|
||||
session-h12's zig2.py (moving: |speed| >= 2 px; zigzag: |pos(n) - mean(pos(n+-1))| >= 1 px and >= 0.75 refresh).
|
||||
- **betweenness** (catches any motion, forward too): r = D(n-1, n+1) / mean(D(n, n-1), D(n, n+1)), RMS image
|
||||
differences of the upper image and the ground left and right of the kart.
|
||||
- lagdiff (each set against the same eye 1 and 2 sets before), eye phase, the scene stamp's steps.
|
||||
- Positive control `ZZ_INJECT=2`: every odd set of the left eye replaced by the image of two sets before.
|
||||
|
||||
Run s3 (`KF_MOCKRUN_RENDER_SCALE=0.75`: the race at 60 fps, scene stamps exactly 0.25/0.75 as on the
|
||||
headset's XRS lines, GPU 4.0 ms p50) and s2 (scale 1.0, GPU 6.7 ms, game 50-53 fps: the scene clock clamps,
|
||||
steps sd 0.3 game frames).
|
||||
|
||||
## Results (view track; moving sets / zigzag)
|
||||
|
||||
| window | phase | s3 L | s3 R | s2 L | s2 R | |dev| p95 px (s3) |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 48.5 | kart and head still (noise floor) | 0 moving; 0 of 236 sets >= 1 px | same | same | same | 0.00 / 0.00 |
|
||||
| 52.5 | standing, head swaying | 112 / 0 | 107 / 0 | 110 / 0 | 105 / 0 | 0.20 / 0.17 |
|
||||
| 58.5 | driving off | 103 / 0 | 108 / 0 | 92 / 0 | 98 / 0 | 0.19 / 0.18 |
|
||||
| 62.5 | driving | 115 / 0 | 118 / 0 | 113 / 0 | 115 / 0 | 0.14 / 0.16 |
|
||||
| 66.5 | driving | 137 / 0 | 143 / 0 | - | - | 0.10 / 0.11 |
|
||||
| control (62.5, 66.5) | odd L sets = n-2 | 87 / 87, 101 / 100 (1.9 refreshes, lag-1 autocorr -1.00) | 0 % | | | 6.1 / 0.16 |
|
||||
|
||||
- Zigzag share 0.0 % in both eyes, standing and driving, at both loads; the deviation stays under 0.1 refresh
|
||||
of motion (p95 0.10-0.20 px at 1.9-2.2 px a refresh). Lag-1 autocorrelation of the deviation -0.01 to -0.60
|
||||
(white position noise gives -0.67; a period-2 jump gives -1.00 with 2-refresh amplitude, as the control).
|
||||
L/R deviations +0.15 to +0.46 (no alternation to phase).
|
||||
- Betweenness, driving with an even scene clock (62.5, 66.5): r < 1 in 0 (upper) and 0-15 (ground) of 238 sets
|
||||
per eye, lag-1 autocorrelation of r +0.88 to +1.00 (smooth); the control: upper r < 1 in 17-28 sets and
|
||||
autocorrelation -0.31/-0.35 in the injected eye only.
|
||||
- lagdiff: no image identical to or nearer an older set while moving (3 of 238 at a sway reversal, both eyes).
|
||||
- Scene stamps: the two eyes carry the same scene words in 2160 of 2160 sets. Where the mock's game ran under
|
||||
60 fps (s2; s3 at 58.5 s) the track-side strips followed the stamp's uneven steps in both eyes in phase
|
||||
(step correlation +0.2 to +0.67, L/R +0.31 to +0.86): the scene clock's own judder, identical per eye, absent
|
||||
on the headset (XRS weights 0.25/0.75 there). The strip track itself is too noisy for H7's rule while
|
||||
driving (fit outliers; not used for the verdict).
|
||||
|
||||
## Verdict
|
||||
|
||||
The eye images KartFrame renders and releases are smooth in both eyes, with the kart standing and driving and
|
||||
the head turning: no period-2 jump, no stale or older-set image, no eye difference, at the noise floor (0 %),
|
||||
while the same test flags an injected period-2 jump in 99-100 % of moving sets. Within what a mock can show,
|
||||
the double image is made after the release: in what SteamVR does with the layers (or in a timing that only
|
||||
the headset has). Not covered: the headset's own GPU/driver timing (the mock ran at two GPU loads, 4.0 and
|
||||
6.7 ms a set, both clean).
|
||||
|
||||
## Next
|
||||
|
||||
`frame/perf/session-h13/README.md`: an xrprobe replay of these recorded race images through GalaxyFrame's
|
||||
loop, with A/B cases for the per-launch eye and the KartFrame hand-over details.
|
||||
@@ -1,109 +0,0 @@
|
||||
# Lane U: does the release wait for the GPU work that wrote the image? Yes (verdict B)
|
||||
|
||||
Question (after session 13: KartFrame's own race images through a minimal loop were clean on the headset in
|
||||
all six cases): can a set be released while its images are still unwritten, so that SteamVR shows the
|
||||
image's content from one reuse ago (H7: the jump follows the pair count)? And what else differs per
|
||||
process between the probe and the game at submission?
|
||||
|
||||
## 1. The order, read from the code
|
||||
|
||||
- Aurora's frame worker encodes both eyes and the sink draw into the swapchain images in one command buffer,
|
||||
then, holding `g_queueSubmitMutex`: `g_queue.Submit(1, &buffer)` and right after it the sink's `submitted`
|
||||
callback (aurora.cpp, `submitEncodedSlot` / the replay path).
|
||||
- Dawn (pinned 13abc3bc, `src/dawn/native/vulkan/QueueVk.cpp`): `Queue::SubmitImpl` records the command
|
||||
buffers into the pending recording context and calls `SubmitPendingCommandsImpl()` at once, which calls
|
||||
`vkQueueSubmit`. Nothing is deferred: when `Submit` returns, the render work is on the VkQueue.
|
||||
- `Bridge::Submitted` -> `AuroraDawnVulkanRelease` (patch `aurora_vulkan_interop.inc`): under Dawn's device
|
||||
guard, the COLOR_ATTACHMENT_OPTIMAL barriers into the pending context and `SubmitPendingCommands()`
|
||||
(a second vkQueueSubmit). Then `GpuCompletion::Signal`: under the same guard, an empty vkQueueSubmit that
|
||||
signals the set's timeline value. A semaphore signal's first synchronization scope is every command
|
||||
earlier in submission order on that queue, so the value cannot signal before the set's render work and
|
||||
its barriers finished. The pacing thread releases only after the watcher saw that value
|
||||
(`PollAheadSet` / `FinishFrame`).
|
||||
- Queue and family: the XR binding gets Dawn's family and queue index 0 (`handles.queueFamily/queueIndex`);
|
||||
the watcher's `vkGetDeviceQueue` uses the same pair; Turnip has one queue family. SteamVR's client submits
|
||||
on that queue inside our calls, under the guard.
|
||||
- Layouts: images stay COLOR_ATTACHMENT_OPTIMAL (Dawn's tracked state, the release barrier, lane Q's
|
||||
first-write barrier); no ownership transfer, as GalaxyFrame (H9 table).
|
||||
|
||||
So a "fence on the real submission" adds no ordering the timeline value lacks; no `fence` mode was built.
|
||||
|
||||
## 2. The mock's checks, and a strict one
|
||||
|
||||
- Old exact check (`MOCK_XR_BUSY_MS`): host reads 512 x 64 bytes of the image's memory at the release (no
|
||||
wait on anything of the app's), then again once an empty submission made at the release completed. Blind
|
||||
spot: work submitted AFTER the release would land after both samples (the stamps would catch a stale
|
||||
image, the bytes would not).
|
||||
- New strict check: the same bytes at the release against the bytes at the image's next
|
||||
`xrAcquireSwapchainImage` (CPU only, no queue). Any write between release and reacquire, however ordered,
|
||||
is an error ("changed between its release and its next acquire").
|
||||
- New release-queue line per swapchain: a waiter thread times the empty submission made inside
|
||||
`xrReleaseSwapchainImage` (as SteamVR's client submits there) until it completes, i.e. how long a
|
||||
submission at the release waits behind our queued GPU work; split by frame parity.
|
||||
- Positive control (`KARTFRAME_NO_GPU_WAIT=1 KARTFRAME_PACING=inline`: release right after submission):
|
||||
strict 13891 of 14282 changed, exact 13909 of 14300; release queue p50 1.37 ms, p90 3.51 (even frames
|
||||
p50 3.40, odd 1.14: the per-set GPU cost alternates with the 60 Hz content). With the default pipelined
|
||||
pacing, `NO_GPU_WAIT` alone is caught once in 14271 (sets are released two refreshes after submission, the
|
||||
GPU long done).
|
||||
|
||||
## 3. Measurements (scripted race, A/B interleaved)
|
||||
|
||||
`hudrace.sh` 100 s, `MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KF_MOCKRUN_END_GATE=2`, lane T's presses (A held from
|
||||
56 s), head sway from 51 s, `KARTFRAME_FRAME_LOG=1`; runs 1-2 at render scale 0.75 (the race at 60 fps),
|
||||
run 3 at 1.0. A = default, B = `KARTFRAME_RELEASE=idle`. Race window (67 s):
|
||||
|
||||
| run | sets/s | late | GPU ms p50 / p99 | end > 1 ms after wake-up | strict changed | exact changed | mock errors | release queue p50 / p99 ms (eye swapchains) | drain before release / end, mean ms |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| A1 | 120.5 | 0.0 % | 3.16 / 4.32 | 0.4 % | 0 of 23857 | 0 of 23880 | 0 | 0.31-0.45 / 0.71-0.73 | - |
|
||||
| B1 | 120.4 | 0.0 % | 3.23 / 4.46 | 13.2 % | 0 of 23867 | 0 of 23890 | 0 | 0.16-0.24 / 0.36-0.41 | 0.33-0.42 / 0.12-0.14 |
|
||||
| A2 | 120.5 | 0.0 % | 3.34 / 4.17 | 0.3 % | 0 of 23871 | 0 of 23894 | 0 | 0.27-0.43 / 0.64-1.39 | - |
|
||||
| B2 | 120.4 | 0.0 % | 3.14 / 4.01 | 12.2 % | 0 of 23877 | 0 of 23900 | 0 | 0.14-0.17 / 0.37-0.41 | 0.31-0.44 / 0.10-0.14 |
|
||||
| A3 (1.0) | 120.4 | 0.0 % | 4.55 / 5.75 | 0.5 % | 0 of 23867 | 0 of 23890 | 0 | 0.25-0.32 / 0.68-1.72 | - |
|
||||
| B3 (1.0) | 120.5 | 0.0 % | 4.47 / 5.63 | 10.7 % | 0 of 23869 | 0 of 23892 | 0 | 0.19-0.26 / 0.35-0.38 | 0.28-0.35 / 0.12-0.15 |
|
||||
|
||||
- Default: a submission made at the release completes in 0.3-0.45 ms p50 (about the floor of a submit,
|
||||
interrupt and wake-up on this machine; with idle it is 0.15-0.25), > 2 ms in at most 0.3 % of releases,
|
||||
even and odd frames alike. The set released was finished and the next one is mostly done by the wake-up:
|
||||
at the release our queue is about as empty as the probe's.
|
||||
- Stamps (A1): 15992 of 15998 eye images rendered for the locate of their submitted poses, 0 for another.
|
||||
- `idle` costs the drain's round trip (0.3-0.4 ms before the releases, 0.1 before xrEndFrame): xrEndFrame
|
||||
later than 1 ms after the wake-up in 11-13 % of frames (fails the 2 % end gate), no late sets, no
|
||||
missed refreshes, GPU per set unchanged. Fine for a headset A/B, not a default.
|
||||
- Machine idle check before every run; SteamVR (vrserver/vrcompositor) running as always.
|
||||
|
||||
## Verdict
|
||||
|
||||
B: the release is ordered correctly. The release cannot precede the write: by Dawn's code (the render work
|
||||
is on the VkQueue before the timeline signal is queued), by the exact check, and by the new strict check
|
||||
(0 of about 143000 released images in six race runs changed between release and next acquire, while the
|
||||
positive control flags 97 %). And at SteamVR's release-time submission our queue is nearly empty, as in the
|
||||
probe. H7's "jump follows the pair count" is therefore not our own image reuse.
|
||||
|
||||
Per process, what the game has that the probe (and every mock run) lacks, ranked:
|
||||
|
||||
1. **The desktop window's presentation.** On the headset the game creates a Gamescope WSI swapchain
|
||||
(854x480, Mailbox; "Creating swapchain for xid" in every game log) and presents into it once per game
|
||||
frame: 60 Hz, i.e. every second refresh, on the same VkQueue as the eyes and SteamVR's client (Dawn's
|
||||
present path waits on the WSI acquire semaphore in a queue submission and vkQueuePresentKHR goes through
|
||||
the gamescope layer). Mock runs set `KARTFRAME_HEADLESS=1` (no swapchain; 63 offscreen presents a second),
|
||||
xrprobe has no desktop swapchain. Period 2 at 120 Hz with a start-up phase against the eye releases fits
|
||||
"every second refresh, which eye per launch". Not reproducible headless (no gamescope here).
|
||||
2. **Queue backlog at SteamVR's release or end submission that only the headset has** (for example
|
||||
created by 1, or the headset's own GPU timing). Measured absent in the mock; `KARTFRAME_RELEASE=idle`
|
||||
removes it and its 10 s log line measures it on the headset.
|
||||
3. **GPU contention with SteamVR's compositor**: the game keeps the GPU busy 3-5 ms a set, with a heavy and
|
||||
a light set alternating at 60 Hz (positive control: 3.4 against 1.1 ms of work left at the release);
|
||||
the probe's blit is a fraction of a millisecond. Next test if 1 and 2 are not it: a render-scale-0.5
|
||||
measurement launch, or xrprobe replay with an added GPU load alternating at 60 Hz.
|
||||
4. Excluded here: release before the GPU finished, a Dawn submission after the signal, timeline semaphore
|
||||
ordering, queue family mismatch, layout transitions. TU_DEBUG=sysmem and the Vulkan layer chain are the
|
||||
same for both (the wrapper exports TU_DEBUG before running KARTFRAME_EXE; both logs show the
|
||||
fdm_injection and Gamescope WSI layers); sysmem changes how Turnip renders a pass, not the memory of
|
||||
SteamVR's images.
|
||||
|
||||
Session 14 (`frame/perf/session-h14/README.md`) tests 1 and 2 in two launches.
|
||||
|
||||
## Commits (lane/U)
|
||||
|
||||
- `xrmock`: strict release check and release-queue latency (only with MOCK_XR_BUSY_MS).
|
||||
- `KARTFRAME_RELEASE=idle` (default unchanged) with DEV.md lines.
|
||||
@@ -1,89 +0,0 @@
|
||||
# Lane W: the compositor's phase, and a lock against it
|
||||
|
||||
Sessions 16 to 18: ending the frame about 4 ms after xrWaitFrame's wake-up was clean once (session 16 launch 3)
|
||||
and doubled in the right eye on repeat (session 18 launch 1), with identical app logs. Working model: SteamVR's
|
||||
compositor takes each eye's image at its own point in the refresh (the right eye later), and the relation between
|
||||
the app's xrWaitFrame grid and those points differs per launch and drifts. This lane makes that relation
|
||||
observable per frame and designs the lock. The lock itself waits for session 19's data (below: why).
|
||||
|
||||
## What is logged now
|
||||
|
||||
- **vrframes.log** (session folder; `KARTFRAME_VRTIMING_FRAMES=1`, written by frame/tools/vrtiming.py in its own
|
||||
process, 4 polls a second, read-only background OpenVR app as before). One `F` line per compositor frame:
|
||||
index, `m_flSystemTimeInSeconds` (openvr.h: "absolute time reference ... aligns with the vsync that running
|
||||
start is relative to"), presents, mispresented, dropped, reprojection flags, then, in ms after that system
|
||||
time (openvr.h: "relative to this frame's SystemTimeInSeconds"), WaitGetPoses called, new poses ready, new
|
||||
frame ready, compositor update start, update end, render start; then present / wait-for-present / idle CPU ms,
|
||||
compositor GPU ms, client interval. SteamVR keeps 128 frames (1.07 s at 120 Hz); each poll writes the frames it
|
||||
has not written, except the newest two (they may still be filling in); `G` lines name any frames missed.
|
||||
One `P` line per poll: CLOCK_MONOTONIC, _RAW and BOOTTIME read around `IVRSystem::GetTimeSinceLastVsync`, its
|
||||
seconds-since-vsync and counter, and the newest frame's index and system time.
|
||||
- **XRF lines** (game.log, `KARTFRAME_FRAME_LOG=1`) end in `m=` (the wake-up, CLOCK_MONOTONIC ms; steady_clock is
|
||||
CLOCK_MONOTONIC on Linux), `r=` (eye images released, ms after the wake-up, -1 on a retained repeat) and `t=`
|
||||
(the frame's predicted display time, CLOCK_MONOTONIC ms through `xrConvertTimeToTimespecTimeKHR`). The end call
|
||||
is `m + e`. One `XRF clock:` line says how t= was converted and XrTime minus CLOCK_MONOTONIC.
|
||||
|
||||
## How the clocks are matched
|
||||
|
||||
1. Without any matching (view A of phase.py): SteamVR measures the app itself. The existing vrtiming summaries
|
||||
already show it for OpenXR apps: "poses -> frame ready" per second equals our end delay (session 16 launch 3:
|
||||
4.60 ms, session 18: 4.53 / 5.00 / 4.21 ms at 4000 / 4500 / 3500 us, session 17 launch 2: 6.52 at 6000). So
|
||||
`posesready` is our wake-up and `frameready` our xrEndFrame, both in SteamVR's own frame-relative ms; the
|
||||
compositor's update and render start are in the same frame. This alone gives the phase.
|
||||
2. With matching (view B): `GetTimeSinceLastVsync` returns seconds since the last vsync and a counter; CLOCK_MONOTONIC
|
||||
read around the call (the call is a few us) gives the vsync in CLOCK_MONOTONIC; if the counter is the F frame
|
||||
index, `offset = vsync_mono - sys` exactly, else modulo one refresh (all a phase needs). phase.py prints the
|
||||
offset, its spread and drift (0 means SteamVR's system time is CLOCK_MONOTONIC itself; a constant means the
|
||||
same clock with another zero; a drift means _RAW, and the P lines carry _RAW to confirm). Check: XRF end
|
||||
minus the nearest frame-ready should be about 0 (plus vrclient's submit time).
|
||||
Headless: no compositor in the mock, so only the format is proven: frame/tools/openvr-stub.c (a fake
|
||||
libopenvr_api.so, system time = CLOCK_MONOTONIC - 1000 s) through `KARTFRAME_STEAMVR_DIR`, beside a mock run.
|
||||
|
||||
## Reading phase.py
|
||||
|
||||
`python3 -I frame/tools/phase.py SESSION_DIR [...]`, per launch (race only unless `--all`):
|
||||
- A: p5/p50/p95 of poses ready, frame ready, update start / end, render start (ms after the vsync reference), and
|
||||
the **margin** = update start minus frame ready (how long before the compositor's update our frame was ready),
|
||||
with histograms of frame ready and of the margin.
|
||||
- B: wake-up, end, release and display time minus the nearest vsync, update start and render start (signed,
|
||||
+-period/2), histograms for the end against each anchor and the wake-up against the vsync.
|
||||
- Drift table (`--every S`): circular means of end-vsync, end-update, end-render, wake-vsync, margin.
|
||||
A clean launch and a doubled one at the same delay must differ in some of these; the model predicts: the same
|
||||
end-minus-wake-up (e) but a different wake-up-vs-vsync, so a different end-vs-update/render start, with the
|
||||
doubled launch's end piled near one anchor (or a margin near 0). If nothing differs, the per-eye pick-up is not
|
||||
tied to anything GetFrameTimings reports, and the next lever is the panel recording against vrframes.log.
|
||||
|
||||
## The lock (design; build it after session 19)
|
||||
|
||||
The game times its end against the compositor's clock instead of its own wake-up: end at
|
||||
`anchor + phase_target`, where anchor is the compositor frame's vsync reference (or update start), extrapolated
|
||||
from the newest known one by whole periods, and phase_target is the value session 19 shows clean (e.g. "1.5 ms
|
||||
before update start"), bounded to [wake-up + 0.5 ms, wake-up + period - pacing margin]. Where the grid comes
|
||||
from, in order of preference:
|
||||
|
||||
1. **From xrWaitFrame's display time, in process, no helper.** If session 19 shows `display time minus the
|
||||
nearest vsync` (B) constant across launches (clean and doubled alike), the compositor's grid is the display
|
||||
time minus a constant, and `end = t - K` replaces `wake + D`. One line in the pacing thread, nothing to fail.
|
||||
The h18 logs argue against it a little (locate-to-display offsets equal while the outcome differed), so this
|
||||
is the case to rule in or out first.
|
||||
2. **Published by the helper (the expected case).** vrtiming.py (already polling, own process) writes a 64-byte
|
||||
record into a file in `$XDG_RUNTIME_DIR` (mmap, seqlock: seq odd while writing): the latest vsync in
|
||||
CLOCK_MONOTONIC ns (via the P-line offset), the period in ns (from 128 frames: sub-us), the medians of update
|
||||
start, render start and frame ready over the last second, and the write time. The game mmaps it once and reads
|
||||
it every frame (no syscall), uses it only when fresh (written < 1 s ago, period within 1 % of
|
||||
xrWaitFrame's), else falls back to the fixed `KARTFRAME_END_DELAY_US`. Switch: `KARTFRAME_END_LOCK=<anchor>:<us>`
|
||||
(vsync|update|render, signed offset), logged per XRF line as the chosen target.
|
||||
3. **OpenVR in process: no.** The OpenXR runtime already runs SteamVR's vrclient in our process; a second
|
||||
`VR_Init` there shares its globals, and `VR_Shutdown` could take down the OpenXR session's connection.
|
||||
|
||||
Risks: (a) the eye pick-up points are not among the reported fields (the compositor may take the eyes at fixed
|
||||
offsets after render start that only the recording shows); then phase_target is found by a sweep of offsets
|
||||
against the compositor's anchor instead of the wake-up (one session, 3 values, each repeated) and the lock still
|
||||
removes the per-launch variation; (b) SteamVR re-phases its running start after a hitch: the helper follows
|
||||
within a poll (0.25 s; 0.1 s when the lock is on), a few hundred ms of doubling at worst; (c) sleep precision
|
||||
(about 0.05-0.1 ms here) against a clean band that sessions 16-18 suggest is about 1 ms wide; (d) the end moves
|
||||
later inside the refresh: no change to the display time or the poses (the set is located two refreshes ahead
|
||||
either way), only less slack before the compositor, so late ends must stay counted (end_late=); (e) the helper
|
||||
dies or SteamVR restarts: stale record, fixed-delay fallback, logged once.
|
||||
|
||||
Session 19 is needed first: it decides between 1 and 2, and which anchor and offset the lock aims at.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Lane X: session 19 frame by frame, and the end lock against the compositor's grid
|
||||
|
||||
Data: session 19, `$KARTFRAME_HOME/sessions/20261016-012613` (launch 1, owner: doubled in the right eye) and
|
||||
`20261016-012746` (launch 2, owner: clean, right eye "more sensitive to movement"), both
|
||||
`KARTFRAME_END_DELAY_US=4000`, race only (33.6 s / 36.5 s, 4022 / 4355 compositor frames). Tool:
|
||||
`python3 -I frame/tools/phase.py DIR` (view C below is new in this lane).
|
||||
|
||||
## 1. Method
|
||||
|
||||
All window numbers are in SteamVR's own clock (no clock matching): compositor frame k reports our frame-ready
|
||||
(our xrEndFrame as SteamVR saw it, about 0.12 ms after our call) relative to its own vsync, normally -3.5 ms,
|
||||
i.e. in the previous refresh. So our end lands at `fr(k) + (sys(k) - sys(k-1))` after vsync k-1, and frame
|
||||
k-1's update start / end are the compositor window it falls in. Frames whose compositor logged no update times
|
||||
(start = end = 0: 140 / 42 frames, around compositor hiccups) are left out. Our release (XRF r=) is 0.05 ms
|
||||
before the end (p50; the doubled launch has a 0.55 ms p95 between release and end, the clean one 0.10), so
|
||||
release and end classify the same.
|
||||
|
||||
Clock note: SteamVR's m_flSystemTimeInSeconds runs on CLOCK_MONOTONIC_RAW (the vsync from
|
||||
GetTimeSinceLastVsync in _RAW minus the frame's system time is constant to +-10 us, folded to one refresh);
|
||||
CLOCK_MONOTONIC drifts 14 / 33 ppm against it in these launches. phase.py now matches through _RAW; its
|
||||
per-poll matching already followed the drift, so view B's numbers did not change. Fixed: view B's "end vs frame
|
||||
ready" check used only positive frame-ready values (it now reads -0.12 ms p50, i.e. the clocks agree).
|
||||
|
||||
## 2. Where the end lands (view C)
|
||||
|
||||
| race frames | doubled 012613 | clean 012746 |
|
||||
| --- | --- | --- |
|
||||
| end after vsync, p5 / p50 / p95 ms | 4.52 / 4.81 / 5.67 | 4.56 / 4.85 / 5.42 |
|
||||
| update start, p5 / p50 / p95 | 0.22 / 2.83 / 3.17 | 0.62 / 3.03 / 3.20 |
|
||||
| update end, p5 / p50 / p95 | 3.37 / 5.25 / 5.49 | 4.74 / 5.32 / 5.50 |
|
||||
| end BEFORE its refresh's update start | **0** | **0** |
|
||||
| end INSIDE the update window | 3021 (77.7 %) | 3824 (88.6 %) |
|
||||
| end AFTER the update end | 865 (22.3 %) | 492 (11.4 %) |
|
||||
| end minus update end, p5 / p50 / p95 | -0.85 / -0.37 / +1.04 | -0.82 / -0.42 / +0.34 |
|
||||
| class changes between consecutive refreshes | **975 (25.1 %)** | **548 (12.7 %)** |
|
||||
| the same, seconds without an early update start | 25.0 % (after 16.0 %) | 11.6 % (after 8.7 %) |
|
||||
|
||||
SteamVR's per-frame presents by class (presented 0x / 2x+ / mispresented):
|
||||
|
||||
| class | doubled 012613 | clean 012746 |
|
||||
| --- | --- | --- |
|
||||
| inside | 46 / 25 / 52 of 3021 (2.4 % not 1x) | 17 / 10 / 20 of 3824 (0.7 %) |
|
||||
| after | 69 / 39 / 121 of 865 (12.5 %) | 22 / 20 / 42 of 492 (8.5 %) |
|
||||
|
||||
Anomalies against end minus update end (doubled launch; the clean one has the same shape at lower rates):
|
||||
-1.0 to 0 ms: 1.4-3.1 %; 0 to +0.25: 4.2 %; +0.25 to +1.25: 11-28 %; later than +1.5 (hitches): 50 %.
|
||||
|
||||
## 3. The update window over time
|
||||
|
||||
The early update start is neither random nor a steady beat: it comes in **episodes**. Each begins with a
|
||||
compositor discontinuity (one frame presented 2-3 times or mispresented, then one or two frames with no update
|
||||
times, sometimes an update a whole refresh late at +8.6 ms), after which the update starts 0.2-1.4 ms after the
|
||||
vsync and ends early too (3.2-4.0 ms; in the clean launch's end-of-race episode 4.1-5.6), and creeps back to
|
||||
about +2.9 over 0.3 s (or 3 s):
|
||||
|
||||
| launch | episodes (race time, length) |
|
||||
| --- | --- |
|
||||
| doubled 012613 | 4.7 s (0.31 s), 9.7 s (0.34 s, after a late update), 15.7 s (0.28 s), 29.6 s (3.2 s) |
|
||||
| clean 012746 | 32.9 s (1.5 s), 34.4 s (1.7 s) (the race's last seconds) |
|
||||
|
||||
The doubled launch's three short ones are 5.0 and 6.0 s apart (a beat-like recurrence of compositor hiccups,
|
||||
cause unknown: a load or scheduling effect on vrcompositor, not our end); its seconds 22-29 also had the whole
|
||||
window 0.2-0.5 ms earlier (update start p50 2.4, end 4.8-5.0) with 22-58 "after" frames a second. Outside the
|
||||
episodes the window is steady at start 2.8-3.2, end 5.2-5.4.
|
||||
|
||||
## 4. The model, checked
|
||||
|
||||
The orchestrator's reading was "our release fell about 3 ms BEFORE the update in at least 5 % of the doubled
|
||||
launch's frames". **The per-frame data contradicts that part: no end in either launch landed before its
|
||||
refresh's update start.** The -3 ms in view B was the nearest-anchor fold: during an episode the update starts
|
||||
about 4.2 ms before our release, past the +-4.17 ms fold, so the nearest start was the next refresh's. In the
|
||||
early-start frames our end lands AFTER the (early) update end instead.
|
||||
|
||||
What the data supports: the end sits 0.4 ms before the compositor's **update end** in both launches (p50
|
||||
-0.37 / -0.42), so jitter on either side (our end's tail: p95 - p50 = 0.6-0.9 ms; the update end's: p50 - p5 =
|
||||
0.6-1.9 ms) carries it across the update end in some refreshes and not in others. The doubled launch crossed
|
||||
twice as often (25 % vs 13 % class changes, also outside the episodes: 25 % vs 12 %), with its update end
|
||||
earlier and its own end later in the tails, plus four re-phase episodes. Frames that land after the update end
|
||||
are the ones SteamVR itself presents 0 or 2 times or mispresents (5-12x the rate of inside frames). With the
|
||||
per-eye pick-up model (each eye takes the latest released image when the compositor binds it, the right eye
|
||||
late in the update): inside = the right eye takes the new set a refresh early (consistently: "right eye more
|
||||
sensitive"), after = both eyes take it on time; every change between the two repeats or skips a set in the right
|
||||
eye only. That matches the right eye doubling and the left staying clean, and the 2:1 ratio of changes matches
|
||||
doubled vs clean.
|
||||
|
||||
What it does not explain (earlier sessions, no compositor log): session 17's 6000 us launch ended about vsync +
|
||||
6.9 (1.6 ms after the update end, every frame) and was "right eye very bad, left nearly perfect with a 2 s beat";
|
||||
session 18's 3500 / 4500 us launches (about vsync + 4.0 / + 5.0, inside the window) were doubled in the right
|
||||
eye. If the window was where session 19 shows it, the right eye has a second sensitive point after the update
|
||||
end (e.g. where the compositor's GPU pass reads it, 1-2 ms after the CPU update) and the clean band is narrow
|
||||
on both sides. Session 20 logs the compositor in every launch, so each verdict gets its own window.
|
||||
|
||||
## 5. Consequences for the lock
|
||||
|
||||
- The medians did not differ between the clean and the doubled launch (wake-up +0.34 / +0.42 after the vsync,
|
||||
end +4.81 / +4.85, update end +5.25 / +5.32): a lock that only tracks the median grid would have produced
|
||||
the same two launches. What the lock must give is **margin from the update end** in the tails, held while
|
||||
the compositor re-phases.
|
||||
- "before" (update start p50 - 1.5 ms, about vsync + 1.5, about 1.1 ms after the wake-up) lands inside the
|
||||
early windows of the episodes (start 0.2-1.4), and is close to the ends at the wake-up (vsync + 0.7) of the
|
||||
doubled sessions 4-15. It is the probe of the model, not the expected fix.
|
||||
- "after" (update end p95 over the last second + 0.3 ms, about vsync + 5.8, about 5.4 ms after the wake-up)
|
||||
clears the update end in at least 95 % of refreshes by construction and moves with an episode's window. Its
|
||||
open risk is the session 17 point (vsync + 6.9) 1.1 ms later.
|
||||
|
||||
## 6. The lock (built, default off)
|
||||
|
||||
Helper: frame/tools/vrtiming.py, when `KARTFRAME_END_PHASE` is set in its environment (the wrapper exports the
|
||||
session.env lines to it), polls every 0.1 s instead of 0.25 s and publishes a 64-byte record (format in
|
||||
vrtiming.py and openxr_integration.cpp, `EndPhaseGrid`) by atomic rename to `KARTFRAME_PHASE_FILE`, default
|
||||
`$XDG_RUNTIME_DIR/kartframe-phase.bin`: the newest vsync in CLOCK_MONOTONIC ns (GetTimeSinceLastVsync, read
|
||||
between two CLOCK_MONOTONIC reads), the refresh period (from the vsyncs of successive polls), update start
|
||||
p5 / p50 and update end p50 / p95 over the last 120 compositor frames (no-update-time frames left out), and
|
||||
the write time.
|
||||
Game: the pacing thread re-reads the file at most every 50 ms (open, pread 64 bytes, close; about 10 us) and
|
||||
aims the end at a phase of the vsync at or before the wake-up (1 ms slack):
|
||||
- `KARTFRAME_END_PHASE=after[:us]` -> vsync + update end p95 + us (default 300),
|
||||
- `KARTFRAME_END_PHASE=before[:us]` -> vsync + update start p50 - us (default 1500),
|
||||
bounded to [now, wake-up + period - pacing margin]. It falls back to `KARTFRAME_END_DELAY_US` (or 4000 us, the
|
||||
session 16/19 setting, when unset) whenever the record is missing, older than 0.5 s, or its period is more than
|
||||
1 % off xrWaitFrame's, and logs `end phase:` once for the mode, at every switch between lock and fallback, and
|
||||
every 10 s the achieved phase (p50 / p95 of the end minus the published update start and update end, the end
|
||||
after the wake-up, and the share of fallback frames). The order is lane V's: the next set (locate, acquire,
|
||||
encode) starts at the wake-up before the sleep, 4 eye pairs; if Aurora was busy and the set starts after the
|
||||
end, in "before" mode that start waits until the published update end + 0.3 ms (bounded by the cycle's
|
||||
deadline) so no acquire or locate lands in the window; both eyes are released back to back in the end call.
|
||||
|
||||
Headless check, results and session 20: frame/perf/session-h20/README.md.
|
||||
@@ -1,180 +0,0 @@
|
||||
# PANELS (lane J): the doubled image on head turns, from a panel recording
|
||||
|
||||
Session `$KARTFRAME_HOME/sessions/20261014-010727` (build `native` 7daa878: 120 Hz, pipelined, sysmem,
|
||||
scene clock, an eye set every refresh), recorded with `KARTFRAME_RECORD=raw` (framecorder `--view raw`:
|
||||
both panels as scanned out, 1920x1080, HEVC). The owner turned the head slowly left and right in a race:
|
||||
"double image still when moving my head"; head still was fine.
|
||||
|
||||
## The recording
|
||||
|
||||
- 17019 frames over 171.5 s (the container's 141.8 s is frame count / 120). Each frame's time is the panel
|
||||
vblank it was grabbed at (pts snap to the 8.33 ms grid within 0.3 ms), so consecutive refreshes are
|
||||
known. The recorder missed refreshes while the GPU was loaded: menus 2 % of gaps, race 22 to 28 %
|
||||
(framecorder: 90 to 100 fps, 3 ms of GPU a frame, headset GPU 93 %). Only pairs of consecutive
|
||||
refreshes are compared. The recorder's GPU load is itself a suspect for the race's 13 % late sets
|
||||
(session 20261013-232117, same build family, no recording: 6.3 %).
|
||||
- Video time + about 5.9 s = the game's `vr pacing` t (race start, the t=36 hitch; framecorder started
|
||||
01:07:48, the game's session at about 01:07:42).
|
||||
|
||||
Tools (`frame/perf/panels/`, no numpy or PIL: ffmpeg decodes, `audioop` does the per-pixel work in C):
|
||||
`analyse.sh SESSION_DIR` runs `extract.sh` (per-eye 480x270 grey), `lagdiff.py` (RMS difference of a
|
||||
central region against the 8 refreshes before, gray levels; codec noise of an identical image is 1.5 to
|
||||
4, a new image 10 to 60), `motion.py` (global image shift per refresh from column/row projections) and
|
||||
`cadence.py` (the summary below). About 4 minutes for this recording.
|
||||
|
||||
## a. Cadence per panel
|
||||
|
||||
Each refresh of each eye is "new", "held" (the previous refresh again) or "stale": pixel for pixel an
|
||||
image the same eye showed 2 to 8 refreshes earlier while the previous refresh differed.
|
||||
|
||||
| part | eye | refreshes | new | held | stale (lag: count) |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| menus (0-25 s) | L | 2821 | 75.0 % | 25.0 % | 0 |
|
||||
| | R | 2821 | 75.7 % | 24.3 % | 0 |
|
||||
| race (25-171 s) | L | 10661 | 85.4 % | 4.1 % | **10.4 %** (2:36 3:141 4:880 5:53) |
|
||||
| | R | 10661 | 90.1 % | 2.9 % | **7.0 %** (2:55 3:643 4:17 5:17) |
|
||||
|
||||
- Holds in the race: 1 refresh 10054 / 10220 (L/R), 2 refreshes 158 / 125, 3: 8 / 1. Held is only
|
||||
visible while nothing moves on the panel (the compositor re-projects a held frame), so the retained
|
||||
repeats of late sets (11 % of ticks in the game log) mostly show as "new" here.
|
||||
- Both eyes: out of step in **19.4 %** of race refreshes (one eye stale while the other is new: L stale
|
||||
1110, R stale 728); stale in both eyes at once 2 of 10661 (0.02 %). Menus: out of step 4.1 %, all of it
|
||||
held/new on static menu content, never stale.
|
||||
- The stale rate does not depend on motion: L 10.6 / 10.2 / 10.3 % and R 7.1 / 6.8 / 7.2 % of refreshes
|
||||
with the image still / slow (0.5-2 px a refresh) / moving (2 px and more, up to 25 px). A stale image
|
||||
matches the earlier one **at zero shift** while the view turns: it appears where it appeared 3 to 5
|
||||
refreshes before, not where the world now is. The compositor did not re-project it from its own
|
||||
pose: it was shown with a newer frame's pose.
|
||||
|
||||
## b. Within one frame
|
||||
|
||||
No doubled edges inside one panel image (full-resolution crops of lamp posts, fences, the screen edge
|
||||
during turns: single sharp edges; `evidence/withinframe_L_48.45s_fullres.png`). Nothing is composited
|
||||
twice: the double is temporal.
|
||||
|
||||
## c. Across frames
|
||||
|
||||
During a turn, an eye's sequence of positions runs smoothly and then, for one or two refreshes, jumps
|
||||
back to where the image was 3 to 5 refreshes (25 to 42 ms) before, and forward again. Example (L,
|
||||
48.4 s, image moving 2-3 px a refresh): refreshes 5814 and 5815 are refreshes 5810 and 5811 again
|
||||
(RMS 3.1 and 2.7 against 16 to 30 for neighbours; `evidence/stale_event_48.4s_L5814-5815_shows_5810-5811.png`).
|
||||
`motion.py`'s per-refresh shift alternates sign far more in the race than in menus (fraction of
|
||||
refreshes whose acceleration reverses: menus 0 to 0.08, race L up to 0.25, R up to 0.52 a second).
|
||||
Seen with a moving eye, an image that jumps back by 3 to 5 refreshes of head motion at about 10 % of
|
||||
refreshes, in one eye at a time, is a second image of everything: the doubling.
|
||||
|
||||
## d. The virtual screen and the world
|
||||
|
||||
They move together: a stale image is the whole eye, screen included. The race timer on the screen
|
||||
shows it frame by frame (L, 60.00-60.30 s): 1.178, 1.178, 1.197, 1.206, **1.178**, 1.206, 1.232,
|
||||
1.232, 1.244, **1.232**, **1.232**, 1.244, 1.273, 1.273, 1.296, **1.273**, **1.273**, 1.296 ...; the right eye
|
||||
at the same refreshes shows other game frames (1.167, 1.147, 1.178, 1.197, 1.197, 1.178, 1.206, 1.197,
|
||||
1.206, 1.260, 1.244, ...), at times two game frames from the left.
|
||||
|
||||
## e. Against the logs
|
||||
|
||||
- Per second, the stale rate stays at 9 to 11 % (L) through the race, uncorrelated with the game's late
|
||||
sets per second (r = -0.2 at any alignment from 2 to 9 s): late sets (repeats) are not the cause.
|
||||
- What differs between menus (0 stale) and race (10 %): when frames are ended. SteamVR's "poses to frame
|
||||
ready" (vrtiming.log) is 0.16 to 0.22 ms in menus and 3 to 6 ms in the race (max 8 to 10): in menus the
|
||||
set finished during the previous refresh and was ended right at the wake-up; in the race the pipelined
|
||||
loop ends it whenever the GPU finishes, anywhere up to 7.3 ms into the refresh.
|
||||
- SteamVR's own counters stay clean (reused 0.5 %, mispresented 1.2 a second): SteamVR shows a new frame
|
||||
nearly every refresh; it is the content of an eye that is old.
|
||||
|
||||
## Mechanism
|
||||
|
||||
Nothing in a set can make one eye pixel-identical to an image from 4 refreshes before: both eyes come
|
||||
from one set, rendered and copied into their swapchain images in one command buffer from one locate.
|
||||
The old images were images SteamVR still had in those swapchains. With two swapchain pairs alternating,
|
||||
the pair a frame showed is acquired and written again one refresh later, while SteamVR, which predicts
|
||||
each frame's display 3 refreshes (25 to 27 ms) after its wake-up, may not yet have composed that frame.
|
||||
SteamVR evidently resolves each eye's image when it composes the eye (it also "shows a swapchain image
|
||||
the moment it is released"), not when the frame was ended: depending on where in the refresh the next
|
||||
acquire, render and release fall, an eye gets the image the frame was ended with, the swapchain's
|
||||
newer one (2 refreshes ahead) or, while the newly acquired image is still being written, the one from
|
||||
two uses before (4 refreshes behind, the stale images). Each eye is composed at its own moment, so the
|
||||
eyes split. Ending sets at a fixed point right after the wake-up (menus here, GalaxyFrame always) keeps
|
||||
whichever image SteamVR takes consistent, hence clean; ending them anywhere in the refresh (this race)
|
||||
mixes them.
|
||||
|
||||
Candidates ruled out: PrimeFrame's 1-3 cadence (SteamVR's counters are clean, stale is not a cadence);
|
||||
late sets shown a refresh after their poses' time and the retained repeats (those are 1-refresh holds,
|
||||
3 to 4 % here, and the stale rate does not follow the late rate); interpolation time jitter (it cannot
|
||||
make an image pixel-identical to an old one); two images composited in one frame (none seen).
|
||||
|
||||
## The mock reproduces it
|
||||
|
||||
`xrmock` (with `MOCK_XR_STAMP`) now logs every acquire, release (with the released image's stamp) and
|
||||
xrEndFrame and at the end of the session replays a vsync grid against three models of when SteamVR
|
||||
resolves an eye's image: `spec` (latched at xrEndFrame), `lazyrel` (the swapchain's newest released
|
||||
image when the eye is composed) and `lazyacq` (its newest acquired image, which until its release still
|
||||
holds its previous content); frames by display time, eyes composed 3 / 1 ms before the vsync
|
||||
(`MOCK_XR_COMPOSITE_MS`). Lines "xrmock: compositor ..." and "xrmock: xrEndFrame after its xrWaitFrame
|
||||
returned". The headset's end times need a loaded GPU: `KF_MOCKRUN_RENDER_SCALE=1.25` (measurement only)
|
||||
puts the mock race at the headset's 4 to 5 % late and xrEndFrame 4 ms after the wake-up (median).
|
||||
|
||||
Scripted Grand Prix (`frame/perf/hudrace.sh NAME 90 MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 ...`), 7148 vsyncs:
|
||||
|
||||
| run | pairs | end after wake-up (<0.5 / 0.5-4 / >4 ms) | lazyacq: eye with its own poses L / R | shown again from 2-8 refreshes before L / R | eyes from different sets | lazyrel: again, split |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| A2s (scale 1.25) | 2 | 4685 / 3039 / 2941 | 2.3 / 2.3 % (off -4: 1919 / 818, +2: 4216 / 5462) | **20.3 / 11.6 %** | **17.6 %** | 9.0 %, 17.6 % |
|
||||
| B6s (scale 1.25) | 6 | 4777 / 3129 / 2469 | 100.0 / 100.0 % | 0 / 0 | 0.0 % | 0, 0.0 % |
|
||||
| A2 (scale 1.0) | 2 | 9800 / 794 / 126 | 1.6 % (+2: 6110) | 0.2 / 0.2 % | 0.2 % | 0.2 %, 0.1 % |
|
||||
| B6 (scale 1.0) | 6 | 9685 / 763 / 111 | 100.0 / 100.0 % | 0 / 0 | 0.0 % | 0, 0.0 % |
|
||||
|
||||
With two pairs and the headset's end times, `lazyacq` gives what the recording showed: one eye at a time
|
||||
an image 4 (and 5) refreshes old, the left more than the right, eyes from different sets 17.6 % (recording
|
||||
19.4 %). At the mock's own unloaded timing (ends at the wake-up) it is consistent (+2 refreshes, almost
|
||||
no stale ones), like the menus and GalaxyFrame. `spec` never shows it, as the stamp check never did.
|
||||
|
||||
## The fix
|
||||
|
||||
`KARTFRAME_EYE_PAIRS` (default 6; 2..8): the per-refresh sets rotate through that many eye swapchain
|
||||
pairs and a pair is acquired again only `pairs - 1` refreshes after the last frame that showed it (5:
|
||||
beyond SteamVR's 3.3-refresh lead plus the refresh a frame stays on show). Under any of the three models
|
||||
every eye then shows the image its frame's poses were rendered for. `=2` is the old alternation. A
|
||||
refresh with no pair cooled off long enough renders nothing (`pairwait=` in the pacing line; 0 in every
|
||||
run, the rotation never waits while a set is shown every refresh). The extra pairs are made with the
|
||||
first per-refresh set (the render-first path, interpolation off, keeps two). Cost: 8 more 1728x1728
|
||||
swapchains (about 290 MB with SteamVR's images), no GPU or CPU time (mock race: B6 116.1 sets/s, 0.4 %
|
||||
late, GPU 4.61 ms; A2 118.8, 0.2 %, 4.60).
|
||||
|
||||
Checks, all runs: 0 errors, 19996 to 21258 releases compared after the GPU passed them and 0 changed,
|
||||
13458 to 14030 stamped eye images all rendered for their submitted poses' locate, every xrEndFrame with
|
||||
its predicted display time; ctest 32/32.
|
||||
|
||||
Also new: `vr pacing` lines end in `end_ms=<p50>/<p99> pairwait=<n>` (xrEndFrame after the wake-up);
|
||||
`KARTFRAME_FRAME_LOG=1` writes one `XRF c= w= e= f= p= i= d= l=` line per frame (cycle, wake-up time,
|
||||
end after it, fresh or retained, pair, released image index per eye, display step, display minus locate
|
||||
in refreshes), to line a recording up with the frames; session-report.py shows the pairs and end times.
|
||||
|
||||
Not known: whether SteamVR is lazy in exactly this way (the mock is a model fitted to one recording);
|
||||
whether 5 refreshes is enough margin on the headset (8 pairs is the next step); whether the recorder's
|
||||
GPU load (3 ms a frame) is what pushed race end times to 3-6 ms in this session.
|
||||
|
||||
## Next headset session (three launches, under a minute each, each recorded)
|
||||
|
||||
Each launch: from the menus straight into a race (Time Trial or Grand Prix, any course). In the race,
|
||||
look at a track-side edge (a post, the fence) and turn the head slowly left and right for about ten
|
||||
seconds, then hold still for five, then once more. Say for each launch: doubled or not, which eye.
|
||||
|
||||
1. `session.env`: `KARTFRAME_EYE_PAIRS=2` `KARTFRAME_FRAME_LOG=1` `KARTFRAME_RECORD=raw`
|
||||
`KARTFRAME_SESSION_ENV_ONCE=1`. The old build's behaviour: doubled on turns, as before.
|
||||
Logs: game.log "OpenXR eye swapchain pairs: 2 (alternating ...)", XRF lines, `end_ms` p50 3 to 6 ms in
|
||||
the race. `frame/perf/panels/analyse.sh <session>`: race stale about 10 % (L) and 7 % (R), eyes out of
|
||||
step about 19 %, menus 0.
|
||||
2. `session.env`: `KARTFRAME_EYE_PAIRS=6` `KARTFRAME_FRAME_LOG=1` `KARTFRAME_RECORD=raw`
|
||||
`KARTFRAME_SESSION_ENV_ONCE=1` (6 is the default; set to make the variable explicit). Expected: no
|
||||
doubling on turns, both eyes alike. Logs: "OpenXR eye swapchain pairs: 6 (a pair is written again 5
|
||||
refreshes ...)", `pairwait=0` every second, late and `end_ms` like launch 1. analyse.sh: stale under
|
||||
0.5 % in both eyes, eyes out of step only by held frames (under 3 %).
|
||||
3. `session.env`: `KARTFRAME_EYE_PAIRS=8` `KARTFRAME_FRAME_LOG=1` `KARTFRAME_RECORD=raw`
|
||||
`KARTFRAME_SESSION_ENV_ONCE=1`. Only if launch 2 still shows stale images (some, fewer than launch 1):
|
||||
more margin. If launch 2 is clean, run launch 2's settings without `KARTFRAME_RECORD` instead, to see
|
||||
whether the recorder's GPU load is what makes the race 13 % late.
|
||||
|
||||
How it decides: launch 2 clean and its stale rate near 0 against launch 1's 10 % -> six pairs stay the
|
||||
default, the mechanism is confirmed. Launch 2 stale at launch 1's rate -> SteamVR does not resolve images
|
||||
this way: line the XRF lines up with the recording (the stale refreshes against `e=`, `p=`, `i=`) to see
|
||||
which image it took. Launch 2 fewer but some -> launch 3 (8 pairs) tells whether the margin is short.
|
||||
@@ -1,92 +0,0 @@
|
||||
# RACE120 (lane I): fitting the race into 120 Hz at 1728 per eye
|
||||
|
||||
All numbers: mock runtime (xrmock), real Turnip GPU, 1728x1728 per eye, 120 Hz, pipelined, TU_DEBUG=sysmem,
|
||||
`hud_virtual_screen = true`, both build locks. SteamVR's own vrserver/vrcompositor were running (idle,
|
||||
no app) during the A/B pairs and the soaks, as they always are beside the game in the headset; the SoC
|
||||
ran at 92-95 C with the GPU at its top clock (880-903 MHz, devfreq). GPU ms are per eye set.
|
||||
|
||||
## How it was measured
|
||||
|
||||
- `KARTFRAME_FPSLOG=2` now times every set, not only the game frame's encode: a "game-frame set" (the
|
||||
game frame's native render and its uploads) and each "refresh set" (both eyes, the panel, the copy into
|
||||
the swapchain). Render passes carry pass timestamps; inside the eye passes Dawn's experimental in-pass
|
||||
timestamps (requested only with FPSLOG=2) split them by draw class: world (scene), 2D on the virtual
|
||||
screen (hud2d), full-view fades (fade), framebuffer effects (efbfx), clears; plus the uniform upload and
|
||||
the sink (the eyes' way into the swapchain images). `frame/perf/gpuprof.py RUN [FIRST_S LAST_S]` makes
|
||||
the table (ms per set, averaged over sets). The marks cost a little; the absolute times of
|
||||
FPSLOG=2 runs are slightly high.
|
||||
- The demo races pick their course at random, so two demo runs are not an A/B. `frame/perf/abgp.sh` runs
|
||||
A and B interleaved (A B A B) on the scripted Grand Prix (frame/perf/hudrace.sh: Luigi Circuit, the same
|
||||
every time), each with the GPU clock and the hottest thermal zone sampled (run.sh's gpufreq.tsv).
|
||||
- Run-to-run and state-to-state spread is large: the same build's refresh set measured 4.7 ms on a cooler
|
||||
machine early on and 7.1 ms at 93 C with the compositor busy. Only interleaved pairs count.
|
||||
|
||||
## Where a race set's GPU time went (before)
|
||||
|
||||
Demo, first 1-player race (t = 100-145 s), build `native` 2fe42c4 + the profiler only (run P0, cooler
|
||||
machine, ms per set):
|
||||
|
||||
| set kind | sets | native (mono) | efb copies | snapshots | eye load+clear | scene | 2D on screen | full-view fade | upload | sink (copy) | sum |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| game-frame set (60 Hz) | 2701 | 0.93 | 0.04 | 0.03 | - | 0.01 | - | - | - | - | 1.07 |
|
||||
| refresh set (120 Hz) | 5399 | - | - | - | 0.27 | 2.55 | 0.16 | 0.30 | 0.03 | 1.38 | 4.73 |
|
||||
|
||||
Hot machine, compositor running, scripted GP (AB4-A1, the copy as shipped): refresh set 7.16 ms = scene
|
||||
3.67, sink 2.35, fade 0.37, 2D 0.28, load 0.40; game-frame set native 0.16 (cut already in) / 1.31 (AB5-A1,
|
||||
whole native render). 21 % of race sets late.
|
||||
|
||||
Reading: the 2D layer on the screen is 56 to 58 draws a frame in these races (not ~400; that count was
|
||||
menus) and costs 0.16 to 0.28 ms of a set. The copy into the swapchain, which nobody had timed, was the
|
||||
largest item after the world itself. The native render drew the whole scene every game frame.
|
||||
|
||||
## Changes, each measured
|
||||
|
||||
| change | evidence (interleaved pairs, same clock/temperature) | saving |
|
||||
| --- | --- | --- |
|
||||
| Eyes reach the swapchain through an sRGB-decoding draw into an sRGB view; the swapchain images lose MUTABLE_FORMAT, so the driver may compress them (`KARTFRAME_SINK=copy` restores the copy) | AB4: sink 2.35 / 2.28 -> 0.47 / 0.48 ms; refresh set 7.16 / 7.08 -> 5.14 / 5.10; race late 21.3 / 19.4 % -> 1.6 / 1.6 %; sets/s 94.8 / 97.0 -> 118.4 / 118.5 | ~1.9 ms every refresh set |
|
||||
| Native render of an immersive race frame (Linux too) stops after the last copy the eyes sample, clipped to the copies' rectangles (here a 6x4 depth copy at the sun for the lens flare, after the whole scene); the desktop window shows black in races (`KARTFRAME_MONO_FULL=1`, `KARTFRAME_NATIVE_CLIP=0`) | AB5 pair 1: native 1.31 -> 0.17 ms a game frame, race late 4.7 -> 2.5 % (pair 2's A run was stopped by the headset marker) | ~1.1 ms a game frame (every second set) |
|
||||
| Eyes rendered straight into the swapchain images (no copy), chosen per session by measuring both with GPU timestamps on the first race | AB3 (copy into MUTABLE images): direct 7.20 / 7.02 vs copy 7.24 / 7.12 ms: the copy saved 2.2 ms but every eye pass got slower on the uncompressed image (load +0.9, fade +0.55, scene +0.5). With the sRGB sink the images are not mutable and direct is impossible; the trial never applies | none (kept for KARTFRAME_SINK=copy/blit) |
|
||||
| HUD as a quad layer (task 2a) | not done: the 2D layer costs 0.16-0.28 ms a set; a quad layer would add its own copy every set and give up the per-eye clip/depth behaviour | - |
|
||||
| Framebuffer effects per eye (task 2b) | efbfx is 0.00 in all the 1p races measured (MKW's race passes have no EFB-sampling 2D effect in the eye); nothing to cut | - |
|
||||
| loadOp/storeOp (task 2c) | eyes are one render pass each (eye plan), clear+load segment 0.12-0.20 ms per eye: the clears of colour and depth/stencil, which are needed | - |
|
||||
|
||||
Full-view fade: one untextured blended quad a frame (0.27-0.39 ms a set) stays; lane I's 90 Hz fix
|
||||
(H4.md) moved every textured full-frame quad off that path.
|
||||
|
||||
## After
|
||||
|
||||
Soak, 10 minutes at 120 Hz through the demo cycle, MOCK_XR_BUSY_MS=1, MOCK_XR_STAMP=1, compositor
|
||||
running, GPU 890 MHz average, SoC up to 92 C (run SOAK120, lane/I 66da38a):
|
||||
|
||||
| part | s | sets/s | late % | GPU p50 / p99 | encode p50 / p99 | game fps |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| menus | 428 | 120.4 | 0.02 | 2.45 / 3.05 | 0.58 / 1.19 | 60.0 |
|
||||
| race 1p (2 races) | 84 | 119.6 | 0.81 | 4.52 / 6.56 | 2.36 / 3.30 | 59.3 |
|
||||
| race 2p | 43 | 120.6 | 0.00 | 4.07 / 5.01 | 2.29 / 3.14 | 58.7 |
|
||||
| race 4p (30 fps logic) | 42 | 115.9 | 3.83 | 5.32 / 6.53 | 1.96 / 3.19 | 29.7 |
|
||||
|
||||
Segments of the 1p race refresh set after: scene 2.95, sink 0.59, fade 0.27, 2D 0.17, load 0.25, upload
|
||||
0.03 = 4.32 ms; game-frame set 0.64 (native 0.50: this soak's courses have a half-size scene copy for
|
||||
the eyes, which needs the whole scene). Mock: 0 errors; 143278 releases checked, 0 unfinished; 40224
|
||||
stamped eye images, all rendered for their poses' locate; end times 0 off.
|
||||
|
||||
Same soak at 90 Hz (MOCK_XR_HZ=90, KARTFRAME_REFRESH_HZ=90; GPU 674 MHz average, up to 90 C; SOAK90):
|
||||
menus 90.5 sets/s, 0.02 % late; race 1p 90.5, 0.01 %, GPU 4.16 / 7.13; race 2p 90.6, 0 %; race 4p 90.6,
|
||||
0 %. Mock: 0 errors, 107876 releases all finished, 30174 stamped eye images all for their poses' locate.
|
||||
|
||||
Menus are unchanged (the screen and environment images are still copied: 1.86 ms of a 2.47 ms set).
|
||||
|
||||
## The two stamp errors of the orchestrator's soak
|
||||
|
||||
Trace: `ERROR stamp: eye 0/1 image rendered for a display time 0.24 ms before the one its submitted poses
|
||||
were located for`, one set. Not a pairing error: the game's DisplayTimeNanos turned the XrTime into
|
||||
steady-clock ns as a delta between two clock reads (steady_clock, then CLOCK_MONOTONIC after the
|
||||
conversion call); a preemption between them shifted that set's stamp (and its interpolation time) by
|
||||
0.24 ms. On Linux the converted CLOCK_MONOTONIC time is steady_clock's own: it is returned as is now.
|
||||
The mock filed an offset of less than a refresh (n = 0) under ">=+2"; it is "between" now.
|
||||
|
||||
## Switches
|
||||
|
||||
KARTFRAME_SINK=srgb (default) | copy | blit; KARTFRAME_DIRECT_EYES=0/1 (forces the direct trial's
|
||||
outcome, copy/blit sinks only); KARTFRAME_MONO_FULL=1; KARTFRAME_NATIVE_CLIP=0; KARTFRAME_FPSLOG=2
|
||||
(segment profile); MOCK_XR_MUTABLE=0 (mock: images without MUTABLE_FORMAT even when asked).
|
||||
@@ -1,197 +0,0 @@
|
||||
# Lane E measurements (headless mock, real Turnip GPU)
|
||||
|
||||
All runs: `frame/perf/run.sh NAME SECONDS VAR=value ...` from the worktree (mock runtime, private
|
||||
Xvfb, scratch home `~/.cache/kf-laneE/home` with warm caches unless noted), then
|
||||
`frame/perf/pacing-parts.py <run dir>` (per part, exact percentiles from KARTFRAME_PACING_SAMPLES) and
|
||||
`frame/perf/hitches.py <run dir>`. Build: lane/E after bba3286 (SDL_SyncWindow fix, quad menus,
|
||||
never half rate, HUD clip). Every row has `MOCK_XR_SKIP=1 TU_DEBUG=sysmem` (the wrapper's default)
|
||||
and `"KF_MOCKRUN_VR=hud_virtual_screen = true"` (the owner's config; without it the race's 2D layer is
|
||||
not reprojected); pacing is the default (auto = pipelined). Eyes 1728 per eye unless noted.
|
||||
|
||||
The attract-mode demo runs 1, 2 and 4 player races in turn (MKW runs 3 and 4 players at 30 fps).
|
||||
"race" below is the 1-player race, the owner's case; the 10-minute soak also had a 2p and a 4p race,
|
||||
listed apart. GPU and encode are ms per eye set (both eyes); sets/s per second of that part; late is
|
||||
% of sets due; repeats % of compositor ticks.
|
||||
|
||||
## Table
|
||||
|
||||
| row | command (after `frame/perf/run.sh`) | part | s | sets/s | late % | repeats % | missed | GPU p50 / p99 | encode p50 / p99 | game fps |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| (a) shipped default, 10 min | `A-1728-120-soak 600 MOCK_XR_SKIP=1 TU_DEBUG=sysmem "KF_MOCKRUN_VR=hud_virtual_screen = true" MOCK_XR_BUSY_MS=1` | menus | 429 | 120.3 | 0.04 | 0.11 | 36 | 1.58 / 3.08 | 0.18 / 0.44 | 59.8 |
|
||||
| | | race (2 races) | 85 | 119.5 | 0.72 | 0.78 | 9 | 5.19 / 6.37 | 2.27 / 3.72 | 58.3 |
|
||||
| | | race 2p | 41 | 112.9 | 6.37 | 6.37 | 0 | 6.18 / 7.61 | 1.90 / 3.32 | 46.0 |
|
||||
| | | race 4p | 42 | 120.6 | 0.02 | 0.02 | 0 | 4.32 / 5.23 | 1.78 / 2.83 | 30.5 |
|
||||
| (b) 1728 tiled | not rerun (see below) | | | | | | | | | |
|
||||
| (c) FDM layer off | not applicable headless (see below) | | | | | | | | | |
|
||||
| (d) 2160 at 120 | `D-2160-120 200 ... KF_MOCKRUN_RENDER_SCALE=1.25` | menus | 157 | 120.1 | 0.07 | 0.28 | 11 | 2.10 / 3.25 | 0.21 / 0.67 | 60.6 |
|
||||
| | | race | 42 | 93.9 | 22.15 | 22.15 | 0 | 7.68 / 9.43 | 2.55 / 4.13 | 51.1 |
|
||||
| (e) 2160 at 90 | `E-2160-90 200 ... KF_MOCKRUN_RENDER_SCALE=1.25 MOCK_XR_HZ=90 KARTFRAME_REFRESH_HZ=90` | menus | 156 | 90.4 | 0.05 | 0.24 | 1 | 2.37 / 3.73 | 0.24 / 0.73 | 60.6 |
|
||||
| | | race | 42 | 90.5 | 0.05 | 0.05 | 0 | 7.41 / 8.67 | 2.36 / 3.67 | 58.8 |
|
||||
| (f) 1728 at 90 | `F-1728-90 200 ... MOCK_XR_HZ=90 KARTFRAME_REFRESH_HZ=90` | menus | 156 | 90.3 | 0.06 | 0.24 | 9 | 2.10 / 4.09 | 0.24 / 0.77 | 60.6 |
|
||||
| | | race | 42 | 90.5 | 0.05 | 0.05 | 0 | 6.88 / 8.09 | 2.57 / 4.03 | 54.3 |
|
||||
|
||||
(a) mock checks: 91202 releases compared after the GPU passed them, 0 changed after release, 0 mock
|
||||
errors; game ticks 71918 / rendered 71465 / late 411 / repeats 453 against the mock's 71956 ended,
|
||||
401 repeated + 43 without a layer = 444 (the difference is the seconds before the first and after
|
||||
the last pacing line). Menus are a quad layer now: 51813 of the 71956 frames.
|
||||
|
||||
Reading the table:
|
||||
- The shipped default holds the 1-player race: 0.72 % late over 85 s of race, GPU p99 6.37 ms (ship
|
||||
rule p99 <= 6.3 ms and late <= 2 %: late passes, p99 is at the line). The 2-player demo race is
|
||||
the only part above 2 % late.
|
||||
- 2160 per eye at 120 Hz does not hold (22 % late, GPU 7.7 ms a set against 8.3 ms a refresh). At
|
||||
90 Hz it holds with room (0.05 % late, GPU p99 8.7 of 11.1 ms). It is the candidate if the owner
|
||||
wants the panel's native resolution: 2160 at 90.
|
||||
- GPU ms per set at 90 Hz is higher than at 120 Hz for the same 1728 eyes (6.9 against 5.2): the
|
||||
GPU's clock follows its load (devfreq), so per-set times at different rates are not comparable;
|
||||
sets/s and late are.
|
||||
- Run-to-run noise is large. The same 1728 1-player race measured 4.6 to 6.1 ms GPU p50 and 0 to 4 %
|
||||
late in back-to-back 135 s runs of one build (t8 to t11 in the scratch runs). A single 200 s row is
|
||||
a direction, not a verdict; the 10-minute soak is the number to use.
|
||||
- (b) and (c) were measured by the previous lane E runs on the pre-fix build (1728 tiled: race 90.5
|
||||
sets/s, 1.5 % late, GPU 5.92 / 8.44; FDM switch: see below) and were not repeated: menus no longer
|
||||
render eyes, and the race's game rate changed with the SDL fix.
|
||||
- (c) Valve's FDM layer is not in the Vulkan chain headless: the startup line reads
|
||||
`Vulkan layer chain (active, 1): VK_LAYER_FROG_gamescope_wsi_aarch64`. Its library is loaded, but
|
||||
as the OpenXR API layer `XrApiLayer_VALVE_fdm_injection` in front of the mock. The A/B is only
|
||||
meaningful in the headset (H1's chain: the loader skipped fdm_injection and rpo, "no
|
||||
vkGetInstanceProcAddr").
|
||||
|
||||
## Game frame rate (the "half speed" of H1)
|
||||
|
||||
aurora's window::pump_events called SDL_SyncWindow every game frame; on X11 with a window whose
|
||||
geometry SDL does not expect (Xvfb without a window manager, gamescope) it ends in SDL_Delay(10).
|
||||
Same build, 1728, demo race: `KARTFRAME_SYNC_WINDOW=1` (old) 38.1 game fps, 72.9 sets/s, 3.7 %
|
||||
late; fixed 59.8 game fps, 120.6 sets/s, 0.0 % late. H1's race ran at 38.5 game fps. Commit 57526ee.
|
||||
`KARTFRAME_FPSLOG=1` logs the game rate every 5 s (run.sh sets it).
|
||||
|
||||
## Validation layer (VUID 08910)
|
||||
|
||||
`vvl2 140 MOCK_XR_SKIP=1 TU_DEBUG=sysmem MOCK_XR_BUSY_MS=1 VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation
|
||||
"KF_MOCKRUN_VR=hud_virtual_screen = true"` (current build, HUD clip shaders included), menus and the
|
||||
demo race (entered at frame 5869, left at 6792): 0 validation messages, 0 mock errors. Chain:
|
||||
`VK_LAYER_FROG_gamescope_wsi_aarch64 VK_LAYER_KHRONOS_validation`. The layer prints to stdout, which
|
||||
mockrun writes into the game log; lane A's run of the same setup showed VUID 08910 and 01387 there,
|
||||
so the empty result is a real one. Fixed by 375e0e3 (TexCopyConv blit pipeline for RGBA8 targets)
|
||||
and 1ab059b (VK_EXT_surface_maintenance1 on the instance). Nothing is left from our code.
|
||||
|
||||
## Hitches (run (a))
|
||||
|
||||
Game frames longer than 20 ms (aurora `hitch:` lines, end_frame to end_frame), with the cause the
|
||||
frame's own counters point at:
|
||||
|
||||
| part | frames > 20 ms | 20-33 | 33-50 | 50-100 | 100-250 | pipelines | textures | disc | worker | no cpu | game cpu | blocked |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| menus (429 s) | 23 | 15 | 6 | 1 | 1 | 0 | 0 | 21 | 0 | 0 | 0 | 2 |
|
||||
| race 1p (68 s after the first 10) | 317 | 315 | 1 | 1 | 0 | 0 | 0 | 3 | 16 | 1 | 161 | 136 |
|
||||
| race 1p start (2 x 10 s) | 141 | 127 | 11 | 1 | 2 | 0 | 0 | 10 | 7 | 6 | 31 | 87 |
|
||||
| race 2p | 796 | 781 | 15 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 771 | 25 |
|
||||
| race 4p (30 fps by design) | 943 | 433 | 509 | 1 | 0 | 0 | 0 | 2 | 0 | 0 | 941 | 0 |
|
||||
|
||||
- Pipelines: none. With a warm cache no frame waited for a pipeline.
|
||||
- 1p race: 8 % of game frames miss one 60 Hz slot (20 to 33 ms), half of them with the game thread
|
||||
computing 13 ms or more of the frame itself ("game cpu": translated game logic plus GX recording),
|
||||
the rest blocked in the game's own waits. VR interpolation hides these from the eyes (sets/s stays
|
||||
at 119.5); they show as a game-frame repeat in motion.
|
||||
- Race start: one 144 ms frame per race (course load: 4 MB read, 7.5 ms texture conversion) and a
|
||||
52 ms one, both inside the fade-in.
|
||||
- Per-tick (compositor) stalls: 9 missed refreshes in the 1p race seconds of the soak, 36 in 429 s
|
||||
of menus (load screens).
|
||||
|
||||
Pipeline cache: the bundled seed `initial_pipeline_cache.db` (runtime/assets/pipeline, copied beside
|
||||
the binary) is stale: its rows are GX config version 19 (2768 bytes) and clear version 3, the code is
|
||||
at 22 and 4, so every row is skipped and a fresh install compiles everything at first use: 536 new
|
||||
pipelines in hitch frames at the first race start of a cold home. See "Cold cache" below for what a
|
||||
regenerated seed does.
|
||||
|
||||
## Half-rate rule
|
||||
|
||||
Commit fffb57c had changed the return rule (return early only when the GPU p90 at half rate is under
|
||||
60 % of a refresh, otherwise after a retry wait doubling 10 to 160 s), because at 2160 and 120 Hz the
|
||||
old rule cycled 10 s at half rate and 3 s at 40 % late. H1 then showed what any return rule does in
|
||||
the headset: 16 drops and 15 returns in 4 minutes, 115 of the 143 race seconds at half rate, judder
|
||||
and head-turn ghosting. Since b240caf the loop never drops by default (a late set repeats the
|
||||
retained layer with its own poses and SteamVR reprojects it); KARTFRAME_HALF_RATE=auto drops after
|
||||
more than 5 % late over 30 s and never comes back in the session; =1 always. Run (a): never
|
||||
triggered (0 switches). Run (d), default: no switch, 22 % late shown as repeats.
|
||||
|
||||
## dlopen audit
|
||||
|
||||
`LD_DEBUG=libs` run (`ld-30s 30 ... LD_DEBUG=libs LD_DEBUG_OUTPUT=...`), game process only. The binary
|
||||
needs libm, libstdc++, libgcc_s, libc, ld-linux (glibc 2.39, gcc-libs 15.1.1). Everything else is
|
||||
dlopened: the OpenXR runtime (here the mock), the OpenXR API layer, the Vulkan loader and ICD, the
|
||||
Vulkan layer, SDL's X11/dbus/udev back ends.
|
||||
|
||||
| library | package | installed |
|
||||
| --- | --- | --- |
|
||||
| ~/.cache/kf-xrmock/.../libxrmock.so (mock runtime; pulls libGLESv2, libEGL) | none (ours, test only) | built 2026-10-13 |
|
||||
| ~/.local/share/Steam/steamrtarm64/libvulkan.so.1 | none: Steam runtime's Vulkan loader | file 2024-12-05 |
|
||||
| ld-linux-aarch64.so.1, libc, libm | glibc 2.39-2 | 2026-09-15 |
|
||||
| libstdc++, libgcc_s | gcc-libs 15.1.1 | 2026-09-15 |
|
||||
| libcap | libcap 2.69-4 | 2026-09-15 |
|
||||
| libdbus-1 | dbus 1.14.10-2 | 2026-09-15 |
|
||||
| libexpat | expat 2.6.2-1 | 2026-09-15 |
|
||||
| libffi | libffi 3.4.6-1 | 2026-09-15 |
|
||||
| libsystemd, libudev | systemd-libs 257.7-2.2 | 2026-09-15 |
|
||||
| libusb-1.0 | libusb 1.0.27-1 | 2026-09-15 |
|
||||
| libz | zlib 1.3.1-1 | 2026-09-15 |
|
||||
| libzstd | zstd 1.5.5-1 | 2026-09-15 |
|
||||
| libdisplay-info | libdisplay-info 0.1.1-3 | 2026-10-07 |
|
||||
| libdrm | libdrm 2.4.134-1 | 2026-10-07 |
|
||||
| libEGL, libGLESv2, libGLdispatch | libglvnd 1.7.0-1 | 2026-10-07 |
|
||||
| libSPIRV-Tools | spirv-tools 1.4.309.0-4 | 2026-10-07 |
|
||||
| libVkLayer_FROG_gamescope_wsi_aarch64 | gamescope 3.16.28-2 | 2026-10-07 |
|
||||
| libVkLayer_VALVE_fdm_injection (as the OpenXR API layer) | deckard-vulkan-layers-linux-aarch64 20260923.1-1 | 2026-10-07 |
|
||||
| libvulkan_freedreno (Turnip) | deckard-mesa-linux-aarch64 26.3.0_devel+gitabc426bb-1 | 2026-10-07 |
|
||||
| libwayland-client | wayland 1.26.0-1 | 2026-10-07 |
|
||||
| libX11, libX11-xcb, libXau, libXcursor, libXdmcp, libXext, libXfixes, libXi, libXrandr, libXrender, libXtst, libxshmfence | libx11 1.8.9, libxau, libxcursor, libxdmcp, libxext, libxfixes, libxi, libxrandr, libxrender, libxtst, libxshmfence | 2026-10-07 |
|
||||
| libxcb, libxcb-dri3/present/randr/shm/sync/xfixes | libxcb 1.17.0-1 | 2026-10-07 |
|
||||
|
||||
The 2026-10-07 dates are not the owner's installs: `/etc/os-release` has BUILD_ID=20261007.6125817,
|
||||
and 850 of the 966 packages carry install times between 15:58 and 16:01 on 2026-10-07, packaged by
|
||||
SteamOS's CI, while pacman.log has no transaction after 2026-10-02 (the only manual one there:
|
||||
`pacman -S --needed base-devel git`). They are the stock image of the current SteamOS build; the
|
||||
other 116 date from 2026-09-15. Nothing the game loads is an owner install.
|
||||
|
||||
Worth knowing: the shell (and Steam's launch environment) puts `~/.local/share/Steam/steamrtarm64`
|
||||
on LD_LIBRARY_PATH, so the game uses the Steam runtime's Vulkan loader (2024-12-05), not the system
|
||||
vulkan-icd-loader 1.4.309; every other library is tried there first and then found in /usr/lib.
|
||||
|
||||
## Cold cache and the pipeline seed
|
||||
|
||||
Same settings as row (a), 200 s, empty home (`KF_PERF_COLD=1`, own `KF_PERF_HOME`):
|
||||
|
||||
| run | seed | boot | race sets/s | late % | GPU p50 / p99 | game fps | race frames > 20 ms (pipelines) |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| C1-cold-stale-seed | bundled (v19 rows, all skipped) | no prewarm | 118.3 | 1.84 | 5.88 / 6.88 | 57.2 | 175 (0) |
|
||||
| C2-cold-new-seed | regenerated (2346 v22 + 10 clear rows) | prewarm 2346 pipelines in 3.8 s, 4220 Dawn blob stores | 105.9 | 12.06 | 6.76 / 8.02 | 51.3 | 552 (0) |
|
||||
|
||||
Neither run has a game frame that waited for a pipeline: first-use compiles run on the compiler
|
||||
workers and the draw is skipped until its pipeline is ready (a missing element for a frame or two,
|
||||
not a stall). The C2 race is worse, but within the run-to-run spread measured above; the seed does
|
||||
not show a pacing benefit in the mock. It does remove the first-use compiles (the old cold run
|
||||
compiled 536 pipelines at the first race start) at a cost of 3.8 s of background compiling at the
|
||||
first boot. How it was produced (current GX config version 22, clear version 4):
|
||||
|
||||
cp build-linux/initial_pipeline_cache.db seed.db
|
||||
sqlite3 seed.db "attach 'file:$HOME/.cache/kf-laneE/home/data/WiiCompiled/Cache/pipeline_cache.db?immutable=1' as u;
|
||||
delete from pipeline_cache;
|
||||
insert into pipeline_cache select * from u.pipeline_cache
|
||||
where (type=0 and config_version=4) or (type=1 and config_version=22);
|
||||
detach u; vacuum;"
|
||||
|
||||
from a home that ran menus and the demo races (1, 2 and 4 players, several courses). Shipping it means
|
||||
replacing runtime/assets/pipeline/initial_pipeline_cache.db (not a lane E file); it goes stale again
|
||||
whenever GXPipelineConfigVersion changes.
|
||||
|
||||
## Other checks
|
||||
|
||||
- Refresh request: `t16-rates90 40 MOCK_XR_SKIP=1 TU_DEBUG=sysmem MOCK_XR_RATES=90` (the mock offers
|
||||
only 90, as SteamVR did in H1): `display refresh rates: 90; 120 Hz not offered, requested none
|
||||
(SteamVR's per-app setting decides)`, no request in the mock trace.
|
||||
- KARTFRAME_HALF_RATE=auto: `D2-2160-120-auto 200 ... KF_MOCKRUN_RENDER_SCALE=1.25 KARTFRAME_HALF_RATE=auto`
|
||||
dropped once, 23 s into the race ("181 of the last 3600 eye sets late; rendering every second refresh
|
||||
for the rest of the session"), and stayed there.
|
||||
- Intro (first-run welcome and controller guide shown, `KF_MOCKRUN_INTRO=1`, 90 s, projected): GPU
|
||||
2.10 / 2.87 ms a set with TU_DEBUG=sysmem, 2.06 / 2.70 tiled. sysmem does not make the guide
|
||||
expensive in the mock.
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
# abgp.sh PREFIX ROUNDS SECONDS "A env..." "B env..." [common env...]: A/B on the scripted Grand Prix (Luigi Circuit)
|
||||
# Each run: frame/perf/hudrace.sh (deterministic: the same course every time, unlike the demo races),
|
||||
# KARTFRAME_FPSLOG=2 segments (gpuprof.py), pacing (pacing-parts.py), GPU clock and temperature
|
||||
# (run.sh gpufreq.tsv). Interleave A and B so they share the clock and thermal state (lane I).
|
||||
P=$1; R=$2; S=$3; A=$4; B=$5; shift 5
|
||||
HERE=$(cd "$(dirname "$0")" && pwd); cd "$HERE/../.."
|
||||
for r in $(seq 1 $R); do for v in A B; do
|
||||
E=$A; [ $v = B ] && E=$B
|
||||
KF_PERF_OUT=${KF_AB_DIR:-$HOME/.cache/kf-ab}/runs KF_PERF_HOME=${KF_AB_DIR:-$HOME/.cache/kf-ab}/home-race frame/perf/hudrace.sh $P-$v$r $S KARTFRAME_FPSLOG=2 MOCK_XR_DUMP_AT=999 $E "$@" > /dev/null 2>&1
|
||||
d=${KF_AB_DIR:-$HOME/.cache/kf-ab}/runs/$P-$v$r
|
||||
echo "== $P-$v$r ($E)"
|
||||
python3 frame/perf/pacing-parts.py $d | grep '| race '
|
||||
python3 frame/perf/gpuprof.py $d | grep -E '^. part|race 1p' | cut -c1-330
|
||||
grep -a 'VR eyes:' $d/game.log | cut -c40-260
|
||||
awk 'NR==1{t0=$1} {t=$1-t0; if(t>50){n++; f+=$2; if($4>tm)tm=$4}} END{printf "gpu MHz avg %.0f, max temp %.1f C\n", f/n/1e6, tm/1000}' $d/gpufreq.tsv
|
||||
done; done
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Background CPU load for timing runs (lane H): frame/perf/bgload.sh SECONDS [JOBS] [NICE]
|
||||
#
|
||||
# JOBS (default 4) loops compiling the mock runtime's C file with clang -O2, at nice NICE (default 5,
|
||||
# as kfbuild.sh builds), for SECONDS, the way a build of another project loads the Frame's cores and
|
||||
# memory. Nothing is written outside a scratch directory under ~/.cache/kf-laneH.
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
SECS=${1:?seconds}; JOBS=${2:-4}; NICE=${3:-5}
|
||||
SRC=$HERE/../tools/xrmock/xrmock.c
|
||||
OUT=$HOME/.cache/kf-laneH/bgload; mkdir -p "$OUT"
|
||||
INC=${OPENXR_INCLUDE:-/usr/include}
|
||||
end=$((SECONDS + SECS))
|
||||
for j in $(seq "$JOBS"); do
|
||||
( while [ $SECONDS -lt $end ]; do
|
||||
nice -n "$NICE" clang -O2 -c "$SRC" -I "$INC" -I "$HERE/../tools/xrmock" -o "$OUT/load$j.o" 2>/dev/null || sleep 1
|
||||
done ) &
|
||||
done
|
||||
wait
|
||||
@@ -1,65 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Where a set's GPU time goes, by part of the demo cycle: gpuprof.py RUN_DIR [FIRST_S LAST_S] (lane I).
|
||||
|
||||
Reads the "GPU ms per game-frame sets / refresh sets" lines KARTFRAME_FPSLOG=2 writes every 5 s (in-pass
|
||||
timestamps: gfx::gpu_timing_mark) and averages each segment per set over a part (menus, race 1p/2p/4p,
|
||||
pacing-parts.py's rule) or over the pacing seconds FIRST_S..LAST_S. Prints a markdown table: ms per set
|
||||
for game-frame sets (native render + an eye set) and refresh sets (an eye set alone), plus the span
|
||||
p50/p99 (first to last timestamp of the set's GPU work)."""
|
||||
import re
|
||||
import sys
|
||||
|
||||
LINE = re.compile(r"GPU ms per (game-frame sets|refresh sets) \((\d+)\): span p50/p99 ([\d.]+)/([\d.]+)((?: [\w-]+=[\d.]+)*)")
|
||||
PACING = re.compile(r"vr pacing: t=(\d+) ")
|
||||
|
||||
|
||||
def main():
|
||||
d = sys.argv[1]
|
||||
window = (int(sys.argv[2]), int(sys.argv[3])) if len(sys.argv) > 3 else None
|
||||
parts = {}
|
||||
screens, in_race, t = 1, False, 0
|
||||
for line in open(f"{d}/game.log", errors="replace"):
|
||||
m = re.search(r"presentation=immersive-race, scene=\d+, screens=(\d+)", line)
|
||||
if m:
|
||||
screens = int(m.group(1))
|
||||
if "entered RaceScene" in line or "presentation=immersive-race" in line:
|
||||
in_race = True
|
||||
elif "exited RaceScene" in line or "presentation=virtual-screen" in line or "presentation=desktop" in line:
|
||||
in_race = False
|
||||
m = PACING.search(line)
|
||||
if m:
|
||||
t = int(m.group(1))
|
||||
for chunk in line.split("; "):
|
||||
m = LINE.search(chunk)
|
||||
if not m:
|
||||
continue
|
||||
if window:
|
||||
if not window[0] <= t <= window[1]:
|
||||
continue
|
||||
part = f"t={window[0]}-{window[1]}"
|
||||
else:
|
||||
part = "menus" if not in_race else ("race 1p" if screens <= 1 else f"race {screens}p")
|
||||
kind, n = m.group(1), int(m.group(2))
|
||||
p = parts.setdefault((part, kind), {"n": 0, "seg": {}, "p50": [], "p99": []})
|
||||
p["n"] += n
|
||||
p["p50"].append(float(m.group(3)))
|
||||
p["p99"].append(float(m.group(4)))
|
||||
for kv in m.group(5).split():
|
||||
k, v = kv.split("=")
|
||||
p["seg"][k] = p["seg"].get(k, 0.0) + float(v) * n
|
||||
keys = []
|
||||
for p in parts.values():
|
||||
for k in p["seg"]:
|
||||
if k not in keys:
|
||||
keys.append(k)
|
||||
print("| part | sets | sets timed | span p50 | span p99 (worst 5 s) | sum | " + " | ".join(keys) + " |")
|
||||
print("| --- | --- | --- | --- | --- | --- | " + " | ".join("---" for _ in keys) + " |")
|
||||
for (part, kind), p in sorted(parts.items()):
|
||||
n = max(p["n"], 1)
|
||||
segs = [p["seg"].get(k, 0.0) / n for k in keys]
|
||||
mid = sorted(p["p50"])[len(p["p50"]) // 2]
|
||||
print(f"| {part} | {kind} | {p['n']} | {mid:.2f} | {max(p['p99']):.2f} | {sum(segs):.2f} | "
|
||||
+ " | ".join(f"{v:.2f}" for v in segs) + " |")
|
||||
|
||||
|
||||
main()
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Game-frame hitches of a run: hitches.py RUN_DIR [RACE_START_WINDOW_S] (lane E).
|
||||
|
||||
Reads aurora's "hitch:" lines (one per game frame longer than 20 ms, end_frame to end_frame, with what
|
||||
the process did meanwhile; aurora-main/lib/gfx/hitch_stats.hpp), "vr pacing clock" (the session start
|
||||
on CLOCK_MONOTONIC), the pacing seconds around "entered/exited RaceScene", and io.tsv (iosample.py:
|
||||
read bytes and newly opened game files every 50 ms). Prints histograms for menus, the race and the
|
||||
first N seconds (10) of each race, and the frames with their likely cause:
|
||||
pipelines blocked on or compiling pipelines (pipeline_wait + inline compiles) for most of the excess
|
||||
textures CPU texture conversion for most of the excess
|
||||
disc game data read in the frame (rchar, newly opened files under DATA/files)
|
||||
worker waiting for the frame worker (Aurora's encode of the previous frame) or its GPU lock
|
||||
no cpu the game thread was runnable but waiting for a CPU (runq_ms) for most of the excess
|
||||
aurora begin inside aurora_begin_frame (frame-worker preparation) for most of the excess
|
||||
game cpu the game thread computed for 80 % of a 60 Hz frame or more (game logic, GX recording)
|
||||
blocked none of these: the thread slept elsewhere (the game's own waits: VI retrace, threads)
|
||||
other older logs without game_cpu_ms: none of the above."""
|
||||
import bisect
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
CAUSES = ("pipelines", "textures", "disc", "worker", "no cpu", "aurora begin", "game cpu", "blocked", "other")
|
||||
BUCKETS = [(20, 33), (33, 50), (50, 100), (100, 250), (250, 1000), (1000, 1e9)]
|
||||
HITCH = re.compile(r"hitch: mono=([\d.]+) frame_ms=([\d.]+)(?: game_cpu_ms=([\d.]+))?(?: runq_ms=([\d.]+) begin_ms=([\d.]+))? worker_wait_ms=([\d.]+) pipelines_new=(\d+) pipeline_wait_ms=([\d.]+) "
|
||||
r"pipeline_inline=(\d+) pipeline_inline_ms=([\d.]+) tex_convert_ms=([\d.]+) tex_convert_kb=(\d+) tex_upload_kb=(\d+)(?: begin_joins=(\d+))?")
|
||||
|
||||
|
||||
def load(d):
|
||||
hitches, races, start = [], [], None
|
||||
t_last, race_open, screens = 0, None, {}
|
||||
with open(os.path.join(d, "game.log"), errors="replace") as f:
|
||||
for line in f:
|
||||
m = re.search(r"vr pacing clock: session start mono=([\d.]+)", line)
|
||||
if m and start is None:
|
||||
start = float(m.group(1))
|
||||
m = re.search(r"vr pacing: t=(\d+) ", line)
|
||||
if m:
|
||||
t_last = int(m.group(1))
|
||||
if "entered RaceScene" in line:
|
||||
race_open = t_last
|
||||
elif "exited RaceScene" in line and race_open is not None:
|
||||
races.append((race_open, t_last + 1))
|
||||
race_open = None
|
||||
m = re.search(r"presentation=immersive-race, scene=\d+, screens=(\d+)", line)
|
||||
if m and race_open is not None:
|
||||
screens[race_open] = int(m.group(1))
|
||||
m = HITCH.search(line)
|
||||
if m:
|
||||
v = [float(x) if x is not None else -1.0 for x in m.groups()]
|
||||
hitches.append(dict(zip(("mono", "ms", "cpu", "runq", "begin", "worker", "pnew", "pwait", "pinl", "pinlms", "tex", "texkb", "upkb", "joins"), v)))
|
||||
if race_open is not None:
|
||||
races.append((race_open, 1e9))
|
||||
# Split-screen demo races (2 and 4 players) are reported apart: the owner races alone.
|
||||
races = [(a, b, screens.get(a, 1)) for a, b in races]
|
||||
io = []
|
||||
p = os.path.join(d, "io.tsv")
|
||||
if os.path.exists(p):
|
||||
for line in open(p):
|
||||
if line.startswith("#"):
|
||||
continue
|
||||
c = line.rstrip("\n").split("\t")
|
||||
io.append((float(c[0]), int(c[1]), int(c[2]), c[3] if len(c) > 3 else ""))
|
||||
return hitches, races, start, io
|
||||
|
||||
|
||||
def io_between(io, a, b):
|
||||
"""Bytes read and files opened between monotonic times a and b (50 ms samples, so widened by one)."""
|
||||
if not io:
|
||||
return 0, []
|
||||
times = [x[0] for x in io]
|
||||
i = max(bisect.bisect_left(times, a) - 1, 0)
|
||||
j = min(bisect.bisect_right(times, b), len(io) - 1)
|
||||
files = [f for x in io[i + 1:j + 1] for f in x[3].split(",") if f]
|
||||
return io[j][1] - io[i][1], files
|
||||
|
||||
|
||||
def cause(h, rbytes, files):
|
||||
excess = h["ms"] - 16.7
|
||||
pipe = h["pwait"] + h["pinlms"]
|
||||
if files or rbytes > 256 * 1024:
|
||||
return "disc"
|
||||
if pipe >= 0.5 * excess:
|
||||
return "pipelines"
|
||||
if h["tex"] >= 0.5 * excess:
|
||||
return "textures"
|
||||
if h["worker"] >= 0.5 * excess:
|
||||
return "worker"
|
||||
if h["runq"] >= 0.5 * excess:
|
||||
return "no cpu"
|
||||
if h["begin"] >= 0.5 * excess:
|
||||
return "aurora begin"
|
||||
if h["cpu"] >= 0.8 * 16.7:
|
||||
return "game cpu"
|
||||
return "blocked" if h["cpu"] >= 0 else "other"
|
||||
|
||||
|
||||
def main(d, window, limit=60):
|
||||
hitches, races, start, io = load(d)
|
||||
if start is None:
|
||||
print("no 'vr pacing clock' line; is this a run of a build with lane E's diagnostics?")
|
||||
return 1
|
||||
groups = {"menus": [], "race": [], "race start (first %ds)" % window: []}
|
||||
for tag in sorted({" %dp" % n for _, _, n in races if n > 1}):
|
||||
groups["race" + tag] = []
|
||||
groups["race start (first %ds)" % window + tag] = []
|
||||
rows = []
|
||||
for h in hitches:
|
||||
t = h["mono"] - start
|
||||
part = "menus"
|
||||
for a, b, n in races:
|
||||
if a <= t < b:
|
||||
tag = "" if n <= 1 else " %dp" % n
|
||||
part = ("race start (first %ds)" % window if t - a < window else "race") + tag
|
||||
rb, files = io_between(io, h["mono"] - h["ms"] / 1000.0, h["mono"])
|
||||
c = cause(h, rb, files)
|
||||
groups[part].append((h, c))
|
||||
rows.append((t, part, h, c, rb, files))
|
||||
print("races at session seconds: %s" % ", ".join("%d-%s (%dp)" % (a, "end" if b >= 1e9 else int(b), n) for a, b, n in races))
|
||||
print()
|
||||
print("| part | frames > 20 ms | " + " | ".join("%d-%s ms" % (a, "" if b >= 1e9 else int(b)) for a, b in BUCKETS) +
|
||||
" | " + " | ".join(CAUSES) + " |")
|
||||
print("| --- " * (len(BUCKETS) + 2 + len(CAUSES)) + "|")
|
||||
for name, items in groups.items():
|
||||
hist = [sum(1 for h, _ in items if a <= h["ms"] < b) for a, b in BUCKETS]
|
||||
causes = [sum(1 for _, c in items if c == k) for k in CAUSES]
|
||||
print("| %s | %d | %s | %s |" % (name, len(items), " | ".join(map(str, hist)), " | ".join(map(str, causes))))
|
||||
print()
|
||||
print("frames over 33 ms (t = session seconds):")
|
||||
shown = 0
|
||||
for t, part, h, c, rb, files in rows:
|
||||
if h["ms"] >= 33 and shown < limit:
|
||||
shown += 1
|
||||
print(" t=%.1f %s %.0f ms (cpu %.0f, runq %.0f, begin %.0f): %s; worker %.1f, pipelines new %d wait %.1f inline %d/%.1f ms, tex %.1f ms %d KiB, "
|
||||
"upload %d KiB, read %d KiB%s" % (t, part, h["ms"], h["cpu"], h["runq"], h["begin"], c, h["worker"], h["pnew"], h["pwait"], h["pinl"], h["pinlms"],
|
||||
h["tex"], h["texkb"], h["upkb"], rb // 1024,
|
||||
(", opened " + " ".join(files[:4])) if files else ""))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not sys.argv[1:]:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
sys.exit(main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 10))
|
||||
@@ -1,42 +0,0 @@
|
||||
#!/bin/bash
|
||||
# A real race in the mock, with the owner's HUD settings, and eye images to look at (lane G).
|
||||
#
|
||||
# frame/perf/hudrace.sh NAME [SECONDS] [VAR=value ...]
|
||||
#
|
||||
# Scripted presses of the right A button (MOCK_XR_PRESS) walk from the title screen through license,
|
||||
# Grand Prix, character, kart and cup to Luigi Circuit (about 30 s in; the scratch home keeps the
|
||||
# license, so later runs are a little faster); the race HUD then sits on the virtual screen
|
||||
# (hud_virtual_screen = true, hand_hud = false, as the owner plays). Eye images are written at 65 and
|
||||
# 75 s (two frames each, every 4th pixel) and converted to PNG in the run directory's dump/.
|
||||
# Look at them: TIME, LAP, the position and the course map must be on the screen in front of the kart.
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
NAME=${1:?name}; SECS=${2:-80}; shift; [ $# -gt 0 ] && shift
|
||||
OUT=${KF_PERF_OUT:-$HOME/.cache/kf-laneH/runs}
|
||||
DUMP=$OUT/$NAME/dump
|
||||
export KF_PERF_OUT=$OUT KF_PERF_HOME=${KF_PERF_HOME:-$HOME/.cache/kf-laneH/home-race}
|
||||
TMP=$OUT/.$NAME-dump; rm -rf "$TMP"; mkdir -p "$TMP" # run.sh empties the run directory first
|
||||
bash "$HERE/run.sh" "$NAME" "$SECS" MOCK_XR_SKIP=1 TU_DEBUG=sysmem "KF_MOCKRUN_VR=hud_virtual_screen = true
|
||||
hand_hud = false" "MOCK_XR_PRESS=/user/hand/right/input/a/click@12/1.5" MOCK_XR_HANDS=1 \
|
||||
MOCK_XR_DUMP=$TMP MOCK_XR_DUMP_AT=65,75 MOCK_XR_DUMP_FRAMES=2 "$@" || true
|
||||
mkdir -p "$DUMP"
|
||||
mv "$TMP"/*.ppm "$TMP"/*.pgm "$DUMP"/ 2>/dev/null || true; rm -rf "${TMP:?}"
|
||||
# PPM (RGB) and, with MOCK_XR_DUMP_ALPHA=1, PGM (alpha) to PNG.
|
||||
python3 -I - "$DUMP"/*.ppm "$DUMP"/*.pgm <<'PY'
|
||||
import os
|
||||
import sys, zlib, struct
|
||||
for src in sys.argv[1:]:
|
||||
if not os.path.exists(src): continue
|
||||
data = open(src, 'rb').read()
|
||||
head = data.split(b'\n', 3)
|
||||
w, h = map(int, head[1].split())
|
||||
n = 1 if head[0] == b'P5' else 3
|
||||
px = data[len(data) - w * h * n:]
|
||||
raw = b''.join(b'\0' + px[y * w * n:(y + 1) * w * n] for y in range(h))
|
||||
chunk = lambda t, d: struct.pack('>I', len(d)) + t + d + struct.pack('>I', zlib.crc32(t + d) & 0xffffffff)
|
||||
open(src[:-4] + '.png', 'wb').write(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2 if n == 3 else 0, 0, 0, 0))
|
||||
+ chunk(b'IDAT', zlib.compress(raw, 6)) + chunk(b'IEND', b''))
|
||||
PY
|
||||
rm -f "${DUMP:?}"/*.ppm "${DUMP:?}"/*.pgm
|
||||
grep -a 'entered RaceScene\|screen clip self-check' "$OUT/$NAME/game.log" | tail -3
|
||||
echo "hudrace: eye images in $DUMP"
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Samples the game's read I/O every 50 ms: KF_IOSAMPLE_EXE=EXE iosample.py OUT.tsv (lane E, frame/perf/run.sh).
|
||||
|
||||
Lines: CLOCK_MONOTONIC seconds, rchar (bytes read by read()-type calls, cached or not), read_bytes
|
||||
(bytes that came from the block device), and the game data files newly open since the last sample
|
||||
(paths under a DATA/files directory). Waits for a process of EXE that starts after the sampler."""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def find_game(exe, before):
|
||||
for pid in os.listdir("/proc"):
|
||||
if pid.isdigit() and pid not in before:
|
||||
try:
|
||||
if os.readlink("/proc/%s/exe" % pid) == exe:
|
||||
return pid
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def main(out, exe):
|
||||
exe = os.path.realpath(exe)
|
||||
before = set(p for p in os.listdir("/proc") if p.isdigit())
|
||||
pid = None
|
||||
while pid is None:
|
||||
pid = find_game(exe, before)
|
||||
if pid is None:
|
||||
time.sleep(0.2)
|
||||
seen = set()
|
||||
with open(out, "w") as f:
|
||||
f.write("# pid %s\n# mono\trchar\tread_bytes\tnew_files\n" % pid)
|
||||
while True:
|
||||
try:
|
||||
io = dict(l.split(": ") for l in open("/proc/%s/io" % pid).read().split("\n") if l)
|
||||
files = set()
|
||||
for fd in os.listdir("/proc/%s/fd" % pid):
|
||||
try:
|
||||
p = os.readlink("/proc/%s/fd/%s" % (pid, fd))
|
||||
except OSError:
|
||||
continue
|
||||
if "/files/" in p:
|
||||
files.add(p.split("/files/", 1)[1])
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
new = sorted(files - seen)
|
||||
seen = files
|
||||
f.write("%.3f\t%s\t%s\t%s\n" % (time.monotonic(), io.get("rchar", "0"), io.get("read_bytes", "0"), ",".join(new)))
|
||||
f.flush()
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1], os.environ["KF_IOSAMPLE_EXE"])
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Consecutive eye images of a scripted mock race (lane S): frame/perf/mockdump/dumprace.sh NAME [VAR=value ...]
|
||||
#
|
||||
# hudrace.sh's Grand Prix to Moo Moo Meadows with: menu taps until 46 s (they also skip the course intro),
|
||||
# the head still until 51 s and then swaying 25 deg with a 1.5 s period (MOCK_XR_SWAY), A held from 56 s
|
||||
# (driving), and 240-set windows of both eyes' released images (MOCK_XR_DUMP_QUEUE: no stalls) at
|
||||
# 48.5 s (kart and head still: the noise floor), 52.5 s (kart standing, head swaying), 58.5, 62.5 and
|
||||
# 66.5 s (driving). Images in $KF_DUMP_DIR (default ~/.cache/kf-laneS/dump-NAME), 432 px across
|
||||
# (render scale 0.75, every 3rd pixel, linear filter: the game runs its race at 60 fps, as on the headset;
|
||||
# at 1.0 the mock's race drops to 50-53 fps and the scene clock clamps). Then:
|
||||
# python3 -I frame/perf/mockdump/zigzag.py <dump dir>
|
||||
# Env: SECS (70), DUMP_AT (the window times), KF_PERF_OUT / KF_PERF_HOME as for run.sh.
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
NAME=${1:?name}; shift
|
||||
DD=${KF_DUMP_DIR:-$HOME/.cache/kf-laneS/dump-$NAME}
|
||||
rm -rf "${DD:?}"; mkdir -p "$DD"
|
||||
export KF_PERF_OUT=${KF_PERF_OUT:-$HOME/.cache/kf-laneS/runs} KF_PERF_HOME=${KF_PERF_HOME:-$HOME/.cache/kf-laneS/home-race}
|
||||
bash "$HERE/../hudrace.sh" "$NAME" "${SECS:-70}" MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KARTFRAME_FRAME_LOG=1 \
|
||||
"MOCK_XR_PRESS=/user/hand/right/input/a/click@12/1.5/0.15/46;/user/hand/right/input/a/click@56/1000/1000" \
|
||||
MOCK_XR_SWAY=25,1.5,51 KF_MOCKRUN_RENDER_SCALE=0.75 MOCK_XR_DUMP="$DD" MOCK_XR_DUMP_AT="${DUMP_AT:-48.5,52.5,58.5,62.5,66.5}" \
|
||||
MOCK_XR_DUMP_FRAMES=80 MOCK_XR_DUMP_STEP=3 MOCK_XR_DUMP_QUEUE=512 "$@"
|
||||
echo "dumprace: $(wc -l < "$DD/index.txt") images in $DD"
|
||||
@@ -1,221 +0,0 @@
|
||||
# zigzag.py DUMPDIR [X0 Y0 W H]: the panel zigzag test (H7 section 1, session-h12 zig2.py) on mock eye images.
|
||||
# DUMPDIR: MOCK_XR_DUMP output with index.txt (xrmock with MOCK_XR_STAMP: eye and display time per image).
|
||||
# Per dump window (t<seconds> prefix) and eye: the global image shift between consecutive sets from column
|
||||
# projections of a region of the upper image (motion.py's fit), the position track, H7's zigzag rule on
|
||||
# sets n-1, n, n+1 one refresh apart (moving: |speed| >= 2 px a refresh over n-4..n+5; zigzag: |pos(n) -
|
||||
# mean(pos(n-1), pos(n+1))| >= 1 px and >= 0.75 refresh of motion), the lag-1 autocorrelation of that
|
||||
# deviation (period 2: near -1), the two eyes' deviations against each other (in phase: +1, against: -1),
|
||||
# and a lagdiff: per set the RMS difference of a central region against the same eye's set 1 and 2 before,
|
||||
# plus how often the image sits where set n-2 was (|pos(n) - pos(n-2)| < 0.25 px while moving).
|
||||
# Pixels: the dump's (MOCK_XR_DUMP_STEP 4: 432 px across 1728).
|
||||
import os, sys, audioop, collections, statistics, warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'panels'))
|
||||
from pgray import to16, rmsdiff16
|
||||
|
||||
D = sys.argv[1]
|
||||
M = 24
|
||||
|
||||
|
||||
def ppm_gray(path):
|
||||
data = open(path, 'rb').read()
|
||||
head = data.split(b'\n', 3)
|
||||
w, h = map(int, head[1].split())
|
||||
return w, h, data[len(data) - w * h * 3:][1::3] # green channel
|
||||
|
||||
|
||||
def region(img, W, x0, y0, w, h):
|
||||
return b''.join(img[(y0 + y) * W + x0:(y0 + y) * W + x0 + w] for y in range(h))
|
||||
|
||||
|
||||
def cols(reg, w, h):
|
||||
c = [0] * w
|
||||
for y in range(h):
|
||||
r = reg[y * w:(y + 1) * w]
|
||||
for x in range(w):
|
||||
c[x] += r[x]
|
||||
return c
|
||||
|
||||
|
||||
def to16v(vals):
|
||||
m = sum(vals) / len(vals)
|
||||
s = max(1.0, max(abs(v - m) for v in vals))
|
||||
return b''.join(int((v - m) * 12000 / s).to_bytes(2, 'little', signed=True) for v in vals)
|
||||
|
||||
|
||||
def fit(prev, cur):
|
||||
n = len(cur)
|
||||
ref = to16v(cur[M:n - M])
|
||||
frag = to16v(prev)
|
||||
off, _ = audioop.findfit(frag, ref)
|
||||
def err(o):
|
||||
if o < 0 or o > 2 * M:
|
||||
return None
|
||||
part = frag[2 * o:2 * (o + n - 2 * M)]
|
||||
f = audioop.findfactor(part, ref)
|
||||
return audioop.rms(audioop.add(part, audioop.mul(ref, 2, -f), 2), 2)
|
||||
e0, em, ep = err(off), err(off - 1), err(off + 1)
|
||||
sub = 0.0
|
||||
if em is not None and ep is not None and em + ep - 2 * e0 > 0:
|
||||
sub = 0.5 * (em - ep) / (em + ep - 2 * e0)
|
||||
return (M - off) - sub
|
||||
|
||||
|
||||
def corr(a, b):
|
||||
if len(a) < 3:
|
||||
return float('nan')
|
||||
ma, mb = statistics.fmean(a), statistics.fmean(b)
|
||||
sa = sum((x - ma) ** 2 for x in a) ** 0.5
|
||||
sb = sum((y - mb) ** 2 for y in b) ** 0.5
|
||||
return sum((x - ma) * (y - mb) for x, y in zip(a, b)) / (sa * sb) if sa > 0 and sb > 0 else float('nan')
|
||||
|
||||
|
||||
# index.txt: file sc frame eye displayns
|
||||
wins = collections.defaultdict(dict) # window -> eye -> {display ns: file}
|
||||
scene = collections.defaultdict(dict) # window -> {display ns: (weight, previous, current game frame)} (left eye)
|
||||
for line in open(os.path.join(D, 'index.txt')):
|
||||
parts = line.split()
|
||||
f, sc, fr, eye, val = parts[:5]
|
||||
if int(eye) < 0 or not os.path.exists(os.path.join(D, f)):
|
||||
continue
|
||||
wins[f.split('-')[0]].setdefault('LR'[int(eye)], {})[int(val)] = f
|
||||
if len(parts) >= 7 and int(eye) == 0 and (int(parts[5]) >> 48) & 1:
|
||||
a = int(parts[5])
|
||||
scene[f.split('-')[0]][int(val)] = ((a & 0xffff) / 65535.0, (a >> 16) & 0xffff, (a >> 32) & 0xffff)
|
||||
PERIOD = 1e9 / float(os.environ.get('MOCK_XR_HZ', '120'))
|
||||
|
||||
|
||||
def rule(pos):
|
||||
"""H7's zigzag rule on a position track {refresh: px}: (tests, zigzags, mean |dev| in refreshes of motion,
|
||||
lag-1 autocorrelation of the deviation, speed median, |dev| p95 over all sets, deviations by refresh)."""
|
||||
tests = zz = 0
|
||||
amps, z, vs, allz = [], {}, [], []
|
||||
for n in sorted(pos):
|
||||
if n - 1 not in pos or n + 1 not in pos:
|
||||
continue
|
||||
zp = pos[n] - 0.5 * (pos[n - 1] + pos[n + 1])
|
||||
allz.append(abs(zp))
|
||||
ks = [k for k in range(n - 4, n + 6) if k in pos]
|
||||
if ks[-1] - ks[0] < 4:
|
||||
continue
|
||||
v = (pos[ks[-1]] - pos[ks[0]]) / (ks[-1] - ks[0])
|
||||
vs.append(abs(v))
|
||||
if abs(v) < 2:
|
||||
continue
|
||||
tests += 1
|
||||
z[n] = zp
|
||||
if abs(zp) >= 1 and abs(zp / v) >= 0.75:
|
||||
zz += 1
|
||||
amps.append(abs(zp / v))
|
||||
a1 = [(z[k], z[k + 1]) for k in sorted(z) if k + 1 in z]
|
||||
q = sorted(allz)
|
||||
return dict(tests=tests, zz=zz, amp=statistics.fmean(amps) if amps else 0.0, ac1=corr([x for x, _ in a1], [y for _, y in a1]),
|
||||
speed=statistics.median(vs) if vs else 0.0, p95=q[int(0.95 * (len(q) - 1))] if q else float('nan'),
|
||||
ge1=sum(1 for x in q if x >= 1), n=len(q), z=z)
|
||||
|
||||
|
||||
def track(items, profs, axis):
|
||||
pos, p, prevn = {}, 0.0, None
|
||||
for n, _ in items:
|
||||
if prevn is not None:
|
||||
p += fit(profs[prevn][axis], profs[n][axis])
|
||||
pos[n] = p
|
||||
prevn = n
|
||||
return pos
|
||||
|
||||
|
||||
for win in sorted(wins):
|
||||
eyes = wins[win]
|
||||
if 'L' not in eyes or 'R' not in eyes:
|
||||
continue
|
||||
v0 = min(min(eyes['L']), min(eyes['R']))
|
||||
tracks, lag, btw = {}, {}, {}
|
||||
for e in 'LR':
|
||||
items = sorted((round((v - v0) / PERIOD), f) for v, f in eyes[e].items())
|
||||
if os.environ.get('ZZ_INJECT') and e == 'L':
|
||||
# Positive control: every odd set of the left eye shows the image of k sets before (the panels'
|
||||
# zigzag: every second refresh about two refreshes of motion off the track).
|
||||
k = int(os.environ['ZZ_INJECT'])
|
||||
byn = dict(items)
|
||||
items = [(n, byn[n - k] if n % 2 and n - k in byn else f) for n, f in items]
|
||||
profs, cen, low = {}, {}, {}
|
||||
for n, f in items:
|
||||
W, H, g = ppm_gray(os.path.join(D, f))
|
||||
# view: columns of the sky, hills and track edge (head and kart turning; clear of the stamp);
|
||||
# left / right: columns of the track-side strips at the horizon (fences, banners): forward motion
|
||||
# moves them apart (scene: (right - left) / 2), turning moves them together.
|
||||
x0, y0, rw, rh = W // 10, H // 12, W * 8 // 10, H * 3 // 10
|
||||
sy0, sh, sw = H * 28 // 100, H * 18 // 100, W * 30 // 100
|
||||
profs[n] = (cols(region(g, W, x0, y0, rw, rh), rw, rh), cols(region(g, W, 2, sy0, sw, sh), sw, sh),
|
||||
cols(region(g, W, W - 2 - sw, sy0, sw, sh), sw, sh))
|
||||
cen[n] = to16(region(g, W, W // 4, H // 8, W // 2, H // 4))
|
||||
# Betweenness regions (any motion, also forward): the ground left and right of the kart.
|
||||
low[n] = (to16(region(g, W, 2, H * 55 // 100, W * 30 // 100, H * 40 // 100)),
|
||||
to16(region(g, W, W - 2 - W * 30 // 100, H * 55 // 100, W * 30 // 100, H * 40 // 100)))
|
||||
tl, tr = track(items, profs, 1), track(items, profs, 2)
|
||||
tracks[e] = {'view': track(items, profs, 0), 'scene': {n: 0.5 * (tr[n] - tl[n]) for n in tl}}
|
||||
lag[e] = {n: (rmsdiff16(cen[n], cen[n - 1]) if n - 1 in cen else None, rmsdiff16(cen[n], cen[n - 2]) if n - 2 in cen else None) for n in cen}
|
||||
# Betweenness r(n) = D(n-1, n+1) / mean(D(n, n-1), D(n, n+1)) per region (RMS differences, no motion
|
||||
# compensation): smooth motion > 1 (two steps differ more than one); an image displaced about two
|
||||
# refreshes off the track (the panels' zigzag) < 1.
|
||||
btw[e] = {}
|
||||
for name, get in (('upper', lambda n: cen[n]), ('ground L', lambda n: low[n][0]), ('ground R', lambda n: low[n][1])):
|
||||
rs = {}
|
||||
for n in cen:
|
||||
if n - 1 in cen and n + 1 in cen:
|
||||
a, b, c = rmsdiff16(get(n), get(n - 1)), rmsdiff16(get(n), get(n + 1)), rmsdiff16(get(n - 1), get(n + 1))
|
||||
if a + b > 2.0: # moving (still images: codec-free, but blending noise)
|
||||
rs[n] = c / (0.5 * (a + b))
|
||||
btw[e][name] = rs
|
||||
span = max(max(tracks['L']['view']), max(tracks['R']['view'])) + 1
|
||||
print(f"== window {win}: sets L {len(tracks['L']['view'])}, R {len(tracks['R']['view'])} over {span} refreshes")
|
||||
# Scene position per set from the left eye's stamp (game frames).
|
||||
sp = {}
|
||||
for v, (w, prev, cur) in scene.get(win, {}).items():
|
||||
span_f = (cur - prev) & 0xffff
|
||||
sp[round((v - v0) / PERIOD)] = (cur - span_f + w * span_f, w, cur)
|
||||
pos_s = {}
|
||||
if sp:
|
||||
ks = sorted(sp)
|
||||
base = sp[ks[0]][2]
|
||||
pos_s = {k: sp[k][0] + 65536 * round((base - sp[k][2]) / 65536) for k in ks}
|
||||
steps = [pos_s[k] - pos_s[k - 1] for k in ks if k - 1 in pos_s]
|
||||
alt = sum(1 for k in ks if k - 1 in pos_s and k - 2 in pos_s and abs((pos_s[k] - pos_s[k - 1]) - (pos_s[k - 1] - pos_s[k - 2])) >= 0.3)
|
||||
ws = collections.Counter(round(sp[k][1], 2) for k in ks)
|
||||
print(f" scene stamp: steps between sets one refresh apart mean {statistics.fmean(steps):.3f} sd {statistics.pstdev(steps):.3f} game frames; "
|
||||
f"consecutive steps differing by 0.3+ {alt} of {len(steps)}; weight 1.0 in {ws.get(1.0, 0)}, 0.0 in {ws.get(0.0, 0)} sets")
|
||||
devs = {}
|
||||
for kind in ('view', 'scene'):
|
||||
for e in 'LR':
|
||||
r = rule(tracks[e][kind])
|
||||
devs[kind, e] = r['z']
|
||||
pos = tracks[e][kind]
|
||||
st = [(pos[k] - pos[k - 1], pos_s[k] - pos_s[k - 1]) for k in pos if k - 1 in pos and k in pos_s and k - 1 in pos_s]
|
||||
cs = corr([a for a, _ in st], [b for _, b in st]) if st else float('nan')
|
||||
print(f" {kind:6s} {e}: speed p50 {r['speed']:.2f} px/refresh; |dev| p95 {r['p95']:.2f} px (>=1 px {r['ge1']} of {r['n']}); moving {r['tests']}, "
|
||||
f"zigzag {r['zz']} ({100.0 * r['zz'] / max(r['tests'], 1):.1f} %, mean {r['amp']:.2f} refreshes); lag-1 autocorr {r['ac1']:+.2f}; "
|
||||
f"step vs scene step corr {cs:+.2f}")
|
||||
common = [n for n in devs[kind, 'L'] if n in devs[kind, 'R']]
|
||||
print(f" {kind:6s} eyes: deviation L vs R corr {corr([devs[kind, 'L'][n] for n in common], [devs[kind, 'R'][n] for n in common]):+.2f} over {len(common)} moving sets")
|
||||
for e in 'LR':
|
||||
l1 = [x for x, y in lag[e].values() if x is not None]
|
||||
l2 = [y for x, y in lag[e].values() if y is not None]
|
||||
stale = sum(1 for x, y in lag[e].values() if x is not None and y is not None and y < 0.5 * x)
|
||||
ident = sum(1 for x, y in lag[e].values() if (x is not None and x < 0.5) or (y is not None and y < 0.5))
|
||||
print(f" lagdiff {e}: vs set before p50 {statistics.median(l1) if l1 else 0:.1f}, two before p50 {statistics.median(l2) if l2 else 0:.1f} "
|
||||
f"gray levels; nearer n-2 than half of n-1: {stale}; identical (<0.5) to n-1 or n-2: {ident}")
|
||||
for name in ('upper', 'ground L', 'ground R'):
|
||||
out = []
|
||||
for e in 'LR':
|
||||
rs = btw[e][name]
|
||||
v = sorted(rs.values())
|
||||
ks = sorted(rs)
|
||||
pairs = [(rs[k], rs[k + 1]) for k in ks if k + 1 in rs]
|
||||
out.append(f"{e} {len(v)} sets, r p50 {statistics.median(v) if v else float('nan'):.2f} p5 {v[int(0.05 * (len(v) - 1))] if v else float('nan'):.2f}, "
|
||||
f"r < 1: {sum(1 for x in v if x < 1.0)}, lag-1 autocorr {corr([a for a, _ in pairs], [b for _, b in pairs]):+.2f}")
|
||||
common = [k for k in btw['L'][name] if k in btw['R'][name]]
|
||||
print(f" between {name:8s}: " + "; ".join(out) + f"; L vs R corr {corr([btw['L'][name][k] for k in common], [btw['R'][name][k] for k in common]):+.2f}")
|
||||
if os.environ.get('ZZ_TRACK'):
|
||||
for n in range(span):
|
||||
print(' ', n, ' '.join(f"{tracks[e][k].get(n, float('nan')):8.2f}" for k in ('view', 'scene') for e in 'LR'),
|
||||
f"{pos_s.get(n, float('nan')):.3f}", sp.get(n, ('',))[1:2])
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Menus and race apart, with exact percentiles: pacing-parts.py RUN_DIR [RUN_DIR ...] (lane E).
|
||||
|
||||
A run dir is frame/perf/run.sh's output: game.log (the "vr pacing:" lines, "entered/exited
|
||||
RaceScene", the mock's summary) and samples.txt (KARTFRAME_PACING_SAMPLES: every set's GPU and
|
||||
encode ms per pacing second). A second belongs to the race from the pacing line after "entered
|
||||
RaceScene" up to the one after "exited RaceScene" (session-report.py's rule). Prints one markdown row
|
||||
per run and part: sets/s, late % (of sets due), repeats % (of ticks), missed refreshes, half-rate
|
||||
switches, GPU ms per set p50/p99, encode ms p50/p99, game frames/s (KARTFRAME_FPSLOG lines), then the mock's own counts against the game's."""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
PACING = re.compile(r"vr pacing: t=(\d+) hz=([\d.]+) mode=(\S+) ticks=(\d+) rendered=(\d+) interpolated=(\d+) "
|
||||
r"late=(\d+) missed=(\d+) repeats=(\d+)")
|
||||
TOTALS = re.compile(r"vr pacing totals: .* ticks=(\d+) rendered=(\d+) interpolated=(\d+) late=(\d+) missed=(\d+) repeats=(\d+)")
|
||||
FPS = re.compile(r"Game frame rate [\d.]+ FPS \((\d+) frames in ([\d.]+) s\); per frame the producer waited ([\d.]+) ms for DONE")
|
||||
MOCK = re.compile(r"xrmock: frames: (\d+) ended; (\d+) showed newly released eyes .*?\), (\d+) repeated a projection layer, "
|
||||
r"(\d+) a quad, (\d+) no layer")
|
||||
|
||||
|
||||
def pct(values, p):
|
||||
if not values:
|
||||
return None
|
||||
v = sorted(values)
|
||||
return v[min(len(v) - 1, int(p * (len(v) - 1) + 0.5))]
|
||||
|
||||
|
||||
def race_part(in_race, screens):
|
||||
if not in_race:
|
||||
return "menus"
|
||||
return "race" if screens <= 1 else ("race 2p" if screens == 2 else "race 4p")
|
||||
|
||||
|
||||
def run(d):
|
||||
# The attract-mode demo alternates 1, 2 and 4 player races (MKW runs 3 and 4 players at 30 fps):
|
||||
# "race" is the 1-player race (the owner's case), "race Np" the split-screen ones.
|
||||
parts = {k: {"s": 0, "ticks": 0, "rendered": 0, "late": 0, "missed": 0, "repeats": 0, "gpu": [], "enc": [], "half": 0,
|
||||
"frames": 0, "fsecs": 0.0, "done_ms": 0.0}
|
||||
for k in ("menus", "race", "race 2p", "race 4p")}
|
||||
screens = 1
|
||||
second_part = {}
|
||||
in_race = False
|
||||
totals = mock = None
|
||||
half = []
|
||||
with open(os.path.join(d, "game.log"), errors="replace") as f:
|
||||
for line in f:
|
||||
m = re.search(r"presentation=immersive-race, scene=\d+, screens=(\d+)", line)
|
||||
if m:
|
||||
screens = int(m.group(1))
|
||||
if "entered RaceScene" in line or "presentation=immersive-race" in line:
|
||||
in_race = True
|
||||
elif "exited RaceScene" in line or "presentation=virtual-screen" in line or "presentation=desktop" in line:
|
||||
in_race = False
|
||||
elif "rendering every second refresh" in line:
|
||||
half.append(("down", in_race))
|
||||
elif "rendering every refresh again" in line:
|
||||
half.append(("up", in_race))
|
||||
m = PACING.search(line)
|
||||
if m:
|
||||
t = int(m.group(1))
|
||||
p = parts[race_part(in_race, screens)]
|
||||
second_part[t] = p
|
||||
p["s"] += 1
|
||||
for i, k in ((4, "ticks"), (5, "rendered"), (7, "late"), (8, "missed"), (9, "repeats")):
|
||||
p[k] += int(m.group(i))
|
||||
continue
|
||||
m = FPS.search(line)
|
||||
if m:
|
||||
p = parts[race_part(in_race, screens)]
|
||||
p["frames"] += int(m.group(1))
|
||||
p["fsecs"] += float(m.group(2))
|
||||
p["done_ms"] += float(m.group(3)) * int(m.group(1))
|
||||
continue
|
||||
m = TOTALS.search(line)
|
||||
if m:
|
||||
totals = [int(x) for x in m.groups()]
|
||||
m = MOCK.search(line)
|
||||
if m:
|
||||
mock = [int(x) for x in m.groups()]
|
||||
sp = os.path.join(d, "samples.txt")
|
||||
if os.path.exists(sp):
|
||||
for line in open(sp):
|
||||
g = re.match(r"t=(\d+) gpu=(\S*) encode=(\S*)", line)
|
||||
if not g or int(g.group(1)) not in second_part:
|
||||
continue
|
||||
p = second_part[int(g.group(1))]
|
||||
p["gpu"] += [float(x) for x in g.group(2).split(",") if x]
|
||||
p["enc"] += [float(x) for x in g.group(3).split(",") if x]
|
||||
return parts, totals, mock, half
|
||||
|
||||
|
||||
def fmt(x):
|
||||
return "-" if x is None else "%.2f" % x
|
||||
|
||||
|
||||
def main(dirs):
|
||||
print("| run | part | s | sets/s | late % | repeats % | missed | GPU ms p50 / p99 | encode ms p50 / p99 | game fps |")
|
||||
print("| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |")
|
||||
checks = []
|
||||
for d in dirs:
|
||||
name = os.path.basename(os.path.normpath(d))
|
||||
parts, totals, mock, half = run(d)
|
||||
for k in ("menus", "race", "race 2p", "race 4p"):
|
||||
p = parts[k]
|
||||
if not p["s"]:
|
||||
continue
|
||||
due = p["rendered"] + p["late"]
|
||||
fps = "%.1f" % (p["frames"] / p["fsecs"]) if p["fsecs"] else "-"
|
||||
print("| %s | %s | %d | %.1f | %.2f | %.2f | %d | %s / %s | %s / %s | %s |" % (
|
||||
name, k, p["s"], p["rendered"] / p["s"], 100.0 * p["late"] / max(due, 1),
|
||||
100.0 * p["repeats"] / max(p["ticks"], 1), p["missed"], fmt(pct(p["gpu"], .5)), fmt(pct(p["gpu"], .99)),
|
||||
fmt(pct(p["enc"], .5)), fmt(pct(p["enc"], .99)), fps))
|
||||
hs = ", ".join("%s in %s" % (a, "race" if r else "menus") for a, r in half) or "none"
|
||||
if totals and mock:
|
||||
ticks, rendered, interp, late, missed, repeats = totals
|
||||
ended, fresh, rep, quad, empty = mock
|
||||
checks.append("%s: half-rate switches: %s. game: ticks %d, rendered %d, late %d, missed %d, repeats %d; mock: ended %d, "
|
||||
"fresh %d, repeated layer %d, no layer %d (repeated + no layer = %d)" % (
|
||||
name, hs, ticks, rendered, late, missed, repeats, ended, fresh, rep, empty, rep + empty))
|
||||
else:
|
||||
checks.append("%s: half-rate switches: %s; no totals or mock summary in the log" % (name, hs))
|
||||
print()
|
||||
for c in checks:
|
||||
print("- " + c)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not sys.argv[1:]:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
main(sys.argv[1:])
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
# analyse.sh SESSION_DIR [OUT_DIR] [RACE_FROM_S]: the whole panel analysis of a session recorded with
|
||||
# KARTFRAME_RECORD=raw (panels.mkv): decode per eye (extract.sh), compare each refresh with the 8 before
|
||||
# it (lagdiff.py), global image motion (motion.py), then cadence.py's summary into OUT_DIR/cadence.txt.
|
||||
# About 4 minutes for a 2.5 minute recording; OUT_DIR (default ~/.cache/kf-panels/<session>) holds 4 GB.
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
S=${1:?session dir}; OUT=${2:-$HOME/.cache/kf-panels/$(basename "$S")}; FROM=${3:-30}
|
||||
[ -s "$S/panels.mkv" ] || { echo "no $S/panels.mkv" >&2; exit 1; }
|
||||
"$HERE/extract.sh" "$S/panels.mkv" "$OUT"
|
||||
python3 -I "$HERE/lagdiff.py" "$OUT" > "$OUT/lagdiff.txt"
|
||||
python3 -I "$HERE/motion.py" "$OUT" > "$OUT/motion.txt" 2>/dev/null
|
||||
python3 -I "$HERE/cadence.py" "$OUT/lagdiff.txt" "$OUT/motion.txt" "$FROM" | tee "$OUT/cadence.txt"
|
||||
@@ -1,79 +0,0 @@
|
||||
# cadence.py LAGDIFF [MOTION] [RACE_FROM_S]: per eye, how each recorded refresh relates to the ones before it
|
||||
# (from lagdiff.py's table; MOTION from motion.py adds the split by image motion):
|
||||
# new differs from every earlier refresh it was compared with,
|
||||
# rep the previous refresh again (a held frame; only seen while nothing moves on the panel),
|
||||
# stale an image the eye showed 2-8 refreshes before, pixel for pixel, while the previous refresh
|
||||
# differed: an old image shown again where it was shown then, i.e. with a newer frame's pose.
|
||||
# Also both eyes together (out of step: one stale or held while the other is new) and the lag of the stale ones.
|
||||
import collections, sys
|
||||
|
||||
lag = sys.argv[1]
|
||||
mot_path = sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] != '-' else None
|
||||
race_from = float(sys.argv[3]) if len(sys.argv) > 3 else 30.0
|
||||
D, T = {}, {}
|
||||
for l in open(lag):
|
||||
r = l.split()
|
||||
D[(int(r[1]), r[2])] = [float(x) for x in r[3:]]
|
||||
T[int(r[1])] = float(r[0])
|
||||
|
||||
|
||||
def cls(i, e):
|
||||
d = D.get((i, e))
|
||||
if d is None or d[0] < 0:
|
||||
return None, 0
|
||||
if d[0] < 2.0:
|
||||
return 'rep', 1
|
||||
for k in range(1, 8):
|
||||
if 0 <= d[k] <= 4.0 and d[k] < 0.35 * d[0]:
|
||||
return 'stale', k + 1
|
||||
return 'new', 0
|
||||
|
||||
|
||||
mot = {}
|
||||
if mot_path:
|
||||
for l in open(mot_path):
|
||||
t, i, e, g, dx, dy = l.split()
|
||||
if g == '1':
|
||||
mot[(int(i), e)] = abs(float(dx))
|
||||
idx = sorted(T)
|
||||
for name, keep in (('before ' + str(race_from) + ' s', lambda t: t < race_from), ('from ' + str(race_from) + ' s', lambda t: t >= race_from)):
|
||||
sel = [i for i in idx if keep(T[i])]
|
||||
print('==', name)
|
||||
for e in 'LR':
|
||||
c = collections.Counter(); lags = collections.Counter(); holds = collections.Counter(); run = 1
|
||||
speed = collections.defaultdict(collections.Counter)
|
||||
for i in sel:
|
||||
k, lg = cls(i, e)
|
||||
if k is None:
|
||||
run = 1
|
||||
continue
|
||||
c[k] += 1
|
||||
if k == 'stale':
|
||||
lags[lg] += 1
|
||||
if k == 'rep':
|
||||
run += 1
|
||||
else:
|
||||
holds[run] += 1
|
||||
run = 1
|
||||
if mot:
|
||||
v = [mot[(j, e)] for j in range(i - 4, i + 1) if (j, e) in mot]
|
||||
if v:
|
||||
s = sum(v) / len(v)
|
||||
b = 'image still (<0.5 px/refresh)' if s < 0.5 else 'slow (0.5-2)' if s < 2 else 'moving (>=2)'
|
||||
speed[b][k] += 1
|
||||
n = sum(c.values()) or 1
|
||||
print('%s: %d refreshes; new %.1f %%, held %.1f %%, stale %.1f %% (lags %s); holds %s' % (
|
||||
e, n, 100 * c['new'] / n, 100 * c['rep'] / n, 100 * c['stale'] / n,
|
||||
' '.join('%d:%d' % kv for kv in sorted(lags.items())), ' '.join('%d:%d' % kv for kv in sorted(holds.items())[:6])))
|
||||
for b, cc in sorted(speed.items()):
|
||||
m = sum(cc.values())
|
||||
print(' %-30s %6d refreshes, stale %.1f %%' % (b, m, 100 * cc['stale'] / m))
|
||||
both = collections.Counter()
|
||||
for i in sel:
|
||||
a, _ = cls(i, 'L'); b, _ = cls(i, 'R')
|
||||
if a and b:
|
||||
both[(a, b)] += 1
|
||||
n = sum(both.values()) or 1
|
||||
out = sum(v for (a, b), v in both.items() if a != b)
|
||||
print('both eyes: %d refreshes, out of step %.1f %%, stale together %.2f %%; %s' % (
|
||||
n, 100 * out / n, 100 * both[('stale', 'stale')] / n, ' '.join('%s/%s:%d' % (a, b, v) for (a, b), v in sorted(both.items()))))
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/bash
|
||||
# extract.sh PANELS.MKV OUTDIR [W H]: decodes a framecorder --view raw recording (both panels side by side)
|
||||
# once into per-eye grayscale raw files (OUTDIR/L.gray, OUTDIR/R.gray, W x H bytes a frame, default 480x270,
|
||||
# the eye halves scaled with area averaging) and OUTDIR/pts.txt (one presentation time in seconds per frame,
|
||||
# the panel vblank the frame was grabbed at). Frames the recorder missed leave gaps in pts.txt.
|
||||
set -euo pipefail
|
||||
in=$1; out=$2; W=${3:-480}; H=${4:-270}
|
||||
mkdir -p "$out"
|
||||
ffprobe -v error -select_streams v:0 -show_entries frame=pts_time -of csv=p=0 "$in" > "$out/pts.txt" &
|
||||
ffmpeg -hide_banner -loglevel error -threads 3 -i "$in" -an -fps_mode passthrough -filter_complex \
|
||||
"[0:v]format=gray,split[a][b];[a]crop=iw/2:ih:0:0,scale=$W:$H:flags=area[l];[b]crop=iw/2:ih:iw/2:0,scale=$W:$H:flags=area[r]" \
|
||||
-map '[l]' -f rawvideo -fps_mode passthrough "$out/L.gray" -map '[r]' -f rawvideo -fps_mode passthrough "$out/R.gray" -y
|
||||
wait
|
||||
echo "$W $H" > "$out/geometry.txt"
|
||||
echo "frames: $(wc -l < "$out/pts.txt"), L $(stat -c %s "$out/L.gray") bytes"
|
||||
@@ -1,29 +0,0 @@
|
||||
# lagdiff.py DIR [T0 T1] > table: per eye and captured frame, the RMS difference (gray levels) of a central
|
||||
# region against the frames 1..8 refreshes earlier (when the recorder caught them), without motion
|
||||
# compensation. Columns: t idx eye d1..d8 (-1 = not captured).
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from pgray import Eyes, to16, rmsdiff16
|
||||
|
||||
d = sys.argv[1]
|
||||
t0 = float(sys.argv[2]) if len(sys.argv) > 2 else 0.0
|
||||
t1 = float(sys.argv[3]) if len(sys.argv) > 3 else 1e9
|
||||
E = Eyes(d)
|
||||
X0, Y0, RW, RH = 100, 40, 280, 110 # above the head-locked overlay, inside both lens circles
|
||||
for e in 'LR':
|
||||
cache = {}
|
||||
for i in range(E.n):
|
||||
t = E.pts[i]
|
||||
if t < t0 - 0.1 or t > t1:
|
||||
continue
|
||||
cache[E.idx[i]] = to16(E.region(e, i, X0, Y0, RW, RH))
|
||||
for k in [k for k in cache if k < E.idx[i] - 8]:
|
||||
del cache[k]
|
||||
if t < t0:
|
||||
continue
|
||||
cur = cache[E.idx[i]]
|
||||
row = []
|
||||
for lag in range(1, 9):
|
||||
p = cache.get(E.idx[i] - lag)
|
||||
row.append('%.2f' % rmsdiff16(cur, p) if p is not None else '-1')
|
||||
print('%.3f %d %s %s' % (t, E.idx[i], e, ' '.join(row)))
|
||||
@@ -1,62 +0,0 @@
|
||||
# motion.py DIR > table: per eye and captured frame, the global image shift (x, y in extract pixels: 480x270
|
||||
# per eye, so 1 px = 4.5 panel px across, 8 down) against the previous captured frame, from column and row
|
||||
# projections of the upper part of the lens circle (tunnel walls, sky: the world, not the HUD overlay).
|
||||
# Columns: t idx eye gap dx dy. gap = refreshes since the previous captured frame.
|
||||
import os, sys, audioop
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from pgray import Eyes
|
||||
|
||||
E = Eyes(sys.argv[1])
|
||||
X0, Y0, RW, RH = 60, 20, 360, 130
|
||||
M = 24 # search +-M px
|
||||
|
||||
|
||||
def profiles(e, i):
|
||||
reg = E.region(e, i, X0, Y0, RW, RH)
|
||||
cols = [0] * RW
|
||||
rows = []
|
||||
for y in range(RH):
|
||||
r = reg[y * RW:(y + 1) * RW]
|
||||
rows.append(sum(r))
|
||||
for x in range(0, RW):
|
||||
cols[x] += r[x]
|
||||
return cols, rows
|
||||
|
||||
|
||||
def to16(vals):
|
||||
m = sum(vals) / len(vals)
|
||||
s = max(1.0, max(abs(v - m) for v in vals))
|
||||
return b''.join(int((v - m) * 12000 / s).to_bytes(2, 'little', signed=True) for v in vals)
|
||||
|
||||
|
||||
def fit(prev, cur):
|
||||
"""Shift of cur against prev (positive: content moved right/down), with sub-pixel refinement."""
|
||||
n = len(cur)
|
||||
ref = to16(cur[M:n - M])
|
||||
frag = to16(prev)
|
||||
off, _ = audioop.findfit(frag, ref) # ref best matches frag[off:off+n-2M]
|
||||
def err(o):
|
||||
if o < 0 or o > 2 * M:
|
||||
return None
|
||||
part = frag[2 * o:2 * (o + n - 2 * M)]
|
||||
f = audioop.findfactor(part, ref)
|
||||
return audioop.rms(audioop.add(part, audioop.mul(ref, 2, -f), 2), 2)
|
||||
e0, em, ep = err(off), err(off - 1), err(off + 1)
|
||||
sub = 0.0
|
||||
if em is not None and ep is not None and em + ep - 2 * e0 > 0:
|
||||
sub = 0.5 * (em - ep) / (em + ep - 2 * e0)
|
||||
return (M - off) - sub
|
||||
|
||||
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
for e in 'LR':
|
||||
prev = None
|
||||
for i in range(E.n):
|
||||
cols, rows = profiles(e, i)
|
||||
if prev is not None:
|
||||
dx = fit(prev[0], cols)
|
||||
dy = fit(prev[1], rows) if len(rows) > 2 * M + 8 else 0.0
|
||||
print('%.3f %d %s %d %.2f %.2f' % (E.pts[i], E.idx[i], e, E.idx[i] - pidx, dx, dy))
|
||||
prev = (cols, rows)
|
||||
pidx = E.idx[i]
|
||||
@@ -1,42 +0,0 @@
|
||||
# Shared helpers for the panel recording analysis (no numpy/PIL: audioop does the per-pixel work in C).
|
||||
# Input: a directory made by extract.sh (L.gray, R.gray, pts.txt, geometry.txt).
|
||||
import audioop, mmap, os, warnings
|
||||
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||
|
||||
|
||||
class Eyes:
|
||||
def __init__(self, d):
|
||||
self.w, self.h = map(int, open(os.path.join(d, 'geometry.txt')).read().split())
|
||||
self.fs = self.w * self.h
|
||||
self.pts = [float(l) for l in open(os.path.join(d, 'pts.txt')) if l.strip()]
|
||||
self.maps = {}
|
||||
for e in 'LR':
|
||||
f = open(os.path.join(d, e + '.gray'), 'rb')
|
||||
self.maps[e] = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
|
||||
self.n = min(len(self.pts), len(self.maps['L']) // self.fs, len(self.maps['R']) // self.fs)
|
||||
# Refresh index of each frame (the recorder stamps frames with their vblank; 120 Hz panels).
|
||||
self.idx = [round(t * 120.0) for t in self.pts[:self.n]]
|
||||
|
||||
def frame(self, e, i):
|
||||
o = i * self.fs
|
||||
return self.maps[e][o:o + self.fs]
|
||||
|
||||
def region(self, e, i, x0, y0, w, h):
|
||||
f = self.frame(e, i)
|
||||
W = self.w
|
||||
return b''.join(f[(y0 + y) * W + x0:(y0 + y) * W + x0 + w] for y in range(h))
|
||||
|
||||
|
||||
def to16(b):
|
||||
"""unsigned 8-bit gray -> signed 16-bit samples halved (so differences never overflow)."""
|
||||
return audioop.mul(audioop.lin2lin(audioop.bias(b, 1, -128), 1, 2), 2, 0.5)
|
||||
|
||||
|
||||
def rmsdiff16(a, b):
|
||||
"""RMS difference in gray levels of two to16() fragments."""
|
||||
return audioop.rms(audioop.add(a, audioop.mul(b, 2, -1), 2), 2) / 128.0
|
||||
|
||||
|
||||
def shifted(img, W, x0, y0, w, h, dx, dy):
|
||||
return b''.join(img[(y0 + dy + y) * W + x0 + dx:(y0 + dy + y) * W + x0 + dx + w] for y in range(h))
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/bin/bash
|
||||
# One measured mock run (lane E): frame/perf/run.sh NAME SECONDS [VAR=value ...]
|
||||
#
|
||||
# Runs frame/tools/mockrun.sh under the shared build lock (one heavy GPU/CPU job at a time) with the
|
||||
# given environment, samples the game's read I/O beside it (iosample.py), and keeps the game log, the
|
||||
# mock trace and the samples in $KF_PERF_OUT/NAME (default ~/.cache/kf-laneE/runs). The user state is
|
||||
# one scratch home ($KF_PERF_HOME, default ~/.cache/kf-laneE/home), so Dawn's and Aurora's pipeline
|
||||
# caches are warm after the first run; KF_PERF_COLD=1 starts from an empty home instead.
|
||||
# Variables of interest: TU_DEBUG=sysmem, KARTFRAME_PACING, KARTFRAME_REFRESH_HZ, MOCK_XR_HZ,
|
||||
# KF_MOCKRUN_RENDER_SCALE, DISABLE_VULKAN_FDM_INJECTION_LAYER=1, MOCK_XR_SKIP=1.
|
||||
# Then: frame/tools/session-report.py <dir>/game.log; frame/perf/hitches.py <dir>.
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
NAME=${1:?name}; SECS=${2:?seconds}; shift 2
|
||||
OUTROOT=${KF_PERF_OUT:-$HOME/.cache/kf-laneE/runs}
|
||||
HOMEDIR=${KF_PERF_HOME:-$HOME/.cache/kf-laneE/home}
|
||||
D=$OUTROOT/$NAME
|
||||
rm -rf "$D"; mkdir -p "$D"
|
||||
[ "${KF_PERF_COLD:-0}" = 1 ] && rm -rf "$HOMEDIR"
|
||||
mkdir -p "$HOMEDIR"
|
||||
for kv in "$@"; do export "$kv"; done
|
||||
export KF_MOCKRUN_HOME=$HOMEDIR KARTFRAME_PACING_SAMPLES=$D/samples.txt KARTFRAME_FPSLOG=${KARTFRAME_FPSLOG:-1}
|
||||
printf '%s\n' "$@" > "$D/env.txt"
|
||||
EXE=${KF_BUILD:-$(cd "$HERE/../.." && pwd)/build-linux}/kartframe
|
||||
# A game process (the binary itself, a Steam launch, Lepton/Waydroid), not a shell whose command line
|
||||
# merely mentions one (an agent's "kfbuild.sh . kartframe" kept this waiting forever).
|
||||
busy() { pgrep -x kartframe || pgrep -f '^[^ ]*reaper SteamLaunch' || pgrep -x waydroid || pgrep -f '^/bin/bash [^ ]*/lepton '; }
|
||||
# Timing runs hold KartFrame's build lock and PrimeFrame's (another project shares this machine's CPU
|
||||
# and GPU), the PrimeFrame one first, as `flock PrimeFrame flock KartFrame` does.
|
||||
PF_LOCK=$HOME/Games/PrimeFrame/.build.lock
|
||||
[ -e "$PF_LOCK" ] && exec 8>"$PF_LOCK" || exec 8>/dev/null
|
||||
exec 9>"$HOME/Games/KartFrame/.build.lock"
|
||||
while :; do
|
||||
while busy >/dev/null; do echo "perf: $NAME: a game is running, retrying in 5 min"; sleep 300; done
|
||||
echo "perf: $NAME waiting for the build locks"; [ -e "$PF_LOCK" ] && flock 8; flock 9
|
||||
busy >/dev/null || break
|
||||
flock -u 9; flock -u 8 2>/dev/null || true
|
||||
done
|
||||
echo "perf: $NAME running ${SECS}s ($*)"
|
||||
KF_IOSAMPLE_EXE=$EXE python3 -I "$HERE/iosample.py" "$D/io.tsv" &
|
||||
SAMPLER=$!
|
||||
# GPU clock (devfreq) and the hottest thermal zone every 0.5 s (lane I: per-set GPU times follow the clock).
|
||||
( while :; do
|
||||
printf '%s %s %s %s\n' "$(date +%s.%N)" "$(cat /sys/class/devfreq/*.gpu/cur_freq 2>/dev/null | head -1)" \
|
||||
"$(cat /sys/class/devfreq/*.gpu/max_freq 2>/dev/null | head -1)" "$(cat /sys/class/thermal/thermal_zone*/temp 2>/dev/null | sort -n | tail -1)"
|
||||
sleep 0.5; done > "$D/gpufreq.tsv" ) &
|
||||
FREQ=$!
|
||||
set +e
|
||||
bash "$HERE/../tools/mockrun.sh" --seconds "$SECS" --exe "$EXE" > "$D/mockrun.out" 2>&1
|
||||
RC=$?
|
||||
set -e
|
||||
kill $SAMPLER $FREQ 2>/dev/null || true
|
||||
LOG=$(ls -t "$HOMEDIR"/logs/*-mock.log 2>/dev/null | head -1)
|
||||
[ -n "$LOG" ] || { cat "$D/mockrun.out"; exit 1; }
|
||||
cp "$LOG" "$D/game.log"
|
||||
[ -f "${LOG%.log}.trace" ] && mv "${LOG%.log}.trace" "$D/mock.trace"
|
||||
rm -f "$LOG"
|
||||
tail -3 "$D/mockrun.out"
|
||||
exit $RC
|
||||
@@ -1,12 +0,0 @@
|
||||
# Headset session 10, launch 1 of 2 (lane O, frame/perf/H9.md): everything KartFrame hands SteamVR the way
|
||||
# GalaxyFrame does (GalaxyFrame was clean on this headset): R8G8B8A8_SRGB eye swapchains (VkFormat 43),
|
||||
# COLOR_ATTACHMENT|SAMPLED usage, an xrLocateViews for the frame's own time before each xrEndFrame (the
|
||||
# first three are the build's new defaults) and GalaxyFrame's OpenXR extension list.
|
||||
# Routine: menus, then a Time Trial; head turns during the countdown (standing still), then driving.
|
||||
KARTFRAME_XR_EXTENSIONS=galaxy
|
||||
KARTFRAME_XR_FORMAT=rgba
|
||||
KARTFRAME_XR_USAGE=galaxy
|
||||
KARTFRAME_END_LOCATE=1
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,10 +0,0 @@
|
||||
# Headset session 10, launch 2 of 2 (lane O, frame/perf/H9.md; only if launch 1 was clean): the eye
|
||||
# swapchain format alone (R8G8B8A8_SRGB, GalaxyFrame's), everything else KartFrame's old way (usage
|
||||
# COLOR_ATTACHMENT|TRANSFER_DST, no extra locate, KartFrame's extension list).
|
||||
# Routine as launch 1.
|
||||
KARTFRAME_XR_FORMAT=rgba
|
||||
KARTFRAME_XR_USAGE=kart
|
||||
KARTFRAME_END_LOCATE=0
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,17 +0,0 @@
|
||||
# Headset session 10 (lane O): KartFrame's hand-over to SteamVR made GalaxyFrame's
|
||||
|
||||
GalaxyFrame was clean on this headset; KartFrame doubles on head turns. frame/perf/H9.md lists every
|
||||
difference in what the two apps hand SteamVR. This build makes the eye swapchains GalaxyFrame's by default
|
||||
(R8G8B8A8_SRGB instead of B8G8R8A8_SRGB, COLOR_ATTACHMENT|SAMPLED instead of |TRANSFER_DST, a locate for the
|
||||
frame's own time right before xrEndFrame); launch 1 also asks for GalaxyFrame's OpenXR extension list.
|
||||
|
||||
Install `01-galaxy-way.env` into `$KARTFRAME_HOME/session.queue/`. Add `02-format-alone.env` only after
|
||||
launch 1 was clean (it then tells whether the format alone does it); if launch 1 still doubles, do not run
|
||||
02 (report back instead).
|
||||
|
||||
**Routine, each launch:** menus, then straight into a Time Trial. During the countdown (kart standing
|
||||
still) look at a track-side edge and turn the head slowly left and right for about ten seconds, hold still
|
||||
five; then drive and do the same for ten seconds, hold still five. Say: doubled or not, which eye,
|
||||
standing still and while driving; and whether the colours look normal (sky blue, not orange).
|
||||
|
||||
What the logs must show is in H9.md section 5.
|
||||
@@ -1,10 +0,0 @@
|
||||
# Headset session 11, launch 1 of 2 (lane Q, frame/perf/session-h11/README.md): hypothesis A. Every frame
|
||||
# whose only layer would be the projection (races, loading) also gets a tiny fully transparent quad layer
|
||||
# (16x16, 1 cm, 2 m ahead, alpha blended), so SteamVR never composites a projection layer alone, as in
|
||||
# KartFrame's menus and in GalaxyFrame (both clean). The first-write barrier stays Dawn's own.
|
||||
# Routine: menus, then a Time Trial; head turns during the countdown (standing still), then driving.
|
||||
KARTFRAME_RACE_QUAD=1
|
||||
KARTFRAME_FIRST_WRITE_BARRIER=dawn
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,10 +0,0 @@
|
||||
# Headset session 11, launch 2 of 2 (lane Q, frame/perf/session-h11/README.md): hypothesis B. No race quad
|
||||
# (the race submits its projection layer alone, as in every earlier session); before each set's first
|
||||
# write to its swapchain images, GalaxyFrame's barrier (ALL_COMMANDS / MEMORY_WRITE to ALL_COMMANDS /
|
||||
# MEMORY_READ|MEMORY_WRITE).
|
||||
# Routine: the same as launch 1.
|
||||
KARTFRAME_RACE_QUAD=0
|
||||
KARTFRAME_FIRST_WRITE_BARRIER=all
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,124 +0,0 @@
|
||||
# Headset session 11 (lane Q): the projection layer alone, and GalaxyFrame's first-write barrier
|
||||
|
||||
Replaces lane P's plan (its eye-pose instrumentation stays in the build: with `KARTFRAME_FRAME_LOG=1` the
|
||||
XRS/XRP lines and the "VR eye poses (10 s)" / "VR eye uniform probe (10 s)" summaries are still logged and
|
||||
must still read 0).
|
||||
|
||||
Inside the app everything is proven correct headless (lane P: both eyes from the same packet and pose;
|
||||
lanes before: pacing, finished releases, exact stamps and end times), and the hand-over was made
|
||||
GalaxyFrame's way (format, usage, end locate, extensions; H9.md) without effect. Two candidates are left:
|
||||
|
||||
- **A, the layer list.** KartFrame's menus (a projection layer, the menu environment, plus quad layers:
|
||||
screen, panel) are clean; its races (a projection layer alone) double. GalaxyFrame always submits its
|
||||
projection with quads (HUD, pointer) and is clean; PrimeFrame submits a projection alone and doubles.
|
||||
If SteamVR on the Frame takes a frame whose only layer is a projection on another path (a per-eye
|
||||
direct path with its own timing), the right eye could suffer there.
|
||||
`KARTFRAME_RACE_QUAD=1` adds one more layer to every such frame: a 16x16 fully transparent quad
|
||||
(alpha 0, `XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT`), in the app space 2 m ahead, 1 cm across,
|
||||
after the projection. Its three images are cleared once (acquired, cleared, the GPU waited for, released);
|
||||
every frame then shows the last released one. Invisible.
|
||||
- **B, the first write.** GalaxyFrame's first write to an eye image after xrWaitSwapchainImage waits on
|
||||
ALL prior commands and memory writes (SteamVR's client's acquire work included); Dawn's tracked barrier
|
||||
waits on colour-attachment output only. `KARTFRAME_FIRST_WRITE_BARRIER=all` submits that barrier
|
||||
(src ALL_COMMANDS / MEMORY_WRITE, dst ALL_COMMANDS / MEMORY_READ|MEMORY_WRITE, layout
|
||||
COLOR_ATTACHMENT_OPTIMAL kept, queue families ignored) on every image of a set (eyes; in menus the screen,
|
||||
the environment, the panel) right after their waits, on Dawn's queue under its lock, before Aurora's set.
|
||||
No Dawn change (a barrier's first scope is everything earlier in submission order on the queue).
|
||||
|
||||
Build defaults: `KARTFRAME_RACE_QUAD=1`, `KARTFRAME_FIRST_WRITE_BARRIER=all` (both on). Other values:
|
||||
`KARTFRAME_RACE_QUAD=0` (none), `=cycle` (also acquire, wait and release one quad image every frame, no
|
||||
GPU work: GalaxyFrame's per-frame pattern); `KARTFRAME_FIRST_WRITE_BARRIER=dawn` (Dawn's barrier alone).
|
||||
Each launch below switches exactly one of the two on.
|
||||
|
||||
## Install
|
||||
|
||||
Build: lane Q's branch, installed with `install --dev`. Put both `01-race-quad.env` and `02-barrier.env`
|
||||
into `$KARTFRAME_HOME/session.queue/`; the wrapper takes them one launch each, in name order. Both record
|
||||
the panels (`KARTFRAME_RECORD=raw`) and log every frame (`KARTFRAME_FRAME_LOG=1`).
|
||||
|
||||
**Routine, each launch:** menus, then straight into a Time Trial. During the countdown (kart standing
|
||||
still) look at a track-side edge and turn the head slowly left and right for about ten seconds, hold still
|
||||
five; then drive and do the same for ten seconds, hold still five. Say: doubled or not, which eye,
|
||||
standing still and while driving, and whether the menus are still clean.
|
||||
|
||||
## Launch 1: `01-race-quad.env` (race quad on, Dawn's barrier)
|
||||
|
||||
What the owner should see: nothing new (the quad is transparent and 1 cm at 2 m). If hypothesis A holds,
|
||||
the race is clean like the menus.
|
||||
|
||||
game.log:
|
||||
- `OpenXR Vulkan swapchains ready: ... first-write barrier dawn (KARTFRAME_FIRST_WRITE_BARRIER), race quad
|
||||
on (KARTFRAME_RACE_QUAD), ...`
|
||||
- once, at the first projection-only frame (the first-run guide, a loading screen or the race start):
|
||||
`OpenXR race quad layer ready: 16x16 VkFormat 43, 3 images cleared to transparent in <about 1.5> ms; a
|
||||
1 cm quad 2 m ahead after every projection-only layer (KARTFRAME_RACE_QUAD=1)`. No `race quad could not
|
||||
be made` warning (with it, the frames went out with the projection alone and the launch tests nothing).
|
||||
- XRF lines end in `n=<layers>`: `n=2` on every race frame (projection + quad); menu frames `n=2` (environment
|
||||
+ screen) or `n=3` (with the panel). No `n=1` once the race quad is ready.
|
||||
- pacing as session 10 (`mode=pipelined`, `end_ms` p50 about 0.2, late near 0); lane P's counters 0.
|
||||
|
||||
`~/.local/share/Steam/logs/xrclient_kartframe.txt`:
|
||||
- one more swapchain: `CreateSwapchain` with format 43 and 16x16, and its `Created remote Vulkan image ...,
|
||||
dimensions 16 x 16, format 43` lines (three images), after the eye swapchains and menu environment.
|
||||
- if SteamVR logs layers per frame, a quad beside the projection on race frames. It may turn the quad into an
|
||||
overlay as it did the menus' screen (`VRMsg_CompositorNewSharedImage`, `openxr_quadlayer_<n>`, H9.md
|
||||
section 1); note whether it does: GalaxyFrame's quads were not overlays.
|
||||
|
||||
## Launch 2: `02-barrier.env` (no race quad, GalaxyFrame's barrier)
|
||||
|
||||
What the owner should see: the race as in sessions 1 to 10 (projection alone), unless hypothesis B holds.
|
||||
|
||||
game.log:
|
||||
- `... first-write barrier all (KARTFRAME_FIRST_WRITE_BARRIER), race quad off (KARTFRAME_RACE_QUAD), ...`;
|
||||
no `first-write barrier could not be submitted` warning; no `race quad layer ready` line.
|
||||
- XRF lines: `n=1` on every race frame.
|
||||
- pacing as launch 1 (the barrier is one small submission per set; mock: no timing change).
|
||||
|
||||
`xrclient_kartframe.txt`: no 16x16 swapchain; otherwise as session 10.
|
||||
|
||||
## What it decides
|
||||
|
||||
- Launch 1 clean, launch 2 doubled: the projection layer alone is the cause (A). Keep the race quad on
|
||||
(default) and tell PrimeFrame (it submits a projection alone and doubles too).
|
||||
- Launch 2 clean, launch 1 doubled: the first write ordered only after colour-attachment output was the
|
||||
cause (B). Keep the barrier on (default) and consider making it Dawn's own barrier.
|
||||
- Both clean: either one is enough; both defaults stay on; a later session can tell them apart with
|
||||
`KARTFRAME_RACE_QUAD=0 KARTFRAME_FIRST_WRITE_BARRIER=dawn` as the control.
|
||||
- Both doubled: neither. What is left from H9.md: the order of the calls (acquire and render the next set
|
||||
before releasing and ending, GalaxyFrame's order) and the eye size (render_scale 0.9 measured for the
|
||||
record only). Panel analysis as before: `frame/perf/panels/analyse.sh <session>` per launch, H7 section
|
||||
1's zigzag table (zigzag refreshes per eye, race against menus).
|
||||
|
||||
## Mock results (lane Q, build 6596f27)
|
||||
|
||||
`frame/perf/hudrace.sh q-race 180 MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KARTFRAME_FRAME_LOG=1
|
||||
KF_MOCKRUN_END_GATE=2` (scripted Grand Prix, 120 Hz, defaults: race quad on, barrier all):
|
||||
|
||||
| check | result |
|
||||
| --- | --- |
|
||||
| mock errors | 0 |
|
||||
| releases changed after the GPU passed them | 0 of 42792 |
|
||||
| stamps for their poses' locate | 33144 of 33148 (4 unstamped), 0 for another locate, 0 with the other eye's |
|
||||
| end times exact | 21481 of 21481 |
|
||||
| ends later than 1 ms after the wake-up (gate 2 %) | 0.3 % |
|
||||
| layer lists per xrEndFrame | PQ 16574 (race: projection + quad), EQ 4867 (menus: environment + screen), none 40; P alone 0; quads on a never-released swapchain 0 |
|
||||
| XRF lines | 21441, all `n=2` |
|
||||
| race quad made | 16x16 VkFormat 43, usage 0x31, 3 images cleared in 1.4 ms |
|
||||
| lane P's counters | eye poses 0, uniform probe 0 / 0 |
|
||||
| pacing | late 32 of 21363 ticks, end_ms p50 0.15, gpu 4.08 ms p50 |
|
||||
|
||||
Validation layer (`VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`, 80 s, menus and race with stamps, the
|
||||
barrier on every set and the quad's clears): 0 messages (best-practices warnings in a check run showed the
|
||||
layer's output reaches game.log), 0 mock errors. ctest 32 of 32. Eye images at 65 and 75 s as before.
|
||||
|
||||
Race timing, interleaved, 90 s each, `MOCK_XR_BUSY_MS=1`, no stamps (old = `KARTFRAME_RACE_QUAD=0
|
||||
KARTFRAME_FIRST_WRITE_BARRIER=dawn`, the behaviour before this lane; new = the defaults):
|
||||
|
||||
| run | errors | end p50 / p99 ms | ends > 1 ms | late sets | gpu p50 / p99 ms | layer lists |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| old 1 | 0 | 0.17 / 0.65 | 0.4 % | 21 | 4.06 / 5.03 | P 6683, EQ 3960 |
|
||||
| new 1 | 0 | 0.18 / 0.64 | 0.3 % | 23 | 4.02 / 5.03 | PQ 6685, EQ 3958 |
|
||||
| old 2 | 0 | 0.18 / 0.70 | 0.5 % | 24 | 3.97 / 5.00 | P 6697, EQ 3954 |
|
||||
| new 2 | 0 | 0.17 / 0.70 | 0.4 % | 27 | 4.04 / 5.08 | PQ 6680, EQ 3960 |
|
||||
|
||||
No timing change. Scratch: `$HOME/.cache/kf-laneQ/runs/`.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Headset session 12, launch 1 of 3 (lane R, frame/perf/session-h12/README.md): the measurement. Build
|
||||
# defaults (sets two refreshes ahead, the rendered poses submitted). The game logs GalaxyFrame's pose check
|
||||
# every 5 s: how far each shown set's eye poses are from a locate for the time it is shown.
|
||||
# Routine: menus, then a Time Trial; head turns during the countdown (standing still), then driving.
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,8 +0,0 @@
|
||||
# Headset session 12, launch 2 of 3 (lane R, frame/perf/session-h12/README.md): lever 1. Each set is
|
||||
# located for the next refresh and started at the wake-up before the frame is ended (GalaxyFrame's order):
|
||||
# one refresh less pose prediction. Late sets repeat the retained layer; judge the doubling, not smoothness.
|
||||
# Routine: the same as launch 1.
|
||||
KARTFRAME_AHEAD_REFRESHES=1
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,8 +0,0 @@
|
||||
# Headset session 12, launch 3 of 3 (lane R, frame/perf/session-h12/README.md): lever 2. Sets rendered two
|
||||
# refreshes ahead as in launch 1, but each race frame that shows a new set is submitted with the poses of a
|
||||
# locate for its own display time (the images stay as rendered).
|
||||
# Routine: the same as launch 1.
|
||||
KARTFRAME_SUBMIT_POSE=fresh
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,171 +0,0 @@
|
||||
# Headset session 12 (lane R): how far the shown poses are from the head, and two levers
|
||||
|
||||
After eleven sessions one problem is left: in races the image doubles on head turns, the right eye worse
|
||||
than the left, standing still as well as driving. Menus are clean, and so is GalaxyFrame on the same
|
||||
SteamVR. Everything inside the app has been proven correct headless (pacing, finished releases, exact
|
||||
stamps and end times, both eyes from one packet and pose; lanes A to Q).
|
||||
|
||||
One measurement was never made in KartFrame: the one GalaxyFrame logs every 5 s and passes. How far is
|
||||
each shown frame's pose from a fresh xrLocateViews for the moment it is shown? GalaxyFrame's clean run:
|
||||
"left eye 0.10 mm 0.024 deg avg, 0.79 mm 0.061 deg max; right eye 0.09 mm 0.024 deg avg, 0.98 mm 0.061 deg
|
||||
max". GalaxyFrame locates each set at one wake-up for the next refresh, so its poses are predicted about
|
||||
one refresh plus SteamVR's lead ahead. KartFrame's sets ahead are located two refreshes ahead, and
|
||||
SteamVR's predicted display time is already about 25 ms after the wake-up: about 42 ms of prediction.
|
||||
If SteamVR composes each eye against a fresh prediction of its own (the right eye later), a pose that far
|
||||
ahead shows as a per-eye displacement the size of the head's unpredicted motion over those tens of ms:
|
||||
the zigzag of one to two refreshes of motion seen on the recordings.
|
||||
|
||||
## What the build logs (all three launches)
|
||||
|
||||
game.log, every 5 s and whenever the scene changes between menus and race:
|
||||
|
||||
- `vr: views of N sets: M for the display time they went out at (off X ms avg, Y max); against a locate
|
||||
then: left eye A mm B deg avg, C mm D deg max; right eye ...; eye distance E-F mm; race|menus, S s; that
|
||||
locate G ms avg H max`. GalaxyFrame's check (the same words): at every xrEndFrame, one xrLocateViews for
|
||||
the display time being ended, in the layer's space, after the release; per eye the distance (mm) and the
|
||||
angle (deg) between the pose the shown images were rendered from and that locate. Counted once per set,
|
||||
when it first goes out. "that locate" is the check's own cost.
|
||||
- `vr: poses predicted ahead (N sets, S s, race|menus): display time minus the set's locate p50 X ms, max Y`.
|
||||
- XRF lines (KARTFRAME_FRAME_LOG=1) end in `aL=<deg> aR=<deg>`: the same angle for every frame, repeats too.
|
||||
|
||||
`frame/tools/session-report.py <session>` shows all of it for menus and race separately.
|
||||
|
||||
## Install
|
||||
|
||||
Build: lane R's branch, `install --dev`. Put `01-measure.env`, `02-one-ahead.env` and `03-fresh-pose.env`
|
||||
into `$KARTFRAME_HOME/session.queue/`; the wrapper takes one per launch, in name order. Each records the
|
||||
panels (`KARTFRAME_RECORD=raw`) and logs every frame (`KARTFRAME_FRAME_LOG=1`).
|
||||
|
||||
**Routine, each launch:** menus, then straight into a Time Trial. During the countdown (kart standing
|
||||
still) look at a track-side edge and turn the head slowly left and right for about ten seconds, hold still
|
||||
five; then drive and do the same for ten seconds, hold still five. Say: doubled or not, which eye,
|
||||
standing still and while driving, and whether the menus are still clean.
|
||||
|
||||
## Launch 1: `01-measure.env` (build defaults)
|
||||
|
||||
What I should see: the race as in sessions 1 to 11. This launch is the measurement.
|
||||
|
||||
game.log: `poses predicted ahead` about 42 ms in the race (two refreshes plus SteamVR's lead; the menus the
|
||||
same). The `views of` lines are the result.
|
||||
|
||||
## Launch 2: `02-one-ahead.env` (`KARTFRAME_AHEAD_REFRESHES=1`)
|
||||
|
||||
Each set is located for the next refresh instead of the one after, and started at the wake-up before the
|
||||
frame is ended (GalaxyFrame's order). One refresh (8.3 ms) less prediction. If sets do not fit a refresh on
|
||||
the headset, late sets repeat the last frame (a stutter); judge the doubling, not the smoothness.
|
||||
|
||||
game.log: `OpenXR pipelined pacing: sets rendered one refresh ahead (KARTFRAME_AHEAD_REFRESHES=1), each
|
||||
started at the wake-up before the frame is ended, ...`, `OpenXR eye swapchain pairs: 4`; `poses predicted
|
||||
ahead` about 33 ms; the pacing lines' `late=` and the race late % in session-report.py (mock: 0.6 %).
|
||||
|
||||
## Launch 3: `03-fresh-pose.env` (`KARTFRAME_SUBMIT_POSE=fresh`)
|
||||
|
||||
Sets two refreshes ahead as in launch 1, but every race frame that shows a new set is submitted with the
|
||||
poses of the check's own locate for that frame's display time. The images stay as rendered. SteamVR then
|
||||
has nothing left to correct but its own compose-time delta; the prediction error of the set (what the
|
||||
check measures) stays in the image as a small swim of the whole picture with the head, the same in both
|
||||
eyes. Menus and repeats of the last frame are unchanged.
|
||||
|
||||
game.log: as launch 1; the `views of` numbers are the error the images now carry.
|
||||
|
||||
## Reading it
|
||||
|
||||
- **Launch 1, per-eye error of a degree or more** (GalaxyFrame: 0.024 avg, 0.06 max), and the zigzag
|
||||
smaller in launch 2 and/or 3: the prediction distance is the cause. Next: make the better launch the
|
||||
default (launch 2's order, or fresh poses), then look at the one that remains.
|
||||
- **Launch 1 errors like GalaxyFrame's** (a few hundredths of a degree avg, under about 0.1 max while
|
||||
turning): the hypothesis is dead; launches 2 and 3 should look like launch 1. Next: an xrprobe session
|
||||
that submits KartFrame's own eye images (recorded from a race) through GalaxyFrame's loop, to split the
|
||||
images from the hand-over.
|
||||
- Either way compare left and right in the `views of` lines: the doubling is worse in the right eye; a
|
||||
right eye error clearly larger than the left would point at the eye poses themselves.
|
||||
- Panel analysis as before: `frame/perf/panels/analyse.sh <session>` per launch, H7 section 1's zigzag
|
||||
table (zigzag refreshes per eye, race against menus).
|
||||
|
||||
## Mock results (lane R)
|
||||
|
||||
Scripted Grand Prix (`frame/perf/hudrace.sh`, 120 Hz, `MOCK_XR_BUSY_MS=1`, `KF_MOCKRUN_END_GATE=2`). The mock's
|
||||
head pose is a function of the display time, so the check reads 0 except for SteamVR-like prediction
|
||||
jitter (0.02 ms) and sets shown later than their own time.
|
||||
|
||||
Defaults, 180 s, `MOCK_XR_STAMP=1 KARTFRAME_FRAME_LOG=1`:
|
||||
|
||||
| check | result |
|
||||
| --- | --- |
|
||||
| mock errors | 0; releases changed after the GPU passed them 0 of 42799 |
|
||||
| stamps for their poses' locate | 34938 of 34946 (8 unstamped), 0 for another locate, 0 with the other eye's |
|
||||
| end times exact | 21475 of 21475; ends > 1 ms after the wake-up 0.8 % (gate 2 %) |
|
||||
| lane P's counters | eye poses 0, uniform probe 0 / 0, pose stamps 34938 < 0.01 deg |
|
||||
| `views of` lines, steady menus and race | 0.00 mm 0.000 deg avg, 0.00 mm 0.002 deg max per eye; off 0.01 avg 0.04 max ms |
|
||||
| `views of`, the two windows at the race start | sets shown up to 233 ms after their own time (loading stalls): up to 5.4 mm 9.6 deg |
|
||||
| `poses predicted ahead` | 41.4 ms p50, 41.6 max (the mock's lead is SteamVR's 3 refreshes, 24.9 ms, plus 2 refreshes) |
|
||||
| the check's locate | 0.001 ms avg, 0.1 ms max (mock; the headset's is in the line) |
|
||||
|
||||
ctest 32 of 32.
|
||||
|
||||
`KARTFRAME_AHEAD_REFRESHES=1` against the default, interleaved, 90 s each, both locks (run.sh):
|
||||
|
||||
| run | errors | end p50 / p99 ms | ends > 1 ms | race late sets | gpu p50 / p99 ms | predicted | GPU clock avg, SoC max |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| two ahead 1 | 0 | 0.18 / 0.80 | 0.6 % | 0.3 % | 4.11 / 5.13 | 41.5 ms | 780 MHz, 96 C |
|
||||
| one ahead 1 | 0 | 0.25 / 0.85 | 0.7 % | 0.7 % | 4.08 / 5.15 | 33.2 ms | 781 MHz, 95 C |
|
||||
| two ahead 2 | 0 | 0.19 / 0.69 | 0.4 % | 0.1 % | 4.06 / 5.10 | 41.5 ms | 786 MHz, 95 C |
|
||||
| one ahead 2 | 0 | 0.25 / 0.91 | 0.9 % | 0.7 % | 4.09 / 5.05 | 33.2 ms | 767 MHz, 88 C |
|
||||
|
||||
One ahead, started before the end: 58 and 61 late sets of about 10500 (lane M's order: about half late).
|
||||
With stamps (120 s): 0 errors, 20486 of 20496 stamps exact, end times exact.
|
||||
|
||||
`KARTFRAME_SUBMIT_POSE=fresh`, `MOCK_XR_STAMP=fresh` (fresh-pose layers must carry a locate for their own
|
||||
display time; each image is checked against its own stamp's locate), 180 s: 0 errors; 17448 layers with new
|
||||
images, all posed by a locate for their display time; their images rendered for that display time 34856,
|
||||
1 to 2 refreshes before 10, 3 or more 26 (race start); pose stamps 34916 < 0.01 deg; ends > 1 ms 0.3 %.
|
||||
Validation layer (`VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation`, 80 s, menus and race, fresh poses and
|
||||
stamps): 0 messages, 0 mock errors.
|
||||
|
||||
Scratch: `$HOME/.cache/kf-laneR/runs/`.
|
||||
|
||||
## Headset results (2026-10-15, sessions 055840 / 060012 / 060244; orchestrator's reading)
|
||||
|
||||
Owner: launch 1 "started really well but the doubling started again"; launch 2 "started poorly with bad
|
||||
doubling in the right eye and occasional in the left, got worse over time"; launch 3 "bad for both eyes the
|
||||
whole time". The Time Trial ghost rendered flat in all three (known, phase D).
|
||||
|
||||
Pose check, race, per 5 s window (left and right eyes read the same angle, as one head pose must):
|
||||
|
||||
| launch | avg deg | max deg | sets shown at their own time | poses predicted ahead | race late sets |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 measure | 0.07-0.19 | 0.26-1.10 | 4097 of 4150 | 41.4 ms | 1.8 % |
|
||||
| 2 one ahead | 0.08-0.30 | 0.80-1.61 | 2012 of 3265 (off 3.2 ms avg) | 33.4 ms | 39.6 %, last window 88 of 543 on time |
|
||||
| 3 fresh pose | 0.05-0.11 | 0.16-0.50 | 5802 of 5900 | 41.5 ms | 2.0 % |
|
||||
|
||||
GalaxyFrame on this headset (44 windows in its own log): avg 0.023 deg median, max 0.078 median, 0.20 worst.
|
||||
Launch 1 is 3-8x GalaxyFrame's average and 2-5x its max, well under "a degree or more" on average.
|
||||
Menus (quad layer): 0.04-0.05 avg, 0.1-0.7 max in all launches.
|
||||
|
||||
Panels (`frame/perf/panels/analyse.sh`, then H7's zigzag rule on sparse captures, `~/.cache/kf-h12/zig2.py`;
|
||||
the recorder captured every second refresh much of the time, so counts are small): zigzag share of moving
|
||||
test refreshes in the race, left / right:
|
||||
|
||||
| launch | left | right | owner |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 55 % (114 of 209) | 54 % (107 of 198) | doubled |
|
||||
| 2 | 22 % (49 of 221) | 79 % (165 of 208) | right bad, left occasional |
|
||||
| 3 | 79 % (103 of 130) | 28 % (36 of 127) | both bad |
|
||||
| H7 launch 1 (same rule, for scale) | 42 % | 99 % | right worse |
|
||||
|
||||
Launch 1 by time: the first 6 s of moving refreshes after the race loaded (video 24-30 s, kart standing,
|
||||
head turning) 1 of 22 zigzag; from video 32 s on (driving) 55-95 % per 2 s window. That is the owner's
|
||||
"started well, then the doubling started". Pacing in launch 1 after the course load: 0 late, GPU 4.6-5.3 ms,
|
||||
end p50 0.3-0.4 ms; nothing in the pacing changes where the zigzag starts.
|
||||
|
||||
Reading: launch 3 handed SteamVR a pose for the very display time of each race frame and the doubling stayed
|
||||
(both eyes per the owner, left-dominant per the panels), so the prediction distance of the hand-over is not
|
||||
the cause; launch 2 did not fit a refresh on the headset (26 % late sets overall, 13.7 % retained repeats,
|
||||
rising through the race) and says nothing beyond that. New facts for the next lane: (1) the affected eye is
|
||||
not fixed: right in launch 2 and H7, left in launch 3, both in launch 1, i.e. decided per launch (a parity
|
||||
of something set at start-up); (2) the zigzag was absent while the kart stood still with the head turning
|
||||
and began with driving (scene motion), in launch 1; (3) the pose error is identical for both eyes, so the
|
||||
eye poses are not it. Next per the plan: an xrprobe session that submits KartFrame's own race eye images
|
||||
through GalaxyFrame's loop; and, headless first, a mock run that dumps 2 s of consecutive eye images while
|
||||
driving with the mock's head sway, measured with the panel scripts, to see whether the period-2 jump is in
|
||||
the images KartFrame renders (interpolation phase per eye) before any headset time is spent.
|
||||
@@ -1,155 +0,0 @@
|
||||
# Headset session 13 (ready, not run): KartFrame's own race images through GalaxyFrame's loop (xrprobe replay)
|
||||
|
||||
Lane S (`frame/perf/LANE_S.md`) found the race eye images KartFrame renders smooth in both eyes, standing
|
||||
and driving, with the head turning (H7's zigzag rule 0 % per eye; an injected period-2 jump is flagged in
|
||||
99-100 % of moving sets). What is left is what SteamVR does with them. This session shows SteamVR the same
|
||||
kind of images from a minimal loop that is known to be clean on this headset (GalaxyFrame's), and changes one
|
||||
hand-over detail at a time.
|
||||
|
||||
## Recording (headless, before the session)
|
||||
|
||||
```
|
||||
KF_DUMP_DIR=~/.cache/kf-laneT/dump-h13rec KF_PERF_OUT=~/.cache/kf-laneT/runs KF_PERF_HOME=~/.cache/kf-laneT/home-race \
|
||||
SECS=61 DUMP_AT=52.5,56.5 frame/perf/mockdump/dumprace.sh h13rec MOCK_XR_SWAY=0 MOCK_XR_DUMP_STEP=2 \
|
||||
"MOCK_XR_PRESS=/user/hand/right/input/a/click@12/1.5/0.15/46;/user/hand/right/input/a/click@54/1000/1000"
|
||||
```
|
||||
|
||||
Head still (`MOCK_XR_SWAY=0`: every image rendered for yaw 0, eyes at x = -+32 mm, the Frame's own per-eye
|
||||
fields of view, the mock's defaults), render scale 0.75 (the race at 60 fps, scene stamps 0.25/0.75 as on the
|
||||
headset), 648 px per eye, two windows of 240 consecutive sets (lane T's recording: 960 images, 1.0 GB, mock 0
|
||||
errors; not in the repo, the images come from the disc). `SECS=58` as first planned cut the second window to 175
|
||||
sets; 61 gives all 240. Checked on the images: 52.5 s is the race after GO (timer 0:10) with the kart still
|
||||
until set 172 (the A hold at 54 s), then driving off; 56.5 s drives through the start gate. Case 1 therefore
|
||||
plays the first 168 sets only (`--replay-sets 168`, 1.4 s with no scene motion beyond the HUD).
|
||||
|
||||
## xrprobe `--replay DIR` (built, lane T)
|
||||
|
||||
`frame/tools/xrprobe/xrprobe.c` (`runReplay`): loads index.txt, takes one window's sets (both eyes, ordered by
|
||||
display time), uploads every image once into an R8G8B8A8_SRGB array (2 layers a set, 0.8 GB for 240 sets).
|
||||
Frame n shows set k = (hold x floor(slot / hold) + offset) mod N, slot = round((predicted display time - first
|
||||
one) / period): a missed refresh skips a set. Each eye's image is blitted (linear) into its 1728 swapchain image,
|
||||
a label strip (case label, the set number with a stepping hue and ticker) is drawn over it 1.5 m ahead and 0.75
|
||||
m up in the image's own frame, and the projection layer carries the recorded pose (identity, x = -+0.032 m,
|
||||
LOCAL) and fov, not the locate's: SteamVR places the race world-fixed in front of the startup direction and
|
||||
reprojects it as the head turns.
|
||||
|
||||
- Default (GalaxyFrame's order, one swapchain per eye): xrWaitFrame, xrBeginFrame, xrLocateViews (LOCAL, the
|
||||
frame's own time), acquire both, blit, fence wait, release both, xrEndFrame.
|
||||
- `--replay-pairs 3` (KartFrame's sets ahead): xrWaitFrame, xrBeginFrame, release the newest finished set due by
|
||||
this display time (else repeat the retained layer), xrEndFrame; then xrLocateViews for two refreshes ahead,
|
||||
acquire the next pair (first in, first out, never the one on display or in flight), blit, submit, no wait.
|
||||
- `--replay-quad`: KartFrame's race quad (16x16, cleared transparent once, 1 cm, 2 m ahead, alpha blended)
|
||||
after every projection layer. `--replay-offset 1`, `--replay-hold 2`, `--replay-window`, `--replay-sets`,
|
||||
`--replay-label` as in `xrprobe --help`.
|
||||
- Log (`--log`): one `R` line a frame (n, display time, slot, set shown, set wanted for this display time,
|
||||
its recorded display time, new or repeated, pair, set started) and SUMMARY lines: frames with a layer, new
|
||||
against repeated, the set matching its display time, set steps between frames, display time steps, fence wait.
|
||||
|
||||
## Session (six launches, one case each; framecorder `--view raw` recording)
|
||||
|
||||
| case | label | content | loop | expected if SteamVR is clean |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 1 StAnd | standing (168 sets) | GalaxyFrame order, one swapchain per eye | clean (control) |
|
||||
| 2 | 2 drivE | driving | same | clean |
|
||||
| 3 | 3 PHASE | driving, `--replay-offset 1` | same | clean |
|
||||
| 4 | 4 PAIrS | driving | `--replay-pairs 3` | clean |
|
||||
| 5 | 5 qUAd | driving | `--replay-pairs 3 --replay-quad` | clean |
|
||||
| 6 | 6 HOLd | driving | `--replay-hold 2` | judder (each image twice), no doubling |
|
||||
|
||||
Install and queue (the orchestrator; nothing else changes, the game stays installed as it is):
|
||||
|
||||
```
|
||||
cmake -S frame/tools/xrprobe -B ~/.cache/kf-xrprobe -G Ninja -DCMAKE_C_COMPILER=clang
|
||||
flock ~/Games/KartFrame/.build.lock cmake --build ~/.cache/kf-xrprobe
|
||||
frame/perf/session-h13/queue.sh ~/.cache/kf-xrprobe/xrprobe ~/.cache/kf-laneT/dump-h13rec
|
||||
```
|
||||
|
||||
queue.sh copies the binary and `frame/tools/xrprobe/replay-case.sh` into `$KARTFRAME_HOME/h13/` and writes
|
||||
`h13-01-standing.env` .. `h13-06-hold.env` into `$KARTFRAME_HOME/session.queue/`. Each makes the KartFrame
|
||||
wrapper run the case instead of the game (`KARTFRAME_EXE`), with the panel recording (`KARTFRAME_RECORD=raw`),
|
||||
SteamVR's per-app settings (120 Hz, 1728), its own session folder (`xrprobe.log`, `stdout.log`,
|
||||
`vrtiming.log`, `panels.mkv`) and `KARTFRAME_SESSION_ENV_ONCE=1`. A case runs 45 s of visible time, then ends
|
||||
by itself (back to the Steam library).
|
||||
|
||||
Owner, each launch from the KartFrame shortcut (the label above the image names the case):
|
||||
|
||||
1. Wait until the race image is up (the recording starts 8 s after the launch).
|
||||
2. Look at a track-side edge (fence posts, the banner, the gate) and turn the head slowly left and right for
|
||||
ten seconds, then hold still for five.
|
||||
3. The same with the left eye closed, then with the right eye closed.
|
||||
4. Say: doubled or not, which eye. Case 6 judders by design; the question there is doubling only.
|
||||
|
||||
The driving window loops every 2 s (a jump back at the loop); the standing one every 1.4 s (no visible jump).
|
||||
|
||||
## Reading it
|
||||
|
||||
- Per launch: `$KARTFRAME_HOME/sessions/<time>/xrprobe.log` (`R` lines, SUMMARY: frames with a layer, repeats,
|
||||
the set against its display time; with pairs also sets released unshown and late), `vrtiming.log`.
|
||||
- Panels: `frame/perf/panels/analyse.sh <session>`, then H7's zigzag rule per eye and case (session-h12's
|
||||
zig2.py), menus-like floor 4-6 %. The number panel beside the label (set k, hue and ticker) gives the set
|
||||
shown on each panel refresh.
|
||||
- **Case 2 doubled like the game**: SteamVR does it to these images however they are handed over; the content
|
||||
(60 Hz scene motion shown at 120 Hz, interpolated) is what matters. Then case 3 tells whether the affected
|
||||
eye follows the content's phase (the per-launch eye: right in H7, left in H12 launch 3), case 6 whether
|
||||
plain 60 Hz content doubles too, and case 1 (no scene motion) should stay clean as in H12 launch 1's
|
||||
standing seconds.
|
||||
- **Case 2 clean, 4 or 5 doubled**: the hand-over detail that case adds (the three-pair rotation, the race quad
|
||||
after the projection layer) is it: A/B it in the game next (`KARTFRAME_EYE_PAIRS`, `KARTFRAME_RACE_QUAD=0`).
|
||||
- **All clean**: the images and the hand-over are fine; what remains is the live game's timing on the headset
|
||||
(when sets are released and ended relative to SteamVR's composition). Next: KartFrame launches that move
|
||||
toward this loop one switch at a time.
|
||||
|
||||
## Mock verification (lane T)
|
||||
|
||||
```
|
||||
XRPROBE=<binary> PROBE_MOCK=1 PROBE_SET=replay PROBE_SECONDS=20 PROBE_OUT=<dir> KF_REPLAY_DIR=<recording> \
|
||||
frame/tools/xrprobe/run-probes.sh # + MOCK_XR_DUMP_AT=4 MOCK_XR_DUMP_FRAMES=6 MOCK_XR_DUMP_STEP=8: dumps
|
||||
python3 -I frame/perf/session-h13/replaycheck.py <dir>
|
||||
```
|
||||
|
||||
The mock runtime (never SteamVR), 120 Hz, 1728 eyes, `MOCK_XR_TRACE_DETAIL=1`, `MOCK_XR_BUSY_MS=1`. 20 s a case
|
||||
(2401 frames); images from a second 8 s run with dumps of 6 releases per swapchain at 4 s.
|
||||
|
||||
| case | mock summary | frame order in the trace (share of frames) | sets (probe) | images: dump vs shown set (k+-1) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 0 errors, 4802 releases finished | wait, begin, locate +0, acquire L R, release L R, end [proj] 100 % | 2401 of 2401 = display-time set, steps 1: 2400 (loop of 168) | 0.19 (0.22: no motion) |
|
||||
| 2 | 0 errors | same, 100 % | 2401 of 2401, steps 1: 2400 | 0.22 (7.54) |
|
||||
| 3 | 0 errors | same, 100 % | 2401 of 2401 (offset 1), steps 1: 2400 | 0.22 (6.93) |
|
||||
| 4 | 0 errors, 4750 releases finished | wait, begin, release L R, end [proj], locate +2, acquire L R: 98.4 % | 2359 of 2399; 31 repeats, 7 unshown, 24 late | 0.23 (7.60), 36 images |
|
||||
| 5 | 0 errors | same with end [proj quad]: 98.6 % | 2367 of 2399; 26 repeats, 6 unshown, 20 late | 0.23 (7.60), 36 images |
|
||||
| 6 | 0 errors | as case 1, 100 % | 2401 of 2401, steps 0: 1200, 2: 1200 (each set twice) | 0.22 (8.19) |
|
||||
|
||||
- Display times: every xrEndFrame carried its frame's predicted display time (2401 of 2401 per case, "end
|
||||
display time = predicted"); the layer's display time steps one refresh in 2400 (cases 4, 5: 2398 of 2398).
|
||||
- Poses: every projection layer "poses from no known locate" (the recorded ones, not a locate's), fov exactly
|
||||
the recorded L -1.0311, 0.8795, 0.8634, -1.0472 / R -0.8777, 1.0325, 0.8706, -1.0393 rad; locates for the
|
||||
frame's own time (cases 1-3, 6) or two refreshes ahead (4, 5); the quad on its own 16x16 swapchain, LOCAL,
|
||||
size 0.010, alpha-blend flag.
|
||||
- Images: every dumped released eye image matches the recorded image of the set the probe logged for that
|
||||
frame and eye, resampled as the linear blit does (mean absolute difference 0.19-0.23 of 255 outside the label
|
||||
strip), nearer it than to sets k-1 and k+1 in 12 of 12 (cases 1-3, 6) and 36 of 36 (4, 5).
|
||||
- Cases 4, 5: the repeats, late and unshown sets all fall within four frames after an xrWaitFrame that returned
|
||||
less than 2 ms after the last (the probe scheduled late on a busy machine, the mock then catching up): 40 of 40
|
||||
and 32 of 32 frames. KartFrame's loop does the same there (no wait inside the cycle); on the headset these
|
||||
show in the SUMMARY's late / unshown counts.
|
||||
- The wrapper path: queue.sh into a scratch KARTFRAME_HOME, then `frame/kartframe.sh` with the mock's
|
||||
XR_RUNTIME_JSON: the queue file became session.env, the wrapper ran replay-case.sh, xrprobe.log landed in the
|
||||
session folder, the queue advanced to the next case.
|
||||
|
||||
## Headset results (2026-10-15, orchestrator's reading)
|
||||
|
||||
Owner, six launches in order: launch 1 "no doubling at all when moving my head"; launches 2 to 5 "no
|
||||
doubling"; launch 6 "no doubling". Remark: a quality difference, "not full 3D all the way around" (expected:
|
||||
the replay shows fixed recorded images, half resolution (dump step 2), moved with the head, so nothing new
|
||||
appears at the edges on a head turn).
|
||||
|
||||
Reading: KartFrame's race eye images, through GalaxyFrame's loop AND through KartFrame's own order (three
|
||||
pairs, locate two refreshes ahead, the race quad), with the 60 Hz content phase either way, and held two
|
||||
refreshes, are all clean on this SteamVR. So the images (lane S), the poses and prediction distance (H12),
|
||||
the layer structure, pair count, quad and content phase (this session) are all cleared. What is left is what
|
||||
differs between the probe and the live game at submission: the game's GPU work on the swapchain image and
|
||||
when SteamVR sees it (the probe's images are uploaded once; the game renders into the image every set and
|
||||
releases after its fence), Dawn's queue and the point at which its commands are really submitted relative to
|
||||
the fence the release waits on, the Vulkan layer chain of the game process, and GPU contention from the
|
||||
game's own ~5 ms per set. Strongest old clue in this light: H7 found the jump size follows the pair count
|
||||
(2 pairs: clean +-2-refresh jumps; 6 pairs: 1 to 8 refreshes), i.e. the swapchain image reuse distance.
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Headset session 13: installs the replay beside KartFrame and queues its six launches, one case each.
|
||||
# frame/perf/session-h13/queue.sh XRPROBE_BINARY [RECORDING_DIR]
|
||||
# Copies the binary and replay-case.sh into $KARTFRAME_HOME/h13/ and writes h13-01..06-<case>.env into
|
||||
# $KARTFRAME_HOME/session.queue/. The KartFrame shortcut's wrapper then takes one per launch, in name order,
|
||||
# and runs the case instead of the game (KARTFRAME_EXE), with its own session folder (xrprobe.log, stdout.log,
|
||||
# vrtiming.log) and the panel recording (KARTFRAME_RECORD=raw). Nothing else changes.
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
KFHOME=${KARTFRAME_HOME:-$HOME/.local/share/KartFrame}
|
||||
BIN=${1:?xrprobe binary}
|
||||
REC=$(cd "${2:-$HOME/.cache/kf-laneT/dump-h13rec}" && pwd)
|
||||
[ -x "$BIN" ] || { echo "no xrprobe binary at $BIN" >&2; exit 1; }
|
||||
[ -s "$REC/index.txt" ] || { echo "no recording (index.txt) in $REC" >&2; exit 1; }
|
||||
mkdir -p "$KFHOME/h13" "$KFHOME/session.queue"
|
||||
cp "$BIN" "$KFHOME/h13/xrprobe"
|
||||
cp "$HERE/../../tools/xrprobe/replay-case.sh" "$KFHOME/h13/"
|
||||
OTHER=$(ls "$KFHOME/session.queue"/*.env 2> /dev/null | grep -v '/h13-' || true)
|
||||
[ -n "$OTHER" ] && echo "note: other queued launches come first or in between (name order): $OTHER"
|
||||
[ -f "$KFHOME/session.env" ] && echo "note: $KFHOME/session.env is waiting and goes before the queue"
|
||||
while IFS='|' read -r num name label args; do
|
||||
F=$KFHOME/session.queue/h13-0$num-${name#*-}.env
|
||||
cat > "$F" << ENV
|
||||
# Headset session 13, launch $num of 6 (frame/perf/session-h13/README.md): xrprobe --replay case "$label"
|
||||
# ($args). Look at a track-side edge, turn the head slowly left and right for ten seconds, hold still;
|
||||
# again with one eye closed, then the other. Doubled or not, which eye?
|
||||
KARTFRAME_EXE=$KFHOME/h13/replay-case.sh
|
||||
KF_REPLAY_CASE=$num
|
||||
KF_REPLAY_DIR=$REC
|
||||
XRPROBE=$KFHOME/h13/xrprobe
|
||||
PROBE_LOG=$KFHOME/sessions/latest/xrprobe.log
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
ENV
|
||||
echo "queued $F"
|
||||
done < <("$HERE/../../tools/xrprobe/replay-case.sh" --list)
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mock check of the session-13 replay: python3 -I replaycheck.py <run-probes PROBE_MOCK=1 output dir> [case ...]
|
||||
|
||||
Per case (xrprobe log <case>.log, mock trace <case>.trace, dumps <case>.dump/ with MOCK_XR_DUMP_AT):
|
||||
- frame order: the calls of each frame in the mock trace (waitFrame, beginFrame, locate +refreshes, acquire,
|
||||
release, endFrame with its layers), the commonest patterns and their share;
|
||||
- display times: endFrame display time against the frame's predicted one; layer poses (from no locate:
|
||||
the recorded ones) and fields of view as submitted;
|
||||
- images: each dumped (released) eye image against the recorded image of the set the probe showed in that
|
||||
frame (R lines), resampled as the probe's linear blit does (in linear light), mean absolute difference in
|
||||
8-bit steps outside the label strip; the neighbouring sets k-1 and k+1 for scale.
|
||||
"""
|
||||
import os, re, sys
|
||||
from collections import Counter
|
||||
|
||||
D = sys.argv[1]
|
||||
CASES = sys.argv[2:] or sorted(f[:-4] for f in os.listdir(D) if f.endswith('.log'))
|
||||
TO_LIN = [((c / 255) / 12.92 if c / 255 <= 0.04045 else ((c / 255 + 0.055) / 1.055) ** 2.4) for c in range(256)]
|
||||
|
||||
|
||||
def to_srgb(v):
|
||||
v = min(max(v, 0.0), 1.0)
|
||||
return 255 * (v * 12.92 if v <= 0.0031308 else 1.055 * v ** (1 / 2.4) - 0.055)
|
||||
|
||||
|
||||
def ppm(path):
|
||||
b = open(path, 'rb').read()
|
||||
m = re.match(rb'P6\s+(\d+)\s+(\d+)\s+255\s', b)
|
||||
w, h = int(m.group(1)), int(m.group(2))
|
||||
return w, h, b[m.end():m.end() + w * h * 3]
|
||||
|
||||
|
||||
for case in CASES:
|
||||
log = open(os.path.join(D, case + '.log')).read()
|
||||
m = re.search(r'replay: window (\S+), (\d+) sets of (\d+)x(\d+)', log)
|
||||
win, nsets, rw, rh = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4))
|
||||
shown = {int(a): int(b) for a, b in re.findall(r'R n=(\d+) \S+ \S+ set=(-?\d+)', log)}
|
||||
# frame order
|
||||
frames, cur = {}, None
|
||||
for line in open(os.path.join(D, case + '.trace')):
|
||||
mm = re.match(r'f(\d+) (\S+)(.*?)( @[-\d.]+)?$', line.rstrip('\n'))
|
||||
if not mm or line.startswith('f0 ') or mm.group(2) in ('dump',):
|
||||
continue
|
||||
f, call, rest = int(mm.group(1)), mm.group(2), mm.group(3)
|
||||
if call == 'locate':
|
||||
call += re.search(r'display=(\S+)', rest).group(1)
|
||||
elif call == 'endFrame':
|
||||
call += '[' + ' '.join(re.findall(r'(proj|quad)\[', rest)) + ']'
|
||||
elif call in ('acquire', 'release', 'waitImage'):
|
||||
call += ' ' + rest.split()[0]
|
||||
elif call in ('endInfo', 'layer0', 'layer1', 'view0', 'view1') or call.startswith(('view', 'layer')):
|
||||
continue
|
||||
frames.setdefault(f, []).append(call)
|
||||
pats = Counter(' > '.join(re.sub(r' sc\d+', '', c) for c in v) for f, v in frames.items() if f > 5)
|
||||
total = sum(pats.values())
|
||||
tr = open(os.path.join(D, case + '.trace')).read()
|
||||
ends = len(re.findall(r'endInfo .*display=\+0\.0000ms', tr)), len(re.findall(r'endInfo ', tr))
|
||||
nopose = len(re.findall(r'proj space=s0:LOCAL .* poses from no known locate', tr)), len(re.findall(r'layer0 proj ', tr))
|
||||
fovs = Counter(re.findall(r'(view\d) sc\d+ .* fov=(\S+) @', tr))
|
||||
print(f'== {case}: window {win}, {nsets} sets of {rw}x{rh}, {len(frames)} frames in the trace')
|
||||
for p, c in pats.most_common(3):
|
||||
print(f' {100 * c / total:5.1f} % {p}')
|
||||
print(f' end display time = predicted: {ends[0]} of {ends[1]}; projection layers with poses from no locate '
|
||||
f'(the recorded ones): {nopose[0]} of {nopose[1]}; fov ' + ', '.join(f'{v} {f} x{c}' for (v, f), c in sorted(fovs.items())))
|
||||
# images
|
||||
dd = os.path.join(D, case + '.dump')
|
||||
if not os.path.isdir(dd):
|
||||
continue
|
||||
idx_src = re.search(r'recording (\S+),', open(os.path.join(D, case + '.stdout')).read()).group(1)
|
||||
sets = {}
|
||||
for line in open(os.path.join(idx_src, 'index.txt')):
|
||||
p = line.split()
|
||||
if p[0].startswith(win + '-') and p[3] in '01' and int(p[4]) > 0:
|
||||
sets.setdefault(int(p[4]), {})[int(p[3])] = p[0]
|
||||
order = [sets[t] for t in sorted(sets) if len(sets[t]) == 2][:nsets]
|
||||
res = []
|
||||
for line in open(os.path.join(dd, 'index.txt')):
|
||||
f, sc, fr = line.split()[:3]
|
||||
sc, fr = int(sc[2:]), int(fr[1:])
|
||||
k = shown.get(fr, -1)
|
||||
if sc >= 2 * 8 or k < 0:
|
||||
continue
|
||||
w, h, px = ppm(os.path.join(dd, f))
|
||||
if w * 8 != 1728 or rw != 648:
|
||||
continue
|
||||
eye = sc % 2
|
||||
scores = []
|
||||
for kk in (k, (k - 1) % nsets, (k + 1) % nsets):
|
||||
_, _, src = ppm(os.path.join(idx_src, order[kk][eye]))
|
||||
tot, cnt = 0.0, 0
|
||||
for j in range(0, h, 2):
|
||||
if 0.15 * h <= j <= 0.30 * h:
|
||||
continue
|
||||
y0, y1 = max(3 * j - 1, 0), 3 * j
|
||||
for i in range(0, w, 2):
|
||||
x0, x1 = max(3 * i - 1, 0), 3 * i
|
||||
for c in range(3):
|
||||
a = lambda y, x: TO_LIN[src[(y * rw + x) * 3 + c]]
|
||||
v = (0.3125 * (0.3125 * a(y0, x0) + 0.6875 * a(y0, x1)) + 0.6875 * (0.3125 * a(y1, x0) + 0.6875 * a(y1, x1)))
|
||||
tot += abs(to_srgb(v) - px[(j * w + i) * 3 + c])
|
||||
cnt += 1
|
||||
scores.append(tot / cnt)
|
||||
res.append((fr, eye, k, scores))
|
||||
if res:
|
||||
worst = max(r[3][0] for r in res)
|
||||
best = sum(1 for r in res if r[3][0] < min(r[3][1:]))
|
||||
print(f' dumped eye images: {len(res)} (frames {min(r[0] for r in res)}-{max(r[0] for r in res)}); against the shown set: '
|
||||
f'mean abs diff max {worst:.2f}, closer than k-1 and k+1 in {best} of {len(res)}; '
|
||||
f'k+-1 mean {sum(sum(r[3][1:]) / 2 for r in res) / len(res):.2f}')
|
||||
@@ -1,8 +0,0 @@
|
||||
# Headset session 14, launch 1 of 2 (lane U, frame/perf/session-h14/README.md): no desktop window swapchain.
|
||||
# KARTFRAME_HEADLESS=1: aurora never configures or presents its Gamescope WSI swapchain (the 60 Hz present on
|
||||
# the eyes' queue that neither the clean xrprobe replay nor any mock run has). Everything else as built.
|
||||
# Routine: menus, then a Time Trial; head turns during the countdown (standing), then while driving.
|
||||
KARTFRAME_HEADLESS=1
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,8 +0,0 @@
|
||||
# Headset session 14, launch 2 of 2 (lane U, frame/perf/session-h14/README.md): desktop window as usual, Dawn's
|
||||
# queue drained before each set's releases and its xrEndFrame (KARTFRAME_RELEASE=idle), so SteamVR's client
|
||||
# submissions there wait behind nothing of ours; game.log reports the drain every 10 s ("release idle drain").
|
||||
# Routine: the same as launch 1.
|
||||
KARTFRAME_RELEASE=idle
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,69 +0,0 @@
|
||||
# Headset session 14 (lane U): the desktop present and the queue at SteamVR's submissions
|
||||
|
||||
Lane U (`frame/perf/LANE_U.md`) proved headless that every set is released only after the GPU finished
|
||||
writing it (Dawn's render submission precedes the timeline signal; a new strict mock check: 0 of about
|
||||
143000 released images changed between release and next acquire, positive control 97 %), and that at the
|
||||
release our queue is nearly empty (a submission made there completes in 0.3-0.45 ms p50), as in session 13's
|
||||
clean xrprobe replay. What the live game has and the probe and every mock run lack: a desktop window
|
||||
swapchain (Gamescope WSI, Mailbox) presented once per game frame, 60 Hz = every second refresh, on the
|
||||
same VkQueue as the eyes and SteamVR's client. Two launches test it from both sides.
|
||||
|
||||
| launch | env file | change | logs to read |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `01-no-desktop.env` | `KARTFRAME_HEADLESS=1`: no desktop swapchain, no presents (as in every mock run) | panels, XRF lines, vr pacing |
|
||||
| 2 | `02-release-idle.env` | `KARTFRAME_RELEASE=idle`: desktop as usual; vkQueueWaitIdle on Dawn's queue before the releases and before xrEndFrame | plus `release idle drain` every 10 s: how much of ours was still queued at those points on the headset |
|
||||
|
||||
Both: `KARTFRAME_FRAME_LOG=1`, `KARTFRAME_RECORD=raw` (panels.mkv), `KARTFRAME_SESSION_ENV_ONCE=1`. Session 12
|
||||
launch 1 (`frame/perf/session-h12/01-measure.env`) is the control if one is wanted (doubled in all 13
|
||||
sessions so far).
|
||||
|
||||
## Install
|
||||
|
||||
Build lane U's branch, `install --dev` (the switch is in the binary; launch 1 needs nothing new). Copy both
|
||||
env files into `$KARTFRAME_HOME/session.queue/` (default
|
||||
`~/.local/share/KartFrame/session.queue/`); the wrapper takes one per launch, in name order.
|
||||
|
||||
Owner routine, each launch: menus, then straight into a Time Trial. During the countdown (kart standing) look at
|
||||
a track-side edge and turn the head slowly left and right for ten seconds, then the same while driving, then
|
||||
each with one eye closed. Say: doubled or not, which eye.
|
||||
|
||||
Launch 1 note: the desktop window stays empty (never configured). If Steam or gamescope objects to a window
|
||||
that never presents (no image in the headset, a stuck launch), end the launch and report it; launch 2 does
|
||||
not depend on it.
|
||||
|
||||
## Reading it
|
||||
|
||||
- `frame/tools/session-report.py <session>` (pacing, late, GPU ms), `frame/perf/panels/analyse.sh <session>`
|
||||
and H7's zigzag rule per eye on the race (session-h12's zig2.py); menus-like floor 4-6 %.
|
||||
- **Launch 1 clean, launch 2 doubled**: the desktop present is it (and not by queue backlog at the
|
||||
releases). Fix: no desktop swapchain in VR (make KARTFRAME_HEADLESS's path the VR default, or stop presenting
|
||||
while the XR session runs). Launch 2's drain line then says whether the present at least delayed our queue.
|
||||
- **Launch 2 clean** (either way for launch 1): a backlog on our queue at SteamVR's release or end submissions,
|
||||
which the mock does not have; launch 2's drain line gives its size (mean / max / >1 ms / >4 ms per 10 s;
|
||||
the mock: 0.3-0.4 ms before the releases, 0.1 before the end, that is the call's own round trip). Next:
|
||||
find what is queued there (the drain size by frame parity) and order it after the release cheaply.
|
||||
- **Both doubled**: neither the present nor our queue at SteamVR's submissions. Next candidate (LANE_U.md,
|
||||
rank 3): GPU contention with SteamVR's compositor from the game's 3-5 ms a set, heavy and light sets
|
||||
alternating at 60 Hz; test with a render-scale-0.5 measurement launch or an xrprobe replay with an added
|
||||
60 Hz GPU load.
|
||||
- Pacing in launch 2: expect xrEndFrame later than 1 ms after the wake-up in about 10-13 % of frames (the
|
||||
drain's round trip; mock), no late sets. Late sets above a few percent make launch 2 unreadable.
|
||||
|
||||
## Headset results (2026-10-15, orchestrator's reading)
|
||||
|
||||
Owner: launch 1 (no desktop) "bad, right eye in particular doubling"; launch 2 (release idle) "bad too, but
|
||||
the left eye didn't have doubling, just a lag spike every 3-4 seconds; the right eye was bad".
|
||||
|
||||
Logs: launch 1 ran headless (aurora "headless: N frames presented to the offscreen target"; no desktop
|
||||
swapchain), race 119.6 sets/s, 0.7 % late, GPU 5.3 ms p50, ends > 1 ms 3.3 %: the desktop present is NOT
|
||||
the cause. Launch 2: the drain before the releases took 4-4.9 ms mean (> 4 ms in ~85 % of releases: the
|
||||
set's own render, not a backlog), before the end 0.4-1.3 ms; cost: 36.8 % late sets, 36 % retained repeats,
|
||||
ends > 1 ms 91 %, 76.6 sets/s (the lag spikes). SteamVR's compositor timing (vrtiming) is identical between
|
||||
the doubled launch 1 and session 13's clean probe case 4: 120 frames/s, 0 reused/dropped, compositor GPU
|
||||
0.85 vs 0.77 ms, vsyncs to photons 1.00.
|
||||
|
||||
Reading: candidates 1 (desktop present) and 2 (backlog at release) are out. What remains is candidate 3,
|
||||
our GPU work (3-5 ms per set, heavy and light sets alternating at 60 Hz) against the compositor's per-eye
|
||||
compose inside the refresh; launch 2 shifted our render by ~4 ms and cleared the LEFT eye only, which fits
|
||||
a phase collision that hits one eye's compose window. Session 15 tests the load from both sides: 90 Hz at
|
||||
1728 (11.1 ms refresh) and 120 Hz at 1200 px (half the work, measurement only).
|
||||
@@ -1,9 +0,0 @@
|
||||
# Headset session 15, launch 1 of 2: the race at 90 Hz, 1728 per eye (the owner's accepted last fallback).
|
||||
# Session 14: the desktop present and queued GPU work at the release are not the cause; a drained queue
|
||||
# cleared the left eye only. Candidate: our 3-5 ms GPU work per set (heavy/light alternating at 60 Hz)
|
||||
# colliding with the compositor's per-eye compose inside the 8.3 ms refresh. 90 Hz gives 11.1 ms.
|
||||
# Routine as always: menus, Time Trial, head turns at the countdown, then driving, hold still, quit.
|
||||
KARTFRAME_REFRESH_HZ=90
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,6 +0,0 @@
|
||||
# Headset session 15, launch 2 of 2 (measurement only, never a shipping setting): 120 Hz with the eyes at
|
||||
# 1200 px, about half the GPU work per set. Clean here and doubled at 1728 = the GPU load is the cause.
|
||||
KARTFRAME_EYE_PX=1200
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,16 +0,0 @@
|
||||
# Headset session 15: GPU load from both sides (90 Hz at 1728; 120 Hz at 1200 px)
|
||||
|
||||
Two launches after session 14 (desktop present and release backlog ruled out; a drained queue cleared the
|
||||
left eye only): `01-90hz.env` (KARTFRAME_REFRESH_HZ=90) and `02-eye1200.env` (new wrapper knob
|
||||
KARTFRAME_EYE_PX=1200, measurement only). Both settings took effect (read-back 5 of 5; 90 Hz; 1200x1200
|
||||
eye swapchains). Pacing: 90.7 sets/s and 120.5 sets/s, 0.0 % late in both; GPU per set 5.2 ms at 90 Hz
|
||||
(1728) and 3.1 ms at 1200 px; ends > 1 ms 3.5 % and 3.1 %.
|
||||
|
||||
Owner: "bad again for both, but the second launch had less doubling with the right eye".
|
||||
|
||||
Reading: neither the refresh period (11.1 ms) nor halving the GPU work removes the doubling, so the SIZE of
|
||||
our GPU work and the refresh rate are not the cause. Two default launches played by mistake just before
|
||||
(no session file) doubled as always. What is left between the clean probe (session 13) and the game: the
|
||||
game process itself around the hand-over, i.e. its threads. The priority boost (nice -15, 673408a) and the
|
||||
utilisation clamp (4b87fcf) both landed after session 3's build (ff9b277, the only clean session) and were
|
||||
in every doubled session since; session 16 turns them off (`session-h16/01-noprio.env`).
|
||||
@@ -1,13 +0,0 @@
|
||||
# Headset session 16, launch 1 of 1: the game WITHOUT its thread priority boost (RealtimeKit nice -15 on the
|
||||
# threads a headset frame waits on, commit 673408a) and WITHOUT the utilisation clamp on the game thread and
|
||||
# frame worker (commit 4b87fcf). Both landed on 2026-10-13 after session 3's build ff9b277, the only session
|
||||
# without doubling, and were in every doubled session since (H4 build 2fe42c4 onwards). Everything else the
|
||||
# game does has been cleared (sessions 12-15: poses, images, layers, pairs, quad, desktop present, release
|
||||
# ordering, GPU load size, refresh rate). Candidate: the boosted threads delay SteamVR's compositor on the
|
||||
# CPU inside the refresh, hitting one eye's compose. Expect the game thread to run a little less smoothly.
|
||||
# Routine as always: menus, Time Trial, head turns at the countdown, then driving, hold still, quit.
|
||||
KARTFRAME_THREADPRIO=off
|
||||
KARTFRAME_UCLAMP=0
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,8 +0,0 @@
|
||||
# Headset session 16, launch 2: each set's render starts 4 ms after the wake-up instead of at once
|
||||
# (KARTFRAME_AHEAD_START_US; sets are still two refreshes ahead, ends still at the wake-up). Session 14's
|
||||
# drained launch shifted our GPU work by ~4 ms and cleared the left eye; this moves the work's phase inside
|
||||
# the refresh without the drain's lateness. Routine as always.
|
||||
KARTFRAME_AHEAD_START_US=4000
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,7 +0,0 @@
|
||||
# Headset session 16, launch 3: each frame is ended 4 ms after SteamVR's wake-up instead of at once
|
||||
# (KARTFRAME_END_DELAY_US; the same set is shown, the next set still starts at the wake-up). Sessions 3 and
|
||||
# 14 had frames ended 4-5 ms late and showed less double image; this tests it without session 14's queue drain.
|
||||
KARTFRAME_END_DELAY_US=4000
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,46 +0,0 @@
|
||||
# Headset session 16
|
||||
|
||||
Three launches, each one a single test of a CPU-side timing cause for the right-eye double image: 01 thread
|
||||
priority boost and utilisation clamp off (01-noprio.env), 02 each set's render start delayed 4 ms after the
|
||||
wake-up (02-ahead-start.env, KARTFRAME_AHEAD_START_US), 03 each frame ended 4 ms after the wake-up
|
||||
(03-end-delay.env, KARTFRAME_END_DELAY_US). Routine for each: menus, Time Trial, head turns at the countdown,
|
||||
driving, hold still, quit; analyse the KARTFRAME_RECORD=raw recording with frame/perf/panels/analyse.sh.
|
||||
|
||||
## Lane V mock check (launch 03)
|
||||
|
||||
Scripted race, frame/perf/hudrace.sh 90 s, MOCK_XR_STAMP=1 MOCK_XR_BUSY_MS=1 KARTFRAME_FRAME_LOG=1
|
||||
KF_MOCKRUN_END_GATE=100 KARTFRAME_END_DELAY_US=4000, against the same race with no delay (60 s):
|
||||
|
||||
| | end delay 4000 us | default |
|
||||
|---|---|---|
|
||||
| mock errors | 0 | 0 |
|
||||
| end after wake-up p50 / p99 | 4.38 / 4.92 ms | 0.28 / 0.85 ms |
|
||||
| ends with the predicted display time | 10659 of 10659 | 7198 of 7198 |
|
||||
| sets rendered a second (late, repeats) | 118 of 120 (0 late, ~0 repeats) | 120 (0, 0) |
|
||||
| end_late= a second | all (by design; the end gate was set to 100) | 0 |
|
||||
|
||||
The delayed end starts the next set before the sleep, as KARTFRAME_AHEAD_REFRESHES=1 does, which holds one
|
||||
more eye pair until the end: the switch therefore defaults to 4 eye pairs. With 3 pairs the early start
|
||||
found no free pair, the set started after the end instead and the race fell to 80 sets a second with 40
|
||||
repeats and pairwait=40 a second.
|
||||
|
||||
## Headset results (2026-10-15, orchestrator's reading)
|
||||
|
||||
Owner: launch 1 (priority boost and uclamp off) "both eyes bad and doubling"; launch 2 (set start delayed
|
||||
4 ms) "left eye good, right eye doubling"; launch 3 (frame ended 4 ms after the wake-up) "BOTH EYES GOOD,
|
||||
NOT DOUBLING; the right eye had a slight jitter, more sensitive to head movements, the left eye dead on".
|
||||
|
||||
The first clean race since session 3. Logs, launch 3: ends 4.50 ms after the wake-up p50 (XRF e: 7083 of
|
||||
7324 race frames in 4-5 ms, 210 in 5-6, 31 later than 6), race 120.3 sets/s, 0.2 % late, 2.3 % retained
|
||||
repeats, 4 eye pairs; GPU per set 7.0-7.3 ms p50 (launch 1: 4.8; the next set now starts before the end).
|
||||
Launch 2: 117.9 sets/s, 3.2 % late. Launch 1: 119.8, 0.7 % late, ends at 0.35 ms.
|
||||
|
||||
Reading: WHEN the frame is ended inside the refresh decides the doubling. Ended at the wake-up (within
|
||||
~0.4 ms; every doubled session 4-15): doubled. Ended ~4.5 ms after the wake-up: clean in both eyes. Session
|
||||
14's drained launch (ends 5.5 ms, left eye clean) and session 3 (late ends, clean) were the same effect.
|
||||
Model: SteamVR's compositor on the Frame picks up the app's layer for each eye at its own point inside the
|
||||
refresh, the right eye later than the left; an end that lands near a pick-up point is seen by one eye for
|
||||
this refresh and by the other for the next, so consecutive refreshes disagree (the period-2 zigzag, worse in
|
||||
the eye whose pick-up it straddles, which eye depending on where the end lands). The right eye's residual
|
||||
jitter at 4.5 ms says its pick-up is near there. Session 17 sweeps the delay (2.5 ms and 6 ms) to find the
|
||||
window; then the clean value becomes the default and its latency cost is measured.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Headset session 17, launch 01 of 2: frames ended 2500 us after the wake-up (session 16: 4000 us was clean in
|
||||
# both eyes with a slight right-eye jitter; at the wake-up every session doubled). Finds the window's edges.
|
||||
# Routine as always: menus, Time Trial, head turns at the countdown, then driving, hold still, quit.
|
||||
KARTFRAME_END_DELAY_US=2500
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,7 +0,0 @@
|
||||
# Headset session 17, launch 02 of 2: frames ended 6000 us after the wake-up (session 16: 4000 us was clean in
|
||||
# both eyes with a slight right-eye jitter; at the wake-up every session doubled). Finds the window's edges.
|
||||
# Routine as always: menus, Time Trial, head turns at the countdown, then driving, hold still, quit.
|
||||
KARTFRAME_END_DELAY_US=6000
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
@@ -1,16 +0,0 @@
|
||||
# Headset session 17: the end-delay window's edges (2.5 ms and 6 ms)
|
||||
|
||||
Same build as session 16. Owner: "both better, but both had doubling in both eyes; launch 1 (2500 us) left
|
||||
eye okay with slight doubling; launch 2 (6000 us) left eye damn near perfect, doubling slightly for half a
|
||||
second every 2 seconds; in both the right eye was very bad."
|
||||
|
||||
Logs: launch 1's ends were NOT at 2.5 ms: XRF e 2-3 ms 3688 frames, 3-4 ms 1184, 4-5 ms 2039, 5-6 ms 289
|
||||
(the set started before the sleep takes longer than 2.5 ms at times, so the end slides to 4-5 ms: a bimodal
|
||||
end time, which itself straddles a pick-up point). Launch 2's ends were tight: 6-7 ms 6348 of 6493. Session
|
||||
16's launch 3 (4000 us, clean): 4-5 ms 7083 of 7324. Race pacing fine in both (119.8 sets/s, 0.8 % / 0.3 %
|
||||
late); compositor "mispresented" 121 / 332 (session 16 launch 3: 88).
|
||||
|
||||
Reading: ends at 6.5 ms are bad for the right eye and nearly clean for the left with a 2 s beat (the left
|
||||
eye's edge drifts slowly against our wake-up clock); ends alternating 2.7 / 4.5 ms are bad for the right
|
||||
eye. Ends at 4-5 ms are clean. The right eye's clean band is narrow and sits around 4-5 ms after the
|
||||
wake-up. Session 18 maps it at 0.5 ms steps (4000 again, 4500, 3500) with the end distribution per launch.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Headset session 18, launch 01 of 3: frames ended 4000 us after the wake-up. Session 16: 4000 clean in both eyes
|
||||
# (right eye slight jitter); session 17: 2500 (ends spread 2.5-5 ms) and 6000 (ends 6-7 ms) both doubled in the
|
||||
# right eye. This maps the clean band at 0.5 ms steps; 4000 again checks it repeats. Routine as always.
|
||||
KARTFRAME_END_DELAY_US=4000
|
||||
KARTFRAME_FRAME_LOG=1
|
||||
KARTFRAME_RECORD=raw
|
||||
KARTFRAME_SESSION_ENV_ONCE=1
|
||||
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user