Skip to content

Protocol 2: negotiated versioning, the POINTER frame, and feedback to a Direct-claimed pad - #47

Merged
emir-hasanbegovic merged 3 commits into
mainfrom
feat/protocol-2-controller-feedback
Sep 1, 2026
Merged

Protocol 2: negotiated versioning, the POINTER frame, and feedback to a Direct-claimed pad#47
emir-hasanbegovic merged 3 commits into
mainfrom
feat/protocol-2-controller-feedback

Conversation

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

Protocol 2: negotiated versioning, the POINTER frame, and feedback to a Direct-claimed pad

The client half of the protocol-2 push, following satellite#86/#87 and
dish-android#174/#175, and the companion to the dish-windows change of the same
name. No virtual controllers: this client forwards physical pads, so everything
below is about what a real pad can send and be driven with.

Versioning is negotiated now

The client offers 2; the satellite settles the session on that offer and echoes
it back. The SETTLED version keys the wire frames, not the offered one, because
a pre-versioning satellite ignores the offer and answers 1 -- reading the offer
back would send 19-byte frames to a 16-byte decoder. A 409 whose range still
overlaps ours is re-offered at the satellite's ceiling instead of dead-ending
the user, and one that does not names which end has to update.

An absent protocolVersion was being read as this build's version rather than
as 1. That is fixed here, and it is the bug that would have made every
pre-versioning satellite look like it had agreed to protocol 2.

The POINTER frame (0x000C, v2, 19 bytes)

The click moved out of the finger flags into a buttons byte and a signed wheel
was appended. This client has no on-screen trackpad, so the only producer is a
pad's own touch surface: it reports one click and no wheel, and the right,
middle and scroll fields ride as zero rather than being synthesised from
gestures the user never made. dish-android forwards a physical pad the same way.

Feedback to a Direct-claimed pad

UsbDeviceGateway gained a writeOutputReport seam, so a raw-HID claim now
drives the pad as well as reading it. The bytes are built by the new pure
core/input/UsbOutputReports.h (byte-exact, host-tested); hidraw wants no
framing of its own, so the builder's length is the transfer length and the
gateway only retries EINTR.

That lands four things on a Direct claim: rumble and the lightbar (which
previously fired only on the SDL path -- the "no output write path" note in
CapabilitySolver is gone), MSG_TRIGGER_EFFECTS replaying the game's own
DualSense blocks verbatim, and MSG_PLAYER_LEDS driving the DualSense bar and
the Switch Pro's player lights.

core/reducer/FeedbackRouting.h is the single owner of both "may the
descriptor claim this" and "where does a dispatch go", so an advertised
capability and a delivered message cannot disagree. Standard (SDL) carries
rumble and a colour and nothing else; Direct carries everything its family has,
but only while the claim is live.

Moonlight

Three real gaps closed here, not just additions:

  • Motion was forwarded as the satellite's raw fixed-point int16 cast to a
    float, where the wire wants deg/s and m/s^2. A pad at rest read correctly and
    a moving one read as spinning at tens of thousands of degrees per second. The
    conversion now lives in one owner (MoonlightTelemetry.h) shared by both
    transports.
  • Motion was gated on a single session-wide flag, so one game opening one sensor
    started the stream for every pad on that host, at whatever rate the hardware
    polled. It is per (pad, motion type) at the requested rate now.
  • A Moonlight-bound pad never declared CAP_BATTERY and never forwarded its
    charge, though the pad was already publishing one.

Plus CONTROLLER_TOUCH: the pad's full-state frame is diffed into per-pointer
DOWN / MOVE / UP, with a tracking-id change closing the old contact first so the
host's pointer set cannot leak an id that is never closed.

RUMBLE_TRIGGERS folds onto the body motors, which the arrival packet already
advertised. No pad this client can claim has impulse-trigger motors -- xpad
binds an Xbox pad as evdev-only and publishes no hidraw node for it -- so the
fold is the honest maximum rather than a shortcut. The two host rumble streams
mix per motor by maximum, so neither can cancel the other; before, whichever
arrived last won and a trigger update could cancel a live body rumble.

Tests

New suites: protocol negotiation, feedback routing, the output report builders
(byte-exact, every offset and every must-stay-zero byte), the touch differ, the
telemetry units and motion gate, the trigger-rumble mix, the POINTER frame and
the two feedback messages, and the manager's OUT-direction dispatch over a
recording gateway. The pure core headers were syntax-checked locally; the full
build, ctest, clang-format, clang-tidy and qmllint runs are CI's (this branch
was developed on a Windows box). The translation gate passes locally: six
catalogues, 1094/1094 each.

… a Direct-claimed pad

The client half of the protocol-2 push, following satellite#86/#87 and
dish-android#174/#175, and the companion to the dish-windows change of the same
name. No virtual controllers: this client forwards physical pads, so everything
below is about what a real pad can send and be driven with.

## Versioning is negotiated now

The client offers 2; the satellite settles the session on that offer and echoes
it back. The SETTLED version keys the wire frames, not the offered one, because
a pre-versioning satellite ignores the offer and answers 1 -- reading the offer
back would send 19-byte frames to a 16-byte decoder. A 409 whose range still
overlaps ours is re-offered at the satellite's ceiling instead of dead-ending
the user, and one that does not names which end has to update.

An absent `protocolVersion` was being read as this build's version rather than
as 1. That is fixed here, and it is the bug that would have made every
pre-versioning satellite look like it had agreed to protocol 2.

## The POINTER frame (0x000C, v2, 19 bytes)

The click moved out of the finger flags into a buttons byte and a signed wheel
was appended. This client has no on-screen trackpad, so the only producer is a
pad's own touch surface: it reports one click and no wheel, and the right,
middle and scroll fields ride as zero rather than being synthesised from
gestures the user never made. dish-android forwards a physical pad the same way.

## Feedback to a Direct-claimed pad

`UsbDeviceGateway` gained a `writeOutputReport` seam, so a raw-HID claim now
drives the pad as well as reading it. The bytes are built by the new pure
`core/input/UsbOutputReports.h` (byte-exact, host-tested); hidraw wants no
framing of its own, so the builder's length is the transfer length and the
gateway only retries EINTR.

That lands four things on a Direct claim: rumble and the lightbar (which
previously fired only on the SDL path -- the "no output write path" note in
`CapabilitySolver` is gone), `MSG_TRIGGER_EFFECTS` replaying the game's own
DualSense blocks verbatim, and `MSG_PLAYER_LEDS` driving the DualSense bar and
the Switch Pro's player lights.

`core/reducer/FeedbackRouting.h` is the single owner of both "may the
descriptor claim this" and "where does a dispatch go", so an advertised
capability and a delivered message cannot disagree. Standard (SDL) carries
rumble and a colour and nothing else; Direct carries everything its family has,
but only while the claim is live.

## Moonlight

Three real gaps closed here, not just additions:

- Motion was forwarded as the satellite's raw fixed-point int16 cast to a
  float, where the wire wants deg/s and m/s^2. A pad at rest read correctly and
  a moving one read as spinning at tens of thousands of degrees per second. The
  conversion now lives in one owner (`MoonlightTelemetry.h`) shared by both
  transports.
- Motion was gated on a single session-wide flag, so one game opening one sensor
  started the stream for every pad on that host, at whatever rate the hardware
  polled. It is per (pad, motion type) at the requested rate now.
- A Moonlight-bound pad never declared `CAP_BATTERY` and never forwarded its
  charge, though the pad was already publishing one.

Plus `CONTROLLER_TOUCH`: the pad's full-state frame is diffed into per-pointer
DOWN / MOVE / UP, with a tracking-id change closing the old contact first so the
host's pointer set cannot leak an id that is never closed.

`RUMBLE_TRIGGERS` folds onto the body motors, which the arrival packet already
advertised. No pad this client can claim has impulse-trigger motors -- xpad
binds an Xbox pad as evdev-only and publishes no hidraw node for it -- so the
fold is the honest maximum rather than a shortcut. The two host rumble streams
mix per motor by maximum, so neither can cancel the other; before, whichever
arrived last won and a trigger update could cancel a live body rumble.

## Tests

New suites: protocol negotiation, feedback routing, the output report builders
(byte-exact, every offset and every must-stay-zero byte), the touch differ, the
telemetry units and motion gate, the trigger-rumble mix, the POINTER frame and
the two feedback messages, and the manager's OUT-direction dispatch over a
recording gateway. The pure core headers were syntax-checked locally; the full
build, ctest, clang-format, clang-tidy and qmllint runs are CI's (this branch
was developed on a Windows box). The translation gate passes locally: six
catalogues, 1094/1094 each.
… mapping

Three things a Windows box could not catch by compiling:

- AppModel.cpp uses the new MoonlightTelemetry / MoonlightTouchDiffer headers
  and std::make_shared, and AppModel.h names proto::kTriggerEffectBlockBytes.
  All four were resolving transitively or not at all; include them directly.
- The Moonlight rumble sink had the two motors crossed. The wire's lowFrequency
  is the LARGE motor -- what SDL calls low_frequency_rumble and what
  dish-windows and dish-android both map to `strong` -- and this sink passed
  (high, low) into applyRumble(strong, weak). The session now hands the sink
  motors rather than wire fields, so the mapping lives in one place and the
  actuator cannot re-derive it differently.
Three findings, all in code this branch added, fixed rather than suppressed:

- `bugprone-unchecked-optional-access` on `directTarget`. The dereference was
  guarded, but by the second half of an `||` whose first half tests a different
  object, which the analysis cannot follow. Splitting the two conditions makes
  it provable and says the truer thing anyway: a model being tracked and a model
  holding a live claim are different facts.
- `modernize-use-auto` twice, on a `static_cast` whose type was spelled on both
  sides.
@emir-hasanbegovic
emir-hasanbegovic merged commit 75cfe1b into main Sep 1, 2026
13 checks passed
@emir-hasanbegovic
emir-hasanbegovic deleted the feat/protocol-2-controller-feedback branch September 1, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant