21 Commits
Author SHA1 Message Date
Robin Anderson 15d5d0fc33 Merge pull request #42 from VioletTides/fd-test-lines
Match whole lines in the inherited descriptor test
2026-10-17 13:23:41 -04:00
Robin Anderson e1b793e2db Merge pull request #41 from VioletTides/release-1.1.0
Number the release 1.1.0
2026-10-17 13:23:34 -04:00
Robin Anderson 887f1f1e7e Match whole lines in the inherited descriptor test
The test looked for the text "50" and a newline anywhere in its output
file. The file also ends with the shell's session ID from ps, so a
session ID ending in 50 failed the test at random. It now compares each
line to the descriptor number.
2026-10-17 12:25:47 -04:00
Robin Anderson 1259fc90be Number the release 1.1.0 2026-10-17 12:23:58 -04:00
Robin Anderson 31a8d0f5c4 Merge pull request #40 from VioletTides/knock-actions
Let people choose what knocking twice or thrice does
2026-10-17 12:22:05 -04:00
Robin Anderson dbc01cd59a Let people choose what knocking twice or thrice does
Settings has a Knock actions screen with Knock twice and Knock thrice
side by side. Each lists four actions, and one press picks one: switch
passthrough, switch the style, show or hide SteamVR's performance graph,
or do nothing. The defaults stay the same, and the lists mark them with
(default).

config.json stores the actions under knock_actions, keyed by kind and
count, so each kind of knock can have its own actions and a new count
needs no format change. The screen sets every kind the same. The
performance graph is the SteamVR setting perfcheck.perfGraphInHMD. The
tour still teaches the default actions.
2026-10-17 12:18:18 -04:00
Robin Anderson bbc292d778 Merge pull request #39 from VioletTides/remove-bar-icon-row
Remove the Classic bar icon row from Settings
2026-10-17 11:45:28 -04:00
Robin Anderson 82976da1aa Remove the Classic bar icon row from Settings
Settings is down to five rows. The classic_bar_icon key in config.json
still works, so anyone who turned it on keeps the colored icon.

The calibrate, update, and report GIFs in the README show the new
Settings screen.
2026-10-17 11:36:18 -04:00
Robin Anderson b3f52b84fe Merge pull request #38 from VioletTides/agents-md
Add AGENTS.md for contributors who use coding agents
2026-10-17 10:50:44 -04:00
Robin Anderson 9f1e6d3c57 Add AGENTS.md for contributors who use coding agents 2026-10-17 10:47:18 -04:00
Robin Anderson 5dfc55e770 Merge pull request #37 from VioletTides/docs-revamp
Revamp the docs
2026-10-17 10:26:09 -04:00
Robin Anderson e33931a206 Revamp the docs
docs/ explains how knock detection works, how calibration sets its
thresholds, what each test recording holds, and how the app fits
together. The numbers come from the current code and test output, so
they include the leg knock changes and today's replay results.

The README and the detector header link to the new pages.
2026-10-17 10:21:38 -04:00
Robin Anderson 391b6d41d9 Merge pull request #35 from VioletTides/idea-template-clearer
Make the idea template quicker to fill in
2026-10-17 01:47:51 -04:00
Robin Anderson ffc5aa022a Make the idea template quicker to fill in
Add a short intro with a link to Discussions, a dropdown for what the
idea is about, example answers in each box, and an optional pick of
the knock kinds the person uses.
2026-10-17 01:43:11 -04:00
Robin Anderson 7898749942 Merge pull request #34 from VioletTides/feature-template
Add an idea template and link issues to Discussions
2026-10-16 20:58:55 -04:00
Robin Anderson 1a7485c8fa Merge pull request #33 from VioletTides/room-view-tests
Test RoomView against a fake Steam debug port
2026-10-16 20:58:43 -04:00
Robin Anderson f55869acf2 Merge pull request #32 from VioletTides/surface-leg-wording
Mention the leg in the surface knock hints
2026-10-16 20:58:30 -04:00
Robin Anderson 086ca05af0 Add an idea template and link issues to Discussions 2026-10-16 20:37:13 -04:00
Robin Anderson f977454de6 Test RoomView against a fake Steam debug port
The fake port serves the bar and popup targets and runs each script in Node
against a fake dashboard bar. The test skips when node is not on PATH.
2026-10-16 20:36:56 -04:00
Robin Anderson b1b217bcbc Say "a surface, like your leg" in the hints 2026-10-16 20:29:29 -04:00
Robin Anderson 616d1d4308 Mention the leg in the surface knock hints 2026-10-16 20:27:50 -04:00
28 changed files with 1145 additions and 58 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
contact_links:
- name: Questions and ideas to talk over
url: https://github.com/VioletTides/KnockThru/discussions
about: Ask how something works, or talk an idea over before filing it.
+44
View File
@@ -0,0 +1,44 @@
name: Idea
description: Suggest a feature or a change to KnockThru
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Thanks for the idea! A sentence or two per box is plenty.
If you'd rather talk it over first, start a [discussion](https://github.com/VioletTides/KnockThru/discussions) instead.
- type: dropdown
id: area
attributes:
label: What is the idea about?
options:
- Something new a knock can do
- How knocks are detected or calibrated
- Settings or the panel in the headset
- Installing or updating
- Something else
validations:
required: true
- type: textarea
id: idea
attributes:
label: What would you like KnockThru to do?
placeholder: Show and hide the performance overlay with a triple knock.
validations:
required: true
- type: textarea
id: why
attributes:
label: When would you use it?
description: What you were doing when you wanted it.
placeholder: I check my frame rate in some games, and I don't want to open a menu each time.
- type: dropdown
id: knock-kinds
attributes:
label: Which knocks do you use?
description: Pick any that apply. This helps if the idea is about knocks.
multiple: true
options:
- Controllers together
- Controller on a surface (or your leg)
- Knock on the headset
+23
View File
@@ -0,0 +1,23 @@
# KnockThru
By default, a double knock toggles Steam Frame passthrough (Room View), and a triple knock switches Room View between translucent and opaque. `knock_actions` in `config.json` can give each kind of knock other actions (`src/knock_actions.*`). Knock actions in Settings sets every kind the same. The app runs on the Frame itself as a SteamVR overlay app.
Read `docs/README.md` before you change knock detection, calibration, or how the app switches passthrough. It links one page for each.
## Commands
- Host build and tests: `cmake -S . -B build && cmake --build build && ctest --test-dir build`
- Frame build: `scripts/build-arm64.sh` (Zig 0.16.0), output in `build-arm64/bin/`
- Release ZIP: `node scripts/package.mjs --build-dir build-arm64 --version X.Y.Z`
- Detector tuning sweep: `KNOCKTHRU_TEST_SET="confirmed_peak=30" build/bin/detector_test`. Add `KNOCKTHRU_TEST_VERBOSE=1` to print every knock and event.
- Replay a recording from a bug report: `build/bin/detector_test knockthru-knocks.csv.gz`
CI runs the host build, `ctest`, and the Frame build on every pull request.
## Rules
- Keep `src/knock_detector.*` free of OpenVR types. The host tests replay the recordings in `tests/data/` through it.
- After any detector change, `ctest` must pass: no actions in `play`, `noise`, or `straps`, and no wrong actions in `mixed`. If you change a threshold, update the docs page that explains it.
- Read poses in `vr::TrackingUniverseStanding`. Poses in the raw universe are wrong on the Frame.
- `knockthru` and `knockthru-ctl` ship as byte-identical copies. SteamVR identifies apps by executable path, so management commands re-exec through `knockthru-ctl` to keep the resident app's identity.
- Call every gesture a "knock" in code, docs, and UI text.
+9 -3
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16)
project(KnockThru VERSION 1.0.2 LANGUAGES CXX)
project(KnockThru VERSION 1.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -14,7 +14,7 @@ endif()
include(cmake/OpenVRLoader.cmake)
find_package(Threads REQUIRED)
add_library(knockthru_core STATIC src/knock_detector.cpp src/config.cpp src/calibrator.cpp src/update.cpp src/report.cpp src/report_server.cpp)
add_library(knockthru_core STATIC src/knock_detector.cpp src/knock_actions.cpp src/config.cpp src/calibrator.cpp src/update.cpp src/report.cpp src/report_server.cpp src/room_view.cpp)
target_include_directories(knockthru_core PUBLIC src third_party/json)
target_compile_options(knockthru_core PRIVATE -Wall -Wextra -Wpedantic)
@@ -24,7 +24,7 @@ target_include_directories(knockthru_ui SYSTEM PRIVATE third_party/stb third_par
target_link_libraries(knockthru_ui PUBLIC knockthru_core)
target_compile_options(knockthru_ui PRIVATE -Wall -Wextra -Wpedantic)
add_executable(knockthru src/main.cpp src/app.cpp src/steamvr.cpp src/poses.cpp src/diagnostics.cpp src/install.cpp src/room_view.cpp src/style.cpp src/sound.cpp src/overlay_texture.cpp)
add_executable(knockthru src/main.cpp src/app.cpp src/steamvr.cpp src/poses.cpp src/diagnostics.cpp src/install.cpp src/perf_overlay.cpp src/style.cpp src/sound.cpp src/overlay_texture.cpp)
target_include_directories(knockthru SYSTEM PRIVATE third_party/vulkan/include)
target_link_libraries(knockthru PRIVATE knockthru_core knockthru_ui openvr_loader Threads::Threads ${CMAKE_DL_LIBS})
target_compile_definitions(knockthru PRIVATE KNOCKTHRU_VERSION="${PROJECT_VERSION}")
@@ -54,6 +54,12 @@ target_link_libraries(update_test PRIVATE knockthru_core)
target_compile_options(update_test PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME update COMMAND update_test)
add_executable(room_view_test tests/room_view_test.cpp)
target_link_libraries(room_view_test PRIVATE knockthru_core Threads::Threads)
target_compile_options(room_view_test PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME room_view COMMAND room_view_test)
set_tests_properties(room_view PROPERTIES SKIP_RETURN_CODE 77)
# Renders the dashboard panel in sample states to PNG files, for review.
add_executable(panel_preview tests/panel_preview.cpp)
target_link_libraries(panel_preview PRIVATE knockthru_ui)
+17 -6
View File
@@ -61,10 +61,12 @@ Pause for a moment, then knock at a steady pace. After your last knock, KnockThr
| You knock | What happens | You feel |
| --- | --- | --- |
| Twice | Passthrough turns on or off | One buzz |
| Three times | The style switches between translucent and opaque. If passthrough is off, it stays off and opens in the new style next time | Two buzzes |
| Thrice | The style switches between translucent and opaque. If passthrough is off, it stays off and opens in the new style next time | Two buzzes |
| Four or more times, or mixed kinds | Nothing | Nothing |
You also hear a click. Steam clicks when passthrough turns on or off, and KnockThru clicks when the style switches. To turn off KnockThru's click, use **Sounds** in Settings.
Those are the defaults. To change what knocking twice or thrice does, open **Settings** and press **Change** on **Knock actions**. The choices are: switch passthrough, switch the style, show or hide SteamVR's performance graph, or do nothing. To give each kind of knock its own actions, set `knock_actions` in the [settings file](#for-tinkerers).
You also hear a click. Steam clicks when passthrough turns on or off, and KnockThru clicks when the style or the performance graph switches. To turn off KnockThru's click, use **Sounds** in Settings.
Three buzzes mean something went wrong. See [Something not working?](#something-not-working) below.
@@ -78,7 +80,7 @@ Open the SteamVR dashboard and pick the **KnockThru** tab to do everything by ha
- **KnockThru On**: turns knocking off for a while without quitting the app.
- **Controllers together**, **Controller on a surface**, **Knock on the headset**: pick which knock kinds count.
- **Knocks in** your game: turns knocks off only in the game you're playing, for games where you knock things on purpose.
- **Settings**: updates, Calibrate, Sounds, Classic bar icon, Guided tour, Report, and Factory reset.
- **Settings**: updates, Calibrate, Knock actions, Sounds, Guided tour, Report, and Factory reset.
## Calibrate
@@ -149,7 +151,14 @@ Put your settings in `~/.config/knockthru/config.json`. This file survives updat
- `debug_port`: the CEF Remote Debugging port. The default is 8080.
- `classic_bar_icon`: `true` shows the dashboard bar icon in the logo colors. The default `false` shows the logo in grey, and white on hover, like Steam's own icons.
- `check_updates`: `false` stops the daily update check. By default, KnockThru asks GitHub for the latest release once a day, a minute after it starts. **Check for updates** in Settings works either way.
- `sounds`: `false` silences the click. By default, KnockThru plays SteamVR's dashboard click on a triple knock and on panel buttons. Steam already clicks when a double knock changes passthrough.
- `sounds`: `false` silences the click. By default, KnockThru plays SteamVR's dashboard click when a knock switches the style or the performance graph, and on panel buttons. Steam already clicks when passthrough changes.
- `knock_actions`: what each kind of knock does twice (`"2"`) and thrice (`"3"`): `passthrough`, `style`, `perf_graph`, or `none`. Kinds and counts you leave out keep their default. **Knock actions** in Settings writes this key, and sets every kind the same. For example, this makes knocking thrice on the headset show and hide the performance graph:
```json
{
"knock_actions": { "headset": { "3": "perf_graph" } }
}
```
- `paused`: `true` while the **KnockThru On** switch is off. Knocks do nothing then.
- `detector`: overrides for the knock detector's settings, such as `confirmed_peak` or `max_gap_s`. The comments in `src/knock_detector.hpp` describe each one. Calibration sets the peaks for you.
- `tour_done`: `true` once the first-run tour is finished or skipped. Set it to `false` to see the tour the next time you open the KnockThru tab. Settings also has a Replay button.
@@ -193,7 +202,7 @@ The log is `~/.config/knockthru/application.log`.
Copy the Linux ARM64 ZIP from the releases page to the Frame, then run:
```sh
unzip -oq knockthru-1.0.2-linux-arm64.zip -d ~/KnockThru
unzip -oq knockthru-1.1.0-linux-arm64.zip -d ~/KnockThru
~/KnockThru/knockthru --install
```
@@ -214,7 +223,7 @@ Steam Frame build (needs Zig 0.16.0) and release ZIP (needs Node.js):
```sh
scripts/build-arm64.sh
node scripts/package.mjs --build-dir build-arm64 --version 1.0.2
node scripts/package.mjs --build-dir build-arm64 --version 1.1.0
```
README images (needs Pillow, numpy, ffmpeg, and Chromium, after the host build):
@@ -235,6 +244,8 @@ To replay the knock recording from a bug report, unzip the report and run this:
build/bin/detector_test knockthru-knocks.csv.gz
```
[`docs/`](docs/README.md) explains how knock detection and calibration work, how they were tuned, and how the app fits together.
</details>
## Credits
+10
View File
@@ -0,0 +1,10 @@
# KnockThru docs
These pages explain how KnockThru works on the inside. To install and use it, read the [main README](../README.md) instead.
- [How knock detection works](knock-detection.md): how KnockThru tells a knock apart from swinging your arms, and where each rule came from.
- [How calibration works](calibration.md): what the calibration walkthrough measures, and how it turns your knocks into thresholds.
- [The test recordings](test-recordings.md): the motion recordings from a Steam Frame that every change gets tested against.
- [How the app fits together](how-the-app-works.md): what runs on the headset, how it switches passthrough, and where your settings live.
Every number in these pages comes from the code or from the test recordings. If you change a threshold, update the page that explains it.
+82
View File
@@ -0,0 +1,82 @@
# How calibration works
Everyone knocks a little differently. Some people knock hard, some barely touch. The default thresholds catch most knocks, but soft ones can slip under them. Calibration measures 10 of your knocks and sets the thresholds for that kind of knock to fit you.
The code is in `src/calibrator.cpp`. The saved result goes to `~/.config/knockthru/calibration.json`.
## The walkthrough
![Calibrating a surface knock: pick the kind, read how to knock, knock 10 times while the bars fill, then knock twice to try it and save](../images/calibrate.gif)
1. **Pick** the kind of knock to calibrate: together, surface, or headset.
2. **Learn** how to knock it, then press Start.
3. **Record** 10 single knocks within 90 s. A ring fills before each one. One buzz means the knock counted.
4. **Try** the new thresholds with a double knock. Save unlocks only if the try works. The try never switches passthrough.
The first 3 s after Start don't count, because the hand coming down from pressing Start once counted as a knock. After each counted knock, the next one has to wait 1 s, which is how long the ring takes to fill.
You can also calibrate from a terminal on the Frame with `knockthru --calibrate together` (or `surface`, or `headset`).
## Catching soft knocks without catching everything else
To hear soft knocks, calibration lowers the spike thresholds while it records. Surface knocks need 30 m/s² instead of 60, and together and headset knocks need 12 instead of 40.
At those thresholds, almost anything looks like a knock. The `play` and `noise` recordings gave 84 and 82 knock candidates in 45 s each, about one a second. Two more problems showed up during a real surface calibration on the Frame:
- Starting a hard swing from rest spiked at 37 m/s², 0.09 to 0.14 s before the real knock. That early spike took the count instead of the knock, which spiked at 146 and 461.
- A slow hand movement counted as a knock.
So calibration is pickier about which knocks count. Knocks less than 0.25 s apart form a burst, and the burst is judged by its knock with the fastest approach. A burst counts when:
1. Nothing else happened in the 0.6 s before it. Waving makes clusters of spikes, and people wait between calibration knocks.
2. Its knock approached at 0.25 m/s or faster. Approach speed is the controller's fastest speed in the 100 ms before the spike.
3. For surface knocks, the controller was nearly still beforehand. It must have averaged under 0.6 m/s from 0.6 to 0.15 s before the knock. Real surface knocks averaged 0.02 to 0.51 m/s there, and false ones in `play` and `noise` mostly 0.64 to 2.8 m/s.
4. Its spike reaches the floor for its kind (see below). A knock under the floor could never trigger anything, so the walkthrough says "too light" instead of counting it.
Together and headset knocks skip the stillness check. Their false knocks never got past the first two rules, and a headset knock starts with your arm rising.
With these rules, calibrating on the 90 s of `play` and `noise` counts no knocks at all. Calibrating on the `together`, `surface`, and `headset` recordings counts 10 of 10. In the Frame session above, the hard knocks count and the swing starts and slow movement don't.
## Why approach speed matters
The spike is a noisy measure of how hard you knocked. It divides a velocity change by the time between two controller updates, and that time varies from 2 to 7 ms. In the knock recordings, the hardest tenth of spikes ran 2.8 to 8.3 times the softest tenth.
Approach speed is steadier. The fastest tenth of approaches ran only 1.7 to 2.5 times the slowest tenth. Slow movement also falls well under it. That's why calibration sets an approach threshold as well as a spike threshold.
## Turning 10 knocks into thresholds
Each kind of knock gets two thresholds:
- **Spike threshold:** 60 percent of your weakest recorded spike. It never goes below the floor for that kind, and never above 150 m/s².
- **Approach threshold:** 55 percent of your second slowest approach. The second slowest, not the slowest, so one odd knock can't drag it down.
The floors come from replaying the recordings:
| Kind | Floor | What goes wrong below it |
| --- | --- | --- |
| Surface | 50 m/s² | Replays split real surface doubles apart |
| Together | 30 m/s² | The strap fitting bumps in the `straps` recording form a double |
| Headset | 20 m/s² | Soft rebounds slip past the 105 ms gap and turn doubles into triples |
A calibration file saved under an older, lower floor loads with the current one.
To use the approach threshold, a set of knocks needs at least one knock that reaches it. Two other ways failed on the recordings:
- Dropping slow knocks before grouping them let leftover spikes form false doubles in `play` and `noise`. Slow knocks still join their set, so clapping runs stay rejected.
- Making every knock in a set reach the threshold lost most together doubles, because the second knock of a quick double starts from the rebound and comes in slow.
### The slider that got cut
An earlier version had a sensitivity slider from 30 to 80 percent, in place of the fixed 55. In the replays, anything stricter than 55 percent only lost real doubles. 30 percent matched 55 on surface and headset knocks and did a little better on together knocks, but it leaves less room for movement the recordings don't have. The slider didn't earn its place, so the walkthrough uses 55 percent.
## How well it works
The tests calibrate each kind on its own recording, apply the result, and replay every recording. These are the results today:
| Kind | Spike threshold | Approach threshold | Doubles found | `mixed` right, of 24 |
| --- | --- | --- | --- | --- |
| Together | 31.2 m/s² | 0.24 m/s | 19 | 22 |
| Surface | 55.6 m/s² | 0.68 m/s | 18 | 23 |
| Headset | 20 m/s² | 0.20 m/s | 22 | 23 |
Every row gives no actions in `play` and `noise`, and no wrong actions in `mixed`. With the default thresholds, the same recordings give 20 together, 18 surface, and 22 headset doubles, and 23 of 24 right in `mixed`.
+61
View File
@@ -0,0 +1,61 @@
# How the app fits together
KnockThru is one program that runs on the Steam Frame itself, not on a PC. SteamVR starts it as an overlay app, the same kind of app as a desktop or chat overlay. It shows up as a tab on the SteamVR dashboard.
The code is C++. Each part of the app lives in its own file pair under `src/`.
## From a knock to passthrough
1. **Reading motion.** A thread reads the headset and controller poses from SteamVR about once a millisecond (`src/poses.cpp`). Poses are only valid in SteamVR's standing tracking space on the Frame, so KnockThru reads them there.
2. **Finding knocks.** The main loop feeds the poses to the knock detector (`src/knock_detector.cpp`). [How knock detection works](knock-detection.md) explains it.
3. **Acting.** The action for each kind of knock and count comes from `knock_actions` in `config.json` (`src/knock_actions.cpp`). **Knock actions** in Settings sets every kind the same. By default, knocking twice turns passthrough on or off, and knocking thrice switches its style. The other actions show or hide SteamVR's performance graph, or do nothing. Both controllers buzz once after knocking twice, and two times after knocking thrice. Three buzzes mean something failed.
Knocks do nothing while KnockThru is paused, or in a game where you turned knocks off.
## Switching passthrough
KnockThru turns passthrough (Room View) on and off by pressing the Room View button on the SteamVR dashboard bar, just as you would.
The Steam client draws the dashboard bar as a web page, and it has a browser debug port (CEF Remote Debugging, port 8080 on the Frame). KnockThru connects to that port, finds the Room View button on the bar page, and clicks it (`src/room_view.cpp`). Then it reads the button's state back to check that passthrough really changed. This is why CEF Remote Debugging must be on in the Frame's Developer settings. The approach comes from [toorux/steam-frame-passthrough-toggle](https://github.com/toorux/steam-frame-passthrough-toggle).
The style is simpler. Translucent and opaque are the SteamVR setting `camera.roomViewStyle`, 3 for translucent and 4 for opaque (`src/style.cpp`). KnockThru writes the setting and checks that SteamVR kept it. The values come from [KominoVR/frame-passthrough-shortcuts](https://github.com/KominoVR/frame-passthrough-shortcuts).
The performance graph works the same way. It is the SteamVR setting `perfcheck.perfGraphInHMD` (`src/perf_overlay.cpp`), which Steam's Quick Access menu calls **Show Perf Overlay in VR**. It shows over games streamed from a PC too.
If a Steam update moves the Room View button, KnockThru can't find it any more. The dashboard tab then says "A Steam update moved the Room View button. Update KnockThru in Settings."
## The dashboard tab
KnockThru draws its own dashboard tab (`src/panel.cpp`). It draws text and shapes into an image in memory (`src/canvas.cpp`) and hands that image to SteamVR as a texture (`src/overlay_texture.cpp`). The tab holds the switches, Settings, the calibration walkthrough, and the first-run tour.
## Two copies of one program
The install folder has `knockthru` and `knockthru-ctl`, and they're the same file. SteamVR tells apps apart by the path of their program. The resident app that SteamVR starts is `knockthru`. Commands like `--uninstall` run through `knockthru-ctl`, so SteamVR never mistakes a command for a second copy of the app.
## Settings and files
| File | What it holds |
| --- | --- |
| `<install folder>/config.json` | The defaults that ship with each release |
| `~/.config/knockthru/config.json` | Your settings. The dashboard tab writes here too |
| `~/.config/knockthru/calibration.json` | Your calibrated thresholds, one entry per kind of knock |
| `~/.config/knockthru/state.json` | What KnockThru remembers between runs, like the last update check |
| `~/.config/knockthru/application.log` | The log |
KnockThru checks for changed settings every 2 s and reloads them. Your files live outside the install folder, so updates never touch them. The [main README](../README.md#for-tinkerers) lists every setting.
## Updates
Once a day, KnockThru asks GitHub for the latest release's manifest (`src/update.cpp`). The manifest lists the release ZIP and its SHA-256 checksum. To install an update, KnockThru:
1. Downloads the ZIP over HTTPS.
2. Checks the ZIP's SHA-256 against the manifest, and stops if they differ.
3. Unpacks it and checks that `knockthru-ctl` is an identical copy of `knockthru`.
4. Copies each file in beside the old one under a temporary name, then renames it into place. A failed copy leaves the old version working.
5. Restarts into the new version.
## Bug reports
**Report** in Settings writes a report with versions, SteamVR state, settings, and the end of the log (`src/report.cpp`). To get the report to your phone, it shows a QR code. The code links to a small web page the headset serves on your Wi-Fi (`src/report_server.cpp`). The link carries a random token, and the server stops after 15 minutes. On your phone, the page copies the report and opens a prefilled GitHub issue.
If knocks are the problem, **Record knocks** adds 20 s of motion to the report. [The test recordings](test-recordings.md) shows how to replay it.
+102
View File
@@ -0,0 +1,102 @@
# How knock detection works
KnockThru watches how your controllers and headset move, and looks for the short, sharp stop that a knock makes. This page explains what it looks for and why. The code is in `src/knock_detector.cpp`, and the thresholds are in `DetectorConfig` in `src/knock_detector.hpp`.
## What KnockThru can see
KnockThru can't read the controllers' accelerometers. The Frame has no `/dev/hidraw` devices that would expose them. What SteamVR does give every app is each device's position and velocity.
KnockThru reads those about once a millisecond. The headset reports a new velocity 1000 times a second, and each controller about 230 times a second.
A knock shows up as a sudden jump in velocity. KnockThru divides the change between two updates by the time between them. That gives an acceleration in m/s², and these pages call it the **spike**.
## Why a big spike isn't enough
The obvious rule is "a hard spike is a knock". It doesn't work. In the `play` recording, ordinary movement spiked at 696 m/s². The hardest knock in any of the knock recordings spiked at 552.
What separates a knock from a swing is what happens right after. When you knock, the controller stops dead. When you swing, it keeps going. In the first recordings, the controller moved at a median of 0.1 to 0.2 m/s 30 ms after a knock, and at 1.6 to 1.7 m/s 30 ms after a swing spike.
So a spike counts as a knock only when all of these hold:
1. It's the biggest spike within 3 updates either side.
2. It's stronger than the threshold for its kind of knock (see below).
3. 30 ms later, the controller moves slower than 0.4 m/s. Surface knocks get 0.55 m/s, because a hard knock on a table bounces back.
4. 30 ms earlier, the controller moved slower than 1.8 m/s. Surface knocks get 2.2 m/s, because knocks on a leg come in fast.
KnockThru judges each spike about 50 ms after it happens, so the "30 ms later" part has already arrived.
## Three kinds of knock
![Three ways to knock: knock the controllers together, knock one controller on a table, or knock on the headset](../images/knock-kinds.gif)
KnockThru works out the kind by checking what the other devices did at the same moment:
- **Headset:** the headset spiked above 25 m/s² within 30 ms of the controller.
- **Together:** the other controller spiked above 20 m/s² within 40 ms, and the two controllers were less than 15 cm apart.
- **Surface:** anything else, like a desk, a table, or your leg.
A together or headset knock has a second device backing it up. So the controller's spike only needs to reach 40 m/s². A surface knock has no witness, so it needs 60 m/s².
Head shaking moved the headset at up to 22 m/s², under the 25 needed for a headset knock. A knock on the headset moved it at 40 to 215 m/s².
## From knocks to a double or a triple
One knock does nothing. KnockThru groups knocks into a set, and acts on sets of 2 or 3.
- **Bounces merge.** Spikes less than 80 ms apart count as one knock.
- **Knocks in a set need a small gap.** A surface knock less than 150 ms after the last one counts as a bounce and gets ignored, because a table bounce can land 120 ms after a real knock. Together and headset knocks only need 105 ms, so fast doubles still count.
- **A set ends after 350 ms of quiet.** Then 2 knocks are a double, which turns passthrough on or off. 3 knocks are a triple, which switches the style. 4 or more do nothing, so steady clapping never counts.
- **A set must match.** Every knock in it must be the same kind, from the same controller. Surface knocks must also land within 6 cm of each other.
- **A set must start from stillness.** It has to begin at least 0.8 s after the last set ended.
The last two rules are about clapping runs. Without them, two weak claps right after a clapping run looked like a double. With them, a set that mixes anything is thrown out whole, and the next set has to wait.
The 350 ms wait plus the 50 ms judging delay is why passthrough switches about 0.4 s after your last knock.
## How the rules were found
After the first prototype, each new rule came from a recording that the older rules got wrong.
### The first prototype
The first five recordings were 45 s each, made on 2026-10-14: knocks of each kind, normal play, and clapping and waving (`noise`). A Python prototype with the spike, the stop, and the 80 ms merge found 18 to 20 doubles in each knock file and none in `play` or `noise`.
The C++ version found one false double at 18.9 s in `noise`. Two weak claps, 500 ms after a 3 s clapping run, formed a pair. The "a set must match" and "start from stillness" rules fixed it.
### Live sessions
Running the detector live with `knockthru --detect` turned up three problems:
- A 1 s cooldown after each action swallowed the first knock of the next set. It's gone. The 0.8 s stillness rule already keeps sets apart.
- Weak third knocks peaked at 42 to 48 m/s², under the 60 that every knock needed then. Together and headset knocks now need only 40.
- Real gaps between knocks ran 170 to 290 ms. A 450 ms window let two weak spikes 357 ms apart in `play` form a false double. The window is now 350 ms.
### Fast doubles
The `fast` recording holds quick together doubles, 89 to 168 ms apart. The 150 ms gap dropped half of them. Rebounds landed 83 to 99 ms after a real knock, so together and headset knocks got a 105 ms gap instead.
### Leg knocks
Knocks on a leg come in faster than knocks on a desk. Most right-hand leg knocks moved faster than 1.8 m/s just before the spike, so the detector took them for arm swings. Surface knocks got their own limit of 2.2 m/s. Together and headset knocks kept 1.8 m/s, because at 2.2 a together calibration counted a knock in the `noise` recording.
Two knocks on a leg also land further apart than two on a desk. In a run of about 40 leg doubles, 7 pairs landed 3.5 to 8.3 cm apart, and the old 3 cm limit dropped them. At 6 cm, 25 doubles count instead of 20.
### Strap fitting
Fitting the controller straps in Into the Radius 2 bumped the controllers together 19 times in 5 s. The default together threshold of 40 m/s² ignores them. A calibration that set it to 20 did not, and the first two bumps turned passthrough on. Calibration now never sets the together threshold below 30. [How calibration works](calibration.md) has the details.
## Changing the thresholds
Every threshold on this page can be overridden in `~/.config/knockthru/config.json`, under `detector`. The names match `DetectorConfig`. For example, this makes surface knocks need a harder spike:
```json
{
"detector": { "controller_peak": 80 }
}
```
Before you change a default in the code, run the tests. [The test recordings](test-recordings.md) explains what they check and how to try a value against every recording.
## Background
Accelerometer chips with built-in tap detection use the same ideas: a threshold, a short pulse, a quiet time, and a window for the second tap. [ST design tip DT0101](https://www.st.com/resource/en/design_tip/dt0101-setting-up-singletap-and-doubletap-recognition-with-sts-mems-accelerometers-stmicroelectronics.pdf) and [NXP AN3919](https://www.nxp.com/docs/en/application-note/AN3919.pdf) describe them.
+92
View File
@@ -0,0 +1,92 @@
# The test recordings
`tests/data/` holds 10 recordings of real motion from a Steam Frame. Each one is every headset and controller pose SteamVR reported, about once a millisecond. The detector tests replay them on every change, so a change that misses real knocks, or finds knocks that aren't there, fails the build.
## What's in each recording
Knocks on controllers, desks, and the headset:
| Recording | Length | What happens |
| --- | --- | --- |
| `together` | 45 s | Controllers knocked together in pairs, about 20 pairs |
| `surface` | 45 s | One controller knocked on a table in pairs, about 20 pairs |
| `headset` | 45 s | A controller knocked on the headset in pairs, about 20 pairs |
| `fast` | 30 s | Quick together doubles, 89 to 168 ms between knocks |
Knocks on a leg:
| Recording | Length | What happens |
| --- | --- | --- |
| `leg` | 70 s | Doubles and triples knocked on a leg with each hand, headset worn |
| `leg-doubles` | 106 s | About 40 doubles on a leg, a few seconds apart, mostly with the right hand |
Everyday movement, where nothing should happen:
| Recording | Length | What happens |
| --- | --- | --- |
| `play` | 45 s | Normal play |
| `noise` | 45 s | Clapping runs and waving, made to fool the detector |
| `straps` | 21 s | Fitting the controller straps in Into the Radius 2, with 19 bumps in 5 s |
A known routine:
| Recording | Length | What happens |
| --- | --- | --- |
| `mixed` | 60 s | 4 doubles of each kind, then 4 triples of each kind, 24 sets in all |
The `straps` clip comes from a 40 minute session. The other 18.7 minutes of play in it gave no actions.
## What the tests check
`tests/detector_test.cpp` replays each recording at the default thresholds. Exact knock counts are unknown for most recordings, so each test sets a floor a little under what the detector finds:
| Recording | The test needs | Found today |
| --- | --- | --- |
| `together`, `surface`, `headset` | At least 18, 16, and 15 doubles of that kind | 20, 18, and 22 |
| `fast` | At least 12 together doubles, nothing else | 13 |
| `leg`, `leg-doubles` | At least 14 and 23 surface actions, nothing else | 18 and 25 |
| `play`, `noise`, `straps` | No actions | None |
| `mixed` | At least 20 of 24 right, none wrong | 23 right, 0 wrong |
A wrong kind still switches passthrough, so the knock files allow a few. `surface` gives 1 together double, and `headset` gives 2 surface doubles.
The same file also tests calibration on the recordings. [How calibration works](calibration.md) has those results.
## Running the tests
On any Linux machine with CMake and a C++17 compiler:
```sh
cmake -S . -B build
cmake --build build
ctest --test-dir build
```
To try a threshold against every recording without changing code, set it through `KNOCKTHRU_TEST_SET`. Add `KNOCKTHRU_TEST_VERBOSE=1` to print every knock and action:
```sh
KNOCKTHRU_TEST_SET="confirmed_peak=30" build/bin/detector_test
```
## Replaying a bug report
When knocks go wrong, the bug report flow has a **Record knocks** button. It records 20 s of motion while the person knocks the way that fails, and adds it to `knockthru-report.zip` as `knockthru-knocks.csv.gz`. Unzip the report and replay the recording like this:
```sh
build/bin/detector_test knockthru-knocks.csv.gz
```
The report includes the reporter's `calibration.json`. To replay with their thresholds, pass them through `KNOCKTHRU_TEST_SET`. Surface knocks use `controller_peak` and `min_approach_surface`, together knocks `confirmed_peak` and `min_approach_together`, and headset knocks `headset_knock_peak` and `min_approach_headset`.
## Making a new recording
On the Frame, in Konsole or over SSH:
```sh
<install folder>/knockthru --record my-knocks.csv --seconds 45
gzip my-knocks.csv
```
`knockthru --detect` prints knocks live without acting on them. Add `--save FILE` to keep the poses from that session too.
Each row of a recording is one device at one moment: the time in seconds, the device (`hmd`, `left`, or `right`), whether the pose was valid, then position, velocity, and angular velocity, each as x, y, and z.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 KiB

After

Width:  |  Height:  |  Size: 996 KiB

+3 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
build="${1:-$root/build-arm64}"
version="${KNOCKTHRU_VERSION:-1.0.2}"
version="${KNOCKTHRU_VERSION:-1.1.0}"
# KNOCKTHRU_TEST_HOOK=1 builds in the test hook from src/app.cpp. Releases never set it.
test_hook="${KNOCKTHRU_TEST_HOOK:-}"
mkdir -p "$build/bin"
@@ -20,7 +20,9 @@ sources=(
src/diagnostics.cpp
src/install.cpp
src/knock_detector.cpp
src/knock_actions.cpp
src/room_view.cpp
src/perf_overlay.cpp
src/style.cpp
src/sound.cpp
src/config.cpp
+68 -23
View File
@@ -8,6 +8,7 @@
#include "knock_detector.hpp"
#include "overlay_texture.hpp"
#include "panel.hpp"
#include "perf_overlay.hpp"
#include "poses.hpp"
#include "report.hpp"
#include "report_server.hpp"
@@ -197,9 +198,10 @@ std::string heard_summary(const Calibrator& calibrator) {
return line;
}
// The resident app. SteamVR starts it as a dashboard overlay. A double knock
// turns Room View on or off, a triple knock switches translucent and opaque,
// and the KnockThru dashboard tab has buttons for everything.
// The resident app. SteamVR starts it as a dashboard overlay. Each knock kind
// and count runs its action from knock_actions. By default, knocking twice turns
// Room View on or off, and knocking thrice switches translucent and opaque. The
// KnockThru dashboard tab has buttons for everything.
class App {
public:
App();
@@ -240,6 +242,8 @@ private:
void read_opaque();
bool toggle_room_view(const std::string& why);
bool toggle_style(const std::string& why);
bool toggle_perf_overlay(const std::string& why);
void run_action(KnockAction action, const KnockEvent& event, const std::string& why, double t);
bool save(const std::function<void()>& write);
void start_check();
void restart_failed(const std::string& version);
@@ -370,7 +374,12 @@ void App::load_haptics() {
}
void App::run() {
log("Ready. Double knock: Room View on or off. Triple knock: translucent or opaque.");
std::string actions;
for (const int count : kKnockCounts) {
const auto shared = config_.knock_actions.shared(count);
actions += std::string(" Knocking ") + count_word(count) + ": " + (shared ? to_string(*shared) : "different for each kind") + ".";
}
log("Ready." + actions);
while (!stopping) {
const double t = clock_.now();
vr::VREvent_t event{};
@@ -487,6 +496,13 @@ void App::handle_click(const PanelClick& click, double t) {
case PanelCommand::Sounds:
if (save([&] { save_sounds(!config_.sounds); })) show(config_.sounds ? "Sounds on" : "Sounds off");
break;
case PanelCommand::SetKnockAction: {
auto actions = config_.knock_actions;
actions.set_all(click.count, click.action);
if (save([&] { save_knock_actions(actions); }))
show(std::string("Knocking ") + count_word(click.count) + ": " + to_string(click.action));
break;
}
case PanelCommand::StartTour: start_tour(); break;
case PanelCommand::TourNext:
if (tour_ && tour_->step != Step::Done) tour_->step = static_cast<Step>(static_cast<int>(tour_->step) + 1);
@@ -593,14 +609,6 @@ void App::handle_click(const PanelClick& click, double t) {
log(std::string("Bug report not saved: ") + error.what());
}
break;
case PanelCommand::BarIcon:
if (save([&] { save_classic_bar_icon(!config_.classic_bar_icon); })) {
show(config_.classic_bar_icon ? "Classic bar icon on" : "Classic bar icon off");
// The icon image changes this frame. Restyle the bar now, not at the
// next 2 s check, so the old tint never shows over the new icon.
next_slow_check_ = 0;
}
break;
case PanelCommand::Kind: {
auto enabled = config_.detector.enabled;
enabled[kind] = !enabled[kind];
@@ -739,15 +747,10 @@ void App::handle_knocks(double t, const std::deque<Knock>& knocks, const std::op
log("Ignored" + what + ": paused");
} else if (config_.blocked_apps.count(state_.app_key) && !practicing) {
log("Ignored" + what + ": knocks are off in " + state_.app_key);
} else if (event->count == 3) {
const bool changed = toggle_style(what);
if (changed) play_click();
haptics_.buzz(changed ? 2 : 3, t);
if (changed && tour_ && tour_->step == Step::Triple) tour_->triple_t = t;
} else if (toggle_room_view(what)) {
// Buzz now, because the switch can take a second. A failure buzzes 3 times later.
haptics_.buzz(1, t);
if (tour_ && tour_->step == Step::Double) tour_->double_t = t;
} else {
// The tour teaches the default actions, whatever Knock actions says.
run_action(practicing ? KnockActions::default_for(event->count) : config_.knock_actions.get(event->kind, event->count),
*event, what, t);
}
}
@@ -866,8 +869,8 @@ void App::draw_panel(double t) {
if (!panel_) return;
state_.busy = room_view_job_.valid() && room_view_job_toggles_;
state_.paused = config_.paused;
state_.classic_bar_icon = config_.classic_bar_icon;
state_.sounds = config_.sounds;
state_.knock_actions = config_.knock_actions;
state_.tour = tour_;
panel_->set_bar_icon(config_.classic_bar_icon);
state_.enabled = config_.detector.enabled;
@@ -907,7 +910,7 @@ void App::run_test_command(const std::string& line, double t, std::deque<Knock>&
std::optional<KnockEvent>& event) {
static const std::map<std::string, PanelCommand> commands{{"RoomView", PanelCommand::RoomView},
{"Style", PanelCommand::Style}, {"Pause", PanelCommand::Pause}, {"Kind", PanelCommand::Kind},
{"BlockApp", PanelCommand::BlockApp}, {"BarIcon", PanelCommand::BarIcon}, {"Calibrate", PanelCommand::Calibrate},
{"BlockApp", PanelCommand::BlockApp}, {"Calibrate", PanelCommand::Calibrate},
{"CancelCalibration", PanelCommand::CancelCalibration}, {"SaveCalibration", PanelCommand::SaveCalibration},
{"Sounds", PanelCommand::Sounds}, {"StartTour", PanelCommand::StartTour}, {"TourNext", PanelCommand::TourNext},
{"TourBack", PanelCommand::TourBack}, {"TourSkip", PanelCommand::TourSkip},
@@ -1046,6 +1049,48 @@ bool App::toggle_room_view(const std::string& why) {
return true;
}
// Runs a knock action. Success buzzes once after knocking twice, and two times after knocking thrice.
// A failure buzzes 3 times.
void App::run_action(KnockAction action, const KnockEvent& event, const std::string& why, double t) {
const int buzzes = event.count - 1;
const auto tick_tour = [&] {
if (!tour_) return;
if (tour_->step == Step::Double && event.count == 2) tour_->double_t = t;
if (tour_->step == Step::Triple && event.count == 3) tour_->triple_t = t;
};
switch (action) {
case KnockAction::Passthrough:
if (toggle_room_view(why)) {
// Buzz now, because the switch can take a second. A failure buzzes 3 times later.
haptics_.buzz(buzzes, t);
tick_tour();
}
return;
case KnockAction::Style:
case KnockAction::PerfGraph: {
// Steam clicks when passthrough switches, so KnockThru clicks only for these.
const bool changed = action == KnockAction::Style ? toggle_style(why) : toggle_perf_overlay(why);
if (changed) play_click();
haptics_.buzz(changed ? buzzes : 3, t);
if (changed) tick_tour();
return;
}
case KnockAction::None:
log("Ignored" + why + ": Knock actions says do nothing");
return;
}
}
bool App::toggle_perf_overlay(const std::string& why) {
try {
show(std::string("Performance graph ") + (knockthru::toggle_perf_overlay() ? "on" : "off") + why);
return true;
} catch (const std::exception& error) {
show(std::string("Performance graph not changed: ") + error.what(), true);
return false;
}
}
bool App::toggle_style(const std::string& why) {
try {
const auto style = knockthru::toggle_style();
+37 -3
View File
@@ -64,6 +64,26 @@ void merge(AppConfig& config, const json& file, const fs::path& path) {
} else if (const auto flag = flags.find(key); flag != flags.end()) {
if (!value.is_boolean()) fail(key + " must be true or false");
*flag->second = value.get<bool>();
} else if (key == "knock_actions") {
// {"surface": {"2": "passthrough", "3": "perf_graph"}}. Missing kinds and counts keep their default.
const auto bad = [&] {
fail("knock_actions maps together, surface, or headset to counts 2 and 3, each passthrough, style, "
"perf_graph, or none");
};
if (!value.is_object()) bad();
config.knock_actions = {};
for (const auto& [kind_name, counts] : value.items()) {
const auto kind = parse_knock_kind(kind_name);
if (!kind || !counts.is_object()) bad();
for (const auto& [count_name, action_name] : counts.items()) {
const auto action = action_name.is_string() ? parse_knock_action(action_name.get<std::string>())
: std::nullopt;
const bool known = std::find(kKnockCounts.begin(), kKnockCounts.end(),
std::atoi(count_name.c_str())) != kKnockCounts.end();
if (!action || !known || count_name != std::to_string(std::atoi(count_name.c_str()))) bad();
config.knock_actions.set(*kind, std::atoi(count_name.c_str()), *action);
}
}
} else if (key == "debug_port") {
if (!value.is_number_integer()) fail("debug_port must be a whole number");
config.debug_port = value.get<int>();
@@ -110,10 +130,12 @@ AppConfig load_config(const fs::path& install_directory) {
namespace {
// A null `value` removes the key.
void update_user_config(const std::string& key, const json& value) {
const auto path = user_directory() / "config.json";
auto file = read_json(path);
file[key] = value;
if (value.is_null()) file.erase(key);
else file[key] = value;
AppConfig check;
merge(check, file, path); // never write a file that load_config would reject
write_json(path, file);
@@ -123,8 +145,6 @@ void update_user_config(const std::string& key, const json& value) {
void save_paused(bool paused) { update_user_config("paused", paused); }
void save_classic_bar_icon(bool classic) { update_user_config("classic_bar_icon", classic); }
void save_knock_types(const std::array<bool, 3>& enabled) {
json kinds = json::array();
for (int k = 0; k < 3; ++k)
@@ -136,6 +156,20 @@ void save_blocked_apps(const std::set<std::string>& apps) { update_user_config("
void save_sounds(bool sounds) { update_user_config("sounds", sounds); }
void save_knock_actions(const KnockActions& actions) {
if (actions.is_default()) {
update_user_config("knock_actions", nullptr);
return;
}
json table = json::object();
for (int k = 0; k < 3; ++k) {
const auto kind = static_cast<KnockKind>(k);
for (const int count : kKnockCounts)
table[to_string(kind)][std::to_string(count)] = to_string(actions.get(kind, count));
}
update_user_config("knock_actions", table);
}
void save_tour_done(bool done) { update_user_config("tour_done", done); }
void reset_user_settings() {
+4 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "knock_actions.hpp"
#include "knock_detector.hpp"
#include <array>
@@ -20,6 +21,7 @@ struct AppConfig {
bool sounds = true; // SteamVR's click on a triple knock and on panel buttons
bool tour_done = false; // the first-run tour finished or skipped
bool check_updates = true; // look for a new release on GitHub once a day
KnockActions knock_actions; // what each knock kind does twice and thrice
};
// What KnockThru remembers between runs that is not a setting, in
@@ -60,10 +62,11 @@ AppConfig load_config(const std::filesystem::path& install_directory);
// Changes settings in the user config.json and keeps every other key. The
// dashboard panel uses these.
void save_paused(bool paused);
void save_classic_bar_icon(bool classic);
void save_knock_types(const std::array<bool, 3>& enabled);
void save_blocked_apps(const std::set<std::string>& apps);
void save_sounds(bool sounds);
// Writes the table, or removes the key when every action is the default.
void save_knock_actions(const KnockActions& actions);
void save_tour_done(bool done);
// Factory reset: deletes the user config.json and calibration.json. Keeps
+65
View File
@@ -0,0 +1,65 @@
#include "knock_actions.hpp"
namespace knockthru {
namespace {
int count_index(int count) {
for (std::size_t i = 0; i < kKnockCounts.size(); ++i)
if (kKnockCounts[i] == count) return static_cast<int>(i);
return -1;
}
} // namespace
const char* to_string(KnockAction action) {
switch (action) {
case KnockAction::Passthrough: return "passthrough";
case KnockAction::Style: return "style";
case KnockAction::PerfGraph: return "perf_graph";
case KnockAction::None: return "none";
}
return "none";
}
std::optional<KnockAction> parse_knock_action(const std::string& name) {
for (const auto action : kKnockActionList)
if (name == to_string(action)) return action;
return std::nullopt;
}
const char* count_word(int count) { return count == 2 ? "twice" : count == 3 ? "thrice" : "more"; }
KnockAction KnockActions::default_for(int count) {
return count == 2 ? KnockAction::Passthrough : count == 3 ? KnockAction::Style : KnockAction::None;
}
KnockActions::Row KnockActions::defaults() {
Row row{};
for (std::size_t i = 0; i < kKnockCounts.size(); ++i) row[i] = default_for(kKnockCounts[i]);
return row;
}
KnockAction KnockActions::get(KnockKind kind, int count) const {
const int i = count_index(count);
return i < 0 ? KnockAction::None : table_[static_cast<int>(kind)][i];
}
void KnockActions::set(KnockKind kind, int count, KnockAction action) {
const int i = count_index(count);
if (i >= 0) table_[static_cast<int>(kind)][i] = action;
}
void KnockActions::set_all(int count, KnockAction action) {
for (int k = 0; k < 3; ++k) set(static_cast<KnockKind>(k), count, action);
}
std::optional<KnockAction> KnockActions::shared(int count) const {
const auto first = get(KnockKind::Together, count);
for (const auto kind : {KnockKind::Surface, KnockKind::Headset})
if (get(kind, count) != first) return std::nullopt;
return first;
}
bool KnockActions::is_default() const { return *this == KnockActions{}; }
} // namespace knockthru
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include "knock_detector.hpp"
#include <array>
#include <optional>
#include <string>
namespace knockthru {
// What a set of knocks does.
enum class KnockAction { Passthrough, Style, PerfGraph, None };
constexpr std::array<KnockAction, 4> kKnockActionList{KnockAction::Passthrough, KnockAction::Style,
KnockAction::PerfGraph, KnockAction::None};
// The knock counts a person can give an action to. A new count needs the
// detector to report it, a word in count_word(), and a default here.
constexpr std::array<int, 2> kKnockCounts{2, 3};
// The name in config.json: passthrough, style, perf_graph, or none.
const char* to_string(KnockAction action);
std::optional<KnockAction> parse_knock_action(const std::string& name);
// "twice" or "thrice", for the panel.
const char* count_word(int count);
// The action for each knock kind and count. Defaults: twice switches
// passthrough, thrice switches the style.
class KnockActions {
public:
static KnockAction default_for(int count);
// A count outside kKnockCounts gives None.
KnockAction get(KnockKind kind, int count) const;
void set(KnockKind kind, int count, KnockAction action);
void set_all(int count, KnockAction action);
// The action every kind shares for `count`, or nothing when they differ.
std::optional<KnockAction> shared(int count) const;
bool is_default() const;
bool operator==(const KnockActions& other) const { return table_ == other.table_; }
private:
using Row = std::array<KnockAction, kKnockCounts.size()>;
std::array<Row, 3> table_ = {defaults(), defaults(), defaults()}; // indexed by KnockKind
static Row defaults();
};
} // namespace knockthru
+1
View File
@@ -16,6 +16,7 @@ const char* to_string(KnockKind kind);
std::optional<KnockKind> parse_knock_kind(const std::string& name);
// Thresholds come from the Steam Frame recordings in tests/data/.
// docs/knock-detection.md explains each one.
struct DetectorConfig {
double controller_peak = 60; // m/s^2, spike on the knocking controller
double confirmed_peak = 40; // m/s^2, controller spike for a together knock, which the other controller confirms
+72 -8
View File
@@ -41,7 +41,7 @@ constexpr float kRadius = 16;
constexpr float kMargin = 40;
constexpr const char* kKindTitles[3] = {"Controllers together", "Controller on a surface", "Knock on the headset"};
constexpr const char* kKindOnce[3] = {"Knock the controllers together.", "Knock one controller on a table or wall.",
constexpr const char* kKindOnce[3] = {"Knock the controllers together.", "Knock one controller on a surface, like your leg.",
"Knock on the side of the headset with a controller."};
constexpr const char* kKindNames[3] = {"a together knock", "a surface knock", "a headset knock"};
@@ -490,11 +490,10 @@ void Panel::settings_screen(const PanelState& state) {
canvas_.rounded_rect({kMargin, top, w, 6 * row}, kRadius, kSurface);
button_row({kMargin, top, w, row}, "Knocks getting missed?", "KnockThru learns how hard you knock", "Calibrate",
std::nullopt, Screen::Pick, false, false);
switch_row({kMargin, top + row, w, row}, PanelClick{PanelCommand::Sounds}, state.sounds, "Sounds",
"Click on a triple knock and on buttons", true, true);
switch_row({kMargin, top + 2 * row, w, row}, PanelClick{PanelCommand::BarIcon}, state.classic_bar_icon,
"Classic bar icon", "Show the logo colors in the dashboard bar", true, true);
canvas_.draw(icon(64, state.classic_bar_icon ? IconStyle::BarColor : IconStyle::BarWhite), static_cast<int>(kMargin + w - 24 - 80 - 40 - 64), static_cast<int>(top + 2 * row + (row - 64) / 2));
button_row({kMargin, top + row, w, row}, "Knock actions", "What knocking twice or thrice does", "Change",
std::nullopt, Screen::Actions, false);
switch_row({kMargin, top + 2 * row, w, row}, PanelClick{PanelCommand::Sounds}, state.sounds, "Sounds",
"Click on knocks and on buttons", true, true);
button_row({kMargin, top + 3 * row, w, row}, "Guided tour", "Learn the knocks again", "Replay",
PanelClick{PanelCommand::StartTour}, std::nullopt, false);
button_row({kMargin, top + 4 * row, w, row}, "Something wrong?", "Tell the developer on GitHub", "Report",
@@ -504,6 +503,68 @@ void Panel::settings_screen(const PanelState& state) {
if (state.message_is_error) message(state, top + 6 * row + 40);
}
// Knock twice and Knock thrice side by side, each a list of actions where one press picks.
void Panel::actions_screen(const PanelState& state) {
button({kMargin, 36, 140, 64}, "Back", std::nullopt, Screen::Settings);
canvas_.text(bold_, "Knock actions", kMargin + 172, 68, 38, kText);
const float gap = 20, w = (kWidth - 2 * kMargin - gap) / kKnockCounts.size();
bool differ = false;
for (std::size_t i = 0; i < kKnockCounts.size(); ++i) {
action_list({kMargin + i * (w + gap), 128, w, 0}, state, kKnockCounts[i]);
differ = differ || !state.knock_actions.shared(kKnockCounts[i]);
}
// config.json can give each kind its own actions. The lists then pick nothing, and a press sets every kind.
const std::string note = differ ? "Your config.json gives knock kinds different actions. A choice here sets them all."
: "Every kind of knock gets these actions. To set kinds apart, edit config.json.";
if (state.message_is_error) message(state, 600);
else canvas_.text(regular_, note, kMargin, 620, 26, kDim);
}
namespace {
// The names the action lists show.
const char* action_name(KnockAction action) {
switch (action) {
case KnockAction::Passthrough: return "Show or hide the room";
case KnockAction::Style: return "Translucent or opaque";
case KnockAction::PerfGraph: return "Performance graph";
case KnockAction::None: return "Do nothing";
}
return "";
}
} // namespace
// A card titled "Knock twice" or "Knock thrice" with one row per action. The
// current action is filled, with a check mark, and the default says so. `rect`
// gives the position and width.
void Panel::action_list(Rect rect, const PanelState& state, int count) {
const float title_h = 66, row = 84;
canvas_.rounded_rect({rect.x, rect.y, rect.w, title_h + kKnockActionList.size() * row + 8}, kRadius, kSurface);
canvas_.text(bold_, std::string("Knock ") + count_word(count), rect.x + 24, rect.y + 38, 30, kText);
const auto current = state.knock_actions.shared(count);
for (std::size_t i = 0; i < kKnockActionList.size(); ++i) {
const auto action = kKnockActionList[i];
const Rect option{rect.x + 8, rect.y + title_h + i * row, rect.w - 16, row - 8};
const bool selected = current == action;
if (selected) canvas_.rounded_rect(option, kRadius, kActive);
else if (hovered(option)) canvas_.rounded_rect(option, kRadius, kSurfaceHover);
const float cy = option.y + option.h / 2;
const auto name = action_name(action);
canvas_.text(bold_, name, option.x + 18, cy, 27, kText);
if (action == KnockActions::default_for(count))
canvas_.text(regular_, "(default)", option.x + 18 + canvas_.measure(bold_, name, 27) + 10, cy, 22,
selected ? kText : kDim);
if (selected) check_mark(option.x + option.w - 18 - 20, cy, 20, 1);
else {
PanelClick click{PanelCommand::SetKnockAction};
click.count = count;
click.action = action;
targets_.push_back({option, click});
}
}
}
// The version and the update button, at the top right of Settings.
void Panel::update_control(const PanelState& state) {
using Status = UpdateView::Status;
@@ -740,7 +801,7 @@ void Panel::tour_knock_step(const PanelState& state, const TourView& tour) {
y = paragraph(bold_, title, x, y, 36, 48, w, 2, kText);
y += 4;
// Lists only the kinds left on, since the others do nothing.
static constexpr const char* kWays[3] = {"controllers together", "a controller on a table", "a controller on the headset"};
static constexpr const char* kWays[3] = {"controllers together", "a controller on a surface like your leg", "a controller on the headset"};
std::vector<std::string> ways;
for (int k = 0; k < 3; ++k)
if (state.enabled[k]) ways.push_back(kWays[k]);
@@ -868,7 +929,8 @@ const Canvas& Panel::render(const PanelState& state, std::optional<std::pair<flo
const bool tour_on_screen = state.tour && screen_ != Screen::Learn;
View view = screen_ == Screen::Main ? View::Main : screen_ == Screen::Settings ? View::Settings
: screen_ == Screen::Reset ? View::Reset : screen_ == Screen::Report ? View::Report : screen_ == Screen::Pick ? View::Pick : View::Learn;
: screen_ == Screen::Reset ? View::Reset : screen_ == Screen::Report ? View::Report : screen_ == Screen::Pick ? View::Pick
: screen_ == Screen::Actions ? View::Actions : View::Learn;
std::optional<TourView::Step> tour_step;
if (calibration) {
using Phase = CalibrationView::Phase;
@@ -896,6 +958,8 @@ const Canvas& Panel::render(const PanelState& state, std::optional<std::pair<flo
report_card(state);
} else if (screen_ == Screen::Pick) {
pick_screen(state);
} else if (screen_ == Screen::Actions) {
actions_screen(state);
} else {
learn_screen(state);
}
+13 -6
View File
@@ -1,6 +1,7 @@
#pragma once
#include "canvas.hpp"
#include "knock_actions.hpp"
#include "knock_detector.hpp"
#include <array>
@@ -64,9 +65,9 @@ struct PanelState {
std::optional<bool> opaque; // Room View style, unknown without SteamVR
bool busy = false; // a Room View change is in progress
bool paused = false;
bool classic_bar_icon = false; // the bar icon in the logo colors
bool sounds = true; // SteamVR's click on a triple knock and on panel buttons
bool sounds = true; // SteamVR's click on knock actions and on panel buttons
std::array<bool, 3> enabled{true, true, true}; // per KnockKind
KnockActions knock_actions;
std::string app_key, app_name; // the running game, empty when none
bool app_blocked = false;
std::optional<CalibrationView> calibration;
@@ -94,7 +95,7 @@ enum class IconStyle {
};
enum class PanelCommand {
RoomView, Style, Pause, Kind, BlockApp, BarIcon,
RoomView, Style, Pause, Kind, BlockApp,
Calibrate, // starts recording a kind, or records it again
CancelCalibration,
SaveCalibration,
@@ -110,15 +111,19 @@ enum class PanelCommand {
CloseReport,
SaveReport, // writes the full report to the home folder
RecordKnocks, // records 20 s of controller motion for the report
SetKnockAction, // gives every kind of knock an action for a knock count
};
struct PanelClick {
PanelCommand command;
KnockKind kind = KnockKind::Together; // for Kind and Calibrate
int count = 0; // for SetKnockAction
KnockAction action = KnockAction::None;
};
// The KnockThru dashboard panel, drawn into an RGBA image. It has a main
// screen, a settings screen with a factory reset confirm, a calibration
// screen, a settings screen with a factory reset confirm, the knock actions
// screen, a calibration
// walkthrough in four steps (pick a kind, learn the knock, record, and try
// it), and the first-run tour in six.
class Panel {
@@ -144,9 +149,9 @@ public:
static void draw_icon(Canvas& canvas, IconStyle style = IconStyle::Tile);
private:
enum class Screen { Main, Settings, Reset, Report, Pick, Learn, SkipTour };
enum class Screen { Main, Settings, Reset, Report, Pick, Learn, SkipTour, Actions };
// What is on screen, for transitions: a screen, a calibration phase, or the tour.
enum class View { Main, Settings, Reset, Report, Pick, Learn, Record, Try, Tour };
enum class View { Main, Settings, Reset, Report, Pick, Learn, Record, Try, Tour, Actions };
struct Target {
Rect rect;
std::optional<PanelClick> click = {};
@@ -189,6 +194,8 @@ private:
void check_mark(float cx, float cy, float radius, float pop);
void knock_scene(KnockKind kind, Rect box, float t, int knocks);
void settings_screen(const PanelState& state);
void actions_screen(const PanelState& state);
void action_list(Rect rect, const PanelState& state, int count);
void update_control(const PanelState& state);
void report_card(const PanelState& state);
void update_card(const UpdateNotice& notice);
+36
View File
@@ -0,0 +1,36 @@
#include "perf_overlay.hpp"
#include <openvr.h>
#include <stdexcept>
#include <string>
namespace knockthru {
namespace {
void check(vr::EVRSettingsError error) {
if (error != vr::VRSettingsError_None)
throw std::runtime_error(std::string("perfcheck.perfGraphInHMD: ") +
vr::VRSettings()->GetSettingsErrorNameFromEnum(error));
}
} // namespace
bool read_perf_overlay() {
vr::EVRSettingsError error = vr::VRSettingsError_None;
const bool shown = vr::VRSettings()->GetBool(vr::k_pch_Perf_Section, vr::k_pch_Perf_PerfGraphInHMD_Bool, &error);
check(error);
return shown;
}
bool toggle_perf_overlay() {
const bool target = !read_perf_overlay();
vr::EVRSettingsError error = vr::VRSettingsError_None;
vr::VRSettings()->SetBool(vr::k_pch_Perf_Section, vr::k_pch_Perf_PerfGraphInHMD_Bool, target, &error);
check(error);
if (read_perf_overlay() != target)
throw std::runtime_error("SteamVR did not keep perfcheck.perfGraphInHMD. The runtime may have rejected it.");
return target;
}
} // namespace knockthru
+15
View File
@@ -0,0 +1,15 @@
#pragma once
namespace knockthru {
// SteamVR's in-headset performance graph, stored in the SteamVR setting
// perfcheck.perfGraphInHMD. Steam's Quick Access menu calls it "Show Perf
// Overlay in VR".
// Needs an active OpenVR session.
bool read_perf_overlay();
// Shows or hides the graph, checks that SteamVR kept the value, and returns
// whether it shows now.
bool toggle_perf_overlay();
} // namespace knockthru
+44
View File
@@ -6,6 +6,7 @@
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <unistd.h>
@@ -13,6 +14,15 @@
using namespace knockthru;
namespace fs = std::filesystem;
namespace {
std::string read_text(const fs::path& path) {
std::ifstream stream(path);
return {std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>()};
}
} // namespace
int main() {
const auto root = fs::temp_directory_path() / ("knockthru-config-test-" + std::to_string(getpid()));
fs::remove_all(root);
@@ -62,6 +72,40 @@ int main() {
}
fs::remove(user / "config.json");
// Knock actions: defaults, a partial table from the file, a bad action, and saving.
const auto defaults = load_config(install).knock_actions;
check(defaults.is_default() && defaults.get(KnockKind::Headset, 2) == KnockAction::Passthrough &&
defaults.get(KnockKind::Headset, 3) == KnockAction::Style,
"twice switches passthrough and thrice switches the style by default");
std::ofstream(user / "config.json") << R"({"knock_actions": {"headset": {"3": "perf_graph"}, "surface": {"2": "none"}}})";
const auto loaded = load_config(install).knock_actions;
check(loaded.get(KnockKind::Headset, 3) == KnockAction::PerfGraph && loaded.get(KnockKind::Surface, 2) == KnockAction::None &&
loaded.get(KnockKind::Together, 3) == KnockAction::Style && !loaded.shared(3),
"knock_actions loads, and kinds and counts it leaves out keep their default");
for (const auto* bad : {R"({"knock_actions": {"headset": {"3": "fly"}}})", R"({"knock_actions": {"headset": {"4": "style"}}})",
R"({"knock_actions": {"elbow": {"2": "style"}}})"}) {
std::ofstream(user / "config.json", std::ios::trunc) << bad;
try {
load_config(install);
check(false, std::string("knock_actions rejects ") + bad);
} catch (const std::exception& error) {
check(std::string(error.what()).find("knock_actions maps") != std::string::npos,
std::string("knock_actions rejects ") + bad);
}
}
fs::remove(user / "config.json");
auto changed = KnockActions{};
changed.set_all(3, KnockAction::PerfGraph);
save_sounds(false);
save_knock_actions(changed);
check(load_config(install).knock_actions == changed && changed.shared(3) == KnockAction::PerfGraph,
"save_knock_actions loads back");
save_knock_actions(KnockActions{});
check(load_config(install).knock_actions.is_default() && !load_config(install).sounds &&
std::ifstream(user / "config.json") && read_text(user / "config.json").find("knock_actions") == std::string::npos,
"saving the defaults removes knock_actions and keeps other settings");
fs::remove(user / "config.json");
{
AppState state = load_state();
check(state.steamvr_version.empty() && state.last_update_check == 0, "state defaults without a file");
+19 -4
View File
@@ -405,6 +405,23 @@ int readme(const std::filesystem::path& out) {
return 0;
}
// Knock actions: the defaults, picking the performance graph for thrice, and a
// config.json that gives kinds different actions. The state changes as the app would.
void knock_actions(const std::filesystem::path& out, PanelState state) {
state.message.clear();
Panel panel(KNOCKTHRU_FONTS);
panel.render(state, std::nullopt, 60);
panel.click(460, 60); // Settings on the main screen
shot(panel, state, 61, out / "panel-settings-actions.png");
panel.click(1050, 266); // Change on the Knock actions row
shot(panel, state, 62, out / "actions-default.png", std::make_pair(885.0f, 400.0f));
panel.click(885, 400); // Performance graph under Knock thrice
state.knock_actions.set_all(3, KnockAction::PerfGraph);
shot(panel, state, 63, out / "actions-changed.png");
state.knock_actions.set(KnockKind::Headset, 2, KnockAction::None);
shot(panel, state, 64, out / "actions-kinds-differ.png");
}
int main(int argc, char** argv) {
if (argc > 2 && std::string(argv[1]) == "--readme") return readme(argv[2]);
const std::filesystem::path out = argc > 1 ? argv[1] : ".";
@@ -449,8 +466,6 @@ int main(int argc, char** argv) {
settings_panel.render(idle);
settings_panel.click(460, 60);
settings_panel.render(idle, std::make_pair(600.0f, 200.0f)).write_png(out / "panel-settings.png");
idle.classic_bar_icon = true;
settings_panel.render(idle).write_png(out / "panel-settings-color.png");
PanelState update = idle;
update.version = "0.1.0";
settings_panel.render(update).write_png(out / "panel-settings-update-idle.png");
@@ -508,11 +523,11 @@ int main(int argc, char** argv) {
"&steamvr=2.18.2&passthrough=on";
settings_panel.render(update).write_png(out / "panel-report-offline.png");
settings_panel.click(900, 638); // Done
settings_panel.click(1050, 632); // Reset on the factory reset row
settings_panel.click(1050, 634); // Reset on the factory reset row
settings_panel.render(idle, std::make_pair(800.0f, 500.0f)).write_png(out / "panel-reset-confirm.png");
idle.classic_bar_icon = false;
tour(out, idle);
knock_actions(out, idle);
Canvas icon(256, 256);
Panel::draw_icon(icon);
+269
View File
@@ -0,0 +1,269 @@
// Room View tests: RoomView talks to a fake Steam debug port, which runs each
// script in Node against a fake dashboard bar. Skips with exit code 77 when
// node is not on PATH.
#include "check.hpp"
#include "room_view.hpp"
#include <json.hpp>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <thread>
using namespace knockthru;
using json = nlohmann::json;
namespace fs = std::filesystem;
namespace {
// The fake bar. Buttons find their React action through __reactFiber, as on the Frame.
// The state file carries whether Room View is on between scripts.
constexpr const char* kPrelude = R"JS(
const fs = require('fs');
const statePath = process.argv[2];
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
const button = (action, onClick) => {
const element = {click: onClick};
element['__reactFiber$test'] = {memoizedProps: {}, return: {memoizedProps: {action}, return: null}};
return element;
};
const buttons = [button({invocation: 2, icon: {enum: 3}, icon_active: {enum: 1}, active: true}, () => {})];
if (state.present)
buttons.push(button({invocation: 2, icon: {enum: 2}, icon_active: {enum: 1}, active: state.on},
() => { state.on = !state.on; }));
globalThis.document = {querySelectorAll: (selector) => {
if (state.broken) throw new Error('querySelectorAll is gone');
return selector === '[role="button"]' ? buttons : [];
}};
let value;
try { value = (EXPRESSION); } finally { fs.writeFileSync(statePath, JSON.stringify(state)); }
process.stdout.write(JSON.stringify(value));
)JS";
std::string read(const fs::path& path) {
std::ifstream stream(path, std::ios::binary);
return {std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>()};
}
// A Steam client debug port on 127.0.0.1 with two targets, the bar and its popup.
class FakeDebugPort {
public:
explicit FakeDebugPort(fs::path directory) : directory_(std::move(directory)) {
set_state(false, true, false);
listener_ = ::socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
::bind(listener_, reinterpret_cast<sockaddr*>(&address), sizeof address);
::listen(listener_, 4);
socklen_t size = sizeof address;
::getsockname(listener_, reinterpret_cast<sockaddr*>(&address), &size);
port_ = ntohs(address.sin_port);
timeval timeout{0, 100000}; // accept wakes up to see stop_
::setsockopt(listener_, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout);
thread_ = std::thread([this] { loop(); });
}
~FakeDebugPort() {
stop_ = true;
thread_.join();
::close(listener_);
}
int port() const { return port_; }
bool bar = true; // whether the target list has the bar page
void set_state(bool on, bool present, bool broken) {
std::ofstream(directory_ / "state.json", std::ios::trunc)
<< json{{"on", on}, {"present", present}, {"broken", broken}}.dump();
}
bool on() const { return json::parse(read(directory_ / "state.json")).value("on", false); }
private:
void loop() {
while (!stop_) {
const int client = ::accept(listener_, nullptr, nullptr);
if (client < 0) continue;
answer(client);
::close(client);
}
}
static std::string receive(int client, std::size_t count) {
std::string data(count, '\0');
for (std::size_t got = 0; got < count;) {
const auto n = ::recv(client, data.data() + got, count - got, 0);
if (n <= 0) return {};
got += static_cast<std::size_t>(n);
}
return data;
}
static void send_all(int client, const std::string& data) { ::send(client, data.data(), data.size(), MSG_NOSIGNAL); }
// An unmasked text frame, as the Steam client sends.
static std::string frame(const std::string& text) {
std::string out(1, static_cast<char>(0x81));
if (text.size() < 126) {
out += static_cast<char>(text.size());
} else {
out += static_cast<char>(126);
out += static_cast<char>(text.size() >> 8);
out += static_cast<char>(text.size() & 0xFF);
}
return out + text;
}
// Reads one masked client frame.
static std::string read_frame(int client) {
const auto head = receive(client, 2);
if (head.size() != 2) return {};
std::size_t length = static_cast<unsigned char>(head[1]) & 0x7F;
if (length == 126) {
const auto bytes = receive(client, 2);
length = (static_cast<unsigned char>(bytes[0]) << 8) | static_cast<unsigned char>(bytes[1]);
}
const auto mask = receive(client, 4);
auto payload = receive(client, length);
for (std::size_t i = 0; i < payload.size(); ++i) payload[i] = static_cast<char>(payload[i] ^ mask[i % 4]);
return payload;
}
void answer(int client) {
std::string headers;
while (headers.find("\r\n\r\n") == std::string::npos) {
const auto byte = receive(client, 1);
if (byte.empty()) return;
headers += byte;
}
const auto ws = "ws://127.0.0.1:" + std::to_string(port_) + "/devtools/page/";
if (headers.rfind("GET /json ", 0) == 0) {
json targets = json::array();
targets.push_back({{"url", "https://steamloopback.host/?vrOverlayKey=valve.steam.gamepadui.barpopup"},
{"webSocketDebuggerUrl", ws + "POPUP"}});
if (bar)
targets.push_back({{"url", "https://steamloopback.host/?vrOverlayKey=valve.steam.gamepadui.bar"},
{"webSocketDebuggerUrl", ws + "BAR"}});
const auto body = targets.dump();
// The connection stays open, as Steam's does, so the client must read by length.
send_all(client, "HTTP/1.1 200 OK\r\nContent-Length: " + std::to_string(body.size()) + "\r\n\r\n" + body);
return;
}
if (headers.rfind("GET /devtools/page/BAR ", 0) != 0) {
send_all(client, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
return;
}
send_all(client, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n");
const auto request = json::parse(read_frame(client), nullptr, false);
if (request.is_discarded()) return;
// An event comes first, longer than 125 bytes, which the client must skip.
send_all(client, frame(json{{"method", "Runtime.consoleAPICalled"}, {"params", {{"pad", std::string(200, 'x')}}}}.dump()));
send_all(client, frame(evaluate(request["id"], request["params"]["expression"])));
}
// Runs `expression` in Node and returns the reply Runtime.evaluate gives.
std::string evaluate(const json& id, const std::string& expression) {
std::string script = kPrelude;
script.replace(script.find("EXPRESSION"), 10, expression);
std::ofstream(directory_ / "script.js", std::ios::trunc) << script;
const auto command = "node '" + (directory_ / "script.js").string() + "' '" +
(directory_ / "state.json").string() + "' 2>/dev/null";
std::string output;
int status = -1;
if (FILE* pipe = ::popen(command.c_str(), "r")) {
char buffer[256];
for (std::size_t n; (n = std::fread(buffer, 1, sizeof buffer, pipe)) > 0;) output.append(buffer, n);
status = ::pclose(pipe);
}
json result;
if (status == 0) result = {{"type", "string"}, {"value", json::parse(output)}};
else result = {{"type", "object"}, {"subtype", "error"}, {"description", "Error: script threw"}};
return json{{"id", id}, {"result", {{"result", result}}}}.dump();
}
fs::path directory_;
int listener_ = -1;
int port_ = 0;
std::atomic<bool> stop_{false};
std::thread thread_;
};
// The message RoomView threw, and whether it was DashboardChanged.
template <typename F> std::pair<std::string, bool> error_of(F f) {
try {
f();
} catch (const DashboardChanged& error) {
return {error.what(), true};
} catch (const std::exception& error) {
return {error.what(), false};
}
return {"", false};
}
} // namespace
int main() {
if (std::system("node --version >/dev/null 2>&1") != 0) {
std::cout << "node is not on PATH, so the Room View tests are skipped\n";
return 77;
}
const auto root = fs::temp_directory_path() / ("knockthru-room-view-test-" + std::to_string(getpid()));
fs::remove_all(root);
fs::create_directories(root);
{
FakeDebugPort steam(root);
const RoomView room_view(steam.port());
check(room_view.active() == false, "active reads off from the bar, skipping the popup page");
steam.set_state(true, true, false);
check(room_view.active() == true, "active reads on");
steam.set_state(false, true, false);
check(room_view.toggle() == true && steam.on(), "toggle clicks the Room View button and returns on");
check(room_view.toggle() == false && !steam.on(), "toggle again returns off");
steam.set_state(false, false, false);
const auto [missing, missing_changed] = error_of([&] { room_view.active(); });
check(missing_changed && missing.find("not found") != std::string::npos,
"a bar without the button throws DashboardChanged: " + missing);
const auto [click, click_changed] = error_of([&] { room_view.toggle(); });
check(click_changed, "toggle without the button throws DashboardChanged");
steam.set_state(false, true, true);
const auto [broken, broken_changed] = error_of([&] { room_view.active(); });
check(broken_changed && broken.find("Dashboard script failed") != std::string::npos,
"a script error throws DashboardChanged: " + broken);
steam.set_state(false, true, false);
steam.bar = false;
const auto [no_bar, no_bar_changed] = error_of([&] { room_view.active(); });
check(!no_bar_changed && no_bar.find("dashboard bar not found") != std::string::npos,
"a target list without the bar says SteamVR may not be running: " + no_bar);
}
// The fake port is closed now.
{
int probe = ::socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
::bind(probe, reinterpret_cast<sockaddr*>(&address), sizeof address);
socklen_t size = sizeof address;
::getsockname(probe, reinterpret_cast<sockaddr*>(&address), &size);
const int closed_port = ntohs(address.sin_port);
::close(probe); // nothing listens on this port
const auto [closed, closed_changed] = error_of([&] { RoomView(closed_port).active(); });
check(!closed_changed && closed.find("CEF Remote Debugging") != std::string::npos,
"a closed port says to turn on CEF Remote Debugging: " + closed);
}
fs::remove_all(root);
return finish();
}
+6 -3
View File
@@ -15,6 +15,7 @@
#include <fstream>
#include <iostream>
#include <iterator>
#include <sstream>
#include <string>
#include <arpa/inet.h>
@@ -247,9 +248,11 @@ int main() {
out.string() + "'"});
for (int i = 0; i < 50 && read(out).find('\n') == std::string::npos; ++i) usleep(20000);
usleep(100000);
const auto listing = read(out);
check(listing.find(std::to_string(held) + "\n") == std::string::npos,
"start_detached closes inherited descriptors");
// Whole lines only: the session ID on the last line can end in the same digits.
std::istringstream lines(read(out));
bool inherited = false;
for (std::string line; std::getline(lines, line);) inherited = inherited || line == std::to_string(held);
check(!inherited, "start_detached closes inherited descriptors");
::close(held);
}