// Writes the dashboard panel in sample states to PNG files. // Usage: panel_preview OUTPUT_DIRECTORY // panel_preview --readme OUTPUT_DIRECTORY // The calibration steps also get frame sequences in OUTPUT_DIRECTORY/frames, at 25 // frames a second, to review the animations. The --readme form writes clean states for // the README images instead. #include "panel.hpp" #include "report_server.hpp" #include #include #include #include #include #include #include using namespace knockthru; // Writes `seconds` of frames, starting at `start`, as NAME-000.png and up. void frames(Panel& panel, const PanelState& state, const std::filesystem::path& out, const std::string& name, double start, double seconds) { std::filesystem::create_directories(out / "frames"); for (int i = 0; i < static_cast(seconds * 25); ++i) { std::ostringstream file; file << name << "-" << std::setw(3) << std::setfill('0') << i << ".png"; panel.render(state, std::nullopt, start + i / 25.0).write_png(out / "frames" / file.str()); } } // Writes a frame at `t`, after one at `t` - 0.5 so a new screen has finished rising in. void shot(Panel& panel, const PanelState& state, double t, const std::filesystem::path& path, std::optional> hover = std::nullopt) { panel.render(state, hover, t - 0.5); panel.render(state, hover, t).write_png(path); } // Walks through the five calibration steps, as clicks and the app would. void calibration(const std::filesystem::path& out, const PanelState& idle) { Panel panel(KNOCKTHRU_FONTS); panel.render(idle, std::nullopt, 10); panel.click(460, 60); // Settings on the main screen panel.render(idle, std::nullopt, 10.2); panel.click(1050, 184); // Calibrate in Settings shot(panel, idle, 10.6, out / "calibrate-1-pick.png", std::make_pair(600.0f, 400.0f)); frames(panel, idle, out, "pick", 20.8, 2.6); panel.click(600, 400); // the surface card shot(panel, idle, 30.6, out / "calibrate-2-learn.png"); frames(panel, idle, out, "learn", 40, 2); PanelState state = idle; state.message.clear(); CalibrationView view; view.kind = KnockKind::Surface; view.peaks = {92, 118, 76}; view.speeds = {1.1, 1.5, 0.8}; view.counted_t = 50; view.knocked_t = 49.7; view.seconds_left = 74; state.calibration = view; shot(panel, state, 50.6, out / "calibrate-3-record.png"); frames(panel, state, out, "record", 50, 1.2); state.calibration->ignored = KnockKind::Together; state.calibration->ignored_t = 51.5; shot(panel, state, 51.8, out / "calibrate-3-record-hint.png"); // The get-ready ring after Start, with a knock that came too soon. PanelState ready = state; ready.calibration->peaks.clear(); ready.calibration->speeds.clear(); ready.calibration->started_t = 55; ready.calibration->too_soon_t = 55.8; shot(panel, ready, 56.2, out / "calibrate-3-record-ready.png"); // Robin's surface calibration on the Frame, 2026-10-15, with its one too-light knock raised to 64. view.peaks = {80, 401, 115, 342, 446, 91, 57, 234, 64, 147}; view.speeds = {1.21, 0.85, 0.71, 0.81, 0.62, 0.77, 0.63, 0.53, 0.72, 1.71}; view.phase = CalibrationView::Phase::Trying; state.calibration = view; shot(panel, state, 70.6, out / "calibrate-4-try.png"); frames(panel, state, out, "try", 71, 2.6); state.calibration->trial_passed = false; state.calibration->trial_heard = "a triple surface knock"; state.calibration->trial_t = 74; shot(panel, state, 74.6, out / "calibrate-4-try-miss.png"); state.calibration->trial_passed = true; shot(panel, state, 75.6, out / "calibrate-4-try-pass.png"); state.calibration.reset(); state.calibration_saved = KnockKind::Surface; shot(panel, state, 80, out / "calibrate-saved.png"); state.enabled[static_cast(KnockKind::Surface)] = false; shot(panel, state, 81, out / "calibrate-saved-kind-off.png"); } // The tour's six steps, with the knock and calibrate steps before and after they finish. void tour(const std::filesystem::path& out, const PanelState& idle) { Panel panel(KNOCKTHRU_FONTS); PanelState state = idle; state.message.clear(); TourView view; state.tour = view; shot(panel, state, 10, out / "tour-1-welcome.png"); state.tour->step = TourView::Step::Kinds; state.enabled = {true, true, false}; shot(panel, state, 20, out / "tour-2-kinds.png"); state.tour->step = TourView::Step::Calibrate; shot(panel, state, 30, out / "tour-3-calibrate.png", std::make_pair(600.0f, 400.0f)); panel.click(600, 400); // the surface card opens the Learn screen shot(panel, state, 32, out / "tour-3-calibrate-learn.png"); panel.click(100, 60); // Back returns to the tour state.tour->calibrated[static_cast(KnockKind::Surface)] = true; shot(panel, state, 34, out / "tour-3-calibrate-saved.png"); state.tour->step = TourView::Step::Double; shot(panel, state, 40, out / "tour-4-double.png"); state.tour->double_t = 49; state.room_view = true; shot(panel, state, 50, out / "tour-4-double-heard.png"); state.tour->step = TourView::Step::Triple; shot(panel, state, 60, out / "tour-5-triple.png"); state.tour->triple_t = 69; state.opaque = true; shot(panel, state, 70, out / "tour-5-triple-heard.png"); state.room_view = false; shot(panel, state, 70, out / "tour-5-triple-heard-off.png"); state.room_view = true; state.tour->step = TourView::Step::Done; shot(panel, state, 80, out / "tour-6-done.png"); state.tour->step = TourView::Step::Triple; panel.render(state, std::nullopt, 89); panel.click(856, 692); // Skip opens the confirm card shot(panel, state, 90, out / "tour-skip-confirm.png"); panel.click(388, 496); // Keep going state.tour->step = TourView::Step::Kinds; state.enabled = {false, false, false}; state.message = "Settings not saved: Cannot write config.json"; state.message_is_error = true; shot(panel, state, 100, out / "tour-2-kinds-none.png"); } // Writes 25 frames a second of one screen, numbered on from earlier calls, and moves the clock on. // It also moves a pointer, which hovers what it rests on, and logs where it is for the README // script to draw. struct Recorder { using Point = std::pair; Panel* panel; std::filesystem::path dir; double t = 100; int index = 0; std::optional pointer; // starts and ends the loop at kRest std::optional from, bend, to; // a pointer move in progress, along a curve through bend double move_t = 0, move_s = 0; std::vector cuts; // frames where the clock jumps, to cross-fade std::vector clicks; // frames where the pointer clicks std::ofstream log{(std::filesystem::create_directories(dir), dir / "pointer.txt")}; void play(const PanelState& state, double seconds) { until(state, t + seconds); } void until(const PanelState& state, double end) { for (; t < end - 1e-6; t += 1 / 25.0) { if (to) { // Minimum-jerk timing, as a hand moves: a smooth start and a long settle. const float p = std::min(1.0f, static_cast((t - move_t) / move_s)); const float e = p * p * p * (10 - 15 * p + 6 * p * p); const auto at = [&](float a, float b, float c) { return (1 - e) * (1 - e) * a + 2 * (1 - e) * e * b + e * e * c; }; pointer = Point{at(from->first, bend->first, to->first), at(from->second, bend->second, to->second)}; if (p >= 1) to.reset(); } std::ostringstream file; file << std::setw(4) << std::setfill('0') << index++ << ".png"; panel->render(state, pointer, t).write_png(dir / file.str()); log << pointer->first << " " << pointer->second << "\n"; } } // Moves the pointer to (x, y) along a slight arc. Longer moves take longer, as with a real pointer. void aim(float x, float y) { from = pointer; to = Point{x, y}; const float dx = x - from->first, dy = y - from->second, length = std::hypot(dx, dy); bend = Point{(from->first + x) / 2 + dy * 0.12f, (from->second + y) / 2 - dx * 0.12f}; move_t = t; move_s = 0.35 + length / 1600; } void click() { clicks.push_back(index); panel->click(pointer->first, pointer->second); } void write_marks() const { std::ofstream marks(dir / "marks.txt"); marks << "cuts"; for (int cut : cuts) marks << " " << cut; marks << "\nclicks"; for (int click : clicks) marks << " " << click; marks << "\n"; } // The first time after the clock where `phase` seconds into a `loop` long animation comes round. double next(double loop, double phase) const { return t + std::fmod(phase - std::fmod(t, loop) + 2 * loop, loop); } }; // A surface calibration from the Settings button to the saved banner, as frames in `dir`. // Counted knocks land on the contacts of the panel's own knock drawing. void readme_calibration(const std::filesystem::path& dir) { Panel panel(KNOCKTHRU_FONTS); PanelState state; state.room_view = false; state.opaque = false; state.app_key = "steam.app.620980"; state.app_name = "Beat Saber"; state.version = "0.2.0"; // A spot that hovers nothing on the main screen or under the Saved banner. const Recorder::Point rest{300, 730}; Recorder rec{&panel, dir}; rec.pointer = rest; rec.play(state, 0.5); rec.aim(460, 64); // Settings rec.play(state, 1.0); rec.click(); rec.play(state, 0.4); rec.aim(1050, 188); // Calibrate rec.play(state, 1.2); rec.click(); rec.play(state, 0.5); rec.aim(600, 420); // the surface card rec.play(state, 1.5); rec.click(); rec.play(state, 1.2); rec.aim(1040, 656); // Start, which the app answers by recording rec.play(state, 1.0); rec.click(); // Robin's surface calibration on the Frame, 2026-10-15, as in calibration(). const std::vector peaks{80, 401, 115, 342, 446, 91, 64, 234, 64, 147}; const std::vector speeds{1.21, 0.85, 0.71, 0.81, 0.62, 0.77, 0.63, 0.53, 0.72, 1.71}; CalibrationView view; view.kind = KnockKind::Surface; view.started_t = rec.t - 2.0; // most of the get-ready time skipped view.seconds_left = 88; state.calibration = view; rec.aim(760, 730); // out of the way while knocking const auto knock = [&](std::size_t i) { auto& v = *state.calibration; rec.until(state, rec.next(2.0, 0.5)); // the record screen knocks once every 2 s v.peaks.push_back(peaks[i]); v.speeds.push_back(speeds[i]); v.knocked_t = v.counted_t = rec.t; v.seconds_left = 90 - static_cast(rec.t - v.started_t); }; knock(0); knock(1); rec.play(state, 1.0); // Skip ahead to just before the tenth knock, which lands as the cross-fade ends. rec.cuts.push_back(rec.index); rec.t += 14.6; for (std::size_t i = 2; i < 9; ++i) state.calibration->peaks.push_back(peaks[i]), state.calibration->speeds.push_back(speeds[i]); state.calibration->knocked_t = state.calibration->counted_t = rec.t - 1.6; state.calibration->seconds_left = 90 - static_cast(rec.t - state.calibration->started_t); knock(9); rec.play(state, 0.9); state.calibration->phase = CalibrationView::Phase::Trying; rec.play(state, 1.0); rec.until(state, rec.next(2.6, 1.2)); // the try screen knocks twice every 2.6 s, then waits 0.4 s state.calibration->trial_passed = true; state.calibration->trial_t = rec.t; rec.play(state, 0.5); rec.aim(1030, 696); // Save rec.play(state, 1.2); rec.click(); state.calibration.reset(); state.calibration_saved = KnockKind::Surface; rec.play(state, 0.8); rec.aim(1043, 632); // Done on the Saved banner, back to Settings rec.play(state, 1.2); rec.click(); rec.play(state, 0.5); rec.aim(100, 68); // Back, to the main screen the loop starts on rec.play(state, 1.0); rec.click(); rec.play(state, 0.4); rec.aim(rest.first, rest.second); // back where the loop starts rec.play(state, 1.6); // the README script cross-fades the last 0.4 s to the first frame rec.write_marks(); } // Installing an update from the dot on the Settings button to the restarted app, as frames in `dir`. void readme_update(const std::filesystem::path& dir) { Panel panel(KNOCKTHRU_FONTS); PanelState state; state.room_view = false; state.opaque = false; state.app_key = "steam.app.620980"; state.app_name = "Beat Saber"; state.version = "1.0.0"; state.update = {UpdateView::Status::Available, "1.1.0", ""}; const Recorder::Point rest{300, 730}; Recorder rec{&panel, dir}; rec.pointer = rest; rec.play(state, 1.2); rec.aim(460, 64); // Settings, with its yellow update dot rec.play(state, 1.1); rec.click(); rec.play(state, 0.6); rec.aim(1035, 68); // Install 1.1.0 rec.play(state, 1.4); rec.click(); state.update.status = UpdateView::Status::Installing; for (int i = 0; i <= 60; ++i) { // the download fills the button over 2.4 s const float p = i / 60.0f; state.update_progress = p * p * (3 - 2 * p); rec.play(state, 0.04); } state.update_progress = 0; state.update.status = UpdateView::Status::Installed; rec.play(state, 1.2); rec.click(); // Restart, where Install was state.update.status = UpdateView::Status::Restarting; rec.play(state, 1.2); // KnockThru restarts from the new files. Its panel opens on the main screen with the update card. rec.cuts.push_back(rec.index); Panel restarted(KNOCKTHRU_FONTS); rec.panel = &restarted; state.version = "1.1.0"; state.update = {UpdateView::Status::Updated, "1.0.0", ""}; state.update_notice = UpdateNotice{false, "You now have version 1.1.0. You had version 1.0.0."}; rec.play(state, 1.0); rec.aim(832, 466); // OK on the card rec.play(state, 1.8); rec.click(); state.update_notice.reset(); rec.play(state, 0.4); rec.aim(rest.first, rest.second); rec.play(state, 1.8); // the README script cross-fades the last 0.4 s to the first frame rec.write_marks(); } // Sending a report: Settings, Report, then Done once the phone has the report, as frames in // `dir`. The README script draws the phone beside it, timed from the Report click. void readme_report(const std::filesystem::path& dir) { Panel panel(KNOCKTHRU_FONTS); PanelState state; state.room_view = false; state.opaque = false; state.app_key = "steam.app.620980"; state.app_name = "Beat Saber"; state.version = "0.2.0"; state.report_url = "http://192.168.2.222:39841/5f0c9a2e81d34b7a"; state.report_full = true; const Recorder::Point rest{300, 730}; Recorder rec{&panel, dir}; rec.pointer = rest; rec.play(state, 0.8); rec.aim(460, 64); // Settings rec.play(state, 1.1); rec.click(); rec.play(state, 0.5); rec.aim(1050, 542); // Report on the Something wrong? row rec.play(state, 1.3); rec.click(); rec.play(state, 0.3); rec.aim(rest.first, rest.second); // out of the way while the phone works rec.play(state, 17.5); rec.aim(951, 627); // Done rec.play(state, 1.1); rec.click(); rec.play(state, 0.4); rec.aim(rest.first, rest.second); rec.play(state, 1.8); // the README script cross-fades the last 0.4 s to the first frame rec.write_marks(); } // Writes the passthrough off, on, and opaque states with no hover highlight. int readme(const std::filesystem::path& out) { Panel panel(KNOCKTHRU_FONTS); PanelState state; state.room_view = false; state.opaque = false; state.app_key = "steam.app.620980"; state.app_name = "Beat Saber"; shot(panel, state, 10, out / "readme-off.png"); state.room_view = true; shot(panel, state, 20, out / "readme-on.png"); state.opaque = true; shot(panel, state, 30, out / "readme-opaque.png"); // The report card, opened from Settings. The README script dims its sample QR code. Panel report_panel(KNOCKTHRU_FONTS); PanelState report = state; report.opaque = false; report.room_view = false; report.version = "0.2.0"; report.report_url = "http://192.168.2.222:39841/5f0c9a2e81d34b7a"; report.report_full = true; report_panel.render(report, std::nullopt, 40); report_panel.click(460, 60); // Settings report_panel.render(report, std::nullopt, 41); report_panel.click(1050, 542); // Report on the Something wrong? row shot(report_panel, report, 43, out / "readme-report.png"); // The page the phone opens from the QR code, as the headset serves it. std::ofstream(out / "readme-phone.html") << report_page( "KnockThru bug report, 2026-10-15 14:02:11\n\nKnockThru: 0.2.0\nSteamVR: 2.18.2\n" "Registered with SteamVR: yes\nStarts with SteamVR: yes\nCurrent app: steam.app.620980\n", "https://github.com/VioletTides/KnockThru/issues/new?template=bug.yml&version=0.2.0", "report.txt"); readme_calibration(out / "calibrate"); readme_update(out / "update"); readme_report(out / "report"); std::cout << "Wrote README panel states to " << out << "\n"; return 0; } int main(int argc, char** argv) { if (argc > 2 && std::string(argv[1]) == "--readme") return readme(argv[2]); const std::filesystem::path out = argc > 1 ? argv[1] : "."; Panel panel(KNOCKTHRU_FONTS); PanelState idle; idle.room_view = false; idle.opaque = false; idle.app_key = "steam.app.620980"; idle.app_name = "Beat Saber"; idle.message = "Passthrough off (double together knock)"; panel.render(idle, std::make_pair(900.0f, 300.0f)).write_png(out / "panel-idle.png"); PanelState ready = idle; ready.update = {UpdateView::Status::Available, "0.2.0", ""}; panel.render(ready).write_png(out / "panel-update-ready.png"); PanelState busy = idle; busy.room_view = true; busy.opaque = true; busy.paused = true; busy.app_blocked = true; busy.enabled = {true, false, true}; panel.render(busy).write_png(out / "panel-paused.png"); PanelState broken; 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"); calibration(out, idle); PanelState none = idle; none.enabled = {false, false, false}; Panel back_panel(KNOCKTHRU_FONTS); back_panel.render(none).write_png(out / "panel-no-kinds.png"); // Opens the settings screen through its button, as a click would. Panel settings_panel(KNOCKTHRU_FONTS); settings_panel.render(idle); settings_panel.click(460, 60); settings_panel.render(idle, std::make_pair(600.0f, 200.0f)).write_png(out / "panel-settings.png"); idle.classic_bar_icon = true; settings_panel.render(idle).write_png(out / "panel-settings-color.png"); PanelState update = idle; update.version = "0.1.0"; settings_panel.render(update).write_png(out / "panel-settings-update-idle.png"); update.update = {UpdateView::Status::Checking, "", ""}; settings_panel.render(update).write_png(out / "panel-settings-update-checking.png"); update.update = {UpdateView::Status::Available, "0.2.0", ""}; settings_panel.render(update, std::make_pair(1000.0f, 68.0f)).write_png(out / "panel-settings-update-available.png"); update.update = {UpdateView::Status::Current, "0.1.0", ""}; settings_panel.render(update).write_png(out / "panel-settings-update-current.png"); update.update = {UpdateView::Status::Installing, "0.2.0", ""}; update.update_progress = 0.45f; settings_panel.render(update).write_png(out / "panel-settings-update-installing.png"); update.update_progress = 0; update.update = {UpdateView::Status::Installed, "0.2.0", ""}; settings_panel.render(update).write_png(out / "panel-settings-update-installed.png"); update.update = {UpdateView::Status::Restarting, "0.2.0", ""}; settings_panel.render(update).write_png(out / "panel-settings-update-restarting.png"); update.version = "0.2.0"; update.update = {UpdateView::Status::Updated, "0.1.0", ""}; settings_panel.render(update).write_png(out / "panel-settings-update-updated.png"); { PanelState updated = idle; updated.version = "0.2.0"; updated.update = {UpdateView::Status::Updated, "0.1.0", ""}; updated.update_notice = UpdateNotice{false, "You now have version 0.2.0. You had version 0.1.0."}; Panel main_panel(KNOCKTHRU_FONTS); main_panel.render(updated).write_png(out / "panel-updated.png"); updated.update_notice = UpdateNotice{true, "Version 0.2.0 was not installed. The downloaded ZIP does not match " "its SHA-256. Nothing changed. You still have version 0.1.0."}; main_panel.render(updated).write_png(out / "panel-update-failed.png"); } update.version = "0.1.0"; update.update = {UpdateView::Status::Failed, "", "Cannot reach GitHub. Check the Wi-Fi connection."}; update.message = "Update check failed: Cannot reach GitHub. Check the Wi-Fi connection."; update.message_is_error = true; settings_panel.render(update).write_png(out / "panel-settings-update-failed.png"); update.update = {}; update.message.clear(); update.message_is_error = false; update.report_url = "http://192.168.2.222:39841/5f0c9a2e81d34b7a"; update.report_full = true; settings_panel.click(1050, 542); // Report on the Something wrong? row settings_panel.render(update, std::nullopt, 0); settings_panel.render(update).write_png(out / "panel-report.png"); update.recording_left = 14; update.recording_knocks = 3; settings_panel.render(update).write_png(out / "panel-report-recording.png"); update.recording_left = -1; update.recorded = true; update.report_saved = "/home/steamos/knockthru-report.txt"; settings_panel.render(update).write_png(out / "panel-report-saved.png"); update.report_saved.clear(); update.report_full = false; update.report_url = "https://github.com/VioletTides/KnockThru/issues/new?template=bug.yml&version=0.1.0" "&steamvr=2.18.2&passthrough=on"; settings_panel.render(update).write_png(out / "panel-report-offline.png"); settings_panel.click(900, 638); // Done settings_panel.click(1050, 632); // Reset on the factory reset row settings_panel.render(idle, std::make_pair(800.0f, 500.0f)).write_png(out / "panel-reset-confirm.png"); idle.classic_bar_icon = false; tour(out, idle); Canvas icon(256, 256); Panel::draw_icon(icon); icon.write_png(out / "icon.png"); Panel::draw_icon(icon, IconStyle::BarWhite); icon.write_png(out / "icon-dashboard.png"); Panel::draw_icon(icon, IconStyle::BarColor); icon.write_png(out / "icon-dashboard-classic.png"); std::cout << "Wrote panel previews to " << out << "\n"; }