ref:306158240648ef59fb5b1ed6bed157e9d28ef644

fix(linux): keep relative mouse input on absolute guests and skip idle host devices

- On an absolute guest, relative moves were pulled back to older positions by lagging MouseSet reports: record our own absolute motion too, so reports are trusted only after the pointer settled. - With capture = qemu, libvirtualhid no longer creates the host keyboard, mouse, touchscreen and pen tablet that never receive events; gamepads are unchanged. - REQ-CMP-001: test that with another capture every event still goes to libvirtualhid, none to QEMU, and pen/touch follow the runtime. Refs #5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHA: 306158240648ef59fb5b1ed6bed157e9d28ef644
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-13 02:04
Parents: a1becb0
5 files changed +92 -11
Type
src/platform/linux/qemu/input.cpp +5 −3
@@ -333,7 +333,7 @@
if (!pointer || !pointer->visible || pointer->sequence == pointer_sequence) {
return;
}
if (pointer_x && std::chrono::steady_clock::now() - last_relative_motion < pointer_settle_time) {
if (pointer_x && std::chrono::steady_clock::now() - last_own_motion < pointer_settle_time) {
return;
}
BOOST_LOG(verbose) << "qemu: guest pointer at "sv << pointer->x << ',' << pointer->y << " from MouseSet"sv;
@@ -355,6 +355,8 @@
if (device.is_absolute()) {
device.set_abs_position((std::uint32_t) x, (std::uint32_t) y);
// MouseSet reports arriving meanwhile are for positions older than this one
last_own_motion = std::chrono::steady_clock::now();
pointer_x = x;
pointer_y = y;
motion_remainder_x = 0;
@@ -382,7 +384,7 @@
pointer_y = y;
if (dx != 0 || dy != 0) {
device.rel_motion(dx, dy);
last_relative_motion = std::chrono::steady_clock::now();
last_own_motion = std::chrono::steady_clock::now();
}
return true;
}
@@ -415,7 +417,7 @@
}
device->rel_motion(delta_x, delta_y);
last_relative_motion = std::chrono::steady_clock::now();
last_own_motion = std::chrono::steady_clock::now();
if (pointer_x && pointer_y && device->width() > 0 && device->height() > 0) {
pointer_x = std::clamp(*pointer_x + delta_x, 0.0, device->width() - 1.0);
pointer_y = std::clamp(*pointer_y + delta_y, 0.0, device->height() - 1.0);
src/platform/linux/qemu/input.h +1 −1
@@ -310,7 +310,7 @@
double motion_remainder_x {0}; ///< Relative motion below one pixel not yet sent.
double motion_remainder_y {0}; ///< Relative motion below one pixel not yet sent.
std::uint64_t pointer_sequence {0}; ///< `MouseSet` report last taken as the pointer position.
std::chrono::steady_clock::time_point last_relative_motion; ///< When relative motion was last sent.
std::chrono::steady_clock::time_point last_own_motion; ///< When pointer motion (relative or absolute) was last sent; newer `MouseSet` reports may still describe older positions.
std::map<std::uint32_t, touch_contact_t> touch_contacts; ///< Active touch contacts by Moonlight pointer id.
std::optional<std::uint32_t> mouse_contact; ///< Contact that drives the mouse when there is no multi-touch.
std::bitset<256> logged_keys; ///< Virtual keys already logged as unmapped.
src/platform/virtualhid_input.cpp +9 −6
@@ -450,7 +450,8 @@
input_context_t::input_context_t():
input_context_t {lvh::BackendKind::platform_default} {}
input_context_t::input_context_t(lvh::BackendKind backend):
input_context_t::input_context_t(lvh::BackendKind backend, bool host_pointer_devices):
host_pointer_devices {host_pointer_devices},
runtime {create_runtime(backend)} {
if (!runtime) {
BOOST_LOG(warning) << "Unable to create libvirtualhid runtime"sv;
@@ -463,6 +464,6 @@
void input_context_t::refresh_keyboard() {
keyboard.reset();
if (!runtime || !runtime->capabilities().supports_keyboard) {
if (!runtime || !host_pointer_devices || !runtime->capabilities().supports_keyboard) {
return;
}
@@ -480,6 +481,6 @@
void input_context_t::refresh_mouse() {
mouse.reset();
if (!runtime || !runtime->capabilities().supports_mouse) {
if (!runtime || !host_pointer_devices || !runtime->capabilities().supports_mouse) {
return;
}
@@ -497,6 +498,6 @@
client_context_t::client_context_t(input_context_t &input):
global {&input} {
if (!global->runtime || !global->host_pointer_devices) {
if (!global->runtime) {
return;
}
@@ -1038,13 +1039,15 @@
} // namespace
input_t input() {
auto raw = new input_raw_t {};
#ifdef SUNSHINE_BUILD_QEMU
if (config::video.capture == "qemu") {
// keyboard, mouse, touch and pen go to the VM; libvirtualhid only creates gamepads
auto raw = new input_raw_t {virtualhid::input_context_t {lvh::BackendKind::platform_default, false}};
raw->qemu = std::make_unique<qemu::input_t>(config::video.qemu_dbus_address);
return {raw};
}
#endif
return {new input_raw_t {}};
return {raw};
}
std::unique_ptr<client_input_t> allocate_client_input_context(input_t &input) {
src/platform/virtualhid_input.h +4 −1
@@ -33,8 +33,10 @@
* @brief Construct the libvirtualhid input context using a selected backend.
*
* @param backend Backend used to create the libvirtualhid runtime.
* @param host_pointer_devices Whether to create the keyboard, mouse, touchscreen and pen tablet;
* false when another backend receives those events and only gamepads use libvirtualhid.
*/
explicit input_context_t(lvh::BackendKind backend, bool host_pointer_devices = true);
explicit input_context_t(lvh::BackendKind backend);
/**
* @brief Recreate the shared keyboard using the runtime's current driver and license state.
@@ -46,6 +48,7 @@
*/
void refresh_mouse();
bool host_pointer_devices {true}; ///< Whether the keyboard, mouse, touchscreen and pen tablet are created.
std::unique_ptr<lvh::Runtime> runtime; ///< libvirtualhid runtime.
std::unique_ptr<lvh::Keyboard> keyboard; ///< Shared virtual keyboard.
std::unique_ptr<lvh::Mouse> mouse; ///< Shared virtual mouse.
tests/unit/platform/linux/qemu/test_input.cpp +73 −0
@@ -21,5 +21,6 @@
#include <src/platform/linux/qemu/frame_store.h>
#include <src/platform/linux/qemu/input.h>
#include <src/platform/linux/qemu/session.h>
#include <src/platform/virtualhid_input.h>
using namespace std::literals;
@@ -309,6 +310,35 @@
}
// @tag requirements: [REQ-INP-002]
TEST_F(QemuInputTest, RelativeMouseOnAnAbsoluteGuestIgnoresMouseSetReportsOfOlderPositions) {
connect_input();
auto session = qemu::shared_session(bus->address());
ASSERT_TRUE(session);
auto store = std::make_shared<qemu::frame_store_t>();
auto registration = session->register_listener(0, store);
ASSERT_TRUE(registration);
ASSERT_TRUE(fake->wait_for_listener(0));
qemu::set_capture_console(session, 0, store);
ASSERT_TRUE(input->wait_for_console(5s));
// the guest moves its hardware cursor for every SetAbsPosition, but QEMU's MouseSet reports lag behind
input->abs_mouse(port_640x400, 100.0f, 100.0f);
input->move_mouse(10, 0);
input->move_mouse(10, 0);
ASSERT_TRUE(fake->mouse_set(0, 110, 100, true)); // the report for the first move arrives
input->move_mouse(10, 0);
ASSERT_TRUE(fake->mouse_set(0, 120, 100, true));
input->move_mouse(10, 0);
EXPECT_EQ(calls(), (std::vector<std::string> {"abs 100 100", "abs 110 100", "abs 120 100", "abs 130 100", "abs 140 100"}));
// once our own motion stopped, the guest's position counts again (it may have moved the pointer)
ASSERT_TRUE(fake->mouse_set(0, 300, 200, true));
std::this_thread::sleep_for(150ms);
input->move_mouse(5, 5);
EXPECT_EQ(calls(), (std::vector<std::string> {"abs 100 100", "abs 110 100", "abs 120 100", "abs 130 100", "abs 140 100", "abs 305 205"}));
}
// @tag requirements: [REQ-INP-002]
TEST_F(QemuInputTest, AbsoluteMouseBecomesRelativeMotionFromTheGuestPointer) {
connect_input();
set_absolute(false);
@@ -525,6 +555,17 @@
EXPECT_EQ(qemu::input_count(), 1u);
auto client = platf::allocate_client_input_context(platform_input);
ASSERT_TRUE(client);
// only gamepads use libvirtualhid: no idle keyboard, mouse, touchscreen or pen tablet on the host
const auto &host = platf::virtualhid::get_input_context(platform_input);
EXPECT_FALSE(host.host_pointer_devices);
EXPECT_EQ(host.keyboard, nullptr);
EXPECT_EQ(host.mouse, nullptr);
EXPECT_EQ(platf::virtualhid::get_client_context(client.get()).touch, nullptr);
EXPECT_EQ(platf::virtualhid::get_client_context(client.get()).pen, nullptr);
platf::virtualhid::get_input_context(platform_input).refresh_keyboard();
platf::virtualhid::get_input_context(platform_input).refresh_mouse();
EXPECT_EQ(host.keyboard, nullptr);
EXPECT_EQ(host.mouse, nullptr);
ASSERT_TRUE(qemu_test::wait_until([&]() {
platf::keyboard_update(platform_input, vk_a, false, 0);
@@ -553,12 +594,44 @@
auto other = platf::input();
ASSERT_TRUE(other);
EXPECT_EQ(qemu::input_count(), 0u) << "no QEMU input unless capture = qemu";
EXPECT_TRUE(platf::virtualhid::get_input_context(other).host_pointer_devices) << "libvirtualhid keeps every device";
auto other_client = platf::allocate_client_input_context(other);
ASSERT_TRUE(other_client);
// every event goes to libvirtualhid, none to the VM, and stopping the stream leaves the VM alone
fake->clear_input_calls(0);
platf::keyboard_update(other, vk_a, false, 0);
platf::keyboard_update(other, vk_a, true, 0);
platf::button_mouse(other, BUTTON_LEFT, false);
platf::button_mouse(other, BUTTON_LEFT, true);
platf::abs_mouse(other, port_640x400, 5.0f, 6.0f);
platf::move_mouse(other, 1, 1);
platf::scroll(other, -120);
platf::hscroll(other, 120);
platf::unicode(other, "x", 1);
platf::touch_update(other_client.get(), port_640x400, touch_event(LI_TOUCH_EVENT_DOWN, 1, 0.5f, 0.5f));
platf::pen_update(other_client.get(), port_640x400, {LI_TOUCH_EVENT_HOVER, LI_TOOL_TYPE_PEN, 0, LI_TILT_UNKNOWN, LI_ROT_UNKNOWN, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f});
platf::streaming_will_stop();
std::this_thread::sleep_for(300ms);
EXPECT_TRUE(fake->input_calls(0).empty());
EXPECT_TRUE(fake->input_calls(1).empty());
}
// @tag requirements: [REQ-INP-003]
TEST_F(QemuInputTest, AdvertisesPenAndTouchWithQemuCapture) {
config::input.native_pen_touch = true;
EXPECT_TRUE(platf::get_capabilities() & platf::platform_caps::pen_touch);
config::input.native_pen_touch = false;
EXPECT_FALSE(platf::get_capabilities() & platf::platform_caps::pen_touch);
}
// @tag requirements: [REQ-INP-003, REQ-CMP-001]
TEST_F(QemuInputTest, PenAndTouchFollowLibvirtualhidWithOtherCaptures) {
config::video.capture = "x11";
const auto runtime = platf::virtualhid::create_runtime();
const bool host_devices = runtime && (runtime->capabilities().supports_touchscreen || runtime->capabilities().supports_pen_tablet);
config::input.native_pen_touch = true;
EXPECT_EQ((bool) (platf::get_capabilities() & platf::platform_caps::pen_touch), host_devices) << "as in a build without the QEMU backend";
config::input.native_pen_touch = false;
EXPECT_FALSE(platf::get_capabilities() & platf::platform_caps::pen_touch);
}