Przeglądaj źródła

Some more changes trying to fix the xinput + mouse issue

Jariullah Safi 4 miesięcy temu
rodzic
commit
53d855acca
2 zmienionych plików z 62 dodań i 34 usunięć
  1. 58 30
      OpenPuck.ino
  2. 4 4
      docs/PROTOCOL.md

+ 58 - 30
OpenPuck.ino

@@ -7,8 +7,8 @@
 //
 //
 // USB presentations: Steam = the genuine 4-slot Valve puck (28DE:1304) with the feature-report command
 // USB presentations: Steam = the genuine 4-slot Valve puck (28DE:1304) with the feature-report command
 // channel (0x83/0xAE/0xB4/0xA3/0xA2/0xAD); desktop keyboard+mouse "lizard" is automatic when Steam is
 // channel (0x83/0xAE/0xB4/0xA3/0xA2/0xAD); desktop keyboard+mouse "lizard" is automatic when Steam is
-// closed. Xbox = a clean XInput controller (045E:028E) + right-pad mouse. Switch = a HORIPAD pad
-// (0F0D:0092). Xbox/Switch are non-composite (no CDC/WebUSB) so strict hosts bind them.
+// closed. Xbox = a clean XInput controller (045E:028E) + right-pad mouse. Switch = a HORIPAD pad (0F0D:0092).
+// Xbox/Switch omit CDC/WebUSB so strict hosts bind them without vendor/config side effects.
 //
 //
 // Protocol reference: docs/PROTOCOL.md.  Bond record = [8 uuid][16 serial].
 // Protocol reference: docs/PROTOCOL.md.  Bond record = [8 uuid][16 serial].
 
 
@@ -61,7 +61,7 @@ static const uint8_t PUCK_HID_DESC[] = {
 #define NSLOT 4
 #define NSLOT 4
 Adafruit_USBD_HID hid[NSLOT];
 Adafruit_USBD_HID hid[NSLOT];
 // XBOX MODE = a MUTUALLY-EXCLUSIVE USB presentation: the device enumerates as EITHER the 4 puck slot
 // XBOX MODE = a MUTUALLY-EXCLUSIVE USB presentation: the device enumerates as EITHER the 4 puck slot
-// interfaces (Steam mode) OR a standard gamepad + a mouse (Xbox mode) — never both. The mode is persisted to
+// interfaces (Steam mode) OR an XInput controller + a mouse (Xbox mode) — never both. The mode is persisted to
 // flash and a button combo (back4+X -> xbox, back4+A -> steam) saves it and reboots, so each boot enumerates
 // flash and a button combo (back4+X -> xbox, back4+A -> steam) saves it and reboots, so each boot enumerates
 // the right interface set. RF (beacon/poll/bonds) is identical in both modes; only the USB side differs.
 // the right interface set. RF (beacon/poll/bonds) is identical in both modes; only the USB side differs.
 static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
 static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
@@ -74,7 +74,7 @@ static bool    g_xbox = false;   // == (g_usbMode != 0); kept for the existing p
 // last selected mode across reboots.
 // last selected mode across reboots.
 static bool    g_persistMode = false;   // false (default) = always boot Steam; true = boot into last mode
 static bool    g_persistMode = false;   // false (default) = always boot Steam; true = boot into last mode
 static uint8_t g_bootMode = 0xFF;       // one-shot: boot into this mode once then clear (used when !persistMode + explicit switch)
 static uint8_t g_bootMode = 0xFF;       // one-shot: boot into this mode once then clear (used when !persistMode + explicit switch)
-volatile uint8_t g_rumble = 0;   // Xbox-mode rumble strength from the host's XInput OUT packet (0=off); drives the haptic relay
+volatile uint8_t g_rumble = 0;   // legacy XInput rumble strength from the host's OUT packet (0=off); drives the haptic relay when the XInput descriptor is used
 volatile unsigned long g_rumbleMs = 0;   // millis of last rumble OUT packet (declared here — used by xi_xfer above the watchdog block); stuck-rumble watchdog
 volatile unsigned long g_rumbleMs = 0;   // millis of last rumble OUT packet (declared here — used by xi_xfer above the watchdog block); stuck-rumble watchdog
 // persisted, runtime-tunable config (Cfg struct + load/saveCfg below; CDC M/F/D/W/B + WebUSB set these):
 // persisted, runtime-tunable config (Cfg struct + load/saveCfg below; CDC M/F/D/W/B + WebUSB set these):
 static int     g_mDiv = 64, g_mFric = 94, g_padSmooth = 35;  // xbox mouse sens/friction%, steam pad smoothing%
 static int     g_mDiv = 64, g_mFric = 94, g_padSmooth = 35;  // xbox mouse sens/friction%, steam pad smoothing%
@@ -201,8 +201,10 @@ static volatile uint8_t g_testHaptic = 0;   // 't<n>' injects n test haptics (ou
 // can likewise latch on the controller if Steam's stop relay is lost. We auto-release on host silence.
 // can likewise latch on the controller if Steam's stop relay is lost. We auto-release on host silence.
 static unsigned long g_haptic82Ms = 0;            // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
 static unsigned long g_haptic82Ms = 0;            // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
 static bool          g_haptic82On = false;        // a non-zero 0x82 haptic is currently active (awaiting host stop)
 static bool          g_haptic82On = false;        // a non-zero 0x82 haptic is currently active (awaiting host stop)
+static unsigned long g_hapticBlockUntil = 0;        // drop Steam haptics briefly during reconnect settle
 #define RUMBLE_STUCK_MS   2500u   // Xbox: release a held rumble if no OUT packet refreshes it for this long (covers a lost stop without cutting normal short rumbles)
 #define RUMBLE_STUCK_MS   2500u   // Xbox: release a held rumble if no OUT packet refreshes it for this long (covers a lost stop without cutting normal short rumbles)
-#define HAPTIC_QUIET_MS    300u   // Steam: if 0x82 haptics go silent this long after being active, send an explicit off (glide stream is ~25ms-spaced, so 300ms silence = really stopped)
+#define HAPTIC_QUIET_MS    300u   // Steam: after this much host silence, consider the current 0x82 haptic stream inactive
+#define HAPTIC_RECONNECT_BLOCK_MS 1500u
 // Seamless lizard (Steam mode): exactly like the real puck — when Steam is running it forwards the gamepad
 // Seamless lizard (Steam mode): exactly like the real puck — when Steam is running it forwards the gamepad
 // report 0x45; when Steam is closed it drives mouse(0x40)+keyboard(0x41) on the SAME puck interface. This is
 // report 0x45; when Steam is closed it drives mouse(0x40)+keyboard(0x41) on the SAME puck interface. This is
 // a PURELY USB-SIDE decision — it changes nothing about the RF poll or the host->controller relay, so the
 // a PURELY USB-SIDE decision — it changes nothing about the RF poll or the host->controller relay, so the
@@ -269,14 +271,34 @@ static void loadBonds() {
   f.close();
   f.close();
 }
 }
 
 
+static bool hapticLinkUp(){
+  return g_connSlot>=0 && (millis()-g_connReplyMs) < 300;
+}
+static bool haptic82Blocked(){
+  return !hapticLinkUp() || (g_hapticBlockUntil && (int32_t)(millis()-g_hapticBlockUntil) < 0);
+}
+static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
+  if(n<3) return false;
+  for(uint16_t i=2;i<n;i++) if(p[i]) return true;   // observed form is [01 01 gain], but treat any trailing non-zero as active
+  return false;
+}
+static void haptic82HostReport(const uint8_t* p, uint16_t n){
+  if(n<3) return;
+  g_haptic82Ms = millis();
+  g_haptic82On = haptic82PayloadOn(p,n);
+}
+
 // ---- command channel; `slot` is the interface index (interface N == bond slot N) ----
 // ---- command channel; `slot` is the interface index (interface N == bond slot N) ----
 static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t const *b, uint16_t n) {
 static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t const *b, uint16_t n) {
   if (type == HID_REPORT_TYPE_OUTPUT) {   // Steam haptic/LED OUTPUT reports 0x80-0x89 -> relay to controller
   if (type == HID_REPORT_TYPE_OUTPUT) {   // Steam haptic/LED OUTPUT reports 0x80-0x89 -> relay to controller
     if (rid >= 0x80 && rid <= 0x89 && n >= 1) {                 // wrap as a SET sub-TLV like the report-01 path
     if (rid >= 0x80 && rid <= 0x89 && n >= 1) {                 // wrap as a SET sub-TLV like the report-01 path
-      uint8_t m = n > (uint16_t)(sizeof g_relayBuf - 2) ? (sizeof g_relayBuf - 2) : n;
-      g_relayBuf[0] = rid; g_relayBuf[1] = m; memcpy(g_relayBuf + 2, b, m);
-      g_relayN = m + 2; g_relayPend = true;
-      if (rid == 0x82) { g_haptic82Ms = millis(); g_haptic82On = (n >= 3 && b[2] != 0); }  // track on/off for the stuck-haptic watchdog
+      bool haptic82 = (rid == 0x82);
+      if (!haptic82 || !haptic82Blocked()) {
+        uint8_t m = n > (uint16_t)(sizeof g_relayBuf - 2) ? (sizeof g_relayBuf - 2) : n;
+        g_relayBuf[0] = rid; g_relayBuf[1] = m; memcpy(g_relayBuf + 2, b, m);
+        g_relayN = m + 2; g_relayPend = true;
+      }
+      if (haptic82) haptic82HostReport(b, n);  // track on/off for the stuck-haptic watchdog
     }
     }
     if (Serial.availableForWrite() > 80) {                      // log so we can see what Steam actually sends (e.g. glide haptics)
     if (Serial.availableForWrite() > 80) {                      // log so we can see what Steam actually sends (e.g. glide haptics)
       Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid, n);
       Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid, n);
@@ -291,8 +313,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
   const uint8_t *pl = b + 2; uint16_t pln = (n >= 2) ? n - 2 : 0;
   const uint8_t *pl = b + 2; uint16_t pln = (n >= 2) ? n - 2 : 0;
   if (cmd == 0x87) g_steamAliveMs = millis();   // Steam's settings/lizard-off heartbeat (~every 3s) -> keep forwarding gamepad, suppress auto-lizard
   if (cmd == 0x87) g_steamAliveMs = millis();   // Steam's settings/lizard-off heartbeat (~every 3s) -> keep forwarding gamepad, suppress auto-lizard
   if (rid == 1 && n >= 2) {   // report 0x01 = raw passthrough -> queue for RF relay to the controller
   if (rid == 1 && n >= 2) {   // report 0x01 = raw passthrough -> queue for RF relay to the controller
-    uint16_t m = n > sizeof g_relayBuf ? sizeof g_relayBuf : n;
-    memcpy(g_relayBuf, b, m); g_relayN = m; g_relayPend = true;
+    bool haptic82 = (cmd == 0x82 && len <= pln);
+    if (!haptic82 || !haptic82Blocked()) {
+      uint16_t m = n > sizeof g_relayBuf ? sizeof g_relayBuf : n;
+      memcpy(g_relayBuf, b, m); g_relayN = m; g_relayPend = true;
+    }
+    if (haptic82) haptic82HostReport(pl, len);  // Steam can send haptics through feature passthrough too
   }
   }
   if (Serial.availableForWrite() > 80) {   // log host feature writes (non-blocking)
   if (Serial.availableForWrite() > 80) {   // log host feature writes (non-blocking)
     Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid, cmd, len);
     Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid, cmd, len);
@@ -724,7 +750,7 @@ static void smoothPad(uint8_t* rep){     // rep = report 0x45; smooth LPad(@18/2
     int16_t ox=(int16_t)rsx,oy=(int16_t)rsy; rep[24]=ox;rep[25]=ox>>8;rep[26]=oy;rep[27]=oy>>8; }
     int16_t ox=(int16_t)rsx,oy=(int16_t)rsy; rep[24]=ox;rep[25]=ox>>8;rep[26]=oy;rep[27]=oy>>8; }
   rpt=rt;
   rpt=rt;
 }
 }
-// button code (g_back[], g_abSwap targets) -> XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide
+// button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide
 static uint16_t codeToXB(uint8_t c){
 static uint16_t codeToXB(uint8_t c){
   switch(c){ case 1:return XB_A; case 2:return XB_B; case 3:return XB_X; case 4:return XB_Y;
   switch(c){ case 1:return XB_A; case 2:return XB_B; case 3:return XB_X; case 4:return XB_Y;
     case 5:return XB_LB; case 6:return XB_RB; case 7:return XB_L3; case 8:return XB_R3;
     case 5:return XB_LB; case 6:return XB_RB; case 7:return XB_L3; case 8:return XB_R3;
@@ -751,8 +777,7 @@ static void rfXboxGamepad(const uint8_t* r){
 // as Lizard's right pad (velocity injected from pad deltas, friction decay, sub-pixel carry). This is purely
 // as Lizard's right pad (velocity injected from pad deltas, friction decay, sub-pixel carry). This is purely
 // a USB-side translation of the received 0x45 report — it touches NOTHING about the RF poll or relay, so the
 // a USB-side translation of the received 0x45 report — it touches NOTHING about the RF poll or relay, so the
 // controller behaves exactly as it does for the bare gamepad. RPad click = left button, LPad click = right.
 // controller behaves exactly as it does for the bare gamepad. RPad click = left button, LPad click = right.
-// (The earlier "mouse vs controller race" was actually the CDC composite, now fixed: Xbox drops CDC/WebUSB,
-//  so the only interfaces are XInput (MI_00) + mouse (MI_01).)
+// Xbox drops CDC/WebUSB, so the only active USB interfaces are XInput (MI_00) + boot mouse (MI_01).
 static void rfXboxMouse(const uint8_t* r){
 static void rfXboxMouse(const uint8_t* r){
   uint32_t b=btnsOf(r);
   uint32_t b=btnsOf(r);
   static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0; static uint8_t pmb=0;
   static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0; static uint8_t pmb=0;
@@ -1278,21 +1303,20 @@ void setup() {
   InternalFS.begin();
   InternalFS.begin();
   loadCfg(); g_xbox = (g_usbMode != 0);   // load persisted config + decide USB presentation BEFORE registering interfaces
   loadCfg(); g_xbox = (g_usbMode != 0);   // load persisted config + decide USB presentation BEFORE registering interfaces
 
 
-  // ---- CLEAN, NON-COMPOSITE presentation for Xbox + Switch ----
+  // ---- CLEAN presentation for Xbox + Switch ----
   // The Adafruit core auto-adds a CDC (Serial) interface at startup, making every device a CDC composite
   // The Adafruit core auto-adds a CDC (Serial) interface at startup, making every device a CDC composite
-  // (bDeviceClass=0xEF/IAD). That breaks the two real-controller personalities:
+  // (bDeviceClass=0xEF/IAD). That breaks the clean controller personalities:
   //   * XBOX: Windows' xusb (Xbox 360) driver matches 045E:028E at the DEVICE level. In a composite the
   //   * XBOX: Windows' xusb (Xbox 360) driver matches 045E:028E at the DEVICE level. In a composite the
-  //     gamepad is interface MI_02 and xusb's INF never matches it -> the controller never appears. (Dropping
-  //     only the mouse earlier left the device composite, so it stayed broken on every host.)
+  //     gamepad must stay MI_00 and the extra mouse must be a plain boot mouse interface.
   //   * SWITCH: a real console rejects composite devices outright; it accepts only a bare HID gamepad.
   //   * SWITCH: a real console rejects composite devices outright; it accepts only a bare HID gamepad.
-  // Fix: tear the auto CDC composite down and rebuild a single-interface device. clearConfiguration() also
+  // Fix: tear the auto CDC composite down and rebuild only the intended controller interfaces. clearConfiguration() also
   // resets bDeviceClass to 0x00 (single function). We deliberately KEEP bcdUSB at 0x0200 (no WebUSB / USB 2.1)
   // resets bDeviceClass to 0x00 (single function). We deliberately KEEP bcdUSB at 0x0200 (no WebUSB / USB 2.1)
   // so the host never requests the WebUSB BOS — whose MS-OS-2.0 blob would otherwise tell Windows to bind
   // so the host never requests the WebUSB BOS — whose MS-OS-2.0 blob would otherwise tell Windows to bind
   // WinUSB to interface 0 (our gamepad). detach()/attach() forces the host to re-read the rebuilt descriptor.
   // WinUSB to interface 0 (our gamepad). detach()/attach() forces the host to re-read the rebuilt descriptor.
   // Force a CLEAN re-enumeration on every boot / mode-switch: detach FIRST so the host drops the previous
   // Force a CLEAN re-enumeration on every boot / mode-switch: detach FIRST so the host drops the previous
   // identity, rebuild the descriptor, then re-attach (at the end of setup). This is done in ALL modes, not
   // identity, rebuild the descriptor, then re-attach (at the end of setup). This is done in ALL modes, not
   // just the clean ones: switching Xbox->Steam while Steam holds the device open could otherwise leave the
   // just the clean ones: switching Xbox->Steam while Steam holds the device open could otherwise leave the
-  // host caching the old 045E:028E device, so the puck sometimes failed to enumerate. The detach gives the OS
+  // host caching the old controller identity, so the puck sometimes failed to enumerate. The detach gives the OS
   // an unambiguous disconnect to release its handles before the new descriptor comes up.
   // an unambiguous disconnect to release its handles before the new descriptor comes up.
   const bool cleanDevice = (g_usbMode == 1 || g_usbMode == 2);
   const bool cleanDevice = (g_usbMode == 1 || g_usbMode == 2);
   USBDevice.detach(); delay(30);
   USBDevice.detach(); delay(30);
@@ -1303,13 +1327,15 @@ void setup() {
   // identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
   // identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
   if (g_usbMode == 0) { USBDevice.setSerialDescriptor(g_unit); }
   if (g_usbMode == 0) { USBDevice.setSerialDescriptor(g_unit); }
   else { snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit, g_usbMode==1?'X':'N'); USBDevice.setSerialDescriptor(g_usbSerial); }
   else { snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit, g_usbMode==1?'X':'N'); USBDevice.setSerialDescriptor(g_usbSerial); }
-  if (g_usbMode == 1) {                // XBOX presentation: a LONE XInput vendor interface == a real wired Xbox 360 pad
+  if (g_usbMode == 1) {                // XBOX presentation: XInput vendor interface + HID boot mouse
     USBDevice.setID(0x045E, 0x028E);   // device-level 045E:028E match -> Windows xusb / SDL / Linux xpad all bind it
     USBDevice.setID(0x045E, 0x028E);   // device-level 045E:028E match -> Windows xusb / SDL / Linux xpad all bind it
     USBDevice.setVersion(0x0200); USBDevice.setDeviceVersion(0x0114);   // bcdUSB 0x0200 = no BOS request (clean, no WinUSB fight)
     USBDevice.setVersion(0x0200); USBDevice.setDeviceVersion(0x0114);   // bcdUSB 0x0200 = no BOS request (clean, no WinUSB fight)
     USBDevice.setManufacturerDescriptor("Microsoft");
     USBDevice.setManufacturerDescriptor("Microsoft");
     USBDevice.setProductDescriptor("Controller");
     USBDevice.setProductDescriptor("Controller");
     g_xinput.setStringDescriptor("Controller"); g_xinput.begin();   // XInput vendor interface (FF/5D/01) -> MI_00
     g_xinput.setStringDescriptor("Controller"); g_xinput.begin();   // XInput vendor interface (FF/5D/01) -> MI_00
-    g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC); g_mouse.setPollInterval(1); g_mouse.begin();   // right-pad mouse -> MI_01 (no CDC/WebUSB, so XInput stays MI_00)
+    g_mouse.setStringDescriptor("OpenPuck Mouse");
+    g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
+    g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC); g_mouse.setPollInterval(1); g_mouse.begin();
   } else if (g_usbMode == 2) {         // SWITCH presentation: a LONE HID HORIPAD/Pokkén pad (console-compatible)
   } else if (g_usbMode == 2) {         // SWITCH presentation: a LONE HID HORIPAD/Pokkén pad (console-compatible)
     USBDevice.setID(0x0F0D, 0x0092);   // HORI Pokkén Tournament Pro Pad (whitelisted by a real Switch console)
     USBDevice.setID(0x0F0D, 0x0092);   // HORI Pokkén Tournament Pro Pad (whitelisted by a real Switch console)
     USBDevice.setVersion(0x0200); USBDevice.setDeviceVersion(0x0200);
     USBDevice.setVersion(0x0200); USBDevice.setDeviceVersion(0x0200);
@@ -1371,8 +1397,13 @@ void loop() {
     if (millis()-g_lastDisc >= (connNow ? 200u : 0u)) { g_lastDisc=millis(); g_rfCh=2; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
     if (millis()-g_lastDisc >= (connNow ? 200u : 0u)) { g_lastDisc=millis(); g_rfCh=2; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
   }
   }
   if (g_connOn && millis()-g_connCooldown > 2500) { rfConnStep(); }            // connected-mode: poll controller, read input
   if (g_connOn && millis()-g_connCooldown > 2500) { rfConnStep(); }            // connected-mode: poll controller, read input
-  // Xbox-mode rumble -> relay the haptic (0x82 [01 01 gain]) to the controller while the host commands it,
-  // re-queued ~40/s like Steam's glide haptic so it sustains. gain scaled by the XInput motor strength.
+  { static bool wasHapticLinkUp=false;
+    bool up=hapticLinkUp();
+    if(up && !wasHapticLinkUp) g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // reconnect hygiene: don't replay Steam's stale startup haptics
+    wasHapticLinkUp=up;
+  }
+  // Legacy XInput rumble -> relay the haptic (0x82 [01 01 gain]) to the controller while the host commands it,
+  // re-queued ~40/s like Steam's glide haptic so it sustains. Not active in the current HID Xbox presentation.
   if (g_usbMode==1 && g_rumble && millis()-g_rumbleMs > RUMBLE_STUCK_MS) g_rumble=0;   // watchdog: lost stop packet -> release the held rumble instead of buzzing forever
   if (g_usbMode==1 && g_rumble && millis()-g_rumbleMs > RUMBLE_STUCK_MS) g_rumble=0;   // watchdog: lost stop packet -> release the held rumble instead of buzzing forever
   if (g_usbMode==1 && g_rumble && !g_relayPend && g_connSlot>=0) {
   if (g_usbMode==1 && g_rumble && !g_relayPend && g_connSlot>=0) {
     static unsigned long lastRumble=0;
     static unsigned long lastRumble=0;
@@ -1381,12 +1412,9 @@ void loop() {
       g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=gain; g_relayPend=true;
       g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=gain; g_relayPend=true;
     }
     }
   }
   }
-  // Steam-mode haptic watchdog: glide haptics are one-shot relays, but if Steam's stop is lost the controller
-  // can latch buzzing. If a 0x82 haptic was active and the host's stream has gone quiet, send one explicit off.
-  if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS && !g_relayPend && g_connSlot>=0) {
-    g_haptic82On=false;
-    g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=0x00; g_relayN=5; g_relayPend=true;   // 0x82 [01 01 00] = haptic off
-  }
+  // Steam-mode haptic guard: do not synthesize haptic packets. The reconnect guard above only drops stale
+  // host 0x82 traffic until the link is stable; active-session haptics are relayed verbatim.
+  if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS) g_haptic82On=false;
   // QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
   // QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
   if (g_qos && g_connSlot>=0 && millis()-g_qosCheckMs>=600) {
   if (g_qos && g_connSlot>=0 && millis()-g_qosCheckMs>=600) {
     uint16_t bad=g_qosBad; g_qosBad=0; g_qosCheckMs=millis();
     uint16_t bad=g_qosBad; g_qosBad=0; g_qosCheckMs=millis();

+ 4 - 4
docs/PROTOCOL.md

@@ -275,10 +275,10 @@ offset  size  meaning
 The RF side stays the same across modes. Only USB enumeration changes.
 The RF side stays the same across modes. Only USB enumeration changes.
 
 
 There are three switchable modes (`0=Steam 1=Xbox 2=Switch`). Steam mode is a CDC + WebUSB composite;
 There are three switchable modes (`0=Steam 1=Xbox 2=Switch`). Steam mode is a CDC + WebUSB composite;
-Xbox and Switch are **clean, non-composite, single-function** devices — the auto-added CDC/WebUSB
+Xbox and Switch are **clean controller-only** devices — the auto-added CDC/WebUSB
 interfaces are torn down (`clearConfiguration`) and `bcdUSB` stays `0x0200` (no USB-2.1 BOS). This is
 interfaces are torn down (`clearConfiguration`) and `bcdUSB` stays `0x0200` (no USB-2.1 BOS). This is
-required because Windows' `xusb` driver matches `045E:028E` at the device level (a composite hides the
-gamepad behind an `MI_xx`) and a real Switch console rejects composite devices. Every boot/mode-switch
+required because Windows' `xusb` driver expects the Xbox 360 controller on the primary device/interface,
+and a real Switch console rejects composite devices. Every boot/mode-switch
 does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly.
 does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly.
 
 
 ### 9.1 Steam mode
 ### 9.1 Steam mode
@@ -295,7 +295,7 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
 ### 9.2 Xbox mode
 ### 9.2 Xbox mode
 
 
 - VID:PID `045E:028E`, clean device (no CDC/WebUSB)
 - VID:PID `045E:028E`, clean device (no CDC/WebUSB)
-- Custom XInput-compatible vendor interface on `MI_00` + a HID mouse on `MI_01` (right-pad emulation)
+- Custom XInput-compatible vendor interface on `MI_00` + HID boot mouse on `MI_01` (right-pad emulation)
 - `0x45` is converted into a 20-byte XInput report
 - `0x45` is converted into a 20-byte XInput report
 
 
 ### 9.3 Switch mode
 ### 9.3 Switch mode