From a3da96f8197ad44e942c252078ea8a8b99f4c767 Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Mon, 5 Oct 2026 15:56:09 +0800 Subject: [PATCH 1/2] feat: add support for stylus barrel roll negotiation & communication --- src/input.cpp | 58 +++++++++++++++++++- src/input_activity.cpp | 6 +++ src/pen_barrel_roll.h | 72 +++++++++++++++++++++++++ src/platform/common.h | 1 + src/platform/windows/input.cpp | 20 ++++++- src/rtsp.cpp | 5 ++ tests/CMakeLists.txt | 6 +++ tests/unit/test_input_activity.cpp | 14 +++++ tests/unit/test_pen_barrel_roll.cpp | 83 +++++++++++++++++++++++++++++ 9 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 src/pen_barrel_roll.h create mode 100644 tests/unit/test_pen_barrel_roll.cpp diff --git a/src/input.cpp b/src/input.cpp index eb3a10697..8ae0eeec7 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -23,6 +23,7 @@ extern "C" { #include "input.h" #include "text_context/bridge.h" #include "input_activity.h" +#include "pen_barrel_roll.h" #include "logging.h" #include "platform/common.h" #include "display_device/session.h" @@ -217,6 +218,8 @@ namespace input { std::vector gamepads; activity::tracker_t activity_tracker; + pen_wire::roll_probe_t pen_roll_probe; + bool input_stopped = false; std::unique_ptr client_context; safe::mail_raw_t::event_t touch_port_event; @@ -615,6 +618,10 @@ namespace input { case SS_PEN_MAGIC: print((PSS_PEN_PACKET) payload); break; + case pen_wire::magic: + print(&reinterpret_cast(payload)->pen); + BOOST_LOG(debug) << "barrelRoll: " << pen_wire::decode_roll(reinterpret_cast(payload)->barrelRoll); + break; case SS_CONTROLLER_ARRIVAL_MAGIC: print((PSS_CONTROLLER_ARRIVAL_PACKET) payload); break; @@ -1352,7 +1359,7 @@ namespace input { * @param packet The pen packet. */ void - passthrough(std::shared_ptr &input, PSS_PEN_PACKET packet) { + passthrough(std::shared_ptr &input, PSS_PEN_PACKET packet, std::uint16_t barrel_roll = pen_wire::unknown) { if (!config::input.mouse) { return; } @@ -1400,6 +1407,7 @@ namespace input { from_clamped_netfloat(packet->pressureOrDistance, 0.0f, 1.0f), contact_area.first, contact_area.second, + barrel_roll == pen_wire::unknown ? rotation : barrel_roll, }; #ifdef SUNSHINE_TESTS @@ -2006,6 +2014,16 @@ namespace input { return batch((PSS_TOUCHPAD_FRAME_PACKET) dest, (PSS_TOUCHPAD_FRAME_PACKET) src); case SS_PEN_MAGIC: return batch((PSS_PEN_PACKET) dest, (PSS_PEN_PACKET) src); + case pen_wire::magic: { + auto *dest_pen = reinterpret_cast(dest); + auto *src_pen = reinterpret_cast(src); + auto result = batch(&dest_pen->pen, &src_pen->pen); + if (result == batch_result_e::batched) { + dest_pen->barrelRoll = src_pen->barrelRoll; + dest_pen->reserved = src_pen->reserved; + } + return result; + } case SS_CONTROLLER_TOUCH_MAGIC: return batch((PSS_CONTROLLER_TOUCH_PACKET) dest, (PSS_CONTROLLER_TOUCH_PACKET) src); case SS_CONTROLLER_MOTION_MAGIC: @@ -2120,6 +2138,11 @@ namespace input { case SS_PEN_MAGIC: passthrough(input, (PSS_PEN_PACKET) payload); break; + case pen_wire::magic: { + auto *packet = reinterpret_cast(payload); + passthrough(input, &packet->pen, pen_wire::decode_roll(packet->barrelRoll)); + break; + } case SS_CONTROLLER_ARRIVAL_MAGIC: passthrough(input, (PSS_CONTROLLER_ARRIVAL_PACKET) payload); break; @@ -2148,6 +2171,24 @@ namespace input { passthrough(std::shared_ptr &input, std::vector &&input_data) { { std::lock_guard lg(input->input_queue_lock); + if (input->input_stopped) { + return; + } + if (input_data.size() >= sizeof(SS_PEN_PACKET) && + util::endian::little(reinterpret_cast(input_data.data())->magic) == SS_PEN_MAGIC) { + input->pen_roll_probe.reset(); + } + // Observe every received sample before hover batching can discard it. + // Keep the probe under the session queue lock and queue the resolved twist. + if (input_data.size() == sizeof(pen_wire::packet_t)) { + auto *packet = reinterpret_cast(input_data.data()); + if (util::endian::little(packet->pen.header.magic) == pen_wire::magic && + pen_wire::valid_size(input_data.size(), packet->pen.header.size)) { + auto azimuth = pen_wire::decode_roll(packet->pen.rotation); + auto roll = input->pen_roll_probe.select(pen_wire::decode_roll(packet->barrelRoll), azimuth); + packet->barrelRoll = util::endian::little(roll); + } + } input->input_queue.push_back(std::move(input_data)); } task_pool.push(passthrough_next_message, input); @@ -2179,11 +2220,24 @@ namespace input { void reset(std::shared_ptr &input) { + { + std::lock_guard lg(input->input_queue_lock); + input->input_stopped = true; + input->input_queue.clear(); + input->pen_roll_probe.reset(); + } task_pool.cancel(key_press_repeat_id); task_pool.cancel(input->mouse_left_button_timeout); // Ensure input is synchronous, by using the task_pool - task_pool.push(reset_input_state); + task_pool.push([input]() { + if (input->client_context) { + platf::pen_input_t cancel {}; + cancel.eventType = LI_TOUCH_EVENT_CANCEL_ALL; + platf::pen_update(input->client_context.get(), {}, cancel); + } + reset_input_state(); + }); } class deinit_t: public platf::deinit_t { diff --git a/src/input_activity.cpp b/src/input_activity.cpp index 8de61d09f..75ee4c849 100644 --- a/src/input_activity.cpp +++ b/src/input_activity.cpp @@ -10,6 +10,7 @@ #include "config.h" #include "cursor_channel.h" #include "input_activity.h" +#include "pen_barrel_roll.h" #include "utility.h" namespace input::activity { @@ -21,6 +22,11 @@ namespace input::activity { } switch (util::endian::little(payload->magic)) { + case pen_wire::magic: + if (!pen_wire::valid_size(payload_size, payload->size)) { + return std::nullopt; + } + return false; case MOUSE_MOVE_REL_MAGIC_GEN5: { if (payload_size < sizeof(NV_REL_MOUSE_MOVE_PACKET)) { return std::nullopt; diff --git a/src/pen_barrel_roll.h b/src/pen_barrel_roll.h new file mode 100644 index 000000000..abc9b1a22 --- /dev/null +++ b/src/pen_barrel_roll.h @@ -0,0 +1,72 @@ +/** + * @file src/pen_barrel_roll.h + * @brief Wire layout for voidlink-c's independent pen barrel roll extension. + */ +#pragma once + +#include +#include +#include +#include + +namespace input::pen_wire { + constexpr std::uint32_t magic = 0x5500000B; + static_assert(magic != SS_TOUCHPAD_MAGIC && magic != SS_TOUCHPAD_FRAME_MAGIC); + constexpr std::uint16_t unknown = 0xffff; + + // Default to legacy azimuth until this session demonstrates real roll changes. + class roll_probe_t { + public: + void reset() { + first_ = unknown; + varied_ = false; + } + + std::uint16_t select(std::uint16_t roll, std::uint16_t azimuth) { + if (roll == unknown) { + return azimuth; + } + if (first_ == unknown) { + first_ = roll; + } + if (roll != first_) { + varied_ = true; + } + return varied_ ? roll : azimuth; + } + + private: + std::uint16_t first_ = unknown; + bool varied_ = false; + }; + + // Mirrors SS_PEN_BARREL_ROLL_PACKET in voidlink-c, retaining the legacy + // prefix and the two-byte trailing reserved field without changing it. +#pragma pack(push, 1) + struct packet_t { + SS_PEN_PACKET pen; + std::uint16_t barrelRoll; + std::uint16_t reserved; + }; +#pragma pack(pop) + + static_assert(sizeof(SS_PEN_PACKET) == 36); + static_assert(offsetof(packet_t, barrelRoll) == 36); + static_assert(sizeof(packet_t) == 40); + + constexpr std::uint16_t + decode_roll(std::uint16_t value) { + if constexpr (std::endian::native == std::endian::big) { + value = std::byteswap(value); + } + return value == unknown ? unknown : value % 360; + } + + constexpr bool + valid_size(std::size_t available, std::uint32_t network_size) { + if constexpr (std::endian::native == std::endian::little) { + network_size = std::byteswap(network_size); + } + return available == sizeof(packet_t) && network_size == sizeof(packet_t) - sizeof(std::uint32_t); + } +} // namespace input::pen_wire diff --git a/src/platform/common.h b/src/platform/common.h index ea42c51d1..9e065585e 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -445,6 +445,7 @@ namespace platf { float pressureOrDistance; // Distance for hover and pressure for contact float contactAreaMajor; float contactAreaMinor; + std::uint16_t barrelRoll = 0xffff; // Independent axial twist, or unknown }; class deinit_t { diff --git a/src/platform/windows/input.cpp b/src/platform/windows/input.cpp index 92c025a5e..aa2a9e316 100644 --- a/src/platform/windows/input.cpp +++ b/src/platform/windows/input.cpp @@ -1846,6 +1846,22 @@ namespace platf { raw->penInfo.type = PT_PEN; + // A stopped session may retain its input context. Retire its virtual pen + // immediately so its last twist cannot keep being replayed after reconnect. + if (pen.eventType == LI_TOUCH_EVENT_CANCEL_ALL) { + auto &pointer = raw->penInfo.penInfo.pointerInfo; + if (pointer.pointerFlags != POINTER_FLAG_NONE) { + populate_common_pointer_info(pointer, {}, LI_TOUCH_EVENT_CANCEL_ALL, 0.0f, 0.0f); + if (!inject_synthetic_pointer_input(raw->global, raw->pen, &raw->penInfo, 1)) { + BOOST_LOG(warning) << "Failed to cancel virtual pen input: " << GetLastError(); + } + } + raw->global->fnDestroySyntheticPointerDevice(raw->pen); + raw->pen = nullptr; + raw->penInfo = {}; + return; + } + auto &penInfo = raw->penInfo.penInfo; penInfo.pointerInfo.pointerType = PT_PEN; penInfo.pointerInfo.pointerId = 0; @@ -1888,9 +1904,9 @@ namespace platf { penInfo.pressure = 0; } - if (pen.rotation != LI_ROT_UNKNOWN) { + if (pen.barrelRoll != LI_ROT_UNKNOWN) { penInfo.penMask |= PEN_MASK_ROTATION; - penInfo.rotation = pen.rotation; + penInfo.rotation = pen.barrelRoll % 360; } else { penInfo.rotation = 0; diff --git a/src/rtsp.cpp b/src/rtsp.cpp index bd9d77e6f..5ed317f22 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -1193,6 +1193,11 @@ namespace rtsp_stream { caps |= platf::platform_caps::remote_text_context; } ss << "a=x-ss-general.featureFlags:" << caps << std::endl; +#ifdef _WIN32 + if (config::input.mouse && (caps & platf::platform_caps::pen_touch)) { + ss << "a=x-ss-general.penBarrelRoll:1" << std::endl; + } +#endif } // Always request new control stream encryption if the client supports it diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1abf7b230..3628d5c34 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,12 @@ set(INSTALL_GMOCK OFF) add_subdirectory("${GTEST_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/googletest") include_directories("${GTEST_SOURCE_DIR}/googletest/include" "${GTEST_SOURCE_DIR}") +add_executable(pen_barrel_roll_unit_tests ${CMAKE_SOURCE_DIR}/tests/unit/test_pen_barrel_roll.cpp) +target_include_directories(pen_barrel_roll_unit_tests PRIVATE "${CMAKE_SOURCE_DIR}/third-party") +target_link_libraries(pen_barrel_roll_unit_tests PRIVATE gtest_main) +set_target_properties(pen_barrel_roll_unit_tests PROPERTIES CXX_STANDARD 23) +add_test(NAME pen_barrel_roll_unit_tests COMMAND pen_barrel_roll_unit_tests) + # coverage # https://gcovr.com/en/stable/guide/compiling.html#compiler-options option(SUNSHINE_TESTS_ENABLE_COVERAGE "Enable gcov coverage instrumentation for tests" OFF) diff --git a/tests/unit/test_input_activity.cpp b/tests/unit/test_input_activity.cpp index cd69b757c..375aab21f 100644 --- a/tests/unit/test_input_activity.cpp +++ b/tests/unit/test_input_activity.cpp @@ -13,8 +13,22 @@ #include #include #include +#include #include +TEST(PenBarrelRollActivity, ValidatesExtendedPacketsBeforeDispatch) { + input::activity::tracker_t tracker; + input::pen_wire::packet_t packet {}; + packet.pen.header.magic = util::endian::little(input::pen_wire::magic); + packet.pen.header.size = util::endian::big(std::uint32_t {36}); + auto result = tracker.evaluate(&packet.pen.header, sizeof(packet)); + ASSERT_TRUE(result.has_value()); + EXPECT_FALSE(*result); + EXPECT_FALSE(tracker.evaluate(&packet.pen.header, 36).has_value()); + packet.pen.header.size = util::endian::big(std::uint32_t {32}); + EXPECT_FALSE(tracker.evaluate(&packet.pen.header, sizeof(packet)).has_value()); +} + #ifdef INPUT_ACTIVITY_STANDALONE_TEST namespace config { input_t input; diff --git a/tests/unit/test_pen_barrel_roll.cpp b/tests/unit/test_pen_barrel_roll.cpp new file mode 100644 index 000000000..d4f72cb1d --- /dev/null +++ b/tests/unit/test_pen_barrel_roll.cpp @@ -0,0 +1,83 @@ +#include + +#include + +#include "src/pen_barrel_roll.h" + +TEST(PenBarrelRollProbe, DefaultsToAzimuthWhileRollIsConstant) { + input::pen_wire::roll_probe_t probe; + for (int i = 0; i < 100; ++i) { + EXPECT_EQ(probe.select(120, i), i); + } +} + +TEST(PenBarrelRollProbe, VariationEnablesIndependentRollForTheSession) { + input::pen_wire::roll_probe_t probe; + EXPECT_EQ(probe.select(359, 90), 90); + EXPECT_EQ(probe.select(0, 180), 0); + EXPECT_EQ(probe.select(0, 270), 0); + EXPECT_EQ(probe.select(359, 270), 359); + EXPECT_EQ(probe.select(input::pen_wire::unknown, 90), 90); + EXPECT_EQ(probe.select(359, 270), 359); +} + +TEST(PenBarrelRollProbe, UnknownValuesDoNotEnableRollAndSessionsStartFresh) { + input::pen_wire::roll_probe_t probe; + EXPECT_EQ(probe.select(input::pen_wire::unknown, 90), 90); + EXPECT_EQ(probe.select(120, 90), 90); + EXPECT_EQ(probe.select(input::pen_wire::unknown, 180), 180); + EXPECT_EQ(probe.select(120, 180), 180); + EXPECT_EQ(probe.select(121, 180), 121); + input::pen_wire::roll_probe_t next_session; + EXPECT_EQ(next_session.select(121, 180), 180); +} +TEST(PenBarrelRollProbe, DisconnectOrLegacyPacketResetsActivatedProbe) { + input::pen_wire::roll_probe_t probe; + EXPECT_EQ(probe.select(120, 90), 90); + EXPECT_EQ(probe.select(121, 90), 121); + probe.reset(); + EXPECT_EQ(probe.select(121, 180), 180); + EXPECT_EQ(probe.select(121, 270), 270); + EXPECT_EQ(probe.select(122, 270), 122); +} + +TEST(PenBarrelRoll, MatchesExtendedWireLayout) { + EXPECT_EQ(sizeof(input::pen_wire::packet_t), 40); + EXPECT_EQ(offsetof(input::pen_wire::packet_t, pen), 0); + EXPECT_EQ(offsetof(input::pen_wire::packet_t, barrelRoll), 36); + EXPECT_EQ(offsetof(input::pen_wire::packet_t, reserved), 38); +} + +TEST(PenBarrelRoll, ReadsLittleEndianTwistAndPreservesLegacyPose) { + input::pen_wire::packet_t packet {}; + packet.pen.tilt = 45; + packet.pen.rotation = 123; + const std::uint8_t encoded[] = {0x0e, 0x01}; // 270 degrees + std::memcpy(&packet.barrelRoll, encoded, sizeof(encoded)); + EXPECT_EQ(input::pen_wire::decode_roll(packet.barrelRoll), 270); + EXPECT_EQ(packet.pen.tilt, 45); + EXPECT_EQ(packet.pen.rotation, 123); +} + +TEST(PenBarrelRoll, PreservesUnknownInsteadOfWrappingIt) { + EXPECT_EQ(input::pen_wire::decode_roll(0xffff), input::pen_wire::unknown); +} + +TEST(PenBarrelRoll, NormalizesDegrees) { + const std::uint8_t encoded[] = {0xd1, 0x02}; // 721 degrees + std::uint16_t value; + std::memcpy(&value, encoded, sizeof(value)); + EXPECT_EQ(input::pen_wire::decode_roll(value), 1); + EXPECT_EQ(input::pen_wire::decode_roll(0), 0); +} + +TEST(PenBarrelRoll, RejectsTruncatedAndIncorrectlySizedPackets) { + const std::uint8_t encoded[] = {0, 0, 0, 36}; + std::uint32_t size; + std::memcpy(&size, encoded, sizeof(size)); + EXPECT_TRUE(input::pen_wire::valid_size(40, size)); + for (std::size_t available : {0, 8, 36, 38, 39, 41}) { + EXPECT_FALSE(input::pen_wire::valid_size(available, size)); + } + EXPECT_FALSE(input::pen_wire::valid_size(40, 0)); +} From a935081207c097bd9d69adae08fb04a7bb2627c2 Mon Sep 17 00:00:00 2001 From: True Zhuanjia Date: Tue, 6 Oct 2026 16:12:54 +0800 Subject: [PATCH 2/2] fix(input): log invalid barrel-roll packets and limit pen cleanup to Windows --- src/input.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/input.cpp b/src/input.cpp index 8ae0eeec7..1ed6d8c7e 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -220,6 +220,7 @@ namespace input { activity::tracker_t activity_tracker; pen_wire::roll_probe_t pen_roll_probe; bool input_stopped = false; + std::chrono::steady_clock::time_point next_invalid_pen_warning {}; std::unique_ptr client_context; safe::mail_raw_t::event_t touch_port_event; @@ -2174,6 +2175,23 @@ namespace input { if (input->input_stopped) { return; } + if (input_data.size() >= sizeof(NV_INPUT_HEADER)) { + const auto *header = reinterpret_cast(input_data.data()); + if (util::endian::little(header->magic) == pen_wire::magic && + !pen_wire::valid_size(input_data.size(), header->size)) { + const auto now = std::chrono::steady_clock::now(); + if (now >= input->next_invalid_pen_warning) { + input->next_invalid_pen_warning = now + std::chrono::seconds(5); + BOOST_LOG(warning) << "Dropping invalid barrel-roll pen packet [session_id=" << input->session_id + << ", payload_bytes=" << input_data.size() + << ", header.size=" << util::endian::big(header->size) + << ", expected_payload_bytes=" << sizeof(pen_wire::packet_t) + << ", expected_header.size=" << sizeof(pen_wire::packet_t) - sizeof(std::uint32_t) + << "] (warnings limited to one per 5 seconds per session)"; + } + return; + } + } if (input_data.size() >= sizeof(SS_PEN_PACKET) && util::endian::little(reinterpret_cast(input_data.data())->magic) == SS_PEN_MAGIC) { input->pen_roll_probe.reset(); @@ -2231,11 +2249,13 @@ namespace input { // Ensure input is synchronous, by using the task_pool task_pool.push([input]() { +#ifdef _WIN32 if (input->client_context) { platf::pen_input_t cancel {}; cancel.eventType = LI_TOUCH_EVENT_CANCEL_ALL; platf::pen_update(input->client_context.get(), {}, cancel); } +#endif reset_input_state(); }); }