Przeglądaj źródła

Relay OUTPUT report 0x81 HAPTIC_PULSE (fixes #163, #166) (#199)

Two different Valve id spaces both use 0x81:

  feature 0x01 cmd 0x81 = ID_CLEAR_DIGITAL_MAPPINGS (the connect-time
                          amp-clicker; correctly dropped in Steam mode)
  OUTPUT report    0x81 = ID_OUT_REPORT_HAPTIC_PULSE, MsgHapticPulse
                          [side][on_us u16][off_us u16][repeat u16]

668bda6 extended the feature-channel drop to the OUTPUT report, reading
it as "Steam's haptic-reset action, a 7-byte report id 0x81 with payloads
00../01..". Those leading 00/01 are side = left/right pad: that was Steam
firing legitimate click pulses, mistaken for spurious clicks. Result: the
trackpad "Regular Press" click, the trigger full-pull click and GripSense
cues all vanished, while "Soft Press" kept working because it rides
0x82 ID_OUT_REPORT_HAPTIC_COMMAND.

Ground truth is SDL steam/controller_structs.h
(ValveTritonOutReportMessageIDs), whose payload sizes match our cloned
report descriptor byte-for-byte: 0x80 rumble(9) 0x81 pulse(7)
0x82 command(3) 0x83 lfo_tone(9) 0x84 log_sweep(8) 0x85 script(3). SDL
also notes Triton drives haptics through OUTPUT reports, and the
controller's feature-command table has no 0x8F/0xEA handler -- so every
Steam haptic arrives in 0x80-0x86 and 0x81 was the only one dropped.

Pulses carry their own repeat_count, so unlike 0x80 rumble or the 0x82
on/off pair there is no latch to strand and no stop frame to lose.

g_drop81 (console S81) now covers only the feature-channel command it was
written for; the mechanism blamed for the buzz in #102 stays suppressed.
ReversePuckFirmware's Deck forwarder had the mirror-image bug -- it
rendered 0x82/0x8F but not 0x81, and 0x8F is a feature-channel id Triton
does not implement -- so it now buzzes on real pulses too.

Both firmwares compile clean. Not hardware-verified.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jari 2 miesięcy temu
rodzic
commit
f2cf9c0ce3

+ 26 - 13
OpenPuck/puck_hid.cpp

@@ -123,9 +123,11 @@ static const uint8_t PUCK_LIZARD_HID_DESC[] = {
 
 static Adafruit_USBD_HID hid[NSLOT];
 
-// Drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS in Steam mode (console "S81" toggles; on by default). It's
-// the confirmed amp-clicker in Steam's per-connect config and OpenPuck doesn't need it (own input translation
-// + id9=0 keepalive). Reversible from the console if it regresses.
+// Drop Steam's relayed FEATURE cmd 0x81 = ID_CLEAR_DIGITAL_MAPPINGS in Steam mode (console "S81" toggles; on
+// by default). It's the confirmed amp-clicker in Steam's per-connect config and OpenPuck doesn't need it (own
+// input translation + id9=0 keepalive). Reversible from the console if it regresses.
+// FEATURE-CHANNEL ONLY: the OUTPUT report with id 0x81 is ID_OUT_REPORT_HAPTIC_PULSE, an unrelated command
+// (see handleSet) -- dropping that one costs the trackpad-click / trigger-full-pull haptics (#163, #166).
 bool g_drop81 = true;
 
 // Per-slot shadow of the controller's SET_SETTINGS_VALUES array (id-indexed u16, ids 0..0x52). Steam writes
@@ -242,8 +244,14 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 	// Steam OUTPUT reports 0x80-0x89. The haptic/actuator reports (0x80-0x86) are relayed to the controller,
 	// and ONLY when they arrive on the CONNECTED slot's interface: we have one controller but expose 4 puck
 	// slots, and a report aimed at a DIFFERENT slot made the controller buzz at random (the slot gate below
-	// fixes that). Must NOT clamp to 0x82-only: ping/grip/test haptics ride other report IDs (0x85/0x86). The
-	// 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and reach the controller via the
+	// fixes that). Must NOT clamp to 0x82-only -- Steam drives the actuators through the WHOLE Triton OUTPUT
+	// report space (ValveTritonOutReportMessageIDs, SDL steam/controller_structs.h; the ids match this
+	// interface's descriptor payload sizes exactly):
+	//   0x80 HAPTIC_RUMBLE(9)  0x81 HAPTIC_PULSE(7)   0x82 HAPTIC_COMMAND(3)
+	//   0x83 HAPTIC_LFO_TONE(9) 0x84 HAPTIC_LOG_SWEEP(8) 0x85 HAPTIC_SCRIPT(3)  0x86 (3, unnamed)
+	// These ids are NOT the feature-0x01 command ids (controller_constants.h) -- same numbers, different
+	// meanings -- so a rule written for one channel must never be applied to the other.
+	// The 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and reach the controller via the
 	// feature-0x01 passthrough path instead.
 	if (type == HID_REPORT_TYPE_OUTPUT) {
 		if (rid >= 0x80 && rid <= 0x89) {
@@ -259,14 +267,19 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		// exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop).
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
-		// Drop the OUTPUT-report form of 0x81 too (Steam's haptic-reset action, a 7-byte report id 0x81 with
-		// payloads 00../01..). Same amp-clicker as the feature-0x01 0x81; g_drop81 only covered the feature
-		// path, so this OUTPUT form was still leaking through and clicking. OpenPuck doesn't need it.
-		bool dropOut81 =
-			(g_drop81 && g_usbMode == MODE_STEAM && rid == 0x81);
-		if (g_hapticRelay && rid >= 0x80 && rid <= 0x86 && !dropOut81 &&
-		    n >= 1 && hapticRelaySlotOk(slot) && !lizardActive() &&
-		    !muted) {
+		// NOTE: report id 0x81 is NOT dropped here, unlike the feature-0x01 cmd 0x81. The two 0x81s live in
+		// DIFFERENT id spaces (SDL steam/controller_structs.h vs controller_constants.h):
+		//   OUTPUT  rid 0x81 = ID_OUT_REPORT_HAPTIC_PULSE, MsgHapticPulse {u8 side; u16 on_us; u16 off_us;
+		//                      u16 repeat_count} = the 7-byte "00.."/"01.." (side=left/right) reports Steam
+		//                      fires for trackpad CLICK feedback ("Regular Press"), the trigger FULL-PULL
+		//                      click and GripSense cues.
+		//   FEATURE cmd 0x81 = ID_CLEAR_DIGITAL_MAPPINGS (the mapping-engine reset, dropped below).
+		// Dropping the OUTPUT form as if it were the same command is what made those haptics missing
+		// (issues #163 / #166): "Soft Press" survived because it rides 0x82 ID_OUT_REPORT_HAPTIC_COMMAND,
+		// while every pulse-based click was thrown away. Pulses are self-terminating (repeat_count in the
+		// payload), so there is no latch to strand and no stop frame to lose.
+		if (g_hapticRelay && rid >= 0x80 && rid <= 0x86 && n >= 1 &&
+		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
 			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
 					     (uint8_t)(n > RELAY_MAXP ?

+ 3 - 2
OpenPuck/serial_console.cpp

@@ -78,10 +78,11 @@ void serialConsolePoll()
 					"# haptic relay (Steam 0x80-0x86) %s\n",
 					g_hapticRelay ? "ON" : "off");
 			} else if (!strcmp(line, "S81")) {
-				// A/B: drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS (the connect amp-clicker)
+				// A/B: drop Steam's relayed FEATURE cmd 0x81 CLEAR_DIGITAL_MAPPINGS (the connect
+				// amp-clicker). Does NOT touch OUTPUT report 0x81 = HAPTIC_PULSE (always relayed).
 				g_drop81 = !g_drop81;
 				Serial.printf(
-					"# drop relayed 0x81 (Steam mode) %s\n",
+					"# drop relayed feature 0x81 (Steam mode) %s\n",
 					g_drop81 ? "ON" : "off");
 			} else if (!strcmp(line, "FC")) {
 				// feature-command capture: log Steam's USB SET/GET commands to serial + suppress I45

+ 1 - 1
ReversePuckFirmware/ctrl_link.cpp

@@ -334,7 +334,7 @@ static uint8_t rxOnce(const uint8_t *base, uint8_t prefix, uint8_t ch,
 					    rfrx[4] == 0x05) {
 						// Relayed Steam OUTPUT (SET): E3 [tlvlen][05][rid][data]. The puck
 						// forwards Steam's rumble/haptic/LED writes this way (legacy type-05
-						// form). Hand rumble (0x80) / haptic pulses (0x82/0x8F) to the Deck so
+						// form). Hand rumble (0x80) / haptic pulses (0x81/0x82/0x8F) to the Deck so
 						// it buzzes; relays are NO_ACK so we DON'T reply.
 						uint8_t rl =
 							(rfrx[3] >= 1) ?

+ 9 - 0
ReversePuckFirmware/deck_input.cpp

@@ -182,6 +182,15 @@ void deckForwardHaptic(uint8_t rid, const uint8_t *d, uint8_t n)
 			right = (uint16_t)(d[6] | (d[7] << 8));
 			rgain = d[8];
 		}
+	} else if (rid == 0x81 && n >= 3) {
+		// OUTPUT report 0x81 = HAPTIC_PULSE [side][on_us u16][off_us u16][repeat u16] -- Steam's trackpad
+		// CLICK and trigger full-pull cues. (0x81 in the FEATURE command space is CLEAR_DIGITAL_MAPPINGS,
+		// a different thing entirely; this path only ever sees relayed OUTPUT reports.) The Deck has no
+		// per-pad clicker, so synthesize a short mid-strength buzz on both motors.
+		if (d[1] || d[2]) { // on_us != 0
+			intensity = 0x6000;
+			left = right = 0x6000;
+		}
 	} else if (rid == 0x82 || rid == 0x8F) {
 		// discrete haptic click/pulse (data ~ [01 01 F7]; trailing byte F7=on, 00=off). The Deck has no
 		// per-pad clicker, so synthesize a short mid-strength buzz on both motors.

+ 2 - 1
ReversePuckFirmware/deck_input.h

@@ -34,7 +34,8 @@ void deckHapticTask();
 void deckText(const char *s);
 
 // Forward a relayed Steam haptic/rumble to the Deck as a HAPTIC frame (the app plays it as Deck rumble).
-// rid = the relayed report id (0x80 SDL Triton rumble, 0x82/0x8F haptic pulse); d/n = its raw payload.
+// rid = the relayed report id (0x80 Triton rumble, 0x81 HAPTIC_PULSE, 0x82 HAPTIC_COMMAND, 0x8F legacy
+// feature pulse); d/n = its raw payload.
 void deckForwardHaptic(uint8_t rid, const uint8_t *d, uint8_t n);
 
 // Is the app currently forwarding (user tapped a puck)? When false, ctrl_link sends a neutral report.

+ 27 - 0
docs/PROTOCOL.md

@@ -347,6 +347,33 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
   feature `0x01` passthrough). Restricting this to `0x82` alone silently dropped the ping/grip/test
   haptics, which use other report IDs.
 
+#### ⚠️ Two different `0x8x` id spaces — never share a rule between them
+
+Steam drives the actuators through the Triton **OUTPUT report** space, which is a *different* id space
+from the feature-`0x01` **command** space even though the numbers overlap. Ground truth: SDL
+`src/joystick/hidapi/steam/controller_structs.h` (`ValveTritonOutReportMessageIDs`) and
+`controller_constants.h`. The payload sizes match this firmware's report descriptor exactly:
+
+| id | OUTPUT report (haptics/actuators) | payload | feature `0x01` command |
+|----|-----------------------------------|---------|------------------------|
+| `0x80` | `HAPTIC_RUMBLE` `[type][intensity u16][{speed u16,gain}L][{…}R]` | 9 | `SET_DIGITAL_MAPPINGS` |
+| `0x81` | `HAPTIC_PULSE` `[side][on_us u16][off_us u16][repeat u16]` | 7 | `CLEAR_DIGITAL_MAPPINGS` |
+| `0x82` | `HAPTIC_COMMAND` `[side][command][gain_db]` | 3 | `GET_DIGITAL_MAPPINGS` |
+| `0x83` | `HAPTIC_LFO_TONE` `[side][gain_db][freq u16][dur u16][lfo_freq u16][lfo_depth]` | 9 | `GET_ATTRIBUTES_VALUES` |
+| `0x84` | `HAPTIC_LOG_SWEEP` `[side][gain_db][dur u16][start u16][end u16]` | 8 | `GET_ATTRIBUTE_LABEL` |
+| `0x85` | `HAPTIC_SCRIPT` `[side][script_id][gain_db]` | 3 | `SET_DEFAULT_DIGITAL_MAPPINGS` |
+| `0x86` | (unnamed) | 3 | `FACTORY_RESET` |
+| `0x87`+ | 63-byte settings/config | 63 | `SET_SETTINGS_VALUES` … |
+
+`0x81` is the trap: the feature-channel `0x81` (`CLEAR_DIGITAL_MAPPINGS`) is dropped in Steam mode on
+purpose (`g_drop81`, console `S81` — it is the connect-time amp-clicker and OpenPuck does its own input
+translation), but the OUTPUT-report `0x81` is `HAPTIC_PULSE` — the left/right pulse Steam fires for
+trackpad **click** feedback ("Regular Press"), the **trigger full-pull** click and GripSense cues.
+Extending the feature-channel drop to the OUTPUT report is what made those haptics missing
+(issues #163 / #166) while "Soft Press" kept working, since that one rides `0x82 HAPTIC_COMMAND`.
+Pulses carry their own `repeat_count`, so they are self-terminating: there is no latch to strand and no
+stop frame that can be lost (unlike the `0x82` on/off pair or `0x80` rumble).
+
 ### 9.2 Xbox mode
 
 - VID:PID `045E:028E`, clean device (no CDC/WebUSB)