Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ if(WINDOWS_POINTER_BUILD_TOOLS)
windows_pointer_target_defaults(windows-pointer-replay)
target_link_libraries(windows-pointer-replay PRIVATE windows_pointer::engine)

if(WIN32)
add_executable(windows-pointer-windows-capture tools/windows-capture.cpp)
windows_pointer_target_defaults(windows-pointer-windows-capture)
target_link_libraries(windows-pointer-windows-capture PRIVATE user32 wtsapi32)
if(MINGW)
target_link_options(
windows-pointer-windows-capture
PRIVATE
-static
-static-libgcc
-static-libstdc++
)
endif()
endif()

if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_executable(windows-pointer-uinput tools/uinput-mouse.cpp)
windows_pointer_target_defaults(windows-pointer-uinput)
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ boundaries.
adapter contract.
- [Testing](docs/testing.md) documents conformance, regression, fuzz, replay,
integration, and performance checks.
- [Windows/Linux VM lab](lab/README.md) runs the same synthetic HID reports
through both operating systems without taking over host USB devices.
- [Contributing](CONTRIBUTING.md) explains how to change the engine or add an
adapter without turning either into folklore.
- [Changelog](CHANGELOG.md) records released behavior.
Expand Down
114 changes: 114 additions & 0 deletions lab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Windows/Linux pointer lab

The safe default is a fully automated, simultaneous Windows/Linux test. It
does not detach the host keyboard, mouse, webcam, Bluetooth radio, or USB
controller.

## everyday commands

```sh
# Build, start both VMs, run the exact comparison, and clean up test devices.
lab/pointer-lab.sh test

# Use a particular input trace.
lab/pointer-lab.sh test lab/traces/full-scenarios.csv

# Manage or inspect the friendly VMs.
lab/pointer-lab.sh start
lab/pointer-lab.sh status
lab/pointer-lab.sh screenshot windows
lab/pointer-lab.sh screenshot linux
lab/pointer-lab.sh stop
```

`test` is unattended. It builds current code, boots and repairs the existing
guests, synchronizes the Linux plugin and Windows collector, and creates two
kernel-backed synthetic USB HID mice. The same report is written to both
devices behind a per-report barrier. The test then:

1. captures Windows Raw Input and desktop pointer events;
2. captures Linux/Hyprland raw and transformed reports;
3. requires every raw and accelerated delta to match exactly;
4. reports guest timing and simultaneous host-write skew;
5. detaches and removes both synthetic devices, including after failures.

Timestamped artifacts are written below `build/lab/dual-vm-*`.

## friendly guests

- `wplab-linux` is Debian 14 with an auto-started Hyprland session, a flat
libinput profile, SSH, and the current plugin.
- `wplab-windows` is a disposable Windows 11 VM with persistent auto-login,
default Windows pointer settings (10/20 with EPP enabled), WinRM on the
libvirt-private network, and a visible Pointer Lab console for Raw Input.
- Both desktops use VNC bound to localhost. Scripts discover the assigned VNC
port dynamically; no `5900`/`5901` ordering is assumed.
- Windows binaries are synchronized over the private VM network instead of
being typed into a console or copied in tiny WinRM chunks.

The visible Windows console is deliberate. Windows does not deliver the same
desktop pointer stream to hidden service or scheduler sessions. The host opens
a fresh interactive PowerShell through the VM's virtual keyboard before each
capture; no physical keyboard is involved.

## initial provisioning

Check the Debian host:

```sh
python3 lab/preflight.py
```

The dependencies are ordinary Debian packages except for the Windows ISO
itself. The preflight checks KVM, libvirt/QEMU, configfs HID support, MinGW,
WinRM support, VNC automation, capture tools, RAM, and disk space.

Provision the Linux guest from the verified Debian cloud image:

```sh
lab/vm-lab.sh provision-linux
lab/vm-lab.sh wait-linux
```

Provision the Windows guest from a genuine Windows 11 ISO:

```sh
lab/vm-lab.sh provision-windows /absolute/path/to/windows-11.iso
lab/vm-lab.sh wait-windows
```

After provisioning, use only the everyday `pointer-lab.sh` commands.

## Bluetooth hardware tests

Real Bluetooth is optional and intentionally separate from pointer-algorithm
conformance.

```sh
sudo lab/bluetooth-radio.sh status
sudo lab/bluetooth-radio.sh attach-windows
sudo lab/bluetooth-radio.sh attach-linux
sudo lab/bluetooth-radio.sh detach
```

This path passes through **one dedicated USB Bluetooth adapter**, not a PCI
USB controller. It refuses any adapter carrying a connected host device. On
the current host, the built-in AX210 is marked `HOST-IN-USE` because it carries
the Pebble, so an additional USB Bluetooth dongle is required.

A dedicated dongle preserves the host keyboard, mouse, webcam, and built-in
Bluetooth. It covers the guest Bluetooth/HID stack and real radio transport.
It does not claim to test the guest against the motherboard's physical xHCI
driver; that distinction is not relevant to pointer acceleration and is not
worth taking over every host USB device.

Physical sensor movement cannot be automated without a mechanical actuator.
When nobody is present, run only the synthetic conformance test.

## unsafe research-only path

`lab/unsafe/full-xhci-controller.sh` is retained only to reproduce low-level
controller experiments. It refuses attachment unless an explicit expert
override is provided because it disconnects every device on the motherboard
controller. It is never called by preflight, `start`, `test`, `status`, or
`stop`.
95 changes: 95 additions & 0 deletions lab/analyze-capture-timing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3

from __future__ import annotations

import argparse
import csv
import pathlib
import statistics


def read_column(path: pathlib.Path, column: str) -> list[int]:
with path.open(newline="", encoding="utf-8") as stream:
rows = (
line
for line in stream
if line.strip() and not line.startswith("#")
)
reader = csv.DictReader(rows)
if reader.fieldnames is None or column not in reader.fieldnames:
raise ValueError(f"{path}: missing {column}")
return [int(row[column]) for row in reader]


def intervals(values: list[int]) -> list[int]:
return [right - left for left, right in zip(values, values[1:])]


def percentile(values: list[int], percent: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
position = (len(ordered) - 1) * percent
lower = int(position)
upper = min(lower + 1, len(ordered) - 1)
fraction = position - lower
return ordered[lower] * (1.0 - fraction) + ordered[upper] * fraction


def describe(name: str, values: list[int], reference: list[int]) -> None:
sample_intervals = intervals(values)
reference_intervals = intervals(reference)
if len(sample_intervals) != len(reference_intervals):
print(
f"{name}: unavailable ({len(values)} samples; "
f"reference has {len(reference)})"
)
return
errors = [
sample - expected
for sample, expected in zip(
sample_intervals, reference_intervals, strict=True
)
]
absolute_errors = [abs(error) for error in errors]
print(
f"{name}: intervals={len(sample_intervals)}, "
f"median={statistics.median(sample_intervals) / 1e6:.3f} ms, "
f"p95_abs_error={percentile(absolute_errors, 0.95) / 1e6:.3f} ms, "
f"max_abs_error={max(absolute_errors, default=0) / 1e6:.3f} ms"
)


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("host", type=pathlib.Path)
parser.add_argument("windows", type=pathlib.Path)
parser.add_argument("linux", type=pathlib.Path)
arguments = parser.parse_args()

scheduled = read_column(arguments.host, "scheduled_ns")
windows_host = read_column(arguments.host, "device_0_start_ns")
linux_host = read_column(arguments.host, "device_1_start_ns")
windows_guest = read_column(arguments.windows, "time_ns")
linux_guest = read_column(arguments.linux, "time_ns")

describe("host→Windows gadget writes", windows_host, scheduled)
describe("host→Linux gadget writes", linux_host, scheduled)
describe("Windows guest receipt", windows_guest, scheduled)
describe("Linux guest receipt", linux_guest, scheduled)

write_skew = [
abs(windows - linux)
for windows, linux in zip(windows_host, linux_host, strict=True)
]
print(
"simultaneous host-write skew: "
f"median={statistics.median(write_skew) / 1e6:.3f} ms, "
f"p95={percentile(write_skew, 0.95) / 1e6:.3f} ms, "
f"max={max(write_skew, default=0) / 1e6:.3f} ms"
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading