38 Commits
Author SHA1 Message Date
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
Robin Anderson 8a90745e25 Merge pull request #31 from VioletTides/release-1.0.2
Number the release 1.0.2
2026-10-16 20:05:21 -04:00
Robin Anderson ff3f150e00 Number the release 1.0.2 2026-10-16 20:03:12 -04:00
Robin Anderson c6a7f8c2cc Merge pull request #30 from VioletTides/leg-knocks
Count knocks on a leg
2026-10-16 20:03:03 -04:00
Robin Anderson ab331f0a85 Say that surface knocks work on a leg 2026-10-16 19:59:18 -04:00
Robin Anderson 9a0d044d3c Let leg knocks in a pair land up to 6 cm apart
Two knocks on a leg spread wider than two knocks on a desk. In a run of
about 40 leg doubles, 7 pairs landed 3.5 to 8.3 cm apart, and the 3 cm
same-spot rule dropped them. At 6 cm, 25 doubles count instead of 20.
play, noise, and straps still give no actions at twice that distance.

tests/data/leg-doubles.csv.gz holds that run.
2026-10-16 19:42:32 -04:00
Robin Anderson e01afc9833 Count knocks on a leg as surface 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 30 ms before the spike, so the
detector threw them out as arm swings. Surface knocks now get their own
limit of 2.2 m/s. Together and headset knocks keep 1.8 m/s, because at
2.2 m/s a together calibration counted one knock in the noise recording.

tests/data/leg.csv.gz holds doubles and triples knocked on a leg with
each hand, headset worn. It gives 16 actions at 2.2 m/s and 9 at 1.8 m/s.
2026-10-16 19:32:42 -04:00
Robin Anderson fae097477b Merge pull request #29 from VioletTides/release-1.0.1
Number the release 1.0.1
2026-10-16 16:32:38 -04:00
Robin Anderson de976ad256 Number the release 1.0.1 2026-10-16 16:29:04 -04:00
Robin Anderson f880edc51b Merge pull request #28 from VioletTides/test-hook
Add a test hook that drives the app over SSH
2026-10-16 16:25:22 -04:00
Robin Anderson abe798d798 Add a test hook that drives the app over SSH
Development builds read commands appended to
~/.config/knockthru/test-commands: open or close the panel, click a
panel pixel, press a button by name, feed a knock or a double or triple
knock, and save the panel as a PNG. Releases leave it out:
build-arm64.sh adds it only with KNOCKTHRU_TEST_HOOK=1, and the release
workflow never sets that. The host build always has it, so CI compiles it.

On the Frame it drove calibration, the bug report and its recording,
the factory reset and tour, and an update from 1.0.1 to a local
1.0.2-test release through Install and Restart.
2026-10-16 16:23:52 -04:00
Robin Anderson 5ddc7c4787 Merge pull request #27 from VioletTides/review-cleanup
Cut idle CPU, speed up the panel, and split the resident app out of main.cpp
2026-10-16 16:04:45 -04:00
Robin Anderson 5d0ac36d02 Remove scripts/analyze.py
Nothing referenced it, and it read only uncompressed CSV files while the
recordings are gzipped. detector_test with KNOCKTHRU_TEST_VERBOSE=1 prints
every knock in a recording.
2026-10-16 16:00:14 -04:00
Robin Anderson a8a1e50f00 Share the test check helper in tests/check.hpp
Three test programs each defined the same check() and failure count.
update_test now prints the same summary as the other two.
2026-10-16 15:54:26 -04:00
Robin Anderson 0e1fd2afe1 Remove two stray blank lines in overlay_texture.cpp 2026-10-16 15:53:53 -04:00
Robin Anderson fce4b03b29 Parse the true or false settings from one table
Five settings repeated the same three lines. A test now loads each of
them and rejects a value that is not true or false. The test that named
the update check weekly now says daily, which is what the app does.
2026-10-16 15:53:38 -04:00
Robin Anderson ec26507e7c Draw wrapped text in the panel through one helper
Ten places wrapped a string and drew its lines one by one with the same
loop. Panel::paragraph does it once. All panel_preview images are
byte-identical.
2026-10-16 15:52:49 -04:00
Robin Anderson 8fdae6111f Keep the dashboard-changed flag in the app, not the panel state
The panel never read it. The app uses it to log a missing Room View
button once per change.
2026-10-16 15:51:07 -04:00
Robin Anderson cef1a82ac7 Split the resident app out of main.cpp
run() was one 721-line function whose 15 lambdas shared its locals. It is
now an App class in app.cpp with one method per job: slow checks, panel
clicks, knocks, background jobs, and drawing. The shared SteamVR helpers,
pose sampling, haptics, the bug report, and install commands move to
their own files. main.cpp keeps the command-line commands.

Behavior is unchanged. The order of steps in the main loop is the same,
except that a finished recording is collected after the knocks of that
loop instead of before.
2026-10-16 15:50:21 -04:00
Robin Anderson 4489a6334f Decode text once per draw
Canvas::text decoded the UTF-8 string inside measure() and again to draw
it.
2026-10-16 15:39:14 -04:00
Robin Anderson 1467a4af6f Keep the last lines of a log in a deque
tail() erased the front of a vector for every line past the limit, which
moved every kept line each time.
2026-10-16 15:39:14 -04:00
Robin Anderson d5ca7dbe42 Draw each panel icon once and keep it
The header, the bar icon row, and the tour welcome drew the logo from
its outlines on every frame. All panel_preview images are byte-identical.
2026-10-16 15:38:22 -04:00
Robin Anderson 126d97ee33 Fill the inside of rounded rectangles without per-pixel blending
An opaque color replaces the pixels under it, so each row's covered run
is now written directly. Most of a panel frame is tile fill. The main
screen renders in half the time on the PC, and all panel_preview images
are byte-identical.
2026-10-16 15:38:22 -04:00
Robin Anderson 67d6605385 Fold repeated style reads, update checks, and labels into one place each
run() read the Room View style and started the update check job in two
places each. calibrate() repeated the thresholds() format, and three
sites spelled out "double" or "triple".
2026-10-16 15:35:16 -04:00
Robin Anderson a3b12616d2 Share the pose, CSV row, and home folder helpers in main.cpp
The pose-to-vector extraction, the CSV row format, the device names, and
the HOME fallback each had two or three copies. Recordings keep the same
bytes: rows without angular velocity still end in 0,0,0.
2026-10-16 15:34:33 -04:00
Robin Anderson ba6d5cea29 Fetch poses only up to the highest device index in use
GetDeviceToAbsoluteTrackingPose filled all 64 slots every millisecond.
On the Frame the pose thread drops from 2.7% to 1.7% of one core.
2026-10-16 15:33:51 -04:00
Robin Anderson 5be8ee2baf Wake the resident loop every 5 ms while the panel is hidden
The detector judges knocks 50 ms after they happen, so a 1 ms loop gains
nothing while nobody looks at the panel. The pose thread still samples
every millisecond. On the Frame the main thread drops from 4.3% to 1.1%
of one core.
2026-10-16 15:33:51 -04:00
Robin Anderson 0862852cbd Find detector history windows by binary search
History is sorted by time, so poll() starts at the first unjudged sample
and the window helpers start at their window instead of scanning all
0.8 s of samples. Detector output is identical on every recording, and
detector_test runs in 2.2 s instead of 3.6 s.
2026-10-16 15:32:02 -04:00
Robin Anderson 361c270089 Merge pull request #26 from VioletTides/remove-docs
Remove the docs folder and CLAUDE.md
2026-10-16 15:23:23 -04:00
Robin Anderson 56833d5671 Remove the docs folder and CLAUDE.md from the repo
docs/calibration-plan.md was a working plan for local branches, and
docs/knock-signal.md was the tuning log. Both stay in git history.
The README and two code comments no longer point at them.

CLAUDE.md is now in .gitignore, so it stays as a local file only.
2026-10-16 15:17:09 -04:00
40 changed files with 2341 additions and 1843 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
+1
View File
@@ -3,3 +3,4 @@
/.zig-cache/
*.log
/compile_commands.json
/CLAUDE.md
-37
View File
@@ -1,37 +0,0 @@
# KnockThru
A double knock toggles Steam Frame passthrough (Room View). A triple knock switches Room View between translucent and opaque. The app runs on the Frame as a SteamVR overlay app with no visible surface.
## Layout
- `src/knock_detector.*`: knock detection. Pure logic with no OpenVR types, so the host tests can replay recordings.
- `src/room_view.*`: clicks the Room View button through Steam's CEF debug port (8080 on the Frame).
- `src/style.*`: switches the SteamVR setting `camera.roomViewStyle` (3 is translucent, 4 is opaque).
- `src/config.*`: loads `assets/config.json`, then `~/.config/knockthru/config.json`, then `calibration.json`.
- `src/update.*`: the GitHub release check, the install that checks the ZIP's SHA-256, and the file swap. `--update` and the Settings header use it.
- `src/report.*`, `src/report_server.*`: the bug report. The report card's QR code opens a page the headset serves on the local Wi-Fi.
- `src/main.cpp`: the CLI commands and the resident app (`--run`).
- `scripts/install.sh`: the installer for people without a PC. Each release attaches it, and the README runs it through `curl | sh`.
- `tests/data/*.csv.gz`: pose recordings from the Frame. `docs/knock-signal.md` explains each one and how the detector was tuned.
## 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 and FrameDrop manifest: `node scripts/package.mjs --build-dir build-arm64 --version X.Y.Z`
- README images: `python3 scripts/readme_media.py` (needs Pillow, numpy, ffmpeg, Chromium, and the host build). The controller and headset sprites in `images/cad/` come from `scripts/cad_sprites.py <CAD folder>`, which needs cadquery and Valve's STEP files.
- 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`. Pass the reporter's calibrated peaks through `KNOCKTHRU_TEST_SET`.
## Testing on the Frame
- `ssh frame` reaches the headset. The dev install lives in `~/knockthru-dev/`.
- To deploy, run `knockthru-ctl --uninstall` first, because a running binary cannot be overwritten. Then copy `knockthru` and `knockthru-ctl`, and run `./knockthru` to install and start.
- The app log is `~/.config/knockthru/application.log`.
## Rules
- `knockthru-ctl` must stay a byte-identical copy of `knockthru`. SteamVR identifies apps by executable path, so management commands re-exec through `knockthru-ctl` to keep the resident app's identity.
- Poses are valid only in the standing tracking universe, not the raw one.
- Any detector change must keep `ctest` passing: no actions in `play` and `noise`, and no wrong actions in `mixed`.
- A release goes out when a `v*` tag is pushed. Tag only with Robin's approval.
+12 -3
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.16)
project(KnockThru VERSION 1.0.0 LANGUAGES CXX)
project(KnockThru VERSION 1.0.2 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/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,10 +24,13 @@ 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/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/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}")
# The host build is never shipped, so it always compiles the test hook and CI catches breaks in it.
# Releases come from scripts/build-arm64.sh, which leaves the hook out unless KNOCKTHRU_TEST_HOOK=1.
target_compile_definitions(knockthru PRIVATE KNOCKTHRU_TEST_HOOK)
target_compile_options(knockthru PRIVATE -Wall -Wextra -Wpedantic)
add_custom_command(TARGET knockthru POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/assets" "$<TARGET_FILE_DIR:knockthru>"
@@ -51,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)
+4 -4
View File
@@ -52,6 +52,8 @@ Any of these three knocks works. Use the same kind for every knock in a set.
![Three ways to knock: knock the controllers together, knock one controller on a table, or knock on the headset. Each one knocks twice, then three times](images/knock-kinds.gif)
A surface knock also works on your leg. Keep the controller where the headset cameras can see it, because knocks out of view get missed more often.
Pause for a moment, then knock at a steady pace. After your last knock, KnockThru waits a moment in case another one follows.
![Timeline: two knocks, a wait of about 0.4 seconds, then passthrough turns on and both controllers buzz. Then three knocks, the same wait, and the style switches with two buzzes](images/timing.gif)
@@ -191,7 +193,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.0-linux-arm64.zip -d ~/KnockThru
unzip -oq knockthru-1.0.2-linux-arm64.zip -d ~/KnockThru
~/KnockThru/knockthru --install
```
@@ -212,7 +214,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.0
node scripts/package.mjs --build-dir build-arm64 --version 1.0.2
```
README images (needs Pillow, numpy, ffmpeg, and Chromium, after the host build):
@@ -233,8 +235,6 @@ To replay the knock recording from a bug report, unzip the report and run this:
build/bin/detector_test knockthru-knocks.csv.gz
```
`docs/knock-signal.md` explains how knock detection works and how it was tuned.
</details>
## Credits
-114
View File
@@ -1,114 +0,0 @@
# Calibration redo plan
Branch `calibration-redo`, worktree `../KnockThru-calibration`. The UI comes first. The logic changes come second and stay small: sensitivity and threshold handling.
## What exists now
- `src/calibrator.*` collects 10 knocks of one kind within 90 s with lowered thresholds. It saves `0.6 * weakest peak` to `calibration.json`, clamped to 50..150 m/s² for surface and 20..150 for together and headset.
- `src/config.cpp` `apply_calibration` maps the saved values onto the detector. Together and headset share `confirmed_peak`, so the softer of the two wins.
- `src/panel.cpp` `calibrate_screen` has two states. One is a list of three kinds with a Start button each. The other is a live screen with a "3 of 10" counter, ten dots, a seconds countdown, and Cancel. A "Saved" banner follows.
- Calibration counts single knocks, not doubles or triples. `Calibrator::add` takes every knock from `take_knocks()`, which logs any knock more than `merge_s` (80 ms) after the last one. The current screen says "10 times, at least 1 second apart" but never says "one knock at a time", so it reads as if it wants doubles.
- The panel is static. `Panel::render` takes no time input. `DashboardPanel::draw` in `src/main.cpp` redraws at most every 50 ms when dirty and every 500 ms otherwise.
- `tests/panel_preview.cpp` writes PNGs of sample states. Nothing previews motion.
## Phase 1: UI
### Prototype on the readme branch
Branch `readme-revamp` (worktree `../KnockThru-readme`, local only, still in progress) already scripts the knock animations in Python for the README GIFs. The panel animations port from it, so the motion matches the README.
- `scripts/readme_media.py` has the easing curves (`ease_in`, `ease_out`, `ease_in_out`, `ease_out_back`) and a `keyframes` interpolator.
- The same file has the knock motion: `knock_motion` (pull back, contact, rebound, settle), `impact` and `burst` (accent lines flying out from the contact), `buzz` (a shake per haptic buzz), and `knock_pair` (two controllers swinging from the wrist). One timeline drives them: first knock at 0.5 s, 0.3 s between knocks, 0.4 s to the action.
- `knock_kinds()` in that file draws one card per knock kind, which is the layout the "Pick a kind" step needs.
- The README loops play a double, then a triple. Calibration wants one knock, then a rest. `knock_motion` already takes the knock count, so the calibration loop calls it with one knock and a longer pause. Only the "Try it" step shows a double.
- `scripts/cad_sprites.py` renders the controller and headset sprites in `images/cad/` from Valve's CAD files, in the logo style.
- The CAD-derived drawings use CC BY-NC-SA 4.0, like the logo. `THIRD_PARTY_NOTICES.md` on that branch says so. Any sprite shipped in the app needs the same notice.
`Canvas` cannot load PNGs or draw rotated images. It can fill polygons, and `logo_shapes.hpp` already bakes the logo into polygons. Tracing the sprites into a polygon header the same way lets the panel scale and rotate them by transforming points. Loading PNGs would instead need a vendored image decoder and a rotated blit.
### Animation groundwork
`calibration-redo` never merges `readme-revamp` and never copies its files. The README work stays unpublished until it is ready on its own. The readme worktree is a read-only reference.
Release order: `calibration-redo` merges and ships first, with the traced sprites in the panel. `readme-revamp` follows and adds README sections for calibration. `panel_preview` frames from this branch can feed those sections.
1. Port the easing curves, `keyframes`, and the knock motion to C++ by hand, beside the canvas code. `scripts/readme_media.py` in the readme worktree stays the reference for timing.
2. Write a tracer that reads the sprite PNGs from a path given on the command line, such as `../KnockThru-readme/images/cad/`. It writes a polygon header, as `logo_shapes.hpp` does for the logo. Only the header lands on `calibration-redo`.
3. Add the new header to the "Logo" section of `THIRD_PARTY_NOTICES.md`, which already covers CAD-derived drawings under CC BY-NC-SA 4.0.
Then the render loop:
1. Pass the current time into `Panel::render`.
2. Add an "animating" flag that the panel reports back. While it is set, `DashboardPanel::draw` redraws at the 50 ms rate even without input.
3. Log how long one `render` plus texture upload takes on the Frame. The redraw rate depends on that number, which nobody has measured yet.
4. Extend `panel_preview` to write a frame sequence for each animated state, so motion can be reviewed on the host.
### Wizard steps
1. **Pick a kind:** three cards, each with a short looping animation of that knock, ported from `knock_kinds()`.
2. **Show how:** one screen per kind with the single-knock animation large. The instruction says "one knock, then wait" in plain words. Ask for a normal knock, not the softest one.
3. **Record:** a pacing cue (a ring that fills over about a second) shows when the next knock can come. Each counted knock pops a dot and pulses a ring. A live strength bar shows each knock's peak against the current threshold. A knock of the wrong kind gets a hint ("That looked like a surface knock") instead of silence.
4. **Review:** plot the recorded peaks with the threshold line. The sensitivity slider sits under the plot, and the line moves as the slider moves.
5. **Try it:** this step is required before saving. The user does one double knock at the chosen threshold. The panel shows whether it would have toggled passthrough, without toggling it. A pass unlocks Save. A miss offers to raise sensitivity or redo the recording.
### Sensitivity slider
- The slider is continuous, with notches at minimum, middle, and maximum.
- Releasing near a notch snaps the knob onto it, with an eased slide.
- Dragging works through the overlay's mouse events. `DashboardPanel` currently turns only clicks into panel commands, so it needs press, move, and release.
Cancel and Back stay on every step. Leaving the panel mid-recording keeps the current behavior: the recording continues until done or timed out.
### UI acceptance
- Every step renders in `panel_preview`, including at least one mid-animation frame per step.
- On the Frame, a full calibration of each kind works from the panel alone.
### Checkpoint, 2026-10-15
Phase 1 runs on the Frame, and Robin checked it in the headset. Testing it turned up problems beyond the UI, fixed on this branch:
- Pose sampling runs on its own thread, so panel drawing cannot stretch the gaps between samples.
- Calibration counts only deliberate knocks, as `docs/knock-signal.md` describes under "Calibration knocks".
- The record bars show approach speed, because the top tenth of spikes ran 2.8 to 8.3 times the bottom tenth in the knock recordings.
- Panel frames take about 1.5 ms on the host, and screen fades about 3.7 ms.
The review chart, the threshold, and the slider still use the spike size. The app logs every knock heard while calibrating, for tuning phase 2.
## Phase 2: logic
1. Replace "60 percent of the weakest knock" with a sensitivity value from 0 to 1. It scales a low percentile of the recorded peaks, not the single minimum. A rebound lands 83 to 99 ms after a knock (`docs/knock-signal.md`), past the 80 ms `merge_s`, so it can count as a weak extra knock and drag the minimum down. The test data sets the fractions at 0, 0.5, and 1.
2. Store the recorded peaks and the sensitivity in `calibration.json`, so the slider can move the threshold later without a new recording.
3. Give together and headset knocks separate thresholds. Today `judge` in `src/knock_detector.cpp` checks both against `confirmed_peak`, and `apply_calibration` keeps the softer of the two. Add a headset threshold to `DetectorConfig`, keep `confirmed_peak` for together knocks, and keep the existing `config.json` keys working.
4. Let the user change sensitivity later without recording again.
5. Lower the knock count if the robust statistic allows it. The test data decides the number.
Later: a "Reset to defaults" action that deletes the saved thresholds.
The existing floors stay: surface never below 50 m/s², together and headset never below 20. `docs/knock-signal.md` explains why.
### Phase 2 status, 2026-10-15
Robin picked "drag the line" (option 1 of two mockups) for the sensitivity step, and asked for whatever testing showed best against movement. Done on this branch:
- The chart, the line, and the slider use approach speed. Each kind saves a spike threshold and an approach threshold, plus the slider position and the recorded knocks.
- Together and headset knocks have separate thresholds.
- `docs/knock-signal.md` ("Calibrated thresholds") has the replay results at each notch.
Later the same day, testing showed the slider earned little: its middle and most sensitive ends gave nearly the same replay results, and its least sensitive end only lost real knocks. Robin chose to drop it. The walkthrough is four steps (pick, learn, record, try), and the approach threshold is fixed at 55 percent of the second slowest knock.
Not done: lowering the knock count.
### Logic acceptance
- `ctest` passes: no actions in `play` and `noise`, and no wrong actions in `mixed`.
- A host test calibrates each kind on its recording, applies the saved thresholds, and replays every recording.
- `mixed` still sorts together and headset knocks correctly with the split thresholds.
- `--calibrate` on the command line keeps working with the new logic.
## Decisions
- No sensitivity slider. The approach threshold is 55 percent of the second slowest recorded knock (see "Phase 2 status").
- The "Try it" step is required before saving.
- Together and headset knocks get separate thresholds.
- Recording opens with a 3 s get-ready ring, and each knock must wait for the 1 s pacing ring.
-129
View File
@@ -1,129 +0,0 @@
# Knock signal findings
Source: five 45 second recordings in `tests/data/`, made with `knockthru --record` on a Steam Frame on 2026-10-14. In the three knock files, the wearer knocked in pairs for the full 45 seconds. Exact counts are unknown, and the estimate is about 20 pairs per file.
## What the Frame provides
- `GetDeviceToAbsoluteTrackingPose` returns valid poses only in `TrackingUniverseStanding`. `TrackingUniverseRawAndUncalibrated` returns `bPoseIsValid = false` for every device.
- Headset velocity updates at 1000 Hz. Each controller updates at about 233 Hz (one sample every 4.3 ms).
- The Frame has no `/dev/hidraw*` devices, so raw controller accelerometer data is not reachable that way.
- Acceleration below is the velocity difference between consecutive changed samples, divided by their time gap.
## What separates a knock from motion
Spike size alone does not work. Normal play reached 697 m/s², above every recorded knock.
| Feature | Knocks | Play and waving |
|---|---|---|
| Speed 30 ms after the spike, median | 0.1 to 0.2 m/s | 1.6 to 1.7 m/s |
| Speed 30 ms before the spike, median | 0.5 to 1.1 m/s | 2.3 to 2.5 m/s |
| Spike width at 233 Hz | 1 to 3 samples | 1 to 2 samples |
The controller nearly stops after a knock. During motion it keeps moving. Spike width does not separate them at this sample rate.
Kinds:
- `headset`: the headset spikes above 25 m/s² within 30 ms of a controller spike. Headset knocks reach 40 to 215 m/s². Head shaking reaches 22 m/s², and controller knocks move the headset less than 10 m/s².
- `together`: the other controller spikes above 20 m/s² within 40 ms, with the controllers less than 15 cm apart. The second controller often spikes much weaker than the first.
- `surface`: anything else. Both knocks of a double must land within 3 cm of each other, because the table does not move.
## Prototype rule and results
A controller spike counts as a knock when all of these hold:
- Peak above 60 m/s².
- Speed 30 ms after the peak below 0.4 m/s.
- Speed 30 ms before the peak below 1.8 m/s.
Spikes less than 80 ms apart merge into one knock, which absorbs bounces. A sequence is knocks of the same kind and the same device, 120 to 450 ms apart. A sequence of 2 is a double, 3 is a triple, and 4 or more is ignored (steady clapping).
Prototype results:
| Recording | Doubles found | Wrong kind | False doubles |
|---|---|---|---|
| `together` | 20 | 0 | |
| `surface` | 18 | 1 (as `together`) | |
| `headset` | 18 | 2 (as `surface`) | |
| `play` | | | 0 |
| `noise` | | | 0 |
A double with the wrong kind still toggles passthrough, unless that kind is turned off in `knock_types`.
The C++ detector (`src/knock_detector.cpp`) adds two rules. Without them, one false double appeared at 18.9 s in `noise`, from two weak claps (peaks 63 and 60 m/s²) 500 ms after a 3 second clapping run.
- Every knock within 450 ms of the previous one joins the same burst. A burst that mixes kinds, sources, or surface spots is rejected as a whole. It does not restart.
- A burst must start at least 0.8 s after the previous burst ended.
## Live session tuning
Two 60 second live sessions with `knockthru --detect` drove these changes. The first one was not saved. It led to two changes:
- The 1 s cooldown after an action is gone. It swallowed the first knock of the next sequence. The 0.8 s stillness rule already keeps sequences apart.
- Surface knocks less than 150 ms apart count as one knock. A table bounce landed 120 ms after a real knock, while real knocks were about 215 ms apart. Together and headset knocks use a shorter gap (see "Fast doubles" below).
The second session is `tests/data/mixed.csv.gz`, with a known routine: 4 each of together, surface, and headset doubles, then 4 of each as triples. The build at that point got 15 of 24 right and read 4 triples as doubles. These changes fixed that:
- A together or headset knock needs only 40 m/s², because the other controller or the headset confirms it. Weak third knocks peaked at 42 to 48 m/s².
- A surface knock may still move at 0.55 m/s 30 ms later, because hard table knocks rebound.
- Knocks in a sequence must be at most 350 ms apart (was 450). Real gaps were 170 to 290 ms. The wider window let two weak spikes 357 ms apart in `play` form a false double.
Result: 22 of 24 right, 0 wrong. The 2 misses are surface doubles where hard knocks bounced several times.
## Fast doubles
`tests/data/fast.csv.gz` is 30 s of fast together doubles. Its knock pairs were 89 to 168 ms apart, so the 150 ms gap dropped half of them. Rebounds in the other recordings land 83 to 99 ms after a real knock. Peak ratio (0.15 to 0.99 for real pairs, 0.39 for a rebound) and controller separation (1.3 to 3.6 cm for real pairs, 1.4 cm for a rebound) do not tell them apart.
- Together and headset knocks need only 105 ms between them (`fast_gap_s`). Surface knocks keep 150 ms.
- At the default thresholds, `fast` gives 13 doubles (was 7), and every other recording keeps its result.
- With together or headset thresholds under 20 m/s², soft rebounds pass the 105 ms gap and turn doubles into triples. Calibration never sets them below 20.
## Calibration knocks
Calibration lowers the thresholds (controller 30 m/s², confirmed 12, partner 8) to catch soft knocks. At those thresholds, `play` and `noise` give a knock candidate about once a second: 84 and 82 in 45 s each. As surface or together knocks, most of them would count.
Two more things showed up in a surface calibration on the Frame. Starting a hard swing from rest spiked 0.09 to 0.14 s before the knock (37 m/s², approach 0.08 to 0.29 m/s), and that spike took the count from the real knock (146 and 461 m/s²). A slow hand movement counted too (approach 0.20 m/s).
Knocks less than 0.25 s apart form a burst, and the burst stands for its knock with the fastest approach. Approach speed is the controller's fastest speed in the 100 ms before the spike. A burst counts when:
- No knock came in the 0.6 s before it. Waving makes clusters of spikes, and the wearer waits between calibration knocks.
- Its knock approached at 0.25 m/s or faster.
- For surface knocks, the controller averaged under 0.6 m/s from 0.6 to 0.15 s before the first knock in the burst. 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. Together and headset knocks skip this rule. Their false knocks never got past the first two rules, and headset knocks start with the arm rising at up to 1.3 m/s.
With these rules, calibration counts 1 knock in the 90 s of `play` and `noise`, and 10 of 10 in `together`, `surface`, and `headset`. That one knock spiked under the surface floor of 50 m/s². Calibration now turns such knocks away as too light, since they could never count, which leaves 0 in `play` and `noise`. In the logged Frame session, the hard knocks count instead of the swing starts, and the slow hand movement does not count.
Approach speed also shows how hard a knock was more steadily than the spike. The spike divides a velocity change by the gap between pose updates, which varies from 2 to 7 ms. In the knock recordings, the top tenth of spikes ran 2.8 to 8.3 times the bottom tenth, and approach speeds ran 1.7 to 2.5 times.
## Calibrated thresholds
Each calibrated knock kind gets two thresholds. Together and headset knocks have their own spike thresholds (`confirmed_peak` and `headset_knock_peak`), so calibrating one does not change the other.
- The spike threshold is 60 percent of the weakest recorded spike, at least 50 m/s² for surface knocks, 30 for together knocks, and 20 for headset knocks. A calibration file saved under a lower floor loads with the current one. "Strap fitting" below explains the together floor.
- The approach threshold (`min_approach`) is 55 percent of the second slowest recorded approach. An earlier build had a sensitivity slider from 30 to 80 percent. In the replays, anything stricter than 55 percent only lost real doubles. 30 percent matched 55 percent on surface and headset knocks and did a little better on together knocks (22 against 20 right in `mixed`), but it leaves less margin against movement that the recordings lack. The walkthrough uses 55 percent and has no slider.
A sequence needs its fastest knock to reach the approach threshold. Two other ways of applying it failed on the recordings:
- Dropping slow knocks before the sequence logic let two leftover spikes form false doubles in `play` (together at approach 0.43 m/s) and `noise` (at 0.64 m/s). A slow knock still has to join its sequence, so clapping runs and busy bursts stay rejected.
- Requiring every knock in a sequence to be fast lost most together doubles (2 to 11 of 20), because the later knocks of a quick double start from the rebound.
The test calibrates each kind on its own recording, applies it, and replays every recording:
| Kind | Spike | Approach | Doubles | `mixed` right |
|---|---|---|---|---|
| together | 31 | 0.24 | 19 | 21 |
| surface | 56 | 0.56 | 18 | 22 |
| headset | 20 | 0.20 | 22 | 22 |
Every row has no actions in `play`, `noise`, and `straps`, and no wrong actions in `mixed`. With default settings, the recordings give 20 together, 18 surface, and 22 headset doubles, and 22 right in `mixed`.
## Strap fitting
`tests/data/straps.csv.gz` is 21 s from a 40 minute `knockthru --detect` session in Into the Radius 2 on 2026-10-16. In it, the wearer fits the strap on each controller, and the controllers bump each other 19 times in 5 s. The rest of the session had 18.7 minutes of play with no actions.
- The bumps count as together knocks at 20 to 45 m/s², with approach speeds of 0.16 to 0.64 m/s.
- The wearer's together calibration had set the spike threshold to 20, from calibration knocks as soft as 20 m/s². At that threshold, the first two bumps (25 and 28 m/s², 287 ms apart) turned passthrough on.
- The next bump came 594 ms after the second, too late to make the burst too long.
- Approach speed and distance from the headset do not separate the bumps from real knocks. Real together knocks in `fast` sit 0.22 to 0.45 m from the headset, and the bumps sat 0.21 to 0.41 m away.
The together floor is 30 m/s². At 30, `straps` gives no action with that calibration, and the calibrated replay above keeps 19 together doubles. The default `confirmed_peak` of 40 already gives no action in `straps`. Headset knocks keep the floor of 20.
Background: [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 threshold, pulse width, quiet time, and latency windows for accelerometer double-tap detection.
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env python3
"""Prints the update rate and the largest acceleration spikes in a knockthru --record CSV."""
import csv
import math
import sys
from collections import defaultdict
def main(path: str, top: int = 20) -> None:
rows = defaultdict(list)
with open(path, newline="") as handle:
for row in csv.DictReader(handle):
if row["valid"] == "1":
rows[row["device"]].append(row)
for device, samples in rows.items():
fresh, spikes = [], []
last = None
for row in samples:
velocity = tuple(float(row[k]) for k in ("vx", "vy", "vz"))
if last is None or velocity != last[1]:
t = float(row["t_s"])
if last is not None and t > last[0]:
dv = math.dist(velocity, last[1])
spikes.append((dv / (t - last[0]), t))
fresh.append(t)
last = (t, velocity)
if len(fresh) < 2:
print(f"{device}: not enough valid samples")
continue
gaps = sorted(b - a for a, b in zip(fresh, fresh[1:]))
span = fresh[-1] - fresh[0]
print(f"{device}: {len(fresh)} fresh samples, {len(fresh) / span:.0f} Hz, "
f"median gap {gaps[len(gaps) // 2] * 1000:.2f} ms")
print(" top spikes (m/s^2 at time s):")
for accel, t in sorted(spikes, reverse=True)[:top]:
print(f" {accel:9.1f} {t:9.3f}")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("Usage: analyze.py <recording.csv> [top]")
main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 20)
+9 -1
View File
@@ -5,13 +5,20 @@ set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
build="${1:-$root/build-arm64}"
version="${KNOCKTHRU_VERSION:-1.0.0}"
version="${KNOCKTHRU_VERSION:-1.0.2}"
# 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"
export ZIG_GLOBAL_CACHE_DIR="$build/zig-global-cache"
export ZIG_LOCAL_CACHE_DIR="$build/zig-local-cache"
sources=(
src/main.cpp
src/app.cpp
src/steamvr.cpp
src/poses.cpp
src/diagnostics.cpp
src/install.cpp
src/knock_detector.cpp
src/room_view.cpp
src/style.cpp
@@ -41,6 +48,7 @@ cd "$root"
zig c++ -target aarch64-linux-gnu.2.31 -std=c++17 -O2 -s -fvisibility=hidden -Wno-deprecated-declarations \
-DLINUX -DPOSIX -DLINUXARM64 -DVR_API_PUBLIC -DVRCORE_NO_PLATFORM -DOPENVR_BUILD_STATIC \
-DKNOCKTHRU_VERSION="\"$version\"" \
${test_hook:+-DKNOCKTHRU_TEST_HOOK} \
"${includes[@]/#/-I}" "${sources[@]}" -ldl -lpthread -o "$build/bin/knockthru"
cp "$build/bin/knockthru" "$build/bin/knockthru-ctl"
+1108
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
#pragma once
namespace knockthru {
// Runs the resident app until SteamVR quits or a signal arrives. SteamVR starts it with --run.
int run_app();
} // namespace knockthru
+7
View File
@@ -3,6 +3,7 @@
#include "config.hpp"
#include <algorithm>
#include <cstdio>
#include <stdexcept>
namespace knockthru {
@@ -102,4 +103,10 @@ KindCalibration Calibrator::save() const {
return *calibration[kind_];
}
std::string thresholds_text(const KindCalibration& calibration) {
char line[128];
std::snprintf(line, sizeof line, "spike %.0f m/s^2, approach %.2f m/s", calibration.peak, calibration.approach);
return line;
}
} // namespace knockthru
+4
View File
@@ -5,6 +5,7 @@
#include <array>
#include <optional>
#include <string>
#include <vector>
namespace knockthru {
@@ -111,4 +112,7 @@ private:
std::vector<double> peaks_, speeds_;
};
// The spike and approach thresholds as one line, for the log and the terminal.
std::string thresholds_text(const KindCalibration& calibration);
} // namespace knockthru
+24 -5
View File
@@ -138,7 +138,25 @@ void Canvas::rounded_rect(Rect rect, float radius, Color color) {
if (c >= 1) break;
blend(right, y, color, c);
}
for (int x = left; x <= right; ++x) blend(x, y, color, 1);
fill_span(y, left, right + 1, color);
}
}
void Canvas::fill_span(int y, int from, int to, Color color) {
if (y < 0 || y >= height_) return;
from = std::max(from, 0);
to = std::min(to, width_);
if (color.a != 255) {
for (int x = from; x < to; ++x) blend(x, y, color, 1);
return;
}
// An opaque color replaces what is under it.
auto* p = &pixels_[(static_cast<std::size_t>(y) * width_ + from) * 4];
for (int x = from; x < to; ++x, p += 4) {
p[0] = color.r;
p[1] = color.g;
p[2] = color.b;
p[3] = 255;
}
}
@@ -213,8 +231,9 @@ void Canvas::arc(float cx, float cy, float inner, float outer, float from, float
}
}
float Canvas::measure(Font& font, const std::string& utf8, float size) {
const auto text = decode_utf8(utf8);
float Canvas::measure(Font& font, const std::string& utf8, float size) { return measure(font, decode_utf8(utf8), size); }
float Canvas::measure(Font& font, const std::u32string& text, float size) {
float width = 0;
for (std::size_t i = 0; i < text.size(); ++i) {
width += font.glyph(text[i], size).advance;
@@ -269,12 +288,12 @@ std::vector<std::string> Canvas::wrap(Font& font, const std::string& utf8, float
}
void Canvas::text(Font& font, const std::string& utf8, float x, float y, float size, Color color, Align align) {
const float width = measure(font, utf8, size);
const auto text = decode_utf8(utf8);
const float width = measure(font, text, size);
if (align == Align::Center) x -= width / 2;
if (align == Align::Right) x -= width;
// Centers the cap height on y. Cap height is about 0.7 of the pixel size.
const float baseline = y + size * 0.36f;
const auto text = decode_utf8(utf8);
float pen = x;
for (std::size_t i = 0; i < text.size(); ++i) {
const auto& glyph = font.glyph(text[i], size);
+3
View File
@@ -84,6 +84,9 @@ private:
std::vector<std::uint8_t> pixels_;
void blend(int x, int y, Color color, float coverage);
// Blends `color` at full coverage over pixels `from` to `to` (exclusive) of row `y`.
void fill_span(int y, int from, int to, Color color);
float measure(Font& font, const std::u32string& text, float size);
};
} // namespace knockthru
+7 -16
View File
@@ -43,6 +43,8 @@ void merge(AppConfig& config, const json& file, const fs::path& path) {
const auto fail = [&](const std::string& what) {
throw std::runtime_error(path.string() + ": " + what);
};
const std::map<std::string, bool*> flags{{"paused", &config.paused}, {"classic_bar_icon", &config.classic_bar_icon},
{"sounds", &config.sounds}, {"tour_done", &config.tour_done}, {"check_updates", &config.check_updates}};
for (const auto& [key, value] : file.items()) {
if (key == "knock_types") {
if (!value.is_array()) fail("knock_types must be a list");
@@ -59,21 +61,9 @@ void merge(AppConfig& config, const json& file, const fs::path& path) {
if (!app.is_string()) fail("blocked_apps must be a list of app keys");
config.blocked_apps.insert(app.get<std::string>());
}
} else if (key == "paused") {
if (!value.is_boolean()) fail("paused must be true or false");
config.paused = value.get<bool>();
} else if (key == "classic_bar_icon") {
if (!value.is_boolean()) fail("classic_bar_icon must be true or false");
config.classic_bar_icon = value.get<bool>();
} else if (key == "sounds") {
if (!value.is_boolean()) fail("sounds must be true or false");
config.sounds = value.get<bool>();
} else if (key == "tour_done") {
if (!value.is_boolean()) fail("tour_done must be true or false");
config.tour_done = value.get<bool>();
} else if (key == "check_updates") {
if (!value.is_boolean()) fail("check_updates must be true or false");
config.check_updates = value.get<bool>();
} 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 == "debug_port") {
if (!value.is_number_integer()) fail("debug_port must be a whole number");
config.debug_port = value.get<int>();
@@ -83,7 +73,8 @@ void merge(AppConfig& config, const json& file, const fs::path& path) {
{"controller_peak", &d.controller_peak}, {"confirmed_peak", &d.confirmed_peak},
{"partner_peak", &d.partner_peak}, {"headset_peak", &d.headset_peak}, {"headset_knock_peak", &d.headset_knock_peak},
{"max_speed_after", &d.max_speed_after}, {"surface_speed_after", &d.surface_speed_after},
{"max_speed_before", &d.max_speed_before}, {"together_distance", &d.together_distance},
{"max_speed_before", &d.max_speed_before}, {"surface_speed_before", &d.surface_speed_before},
{"together_distance", &d.together_distance},
{"same_spot", &d.same_spot}, {"merge_s", &d.merge_s}, {"min_gap_s", &d.min_gap_s}, {"fast_gap_s", &d.fast_gap_s},
{"max_gap_s", &d.max_gap_s}, {"quiet_before_s", &d.quiet_before_s}};
if (!value.is_object()) fail("detector must be an object");
+135
View File
@@ -0,0 +1,135 @@
#include "diagnostics.hpp"
#include "config.hpp"
#include "steamvr.hpp"
#include "style.hpp"
#include "update.hpp"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <deque>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <sys/utsname.h>
namespace knockthru {
namespace fs = std::filesystem;
namespace {
fs::path home_directory() {
const auto home = std::getenv("HOME");
return home && *home ? home : "/tmp";
}
// The last `count` lines of a text file, or a note if it is missing.
std::string tail(const fs::path& path, std::size_t count) {
std::ifstream stream(path);
if (!stream) return "(missing)\n";
std::deque<std::string> lines;
for (std::string line; std::getline(stream, line);) {
lines.push_back(std::move(line));
if (lines.size() > count) lines.pop_front();
}
std::string text;
for (const auto& line : lines) text += line + "\n";
return text.empty() ? "(empty)\n" : text;
}
std::string os_name() {
std::ifstream stream("/etc/os-release");
std::string name = "unknown";
for (std::string line; std::getline(stream, line);)
if (line.rfind("PRETTY_NAME=", 0) == 0) name = line.substr(12);
name.erase(std::remove(name.begin(), name.end(), '"'), name.end());
utsname system{};
if (::uname(&system) == 0) name += std::string(", kernel ") + system.release;
return name;
}
} // namespace
fs::path report_path() { return home_directory() / "knockthru-report.txt"; }
fs::path recording_path() { return home_directory() / "knockthru-knocks.csv.gz"; }
void write_recording(const KnockRecording& recording) {
const auto csv = user_directory() / "knocks.csv";
{
std::ofstream out(csv, std::ios::trunc);
out << kCsvHeader;
for (const auto& s : recording.samples) write_csv_row(out, s.t - recording.start, s.device, s.position, s.velocity);
if (!out) throw std::runtime_error("Cannot write " + csv.string());
}
if (run_program({"gzip", "-f", csv.string()}) != 0) throw std::runtime_error("gzip could not compress the recording");
fs::copy_file(csv.string() + ".gz", recording_path(), fs::copy_options::overwrite_existing);
fs::remove(csv.string() + ".gz");
std::ofstream summary(user_directory() / "knocks.txt", std::ios::trunc);
summary << "Recorded " << recording.samples.size() << " samples over " << KnockRecording::kSeconds << " s. Heard "
<< recording.heard.size() << " knocks and actions:\n";
for (const auto& line : recording.heard) summary << line << "\n";
}
std::string report_text(bool vr, const std::string& room_view) {
std::ostringstream out;
const auto now = std::time(nullptr);
char stamp[32];
std::strftime(stamp, sizeof stamp, "%F %T", std::localtime(&now));
out << "KnockThru bug report, " << stamp << "\n\n";
out << "KnockThru: " << kVersion << "\n";
out << "Program: " << executable().string() << "\n";
out << "System: " << os_name() << "\n";
if (vr) {
auto* apps = vr::VRApplications();
out << "SteamVR: " << steamvr_version() << "\n";
const auto remembered = load_state();
if (!remembered.steamvr_since.empty())
out << "SteamVR seen since: " << remembered.steamvr_since
<< (remembered.steamvr_previous.empty() ? "" : ", before that " + remembered.steamvr_previous) << "\n";
out << "Registered with SteamVR: " << (apps->IsApplicationInstalled(kAppKey) ? "yes" : "no") << "\n";
out << "Starts with SteamVR: " << (apps->GetApplicationAutoLaunch(kAppKey) ? "yes" : "no") << "\n";
const auto pid = apps->GetApplicationProcessId(kAppKey);
out << "Running: " << (pid ? "yes, pid " + std::to_string(pid) : std::string("no")) << "\n";
try {
out << "Style: " << to_string(read_style()) << "\n";
} catch (const std::exception& error) {
out << "Style: " << error.what() << "\n";
}
const auto key = scene_app_key();
out << "Current app: " << (key.empty() ? "none" : key) << "\n";
} else {
out << "SteamVR: not running\n";
}
out << "Room View: " << room_view << "\n";
const auto user = user_directory();
// The summary of the last knock recording, if it is under an hour old.
std::error_code age_error;
const auto knocks = user / "knocks.txt";
if (fs::exists(knocks, age_error) &&
fs::file_time_type::clock::now() - fs::last_write_time(knocks, age_error) < std::chrono::hours(1))
out << "\n== Knock recording, knockthru-knocks.csv.gz ==\n" << tail(knocks, 200);
for (const auto* name : {"config.json", "calibration.json"})
out << "\n== " << (user / name).string() << " ==\n" << tail(user / name, 400);
out << "\n== " << (user / "application.log").string() << ", last 300 lines ==\n" << tail(user / "application.log", 300);
const auto home = std::getenv("HOME");
if (home && *home) {
const auto steam_log = fs::path(home) / ".local/share/Steam/logs/vrclient_knockthru.txt";
out << "\n== " << steam_log.string() << ", last 100 lines ==\n" << tail(steam_log, 100);
}
return out.str();
}
fs::path write_report(bool vr, const std::string& room_view) {
const auto path = report_path();
std::ofstream file(path, std::ios::trunc);
if (!(file << report_text(vr, room_view))) throw std::runtime_error("Cannot write " + path.string());
return path;
}
} // namespace knockthru
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "poses.hpp"
#include <filesystem>
#include <string>
#include <vector>
namespace knockthru {
// The bug report files go in the home folder, outside ~/.config, so a person can find them to attach.
std::filesystem::path report_path();
std::filesystem::path recording_path();
// 20 s of headset and controller motion, in the tests/data CSV format, for a knock bug report.
struct KnockRecording {
static constexpr double kSeconds = 20;
double start = 0;
std::vector<PoseThread::Sample> samples;
std::vector<std::string> heard; // one line per knock and action, for the report
};
// Writes the recording as ~/knockthru-knocks.csv.gz and its summary as knocks.txt.
// The angular velocity columns are zero, because the detector does not read them.
void write_recording(const KnockRecording& recording);
// A bug report for a GitHub issue: versions, SteamVR state, settings, and the
// end of the logs. `vr` is false when SteamVR is not running, and `room_view`
// describes what the caller read from Room View.
std::string report_text(bool vr, const std::string& room_view);
// Writes report_text() to report_path() and returns that path.
std::filesystem::path write_report(bool vr, const std::string& room_view);
} // namespace knockthru
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <openvr.h>
#include <chrono>
#include <thread>
#include <vector>
namespace knockthru {
// Short buzzes on both controllers. Uses SteamVR input actions when the action
// manifest is loaded, and the older direct pulse call otherwise.
class Haptics {
public:
void use_actions(vr::VRActionHandle_t left, vr::VRActionHandle_t right) { left_ = left; right_ = right; }
// Queues `count` pulses, 150 ms apart.
void buzz(int count, double now) {
for (int i = 0; i < count; ++i) pending_.push_back(now + 0.15 * i);
}
void update(double now) {
for (auto it = pending_.begin(); it != pending_.end();) {
if (*it > now) { ++it; continue; }
pulse();
it = pending_.erase(it);
}
}
private:
vr::VRActionHandle_t left_ = vr::k_ulInvalidActionHandle;
vr::VRActionHandle_t right_ = vr::k_ulInvalidActionHandle;
std::vector<double> pending_;
void pulse() {
if (left_ != vr::k_ulInvalidActionHandle) {
for (const auto action : {left_, right_})
vr::VRInput()->TriggerHapticVibrationAction(action, 0, 0.07f, 160, 1.0f, vr::k_ulInvalidInputValueHandle);
return;
}
auto* system = vr::VRSystem();
for (int i = 0; i < 14; ++i) { // 14 x 5 ms
for (const auto role : {vr::TrackedControllerRole_LeftHand, vr::TrackedControllerRole_RightHand})
system->TriggerHapticPulse(system->GetTrackedDeviceIndexForControllerRole(role), 0, 3999);
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
};
} // namespace knockthru
+146
View File
@@ -0,0 +1,146 @@
#include "install.hpp"
#include "canvas.hpp"
#include "config.hpp"
#include "diagnostics.hpp"
#include "panel.hpp"
#include "steamvr.hpp"
#include "update.hpp"
#include <json.hpp>
#include <chrono>
#include <csignal>
#include <fstream>
#include <iostream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
#include <unistd.h>
namespace knockthru {
namespace fs = std::filesystem;
using json = nlohmann::json;
namespace {
fs::path app_binary() { return install_directory() / "knockthru"; }
fs::path manifest_path() { return user_directory() / "app.vrmanifest"; }
fs::path icon_path() { return user_directory() / "icon.png"; }
void register_app() {
const auto binary = app_binary().generic_u8string();
Canvas icon(256, 256);
Panel::draw_icon(icon);
fs::create_directories(icon_path().parent_path());
if (!icon.write_png(icon_path())) throw std::runtime_error("Cannot write " + icon_path().string());
json app = {
{"app_key", kAppKey}, {"launch_type", "binary"}, {"is_dashboard_overlay", true},
{"binary_path_linux", binary},
// The Frame's native ARM runtime reads this key, not binary_path_linux (from KominoVR).
{"binary_path_linux_arm", binary},
{"arguments", "--run"},
{"image_path", icon_path().generic_u8string()},
{"action_manifest_path", (install_directory() / "actions.json").generic_u8string()},
{"strings", {{"en_us", {{"name", "KnockThru"}, {"description", "Double knock to toggle passthrough"}}}}}};
fs::create_directories(manifest_path().parent_path());
std::ofstream(manifest_path(), std::ios::trunc) << json{{"source", "builtin"}, {"applications", json::array({app})}}.dump(2);
check_app(vr::VRApplications()->AddApplicationManifest(manifest_path().u8string().c_str(), false), "Register with SteamVR");
}
// Stops every running resident KnockThru: a process of this user whose program
// is named knockthru and whose arguments include --run. After an update renames
// a new file over the old one, the running process's exe link reads
// "knockthru (deleted)". It reads /proc instead of asking SteamVR, which files
// knockthru-ctl under KnockThru's app key when STEAMVR_APPKEY leaks into it.
void stop_running_app() {
std::vector<pid_t> stopped;
std::error_code error;
for (const auto& entry : fs::directory_iterator("/proc", error)) {
const auto name = entry.path().filename().string();
if (name.find_first_not_of("0123456789") != std::string::npos) continue;
const auto pid = static_cast<pid_t>(std::stol(name));
if (pid == getpid()) continue;
std::error_code ignored;
const auto exe = fs::read_symlink(entry.path() / "exe", ignored);
if (ignored || (exe.filename() != "knockthru" && exe.filename() != "knockthru (deleted)")) continue;
std::ifstream file(entry.path() / "cmdline", std::ios::binary);
std::string args((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
if (args.find(std::string("--run") + '\0') == std::string::npos) continue;
if (::kill(pid, SIGTERM) == 0) stopped.push_back(pid);
}
for (const auto pid : stopped)
for (int i = 0; i < 50 && fs::exists(fs::path("/proc") / std::to_string(pid)); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// SteamVR's own per-app file, which holds the autostart flag.
fs::path steamvr_app_config() {
return user_directory().parent_path() / "openvr" / "config" / "vrappconfig" /
(std::string(kAppKey) + ".vrappconfig");
}
} // namespace
int install() {
stop_running_app(); // restart so an update takes effect
VrSession session(vr::VRApplication_Utility);
register_app();
check_app(vr::VRApplications()->SetApplicationAutoLaunch(kAppKey, true), "Turn on SteamVR autostart");
// SteamVR clears the old app's PID a few ms after the process exits. A launch
// before that fails with ApplicationAlreadyRunning.
for (int i = 0; i < 30 && vr::VRApplications()->GetApplicationProcessId(kAppKey) != 0; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
check_app(vr::VRApplications()->LaunchDashboardOverlay(kAppKey), "Start KnockThru");
std::cout << "KnockThru is running and starts with SteamVR. Double knock to toggle passthrough.\n";
return 0;
}
void unregister() {
if (vr::VRApplications()->IsApplicationInstalled(kAppKey)) {
vr::VRApplications()->SetApplicationAutoLaunch(kAppKey, false);
check_app(vr::VRApplications()->RemoveApplicationManifest(manifest_path().u8string().c_str()), "Unregister");
}
std::error_code error;
fs::remove(manifest_path(), error);
fs::remove(icon_path(), error);
}
int uninstall(bool purge) {
VrSession session(vr::VRApplication_Utility);
stop_running_app();
unregister();
if (!purge) {
std::cout << "KnockThru stopped and removed from SteamVR. Your settings stay in "
<< user_directory().string() << ". Add --purge to delete them.\n";
return 0;
}
fs::remove_all(user_directory());
fs::remove(steamvr_app_config());
fs::remove(report_path());
fs::remove(recording_path());
std::cout << "KnockThru stopped and removed from SteamVR. Deleted " << user_directory().string()
<< ", " << steamvr_app_config().string() << ", and the report files in your home folder.\n";
return 0;
}
int update() {
const auto work = user_directory() / "update";
const auto release = fetch_latest_release(work);
if (compare_versions(release.version, kVersion) <= 0) {
std::cout << "KnockThru " << kVersion << " is up to date. The latest release is " << release.version << ".\n";
return 0;
}
std::cout << "Installing KnockThru " << release.version << " from " << release.url << "\n";
install_release(release, install_directory(), work);
// The new files are in place. install() restarts the resident app from them.
return install();
}
} // namespace knockthru
+20
View File
@@ -0,0 +1,20 @@
#pragma once
namespace knockthru {
// Registers with SteamVR, turns on autostart, and starts the resident app. A
// running resident app stops first, so an update takes effect.
int install();
// Stops the resident app and removes it from SteamVR. With `purge`, also deletes
// ~/.config/knockthru, SteamVR's file for the app, and the report files in the home
// folder. The install folder stays, because FrameDrop or the person who unpacked it owns it.
int uninstall(bool purge);
// Installs the latest release over this one, then restarts KnockThru.
int update();
// Removes KnockThru's manifest and autostart from SteamVR. Needs a SteamVR session.
void unregister();
} // namespace knockthru
+25 -16
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <cmath>
#include <iterator>
namespace knockthru {
namespace {
@@ -46,35 +47,41 @@ void KnockDetector::push(Device device, double t, const Vec3& position, const Ve
while (!history.empty() && history.front().t < t - kKeep) history.pop_front();
}
// History is sorted by time, so each lookup starts with a binary search.
KnockDetector::History::const_iterator KnockDetector::first_from(const History& history, double t) {
return std::lower_bound(history.begin(), history.end(), t, [](const Sample& s, double t) { return s.t < t; });
}
const KnockDetector::Sample* KnockDetector::nearest(const History& history, double t) {
const Sample* best = nullptr;
for (const auto& sample : history)
if (!best || std::abs(sample.t - t) < std::abs(best->t - t)) best = &sample;
return best;
if (history.empty()) return nullptr;
const auto after = first_from(history, t);
if (after == history.begin()) return &*after;
const auto before = std::prev(after);
// The earlier sample wins a tie.
if (after == history.end() || std::abs(before->t - t) <= std::abs(after->t - t)) return &*before;
return &*after;
}
double KnockDetector::mean_speed(const History& history, double from, double to) {
double sum = 0;
int count = 0;
for (const auto& sample : history)
if (sample.t >= from && sample.t <= to) {
sum += norm(sample.velocity);
++count;
}
for (auto it = first_from(history, from); it != history.end() && it->t <= to; ++it) {
sum += norm(it->velocity);
++count;
}
return count ? sum / count : 0;
}
double KnockDetector::max_speed(const History& history, double from, double to) {
double fastest = 0;
for (const auto& sample : history)
if (sample.t >= from && sample.t <= to) fastest = std::max(fastest, norm(sample.velocity));
for (auto it = first_from(history, from); it != history.end() && it->t <= to; ++it)
fastest = std::max(fastest, norm(it->velocity));
return fastest;
}
double KnockDetector::peak_in(const History& history, double from, double to) {
double peak = 0;
for (const auto& sample : history)
if (sample.t >= from && sample.t <= to) peak = std::max(peak, sample.accel);
for (auto it = first_from(history, from); it != history.end() && it->t <= to; ++it) peak = std::max(peak, it->accel);
return peak;
}
@@ -109,7 +116,7 @@ std::optional<Knock> KnockDetector::judge(Device device, std::size_t i) const {
if (sample.accel < threshold) return std::nullopt;
const auto* before = nearest(history, sample.t - 0.03);
const auto* after = nearest(history, sample.t + 0.03);
if (!before || !after || norm(before->velocity) >= config_.max_speed_before ||
if (!before || !after || norm(before->velocity) >= (surface ? config_.surface_speed_before : config_.max_speed_before) ||
norm(after->velocity) >= (surface ? config_.surface_speed_after : config_.max_speed_after))
return std::nullopt;
return knock;
@@ -164,8 +171,10 @@ std::optional<KnockEvent> KnockDetector::poll(double now) {
for (const auto device : {Device::Left, Device::Right}) {
const auto& history = history_[index(device)];
auto& judged = judged_until_[index(device)];
for (std::size_t i = 0; i < history.size(); ++i) {
if (history[i].t <= judged || history[i].t > horizon) continue;
// Samples up to `judged` were judged by an earlier call.
auto i = static_cast<std::size_t>(std::upper_bound(history.begin(), history.end(), judged,
[](double t, const Sample& s) { return t < s.t; }) - history.begin());
for (; i < history.size() && history[i].t <= horizon; ++i) {
if (auto knock = judge(device, i)) found.push_back(*knock);
judged = history[i].t;
}
+4 -2
View File
@@ -15,7 +15,7 @@ enum class KnockKind { Together, Surface, Headset };
const char* to_string(KnockKind kind);
std::optional<KnockKind> parse_knock_kind(const std::string& name);
// Thresholds come from the recordings described in docs/knock-signal.md.
// Thresholds come from the Steam Frame recordings in tests/data/.
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
@@ -25,8 +25,9 @@ struct DetectorConfig {
double max_speed_after = 0.4; // m/s, 30 ms after the spike
double surface_speed_after = 0.55; // m/s, table knocks rebound
double max_speed_before = 1.8; // m/s, 30 ms before the spike
double surface_speed_before = 2.2; // m/s, knocks on a leg come in faster
double together_distance = 0.15; // m, controller separation for a together knock
double same_spot = 0.03; // m, surface knocks in one sequence must land this close
double same_spot = 0.06; // m, surface knocks in one sequence must land this close. Leg knocks spread more than desk knocks.
double merge_s = 0.08; // spikes closer than this are one knock (bounces)
double min_gap_s = 0.15; // closer surface knocks are table bounces
double fast_gap_s = 0.105; // closer together or headset knocks are rebounds
@@ -95,6 +96,7 @@ private:
std::optional<Knock> judge(Device device, std::size_t index) const;
void add(const Knock& knock);
std::optional<KnockEvent> finish_sequence();
static History::const_iterator first_from(const History& history, double t);
static const Sample* nearest(const History& history, double t);
static double peak_in(const History& history, double from, double to);
static double mean_speed(const History& history, double from, double to);
+35 -1378
View File
File diff suppressed because it is too large. Load diff
-2
View File
@@ -211,7 +211,6 @@ struct OverlayTexture::Impl {
}
VkCommandPoolCreateInfo pool_info{};
pool_info.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
pool_info.queueFamilyIndex = family;
@@ -290,7 +289,6 @@ void OverlayTexture::show(vr::VROverlayHandle_t overlay, const std::uint8_t* rgb
check(v.EndCommandBuffer(v.commands), "end");
VkSubmitInfo submit{};
submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submit.commandBufferCount = 1;
submit.pCommandBuffers = &v.commands;
+39 -57
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"};
@@ -92,6 +92,28 @@ Panel::Panel(const std::filesystem::path& fonts)
background_.rounded_rect({0, 0, kWidth, kHeight}, 28, kBackground);
}
// Drawing the icon from its outlines is slow, so each size and style is drawn once and kept.
const Canvas& Panel::icon(int size, IconStyle style) {
const auto key = std::make_pair(size, style);
auto found = icons_.find(key);
if (found == icons_.end()) {
found = icons_.emplace(key, Canvas(size, size)).first;
draw_icon(found->second, style);
}
return found->second;
}
// Draws `text` wrapped to `width` in at most `max_lines` lines, `leading` pixels apart.
// Returns the y after the last line.
float Panel::paragraph(Font& font, const std::string& text, float x, float y, float size, float leading, float width,
int max_lines, Color color) {
for (const auto& line : canvas_.wrap(font, text, size, width, max_lines)) {
canvas_.text(font, line, x, y, size, color);
y += leading;
}
return y;
}
bool Panel::hovered(Rect rect) const { return hover_ && rect.contains(hover_->first, hover_->second); }
void Panel::switch_control(float right, float cy, bool on, bool enabled, float scale) {
@@ -119,17 +141,12 @@ void Panel::button(Rect rect, const std::string& label, std::optional<PanelClick
}
void Panel::header(const std::string& title) {
Canvas icon(56, 56);
draw_icon(icon);
canvas_.draw(icon, static_cast<int>(kMargin), 40);
canvas_.draw(icon(56, IconStyle::Tile), static_cast<int>(kMargin), 40);
canvas_.text(bold_, title, kMargin + 76, 68, 38, kText);
}
void Panel::message(const PanelState& state, float y) {
for (const auto& line : canvas_.wrap(regular_, state.message, 26, kWidth - 2 * kMargin, 2)) {
canvas_.text(regular_, line, kMargin, y, 26, state.message_is_error ? kError : kDim);
y += 36;
}
paragraph(regular_, state.message, kMargin, y, 26, 36, kWidth - 2 * kMargin, 2, state.message_is_error ? kError : kDim);
}
void Panel::passthrough_tile(Rect rect, const PanelState& state) {
@@ -142,11 +159,7 @@ void Panel::passthrough_tile(Rect rect, const PanelState& state) {
const std::string value = state.busy ? "Switching" : broken ? "Unavailable" : !state.room_view ? "Checking" : on ? "On" : "Off";
canvas_.text(bold_, value, x, rect.y + 160, broken ? 64 : 96, kText);
if (broken) {
float y = rect.y + rect.h - 92;
for (const auto& line : canvas_.wrap(regular_, state.room_view_error, 26, w, 2)) {
canvas_.text(regular_, line, x, y, 26, kText);
y += 36;
}
paragraph(regular_, state.room_view_error, x, rect.y + rect.h - 92, 26, 36, w, 2, kText);
}
targets_.push_back({rect, PanelClick{PanelCommand::RoomView}, std::nullopt});
}
@@ -347,10 +360,7 @@ void Panel::learn_screen(const PanelState& state) {
std::string("Hold still while the ring fills."),
std::string("Do it 10 times. One buzz means it counted."),
std::string("Knock as hard as you would to switch passthrough.")}) {
for (const auto& part : canvas_.wrap(regular_, line, 28, w, 2)) {
canvas_.text(regular_, part, x, y, 28, kDim);
y += 40;
}
y = paragraph(regular_, line, x, y, 28, 40, w, 2, kDim);
y += 14;
}
button({kWidth - kMargin - 240, 616, 240, 72}, "Start", PanelClick{PanelCommand::Calibrate, learn_kind_}, std::nullopt,
@@ -461,11 +471,8 @@ void Panel::try_screen(const PanelState& state, const CalibrationView& view) {
const float shake = anim::buzz(since, 0, 2) * 2;
canvas_.text(bold_, "Not quite", status.x + 40 + shake, status.y + 70, 34, kError);
float y = status.y + 124;
for (const auto& line : canvas_.wrap(regular_, "Heard " + view.trial_heard + ". Try again, or record your knocks again.",
26, status.w - 80, 3)) {
canvas_.text(regular_, line, status.x + 40, y, 26, kDim);
y += 36;
}
paragraph(regular_, "Heard " + view.trial_heard + ". Try again, or record your knocks again.", status.x + 40, y,
26, 36, status.w - 80, 3, kDim);
}
button({kMargin, 656, 260, 72}, "Record again", PanelClick{PanelCommand::Calibrate, view.kind}, std::nullopt);
@@ -487,9 +494,7 @@ void Panel::settings_screen(const PanelState& state) {
"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 icon(64, 64);
draw_icon(icon, state.classic_bar_icon ? IconStyle::BarColor : IconStyle::BarWhite);
canvas_.draw(icon, static_cast<int>(kMargin + w - 24 - 80 - 40 - 64), static_cast<int>(top + 2 * row + (row - 64) / 2));
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 + 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",
@@ -594,10 +599,7 @@ void Panel::report_card(const PanelState& state) {
}
const std::string hint = state.report_full ? "Hold the phone up to a lens. Use the Frame's Wi-Fi."
: "The Frame is offline. Save the report below to attach it.";
for (const auto& line : canvas_.wrap(regular_, hint, 26, text_w, 3)) {
canvas_.text(regular_, line, x, y, 26, kDim);
y += 36;
}
paragraph(regular_, hint, x, y, 26, 36, text_w, 3, kDim);
// Knock problems need the motion itself, so the report can carry a recording.
const Rect record{x, card.y + 420, 240, 64};
@@ -610,11 +612,7 @@ void Panel::report_card(const PanelState& state) {
else if (state.recorded) record_note = state.report_full ? "Recording added. Your phone gets it in one report file."
: "Recording saved as knockthru-knocks.csv.gz.";
const float record_x = record.x + record.w + 24;
float ry = record.y + record.h / 2 - 16;
for (const auto& line : canvas_.wrap(regular_, record_note, 24, text_w - record.w - 24, 2)) {
canvas_.text(regular_, line, record_x, ry, 24, kDim);
ry += 32;
}
paragraph(regular_, record_note, record_x, record.y + record.h / 2 - 16, 24, 32, text_w - record.w - 24, 2, kDim);
// The code on white with a 4-module quiet zone, in whole pixels per module.
const Rect white{card.x + card.w - 48 - box, card.y + 48, box, box};
@@ -666,11 +664,7 @@ void Panel::confirm_card(const std::string& title, const std::string& text, cons
const Rect card{220, 190, kWidth - 440, 390};
canvas_.rounded_rect(card, kRadius, kSurface);
canvas_.text(bold_, title, card.x + 48, card.y + 72, 38, kText);
float y = card.y + 140;
for (const auto& line : canvas_.wrap(regular_, text, 28, card.w - 96, 3)) {
canvas_.text(regular_, line, card.x + 48, y, 28, kText);
y += 40;
}
paragraph(regular_, text, card.x + 48, card.y + 140, 28, 40, card.w - 96, 3, kText);
const float by = card.y + card.h - 48 - 72;
button({card.x + 48, by, 240, 72}, cancel, std::nullopt, cancel_to);
button({card.x + card.w - 48 - 240, by, 240, 72}, confirm, confirm_click, Screen::Main, true, true, danger);
@@ -742,14 +736,11 @@ void Panel::tour_knock_step(const PanelState& state, const TourView& tour) {
const float x = box.x + box.w + 48, w = kWidth - kMargin - x;
float y = 190;
for (const auto& line : canvas_.wrap(bold_,
triple ? "Knock three times to switch between translucent and opaque" : "Knock twice to switch passthrough", 36, w, 2)) {
canvas_.text(bold_, line, x, y, 36, kText);
y += 48;
}
const auto title = triple ? "Knock three times to switch between translucent and opaque" : "Knock twice to switch passthrough";
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]);
@@ -757,10 +748,7 @@ void Panel::tour_knock_step(const PanelState& state, const TourView& tour) {
for (std::size_t i = 0; i < ways.size(); ++i)
list += (i == 0 ? "" : i + 1 == ways.size() ? (ways.size() == 2 ? " or " : ", or ") : ", ") + ways[i];
const std::string how = ways.size() > 1 ? "Any of these work: " + list + "." : "Use " + list + ".";
for (const auto& line : canvas_.wrap(regular_, how, 28, w, 3)) {
canvas_.text(regular_, line, x, y, 28, kDim);
y += 40;
}
paragraph(regular_, how, x, y, 28, 40, w, 3, kDim);
const Rect status{x, 408, w, 220};
const double heard_t = triple ? tour.triple_t : tour.double_t;
@@ -768,11 +756,7 @@ void Panel::tour_knock_step(const PanelState& state, const TourView& tour) {
if (!triple && !state.room_view_error.empty()) {
canvas_.rounded_rect(status, kRadius, kSurface);
canvas_.text(bold_, "Passthrough unavailable", status.x + 40, status.y + 64, 34, kError);
float line_y = status.y + 118;
for (const auto& line : canvas_.wrap(regular_, state.room_view_error, 26, status.w - 80, 2)) {
canvas_.text(regular_, line, status.x + 40, line_y, 26, kDim);
line_y += 36;
}
paragraph(regular_, state.room_view_error, status.x + 40, status.y + 118, 26, 36, status.w - 80, 2, kDim);
} else if (heard_t < 0) {
canvas_.rounded_rect(status, kRadius, kSurface);
const float pulse = 0.5f + 0.5f * std::sin(static_cast<float>(now_) * 2 * anim::kPi / 1.4f);
@@ -805,9 +789,7 @@ void Panel::tour_screen(const PanelState& state, const TourView& tour) {
const float cx = kWidth / 2.0f;
switch (tour.step) {
case Step::Welcome: {
Canvas icon(152, 152);
draw_icon(icon);
canvas_.draw(icon, (kWidth - 152) / 2, 150);
canvas_.draw(icon(152, IconStyle::Tile), (kWidth - 152) / 2, 150);
canvas_.text(bold_, "Welcome to KnockThru", cx, 370, 48, kText, Align::Center);
canvas_.text(regular_, "Knock to switch passthrough, without the side button.", cx, 440, 30, kDim, Align::Center);
canvas_.text(regular_, "This tour takes a few minutes.", cx, 484, 30, kDim, Align::Center);
+5 -1
View File
@@ -4,6 +4,7 @@
#include "knock_detector.hpp"
#include <array>
#include <map>
#include <optional>
#include <string>
#include <utility>
@@ -60,7 +61,6 @@ struct UpdateNotice {
struct PanelState {
std::optional<bool> room_view; // unknown until the first debug port read
std::string room_view_error; // why Room View cannot be read or changed
bool dashboard_changed = false; // the Room View button is missing, likely after a Steam update
std::optional<bool> opaque; // Room View style, unknown without SteamVR
bool busy = false; // a Room View change is in progress
bool paused = false;
@@ -168,8 +168,12 @@ private:
std::optional<View> view_;
std::optional<TourView::Step> tour_step_; // the tour step on screen, so each step rises in
double view_t_ = 0;
std::map<std::pair<int, IconStyle>, Canvas> icons_; // drawn icons by size and style
const Canvas& icon(int size, IconStyle style);
bool hovered(Rect rect) const;
float paragraph(Font& font, const std::string& text, float x, float y, float size, float leading, float width,
int max_lines, Color color);
void header(const std::string& title);
void main_screen(const PanelState& state);
void pick_screen(const PanelState& state);
+27
View File
@@ -0,0 +1,27 @@
#include "poses.hpp"
#include <algorithm>
#include <cstdio>
namespace knockthru {
Vec3 position_of(const vr::TrackedDevicePose_t& pose) {
const auto& m = pose.mDeviceToAbsoluteTracking.m;
return {m[0][3], m[1][3], m[2][3]};
}
Vec3 velocity_of(const vr::TrackedDevicePose_t& pose) {
const float* v = pose.vVelocity.v;
return {v[0], v[1], v[2]};
}
void write_csv_row(std::ostream& out, double t, Device device, const Vec3& p, const Vec3& v, const float* angular) {
char line[256];
const int n = std::snprintf(line, sizeof line, "%.6f,%s,1,%.5f,%.5f,%.5f,%.5f,%.5f,%.5f,", t,
kDeviceNames[static_cast<int>(device)], p[0], p[1], p[2], v[0], v[1], v[2]);
if (angular) std::snprintf(line + n, sizeof line - n, "%.5f,%.5f,%.5f\n", angular[0], angular[1], angular[2]);
else std::snprintf(line + n, sizeof line - n, "0,0,0\n");
out << line;
}
} // namespace knockthru
+130
View File
@@ -0,0 +1,130 @@
#pragma once
#include "knock_detector.hpp"
#include <openvr.h>
#include <array>
#include <atomic>
#include <chrono>
#include <functional>
#include <mutex>
#include <ostream>
#include <thread>
#include <vector>
namespace knockthru {
constexpr std::array<const char*, 3> kDeviceNames{"hmd", "left", "right"}; // indexed by Device
Vec3 position_of(const vr::TrackedDevicePose_t& pose);
Vec3 velocity_of(const vr::TrackedDevicePose_t& pose);
constexpr const char* kCsvHeader = "t_s,device,valid,px,py,pz,vx,vy,vz,wx,wy,wz\n";
// One row of the tests/data CSV format. Without `angular`, its columns are zero.
void write_csv_row(std::ostream& out, double t, Device device, const Vec3& p, const Vec3& v,
const float* angular = nullptr);
// Reads headset and controller poses once per call. SteamVR poses are only
// valid in the standing universe on the Frame.
class PoseSampler {
public:
using Callback = std::function<void(Device, const vr::TrackedDevicePose_t&)>;
void sample(const Callback& callback) {
const auto now = std::chrono::steady_clock::now();
if (now >= next_refresh_) {
auto* system = vr::VRSystem();
indices_[0] = vr::k_unTrackedDeviceIndex_Hmd;
indices_[1] = system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand);
indices_[2] = system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_RightHand);
next_refresh_ = now + std::chrono::seconds(1);
}
// Fetches only up to the highest device index in use, not all 64.
std::uint32_t count = 0;
for (const auto index : indices_)
if (index < vr::k_unMaxTrackedDeviceCount) count = std::max(count, index + 1);
vr::VRSystem()->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses_.data(), count);
for (std::size_t i = 0; i < 3; ++i) {
if (indices_[i] >= vr::k_unMaxTrackedDeviceCount) continue;
const auto& pose = poses_[indices_[i]];
if (pose.bPoseIsValid) callback(static_cast<Device>(i), pose);
}
}
static void feed(KnockDetector& detector, double t, Device device, const vr::TrackedDevicePose_t& pose) {
detector.push(device, t, position_of(pose), velocity_of(pose));
}
static void write_csv(std::ostream& out, double t, Device device, const vr::TrackedDevicePose_t& pose) {
write_csv_row(out, t, device, position_of(pose), velocity_of(pose), pose.vAngularVelocity.v);
}
private:
std::array<vr::TrackedDeviceIndex_t, 3> indices_{};
std::array<vr::TrackedDevicePose_t, vr::k_unMaxTrackedDeviceCount> poses_{};
std::chrono::steady_clock::time_point next_refresh_{};
};
class Clock {
public:
double now() const { return std::chrono::duration<double>(std::chrono::steady_clock::now() - start_).count(); }
// Sleeps until the next tick, `step` after the last one.
void tick(std::chrono::milliseconds step = std::chrono::milliseconds(1)) {
next_ += step;
std::this_thread::sleep_until(next_);
}
private:
std::chrono::steady_clock::time_point start_ = std::chrono::steady_clock::now();
std::chrono::steady_clock::time_point next_ = start_;
};
// Samples poses every millisecond on its own thread. Knock strength is the
// velocity change between samples over the time between them, so a main loop
// busy drawing the panel would stretch those gaps and garble the strength.
class PoseThread {
public:
struct Sample {
Device device;
double t;
Vec3 position, velocity;
};
explicit PoseThread(const Clock& clock) : thread_([this, &clock] { loop(clock); }) {}
~PoseThread() {
stop_ = true;
thread_.join();
}
PoseThread(const PoseThread&) = delete;
// Returns the samples since the last call, oldest first.
std::vector<Sample> take() {
std::vector<Sample> samples;
std::lock_guard<std::mutex> lock(mutex_);
samples.swap(samples_);
return samples;
}
private:
void loop(const Clock& clock) {
PoseSampler sampler;
Clock ticker;
while (!stop_) {
const double t = clock.now();
sampler.sample([&](Device device, const vr::TrackedDevicePose_t& pose) {
std::lock_guard<std::mutex> lock(mutex_);
samples_.push_back({device, t, position_of(pose), velocity_of(pose)});
});
ticker.tick();
}
}
std::mutex mutex_;
std::vector<Sample> samples_;
std::atomic<bool> stop_{false};
std::thread thread_; // last, so it starts after the members it uses
};
} // namespace knockthru
+76
View File
@@ -0,0 +1,76 @@
#include "steamvr.hpp"
#include "config.hpp"
#include <array>
#include <cstdint>
#include <ctime>
#include <fstream>
#include <iostream>
namespace knockthru {
namespace fs = std::filesystem;
volatile std::sig_atomic_t stopping = 0;
namespace {
std::ofstream log_file;
} // namespace
void log(const std::string& message) {
const auto now = std::time(nullptr);
char stamp[32];
std::strftime(stamp, sizeof stamp, "%F %T", std::localtime(&now));
std::cout << message << std::endl;
if (log_file.is_open()) log_file << stamp << " " << message << std::endl;
}
void open_log() {
const auto path = user_directory() / "application.log";
fs::create_directories(path.parent_path());
std::error_code error;
if (fs::exists(path) && fs::file_size(path, error) > 1024 * 1024) fs::rename(path, path.string() + ".old", error);
log_file.open(path, std::ios::app);
}
// Resolved once, because /proc/self/exe stops resolving after the install
// directory is deleted.
fs::path executable() {
static const fs::path path = fs::canonical("/proc/self/exe");
return path;
}
fs::path install_directory() { return executable().parent_path(); }
void check_app(vr::EVRApplicationError error, const char* what) {
if (error != vr::VRApplicationError_None)
throw std::runtime_error(std::string(what) + ": " + vr::VRApplications()->GetApplicationsErrorNameFromEnum(error));
}
std::string scene_app_key() {
const auto pid = vr::VRCompositor() ? vr::VRCompositor()->GetCurrentSceneFocusProcess() : 0;
if (!pid) return "";
std::array<char, vr::k_unMaxApplicationKeyLength> key{};
if (vr::VRApplications()->GetApplicationKeyByProcessId(pid, key.data(), static_cast<uint32_t>(key.size())) !=
vr::VRApplicationError_None) return "";
return key.data();
}
std::string app_name(const std::string& key) {
if (key.empty()) return "";
std::array<char, 256> name{};
vr::EVRApplicationError error = vr::VRApplicationError_None;
vr::VRApplications()->GetApplicationPropertyString(key.c_str(), vr::VRApplicationProperty_Name_String,
name.data(), static_cast<uint32_t>(name.size()), &error);
return error == vr::VRApplicationError_None ? name.data() : "";
}
std::string steamvr_version() {
const char* version = vr::VRSystem() ? vr::VRSystem()->GetRuntimeVersion() : nullptr;
return version && *version ? version : "unknown";
}
} // namespace knockthru
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <openvr.h>
#include <chrono>
#include <csignal>
#include <filesystem>
#include <stdexcept>
#include <string>
#include <thread>
namespace knockthru {
inline constexpr const char* kAppKey = "local.knockthru";
inline constexpr const char* kPanelKey = "local.knockthru.panel";
inline constexpr const char* kVersion = KNOCKTHRU_VERSION;
// Set by SIGINT, SIGTERM, and SteamVR's quit event. Long waits check it.
extern volatile std::sig_atomic_t stopping;
// Prints a line, and appends it with a timestamp to application.log once open_log() ran.
void log(const std::string& message);
void open_log();
// This program's file, and the folder it runs from.
std::filesystem::path executable();
std::filesystem::path install_directory();
class VrSession {
public:
// With `wait`, retries every 5 s until SteamVR is up or the process stops.
explicit VrSession(vr::EVRApplicationType type, bool wait = false) {
for (unsigned attempt = 0; !stopping; ++attempt) {
vr::EVRInitError error = vr::VRInitError_None;
vr::VR_Init(&error, type);
if (error == vr::VRInitError_None) return;
const std::string reason = vr::VR_GetVRInitErrorAsEnglishDescription(error);
if (!wait) throw std::runtime_error("SteamVR unavailable: " + reason);
if (attempt % 12 == 0) log("Waiting for SteamVR: " + reason);
for (int i = 0; i < 50 && !stopping; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
throw std::runtime_error("Stopped while waiting for SteamVR");
}
~VrSession() { vr::VR_Shutdown(); }
VrSession(const VrSession&) = delete;
};
// Throws with SteamVR's name for `error`, unless it is VRApplicationError_None.
void check_app(vr::EVRApplicationError error, const char* what);
// The app key of the app in front, or "" for none.
std::string scene_app_key();
// The display name of the app with `key`, or "".
std::string app_name(const std::string& key);
std::string steamvr_version();
} // namespace knockthru
+19
View File
@@ -0,0 +1,19 @@
#pragma once
// The pass and fail report the test programs share.
#include <iostream>
#include <string>
inline int failures = 0;
inline void check(bool ok, const std::string& what) {
std::cout << (ok ? " ok " : " FAIL ") << what << "\n";
if (!ok) ++failures;
}
// Prints the summary. Returns the exit code for main().
inline int finish() {
std::cout << (failures ? std::to_string(failures) + " failed\n" : "all passed\n");
return failures ? 1 : 0;
}
+18 -14
View File
@@ -1,4 +1,5 @@
// Config tests: the tour flag and factory reset, in a scratch user directory.
#include "check.hpp"
#include "config.hpp"
#include <cstdlib>
@@ -12,17 +13,6 @@
using namespace knockthru;
namespace fs = std::filesystem;
namespace {
int failures = 0;
void check(bool ok, const std::string& what) {
std::cout << (ok ? " ok " : " FAIL ") << what << "\n";
if (!ok) ++failures;
}
} // namespace
int main() {
const auto root = fs::temp_directory_path() / ("knockthru-config-test-" + std::to_string(getpid()));
fs::remove_all(root);
@@ -55,7 +45,22 @@ int main() {
check(fs::exists(user / "application.log"), "reset keeps application.log");
const auto after = load_config(install);
check(!after.tour_done && !after.paused, "settings are back to defaults after reset");
check(after.check_updates, "the weekly update check is on by default");
check(after.check_updates, "the daily update check is on by default");
std::ofstream(user / "config.json") << R"({"paused": true, "classic_bar_icon": true, "sounds": false,
"tour_done": true, "check_updates": false})";
const auto flags = load_config(install);
check(flags.paused && flags.classic_bar_icon && !flags.sounds && flags.tour_done && !flags.check_updates,
"every true or false setting loads from config.json");
std::ofstream(user / "config.json") << R"({"sounds": "yes"})";
try {
load_config(install);
check(false, "a setting that is not true or false is rejected");
} catch (const std::exception& error) {
check(std::string(error.what()).find("sounds must be true or false") != std::string::npos,
"a setting that is not true or false is rejected");
}
fs::remove(user / "config.json");
{
AppState state = load_state();
@@ -78,6 +83,5 @@ int main() {
}
fs::remove_all(root);
std::cout << (failures ? std::to_string(failures) + " failed\n" : "all passed\n");
return failures ? 1 : 0;
return finish();
}
Binary file not shown.
Binary file not shown.
+15 -11
View File
@@ -1,5 +1,6 @@
// Detector tests: synthetic sequences, then replays of the Steam Frame
// recordings in tests/data/ (see docs/knock-signal.md).
// recordings in tests/data/.
#include "check.hpp"
#include "calibrator.hpp"
#include "config.hpp"
#include "knock_detector.hpp"
@@ -17,13 +18,6 @@ using namespace knockthru;
namespace {
int failures = 0;
void check(bool ok, const std::string& what) {
std::cout << (ok ? " ok " : " FAIL ") << what << "\n";
if (!ok) ++failures;
}
std::vector<KnockEvent> drain(KnockDetector& detector, double now, std::vector<KnockEvent> events = {}) {
if (auto event = detector.poll(now)) events.push_back(*event);
return events;
@@ -116,7 +110,8 @@ DetectorConfig test_config() {
{"min_approach_together", &config.min_approach[0]}, {"min_approach_surface", &config.min_approach[1]},
{"min_approach_headset", &config.min_approach[2]},
{"max_speed_after", &config.max_speed_after}, {"surface_speed_after", &config.surface_speed_after},
{"max_speed_before", &config.max_speed_before}, {"same_spot", &config.same_spot},
{"max_speed_before", &config.max_speed_before}, {"surface_speed_before", &config.surface_speed_before},
{"same_spot", &config.same_spot},
{"min_gap_s", &config.min_gap_s}, {"fast_gap_s", &config.fast_gap_s}, {"max_gap_s", &config.max_gap_s},
{"quiet_before_s", &config.quiet_before_s}};
std::stringstream list(set);
@@ -231,6 +226,16 @@ void replay_tests() {
check(surface["surfacex2"] >= 16, "surface: at least 16 surface doubles");
auto headset = replay("headset");
check(headset["headsetx2"] >= 15, "headset: at least 15 headset doubles");
// Doubles and triples knocked on a leg with each hand, headset worn. At
// surface_speed_before 1.8 most right-hand knocks were lost, giving 9 actions.
auto leg = replay("leg");
check(leg["surfacex2"] + leg["surfacex3"] >= 14 && total(leg) == leg["surfacex2"] + leg["surfacex3"],
"leg: at least 14 surface actions, nothing else");
// About 40 doubles knocked on a leg, a few seconds apart, mostly with the
// right hand. At same_spot 0.03, 20 counted.
auto leg_doubles = replay("leg-doubles");
check(leg_doubles["surfacex2"] >= 23 && total(leg_doubles) == leg_doubles["surfacex2"],
"leg-doubles: at least 23 surface doubles, nothing else");
check(total(replay("play")) == 0, "play: no actions");
check(total(replay("noise")) == 0, "noise: no actions");
// The controllers bumping while the wearer fit the strap on each one, in Into the Radius 2.
@@ -344,6 +349,5 @@ int main(int argc, char** argv) {
replay_tests();
calibration_tests();
calibrated_replay_tests();
std::cout << (failures ? std::to_string(failures) + " failed\n" : "all passed\n");
return failures ? 1 : 0;
return finish();
}
-1
View File
@@ -434,7 +434,6 @@ int main(int argc, char** argv) {
broken.room_view_error = "Turn on CEF Remote Debugging in the Frame's Developer settings.";
broken.opaque = false;
panel.render(broken).write_png(out / "panel-broken.png");
broken.dashboard_changed = true;
broken.room_view_error = "A Steam update moved the Room View button. Update KnockThru in Settings.";
panel.render(broken).write_png(out / "panel-dashboard-changed.png");
+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();
}
+2 -9
View File
@@ -1,5 +1,6 @@
// Update tests: version order, manifest parsing, SHA-256, the file swap, and the issue link,
// in a scratch directory. The install test reads a local ZIP through curl's file:// support.
#include "check.hpp"
#include "report.hpp"
#include "report_server.hpp"
#include "update.hpp"
@@ -28,13 +29,6 @@ namespace fs = std::filesystem;
namespace {
int failures = 0;
void check(bool ok, const std::string& what) {
std::cout << (ok ? " ok " : " FAIL ") << what << "\n";
if (!ok) ++failures;
}
void write(const fs::path& path, const std::string& text) {
fs::create_directories(path.parent_path());
std::ofstream(path, std::ios::trunc | std::ios::binary) << text;
@@ -309,6 +303,5 @@ int main() {
}
fs::remove_all(root);
if (failures) std::cout << failures << " update checks failed\n";
return failures ? 1 : 0;
return finish();
}