feat: relative mouse on the HID interface, and the verifier to prove it

The board now enumerates as a boot keyboard plus a relative mouse on the same
HID interface. The pointer is captured, never grabbed: Pointer Lock in the
page, so Esc (the browser's own, never forwarded) always frees it, and every
frame restates the whole button byte so a frame lost in the WiFi cannot leave
a button held. Losing the lock, the tab or the link runs the same release
funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames
with E11 rather than sending bytes a boot interface would misparse as keys.

src/mouse.h owns the report, src/output.h the queues and the funnel; the page,
relayctl.py and watch_target.py carry the button bits and the evdev decoding.
The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the
first version guessed from the report's field order, so a working wheel looked
dead while the summary said no scroll happened.

LLM-Generated: true
This commit is contained in:
2026-11-08 23:27:42 -05:00
parent 9bc8d5213c
commit ece0313d02
19 changed files with 3135 additions and 324 deletions
+157
View File
@@ -0,0 +1,157 @@
// tests for the emulated mouse: tests/test_mouse.cpp
//
// The two things worth proving about a relative mouse are that a frame which changes
// nothing sends nothing, and that a button release is never conditional on movement.
// Everything else about it is arithmetic.
#include <cstddef>
#include <cstring>
#include "mouse.h"
#include "test.h"
using kr::mouse::Button;
using kr::mouse::Frame;
using kr::mouse::MouseReport;
using kr::mouse::MouseState;
namespace {
bool allZero(const MouseReport &r) {
const uint8_t *b = reinterpret_cast<const uint8_t *>(&r);
for (size_t i = 0; i < sizeof(MouseReport); i++) {
if (b[i] != 0) return false;
}
return true;
}
} // namespace
int main() {
CASE("the report is the five bytes the descriptor promises");
{
CHECK_EQ(sizeof(MouseReport), 5);
// The layout is asserted, not assumed: buttons, X, Y, wheel, pan. A struct that
// reordered itself would still compile and would move the target's cursor with the
// wheel byte.
CHECK_EQ(offsetof(MouseReport, buttons), 0);
CHECK_EQ(offsetof(MouseReport, dx), 1);
CHECK_EQ(offsetof(MouseReport, dy), 2);
CHECK_EQ(offsetof(MouseReport, wheel), 3);
CHECK_EQ(offsetof(MouseReport, pan), 4);
}
CASE("button bits are the report's order, not the DOM's");
{
CHECK_EQ(kr::mouse::kBtnLeft, 0x01);
CHECK_EQ(kr::mouse::kBtnRight, 0x02);
CHECK_EQ(kr::mouse::kBtnMiddle, 0x04);
CHECK_EQ(kr::mouse::kBtnBackward, 0x08);
CHECK_EQ(kr::mouse::kBtnForward, 0x10);
CHECK_EQ(kr::mouse::kBtnAll, 0x1F);
}
CASE("movement produces a report, nothing produces nothing");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{0, 30, -4, 0, 0}, r));
CHECK_EQ(r.buttons, 0);
CHECK_EQ(r.dx, 30);
CHECK_EQ(r.dy, -4);
CHECK_EQ(r.wheel, 0);
CHECK_EQ(r.pan, 0);
// An idle frame is the whole endpoint for that frame and says nothing, so it is
// dropped rather than sent -- this is what stops a chatty controller keeping a
// target busy at 125 Hz with a pointer that is not moving.
CHECK(!m.apply(Frame{0, 0, 0, 0, 0}, r));
CHECK(!m.apply(Frame{0, 0, 0, 0, 0}, r));
}
CASE("a button change is a report even with no movement at all");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r));
CHECK_EQ(r.buttons, kr::mouse::kBtnLeft);
CHECK(allZero(kr::mouse::kReportClear)); // the report a fail-safe sends
// Repeating the same button byte with no movement is not a new state.
CHECK(!m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r));
CHECK(m.apply(Frame{0, 0, 0, 0, 0}, r)); // ...but letting go is
CHECK_EQ(r.buttons, 0);
}
CASE("buttons are restated, not diffed");
{
MouseState m;
MouseReport r;
m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r);
m.apply(Frame{kr::mouse::kBtnLeft | kr::mouse::kBtnRight, 0, 0, 0, 0}, r);
CHECK_EQ(r.buttons, kr::mouse::kBtnLeft | kr::mouse::kBtnRight);
// Letting go of one reports the state that remains, which is the whole reason a
// frame lost in the network cannot strand a button: the next frame is the truth.
m.apply(Frame{kr::mouse::kBtnRight, 5, 0, 0, 0}, r);
CHECK_EQ(r.buttons, kr::mouse::kBtnRight);
CHECK_EQ(r.dx, 5);
CHECK_EQ(m.buttons(), kr::mouse::kBtnRight);
CHECK(m.down());
}
CASE("only the five buttons the report has");
{
MouseState m;
MouseReport r;
// The parser refuses a sixth bit, so this is a second line of defence rather than
// the behaviour anyone relies on: whatever arrives cannot set a padding bit.
m.apply(Frame{0xFF, 1, 0, 0, 0}, r);
CHECK_EQ(r.buttons, 0x1F);
CHECK_EQ(m.buttons(), 0x1F);
}
CASE("releaseAll lets go, from any state, and is idempotent");
{
MouseState m;
MouseReport r;
// Nothing was ever down; letting go is still well-defined and leaves the same state.
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
CHECK(!m.down());
m.apply(Frame{kr::mouse::kBtnMiddle, 0, 0, 0, 0}, r);
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
CHECK(!m.down());
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
// And after letting go, the next movement frame restates the empty button byte -- so
// a release that never reached the target is corrected by the next one regardless.
CHECK(m.apply(Frame{0, 4, 0, 0, 0}, r));
CHECK_EQ(r.buttons, 0);
}
CASE("the full range of one signed byte survives");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{0, 127, -127, 127, -127}, r));
CHECK_EQ(r.dx, 127);
CHECK_EQ(r.dy, -127);
CHECK_EQ(r.wheel, 127);
CHECK_EQ(r.pan, -127);
}
CASE("report equality is byte equality");
{
const MouseReport a{1, 2, 3, 4, 5};
const MouseReport b{1, 2, 3, 4, 5};
const MouseReport c{1, 2, 3, 4, 6};
const MouseReport d{0, 2, 3, 4, 5};
CHECK(kr::mouse::reportsEqual(a, b));
CHECK(!kr::mouse::reportsEqual(a, c)); // pan differs: still a different report
CHECK(!kr::mouse::reportsEqual(a, d));
}
return report("mouse");
}