Bläddra i källkod

Merge branch 'main' into switch-pro-mode-real-console

Jariullah Safi 4 månader sedan
förälder
incheckning
8579b555c8

+ 10 - 7
OpenPuck/OpenPuck.ino

@@ -74,16 +74,19 @@ void setup() {
   if (puckMode) { USBDevice.setSerialDescriptor(g_unit); }
   else { snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit, MODE_SUFFIX[g_usbMode-1]); USBDevice.setSerialDescriptor(g_usbSerial); }
 
+  // SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
+  // and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
+  if (puckMode) usb_web.begin();
+  if (puckMode && !keepCdc) wakeHidBegin();
+
   g_active->begin();   // register this mode's USB interface(s) + set VID/PID/strings
 
-  // Boot-mouse wake interface so the host honors USBDevice.remoteWakeup() in this mode (see wake_hid.h).
-  // Added for every clean mode, and for puck mode on a normal (CDC-dropped) boot. Skipped only on the one-shot
-  // debug boot, where CDC takes the endpoint instead.
-  if (!keepCdc) wakeHidBegin();
+  // Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
+  // no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead).
+  if (!puckMode && !keepCdc) wakeHidBegin();
 
-  // WebUSB config panel -- every mode. Puck: historical CDC+HID+vendor stack (Steam + Chrome can share it).
-  // Clean modes: bare gamepad + WebUSB (begin() sets bcdUSB 0x0210 + BOS).
-  usb_web.begin();
+  // WebUSB config panel -- every mode. Puck: registered above (IF 0) before wake + slots; clean modes after controller.
+  if (!puckMode) usb_web.begin();
   // Enable USB Remote Wakeup (bit 5) so the host lets us signal wake-from-sleep. Bit 7 is always required.
   USBDevice.setConfigurationAttribute(0x80 | 0x20);  // bmAttributes: required(0x80) | remote_wakeup(0x20)
   USBDevice.attach();   // re-connect with the final descriptor (host re-reads it fresh -> deterministic enumeration)

+ 27 - 4
OpenPuck/haptics.cpp

@@ -28,6 +28,8 @@ void hapticSendShutdown(){
 
 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 unsigned long g_rumble80Ms = 0;     // millis of last translated host rumble (0x80)
+static bool          g_rumble80On = false; // Steam/Triton rumble is latched on until an explicit zero report
 static unsigned long g_reinitAt = 0;       // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
 static uint8_t       g_reinitLeft = 0;     // how many re-init shots remain in this connect window
 static bool          g_hapClearArmed=false;// haptic activity happened -> arm a clear once it goes idle (catches a
@@ -58,7 +60,7 @@ bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen){
   g_rqHead = nx;
   // Any haptic relay (Steam OR Xbox rumble OR test) arms the idle-clear and refreshes its timer -- so the
   // during-use buzz gets cleared in EVERY USB mode, not just Steam. The re-init's own 0x81/0x87 don't match.
-  if (rid==0x82){ g_haptic82Ms = millis(); g_hapClearArmed = true; }
+  if (rid==0x82 || rid==0x80){ g_haptic82Ms = millis(); g_hapClearArmed = true; }
   __set_PRIMASK(pm); return true;
 }
 
@@ -126,11 +128,12 @@ static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
   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 hapticCancelPendingOn(){   // void queued 0x82-ON entries (stale Steam haptics across a reconnect)
+static void hapticCancelPendingOn(){   // void queued ON entries (stale Steam haptics / translated rumble across a reconnect)
   uint32_t pm = __get_PRIMASK(); __disable_irq();
   for(uint8_t i=g_rqTail; i!=g_rqHead; i=rqNext(i)){
     RelayMsg &m = g_rq[i];
     if(m.rid==0x82){ bool on=false; for(uint8_t j=2;j<m.len;j++) if(m.data[j]){on=true;break;} if(on) m.rid=0; }
+    if(m.rid==0x80){ bool on=false; for(uint8_t j=0;j<m.len;j++) if(m.data[j]){on=true;break;} if(on) m.rid=0; }
   }
   __set_PRIMASK(pm);
 }
@@ -144,6 +147,25 @@ void haptic82HostReport(const uint8_t* p, uint16_t n){
   // "click"/buzz that the real puck never produces. (Connect-time clearing still runs in hapticTask().)
   g_haptic82On = haptic82PayloadOn(p,n);
 }
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq){
+  bool on = lowFreq || highFreq;
+  if(on && haptic82Blocked()) return false;       // same settle gate as native Steam haptics
+  if(!on && !hapticLinkUp()) return false;
+
+  // SDL's current Steam/Triton structs define output report 0x80 as:
+  //   type, uint16 intensity, {uint16 speed, int8 gain} left/right.
+  // We map conventional gamepad low/high-frequency motors to left/right speeds and use max as intensity.
+  uint16_t intensity = lowFreq > highFreq ? lowFreq : highFreq;
+  uint8_t p[9];
+  p[0] = on ? 0x04 : 0x00;   // haptic_type_t::HAPTIC_TYPE_RUMBLE; 0 is the off/zero report
+  p[1] = (uint8_t)(intensity & 0xFF); p[2] = (uint8_t)(intensity >> 8);
+  p[3] = (uint8_t)(lowFreq   & 0xFF); p[4] = (uint8_t)(lowFreq   >> 8); p[5] = 0;
+  p[6] = (uint8_t)(highFreq  & 0xFF); p[7] = (uint8_t)(highFreq  >> 8); p[8] = 0;
+  if(!relayEnqueue(0x80, p, sizeof p)) return false;
+  g_rumble80Ms = millis();
+  g_rumble80On = on;
+  return true;
+}
 // Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate).
 void rfConnQueueHapticRelay(){
   if(relayPending()) return;            // host relays first; injectables wait for an idle cycle
@@ -217,7 +239,7 @@ void hapticReinit(){
   relayEnqueue(0x81, T81B, sizeof T81B);
 }
 void hapticInit(){
-  g_rqHead = g_rqTail = 0; g_haptic82On=false;
+  g_rqHead = g_rqTail = 0; g_haptic82On=false; g_rumble80On=false;
   g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // boot: block stale Steam 0x82 until link stable
   // NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
   // zero-gain [01 01 00] "stop". Injecting our invented stop frame (which the real puck never sends) at
@@ -231,7 +253,7 @@ void hapticInit(){
 // link-up edge. Idempotent -- safe to call repeatedly.
 void hapticOnReconnect(){
   g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // no haptics relayed for the next 3s
-  g_haptic82On = false;
+  g_haptic82On = false; g_rumble80On = false;
   hapticCancelPendingOn();                                     // drop any haptic ON queued before the link came up
   // Be PROACTIVE: start re-initing the haptic engine right after the link is up and repeat across the whole
   // settle window, instead of waiting for the block to end. The brief connect buzz engages early (during the
@@ -268,6 +290,7 @@ void hapticTask(){
   // are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
   // click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.
   if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS) g_haptic82On=false;
+  if (g_rumble80On && millis()-g_rumble80Ms > 2500u) hapticSteamRumble(0, 0);
   // Haptic activity has gone idle for a while -> fire one re-init to clear any latch it left behind (the buzz
   // that engages during/after use and won't self-clear, incl. after a mode switch). Fires only after a quiet
   // gap, so it never interrupts active haptics; the brightness-less re-init is silent (settings, no play, no

+ 3 - 2
OpenPuck/haptics.h

@@ -1,6 +1,6 @@
 // haptics.h -- host -> controller relay queue (haptics + settings) + the watchdogs that stop a stuck buzz.
 //
-// Steam (and legacy XInput rumble) send OUTPUT/feature reports the real dongle forwards to the controller as
+// Steam and translated host rumble send OUTPUT/feature reports the real dongle forwards to the controller as
 // a SET sub-TLV inside the E3 poll. We do the same: handleSet()/the console enqueue with relayEnqueue(), and
 // rfConnFlushRelay() emits ONE entry per poll cycle (rf_link.cpp), never at raw loop rate.
 //
@@ -43,7 +43,7 @@
 // cmd 0x9F, payload "off!" -- captured from the real puck). Sent as a small burst because the RF relay is NO-ACK.
 #define HAPTIC_SHUTDOWN_SHOTS 3u
 
-// ---- relay queue (written by puck_hid.cpp, mode_xinput.cpp, serial_console.cpp; drained by rf_link.cpp) ----
+// ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
 // goes on the air). ISR-safe (brief PRIMASK critical section). Returns false (dropped) when the ring is full.
 bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen);
@@ -75,6 +75,7 @@ bool hapticLinkUp();
 bool haptic82Blocked();
 bool hapticRelaySlotOk(int slot);
 void haptic82HostReport(const uint8_t* p, uint16_t n);
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq);   // queue Steam/Triton output report 0x80 rumble
 
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared

+ 1 - 1
OpenPuck/identity.cpp

@@ -7,7 +7,7 @@ char g_unit[16];
 char g_board[16];
 char g_usbSerial[18];
 
-const uint8_t ATTR83[25] = {     // 0x83 attributes (product 0x1304 = puck)
+const uint8_t ATTR83[25] = {     // 0x83 attributes (product 0x1304 = Proteus puck)
   0x01,0x04,0x13,0x00,0x00,0x02,0x00,0x00,0x00,0x00,0x0A,0xF2,0xF9,0xD2,0x68,
   0x04,0x53,0xD0,0x18,0x6A,0x09,0x47,0x00,0x00,0x00 };
 

+ 1 - 1
OpenPuck/identity.h

@@ -9,7 +9,7 @@ extern char g_unit[16];        // "FXB99602xxxxx"
 extern char g_board[16];       // "MXB99602xxxxx"
 extern char g_usbSerial[18];   // per-mode USB serial (Steam uses g_unit; others get a suffix)
 
-// 0x83 attributes (product 0x1304 = puck), returned by the puck feature command channel.
+// 0x83 attributes, returned by the puck feature command channel.
 extern const uint8_t ATTR83[25];
 
 void genSerial();

+ 12 - 0
OpenPuck/mode_hidgyro.cpp

@@ -2,6 +2,7 @@
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
+#include "haptics.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -24,6 +25,16 @@ static const uint8_t GYRO_HID_DESC[]={
 static unsigned long g_gyroLastMs=0;
 static Adafruit_USBD_HID g_hidGyro;
 
+static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_t n){
+  if(type!=HID_REPORT_TYPE_OUTPUT || n<1) return;
+  uint8_t id; const uint8_t* p; uint16_t pn;
+  if(rid==0){ id=b[0]; p=b+1; pn=(uint16_t)(n-1); }
+  else      { id=rid;  p=b;   pn=n;               }
+  // DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
+  if(id!=0x05 || pn<5) return;
+  hapticSteamRumble((uint16_t)p[4]*257u, (uint16_t)p[3]*257u);   // DS4: left=low, right=high
+}
+
 static void hidGyroBuild(uint8_t out[63]){
   uint32_t b=psButtonsFromSteam(g_in.buttons);
   bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
@@ -59,6 +70,7 @@ void HidGyroController::begin(){
   USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
   USBDevice.setProductDescriptor("Wireless Controller");
   g_hidGyro.enableOutEndpoint(true);
+  g_hidGyro.setReportCallback(NULL, hidGyroSet);
   g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
   g_hidGyro.setPollInterval(4);
   g_hidGyro.begin();

+ 41 - 0
OpenPuck/mode_ps5.cpp

@@ -2,6 +2,7 @@
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
+#include "haptics.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -32,6 +33,45 @@ static const uint8_t PS5_HID_DESC[]={
 static unsigned long g_ps5LastMs=0;
 static Adafruit_USBD_HID g_ps5;
 
+static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t* buf, uint16_t reqlen){
+  if(type!=HID_REPORT_TYPE_FEATURE || !buf || reqlen==0) return 0;
+  memset(buf,0,reqlen);
+  switch(rid){
+    case 0x03: {                         // capabilities: identify as DualSense-capable (SDL checks byte 2 == 0x28)
+      if(reqlen<47) return 0;
+      buf[0]=0x00; buf[1]=0x28; buf[2]=0x01; buf[3]=0x00;
+      buf[4]=0x0E;                       // sensors + lightbar + vibration capability bits
+      return 47;
+    }
+    case 0x05: {                         // calibration: neutral-ish non-zero block so SDL's read succeeds
+      uint16_t n = reqlen<40 ? reqlen : 40;
+      for(uint16_t i=0;i<n;i++) buf[i]=0;
+      return n;
+    }
+    case 0x09: {                         // serial number feature
+      uint16_t n = reqlen<20 ? reqlen : 20;
+      for(uint16_t i=0;i<n && i<12;i++) buf[i]=(uint8_t)("010203040506"[i]);
+      return n;
+    }
+    case 0x20: {                         // firmware info
+      uint16_t n = reqlen<63 ? reqlen : 63;
+      buf[0]=0x01; buf[1]=0x00;          // minimal non-zero firmware version
+      return n;
+    }
+    default:
+      return 0;
+  }
+}
+static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_t n){
+  if(type!=HID_REPORT_TYPE_OUTPUT || n<1) return;
+  uint8_t id; const uint8_t* p; uint16_t pn;
+  if(rid==0){ id=b[0]; p=b+1; pn=(uint16_t)(n-1); }
+  else if(rid==0x02 && b[0]==0x02 && n>=5){ id=rid; p=b+1; pn=(uint16_t)(n-1); } // some paths leave report id in b
+  else      { id=rid;  p=b;   pn=n;               }
+  if(id!=0x02 || pn<4) return;
+  hapticSteamRumble((uint16_t)p[3]*257u, (uint16_t)p[2]*257u);   // DualSense: left=low, right=high
+}
+
 static void ps5Build(uint8_t out[63]){
   uint32_t b=psButtonsFromSteam(g_in.buttons);
   bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
@@ -61,6 +101,7 @@ void Ps5Controller::begin(){
   USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
   USBDevice.setProductDescriptor("DualSense Wireless Controller");
   g_ps5.enableOutEndpoint(true);
+  g_ps5.setReportCallback(ps5Get, ps5Set);
   g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
   g_ps5.setPollInterval(4);
   g_ps5.begin();

+ 16 - 1
OpenPuck/mode_switch_pro.cpp

@@ -2,6 +2,7 @@
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
+#include "haptics.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -50,6 +51,17 @@ static unsigned long g_swProLastMs=0;
 static Adafruit_USBD_HID g_swPro;
 static uint8_t g_swProReportMode=0;   // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
 static const uint8_t JC_MAC[6]={0x7C,0xBB,0x8A,0x00,0x00,0x01};   // synthetic but stable controller MAC
+static uint16_t jcRumbleAmp(const uint8_t r[4]){
+  // Nintendo's 4-byte rumble encoding is frequency+amplitude packed through tables. For translation to the
+  // Steam controller, use the amplitude-bearing fields directly and scale them into a 16-bit motor speed.
+  if((r[0]==0x00 && r[1]==0x01 && r[2]==0x40 && r[3]==0x40) || (r[0]==0x00 && r[1]==0x00 && r[2]==0x01 && r[3]==0x40)) return 0;
+  uint16_t amp = ((uint16_t)(r[1] & 0xFE) << 8) | r[3];
+  return amp ? amp : 0x0800;
+}
+static void jcRumble(const uint8_t* p, uint16_t pn){
+  if(pn<9) return;                       // [timer][left rumble x4][right rumble x4]
+  hapticSteamRumble(jcRumbleAmp(p+1), jcRumbleAmp(p+5));
+}
 static int jcStick12(int16_t v, bool inv){   // steam int16 (center 0) -> 12-bit (center 0x800), clamped
   int a = 2048 + (inv ? -((int)v>>4) : ((int)v>>4));
   return a<0?0:(a>4095?4095:a);
@@ -223,9 +235,12 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_
   }
   if(id==0x01){                        // [timer][rumble x8][subcmd][args...]
     if(pn<10) return;
+    jcRumble(p, pn);
     jcSubcmd(p[9], p+10, (pn>10)?(uint16_t)(pn-10):0);
+    return;
   }
-  // report 0x10 (rumble only) / 0x82: ignored
+  if(id==0x10){ jcRumble(p, pn); return; }
+  // report 0x82: ignored
 }
 
 void SwitchProController::begin(){

+ 10 - 13
OpenPuck/mode_xinput.cpp

@@ -25,8 +25,8 @@ enum { XB_DUP=0x0001,XB_DDOWN=0x0002,XB_DLEFT=0x0004,XB_DRIGHT=0x0008, XB_START=
 static uint8_t g_xiItf=0xFF, g_xiEpIn=0, g_xiEpOut=0;
 static uint8_t g_xiInBuf[32], g_xiOutBuf[32];
 
-// legacy XInput rumble strength from the host's OUT packet (0=off); drives the haptic relay
-static volatile uint8_t g_rumble = 0;
+// XInput rumble strengths from the host's OUT packet; translated to Steam/Triton report 0x80.
+static volatile uint16_t g_rumbleLow = 0, g_rumbleHigh = 0;
 static volatile unsigned long g_rumbleMs = 0;   // millis of last rumble OUT packet (stuck-rumble watchdog)
 #define RUMBLE_STUCK_MS 2500u   // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop without cutting normal short rumbles)
 
@@ -54,7 +54,9 @@ static bool xi_xfer(uint8_t rhport,uint8_t ep,xfer_result_t res,uint32_t n){(voi
   if(ep==g_xiEpOut){
     // XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
     if(n>=5 && g_xiOutBuf[0]==0x00 && g_xiOutBuf[1]==0x08){
-      uint8_t big=g_xiOutBuf[3], sml=g_xiOutBuf[4]; g_rumble = big>sml?big:sml; g_rumbleMs = millis();   // stamp for the stuck-rumble watchdog
+      uint8_t big=g_xiOutBuf[3], sml=g_xiOutBuf[4];
+      g_rumbleLow=(uint16_t)big*257u; g_rumbleHigh=(uint16_t)sml*257u; g_rumbleMs = millis();   // stamp for the stuck-rumble watchdog
+      hapticSteamRumble(g_rumbleLow, g_rumbleHigh);
     }
     usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);    // re-arm OUT
   }
@@ -143,7 +145,7 @@ static void rfXboxMouse(const uint8_t* r){
 
 // ===================== IController =====================
 void XboxController::begin(){
-  g_rumble=0;
+  g_rumbleLow=g_rumbleHigh=0;
   USBDevice.setID(0x045E, 0x028E);   // device-level 045E:028E match -> Windows xusb / SDL / Linux xpad all bind it
   // bcdDevice 0x0115 (was 0x0114): Windows caches the config descriptor by VID:PID:bcdDevice, so any change to
   // this mode's interfaces (here: the wake-mouse interface) MUST bump bcdDevice or Windows serves a stale
@@ -160,15 +162,10 @@ void XboxController::onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen
   (void)fresh; (void)bodyTlen;
   rfXboxGamepad(rep); rfXboxMouse(rep);   // standard gamepad + right-pad mouse (2nd interface)
 }
-// 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.
+// Lost-stop watchdog: Steam/Triton rumble is latched, so force a zero report if the host stops refreshing.
 void XboxController::task(){
-  if (g_rumble && millis()-g_rumbleMs > RUMBLE_STUCK_MS) g_rumble=0;
-  if (g_rumble && !relayPending() && g_connSlot>=0) {
-    static unsigned long lastRumble=0;
-    if (millis()-lastRumble>=25) { lastRumble=millis();
-      uint8_t gain = g_rumble<0x30?0x30:g_rumble;   // floor so low rumble is still feelable
-      uint8_t pl[3]={0x01,0x01,gain}; relayEnqueue(0x82,pl,3);
-    }
+  if ((g_rumbleLow || g_rumbleHigh) && millis()-g_rumbleMs > RUMBLE_STUCK_MS) {
+    g_rumbleLow=g_rumbleHigh=0;
+    hapticSteamRumble(0, 0);
   }
 }

+ 8 - 5
OpenPuck/puck_hid.cpp

@@ -13,8 +13,8 @@
 uint8_t g_fwdNewOnly = 1;
 SteamPuckController g_steamPuck;
 
-// ---- cloned puck HID report descriptor (verbatim): mouse(0x40)+keyboard(0x41)+vendor(FF00) with the 63-byte
-//      FEATURE command reports on report id 1/2. Each of the 4 interfaces uses this. ----
+// ---- cloned puck HID report descriptor: mouse(0x40)+keyboard(0x41)+vendor(FF00) with the 63-byte FEATURE
+//      command reports on report id 1/2. Each of the 4 interfaces uses this. ----
 static const uint8_t PUCK_HID_DESC[] = {
   0x05,0x01,0x09,0x02,0xA1,0x01,0x85,0x40,0x09,0x01,0xA1,0x00,0x05,0x09,0x19,0x01,
   0x29,0x02,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,
@@ -91,6 +91,7 @@ static bool g_autoLizard = true;            // master switch; false => Steam mod
 // the controller while presenting lizard (Steam isn't reading 0x45 back), Steam loops the same haptic -> buzz.
 static inline bool steamDrivingGamepad(){ return g_steamAliveMs && (millis()-g_steamAliveMs < LIZARD_WD_MS); }
 static inline bool lizardActive(){ return modeIsPuck(g_usbMode) && (g_usbMode==MODE_LIZARD || (g_autoLizard && !steamDrivingGamepad())); }
+static inline void hostStampAlive(){ g_steamAliveMs = millis(); }
 // Right after the host resumes from suspend, MUTE input forwarding briefly. Otherwise the controller's input
 // in that instant (a trackpad click/trigger, or residual button state from the wake gesture) gets forwarded
 // as a real click/keypress into the just-woken desktop -- which was activating the highlighted Start tile
@@ -110,7 +111,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
     // pushed here -- 0x87 (lizard-off/settings) reaches the controller through the feature-0x01 passthrough path.
     if (rid >= 0x80 && rid <= 0x89) {
       hapLogAdd((uint8_t)slot, rid, b, n);   // capture ALL OUTPUT reports (even un-relayed) for the 'H' dump
-      g_steamAliveMs = millis();   // ANY Steam OUTPUT report (not just the 0x87 heartbeat) means Steam is present and
+      hostStampAlive();   // ANY Steam OUTPUT report (not just the 0x87 heartbeat) means Steam is present and
                                    // driving -> leave lizard for gamepad NOW, so a haptic that arrives before the
                                    // first 0x87 doesn't get relayed while we're still presenting lizard (-> buzz loop).
     }
@@ -138,7 +139,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
   Slot &S = g_slot[slot];
   uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
   const uint8_t *pl = b + 2; uint16_t pln = (n >= 2) ? n - 2 : 0;
-  if (cmd >= 0x80 && cmd <= 0x89) g_steamAliveMs = millis();   // any Steam settings/haptic/LED report (incl. the 0x87 lizard-off heartbeat) -> Steam present, forward gamepad, suppress auto-lizard
+  if (cmd >= 0x80 && cmd <= 0x89) hostStampAlive();   // settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
   // Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
   // confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown()
   // bursts it for NO-ACK reliability (the single hook the test button + host-suspend also drive).
@@ -175,6 +176,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
       uint8_t idx = pln > 0 ? pl[0] : 1; const char *s = (idx == 0) ? g_board : (idx == 1) ? g_unit : "NA";
       S.resp[0] = 0xAE; S.resp[1] = 0x14; S.resp[2] = idx; memset(S.resp + 3, 0, 60); memcpy(S.resp + 3, s, strlen(s)); S.resp_len = 63; break; }
     case 0xB4:    // connection/version state per slot: value 0x02 = controller connected, 0x01 = not
+      hostStampAlive();   // SDL Triton polls this on init; treat like Steam contact so we forward 0x45
       S.resp[0] = 0xB4; S.resp[1] = 0x01;
       S.resp[2] = (slot == g_connSlot && !g_xbox && (millis() - g_connReplyMs < 500)) ? 0x02 : 0x01;
       S.resp_len = 63; break;
@@ -198,6 +200,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t con
 static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type, uint8_t *buf, uint16_t reqlen) {
   (void)rid;
   if (type != HID_REPORT_TYPE_FEATURE) return 0;
+  if (g_usbMode == MODE_STEAM) hostStampAlive();   // host reading feature data => gamepad consumer active (Steam/SDL)
   Slot &S = g_slot[slot];
   uint16_t n = S.resp_len ? S.resp_len : 63; if (n > reqlen) n = reqlen;
   memcpy(buf, S.resp, n); return n;
@@ -254,7 +257,7 @@ void SteamPuckController::onReport45(const uint8_t* rep, bool fresh, uint8_t bod
     // FRESH reports (the real puck dedupes -> Steam gets a clean unique stream; sending stale repeats makes
     // Steam's velocity/smoothing stair-step). g_fwdNewOnly toggles for A/B.
     if((fresh || !g_fwdNewOnly) && g_slot[g_connSlot].used && hid[g_connSlot].ready())
-      hid[g_connSlot].sendReport(0x45, rep+1, blen);   // Steam/SDL: input report -> "connected"
+      hid[g_connSlot].sendReport(0x45, rep+1, blen);   // Steam/SDL Triton: input report 0x45
   }
 }
 

+ 1 - 1
OpenPuck/puck_hid.h

@@ -1,6 +1,6 @@
 // puck_hid.h -- the Steam Controller puck personality (MODE_STEAM + MODE_LIZARD).
 //
-// This is the controller that wears Valve's identity (28DE:1304) and exposes the four puck HID slot interfaces
+// This is the controller that wears Valve's Proteus puck identity (28DE:1304) and exposes the four puck HID slot interfaces
 // (interface N owns bond slot N). It implements:
 //   - the puck feature COMMAND CHANNEL (handleSet/handleGet): reports 0x83/0xAE/0xB4/0xAD/0xA2/0xA3 the host
 //     uses to read attributes/serials and read/write/clear bond slots, plus the host->controller haptic relay

+ 2 - 2
docs/PROTOCOL.md

@@ -30,7 +30,7 @@ An empty slot is all zeroes.
 
 ### 3.1 Steam-mode USB layout
 
-Steam mode enumerates as Valve `28DE:1304` and exposes four HID interfaces with the same report descriptor as the real puck. Each interface owns one bond slot.
+Steam mode enumerates as Valve `28DE:1142` and exposes four HID interfaces with the same report descriptor as the real puck. Each interface owns one bond slot. The firmware uses the classic Steam Controller dongle PID because SDL's Steam HIDAPI driver reliably treats `0x1142` as the four-slot wireless receiver; newer puck PIDs may work in Steam but are not consistently surfaced as Steam Controllers to SDL apps. SDL also expects the Steam slot HIDs on USB interface numbers 1..4, so WebUSB is registered before the slot HIDs in puck mode to reserve interface 0.
 
 Relevant report IDs:
 
@@ -320,7 +320,7 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
 
 ### 9.1 Steam mode
 
-- VID:PID `28DE:1304`
+- VID:PID `28DE:1142`
 - Four puck HID interfaces (CDC + WebUSB also present)
 - `0x45` reports are forwarded to the connected slot's HID interface
 - **Seamless lizard**: when Steam is driving the device, `0x45` is forwarded; when it isn't