Przeglądaj źródła

DS4/5 Gyro fix and QAM mapping (#38)

* Gyro fix and qam mapping

* Update BUILD_AND_DEPLOY.md

* enhanced lizard mode working

* qam mapping works
Jari 4 miesięcy temu
rodzic
commit
c9c143573e

+ 5 - 3
OpenPuck/config.cpp

@@ -16,19 +16,20 @@ static uint8_t g_debugCdc = 0;   // persisted one-shot arm, stored in Cfg.rsvd0
 int     g_mDiv = 64, g_mFric = 94;
 uint8_t g_abSwap = 0;
 uint8_t g_back[4] = {5,6,7,8};   // L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad U/D/L/R)
+uint8_t g_qamMap = 0;            // 0 = default (unmapped, uses hardcoded behavior per mode)
 
 // poll rate is fixed. Faster than the controller can refresh wastes airtime; slower adds latency. Any rate
 // persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-#define CFG_MAGIC 0xC7   // bumped (chordBtn[3]): old cfg ignored -> clean defaults on first boot
-struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3]; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
+#define CFG_MAGIC 0xC8   // bumped (qamMap): old cfg ignored -> clean defaults on first boot
+struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3], qamMap; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 void saveCfg(){
   Cfg c={CFG_MAGIC,g_usbMode,(uint8_t)g_mDiv,(uint8_t)g_mFric,g_debugCdc,g_abSwap,
          {g_back[0],g_back[1],g_back[2],g_back[3]},(uint8_t)(g_pollUs/100),(uint8_t)(g_persistMode?1:0),g_bootMode,
-         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]}};
+         {g_chordBtn[0],g_chordBtn[1],g_chordBtn[2]},g_qamMap};
   InternalFS.remove(CFG_FILE); File f(InternalFS);
   if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
 }
@@ -52,6 +53,7 @@ void loadCfg(){
       else                 g_usbMode = g_persistMode ? (modeValid(c.mode)?c.mode:0) : 0;
       static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_HORI};
       for(int i=0;i<3;i++) g_chordBtn[i]=modeValid(c.chordBtn[i])?c.chordBtn[i]:CHORD_DEF[i];
+      g_qamMap = c.qamMap;
     }
     f.close();
   }

+ 2 - 1
OpenPuck/config.h

@@ -62,7 +62,8 @@ void armDebugCdcNextBoot();          // persist the one-shot (caller reboots)
 // persisted, runtime-tunable config:
 extern int     g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
 extern uint8_t g_abSwap;        // 1 = swap A/B and X/Y (Nintendo face-button layout)
-extern uint8_t g_back[4];       // back paddles L4,R4,L5,R5 -> button codes (0..11 buttons, 12..15 D-pad U/D/L/R)
+extern uint8_t g_back[4];       // back paddles L4,R4,L5,R5 -> button codes (0..15 standard, 16=PS Touch Click, 17=PS5 Mute)
+extern uint8_t g_qamMap;        // QAM (3 dots) physical button -> same code space (0 = default/unmapped)
 
 #define POLL_US_DEFAULT 4000u   // 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
 #define USB_STREAM_MS   4u      // host-side HID stream cadence for translated modes (~250 Hz)

+ 4 - 2
OpenPuck/gamepad_util.cpp

@@ -55,11 +55,13 @@ void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int
 static void psOrBackCode(uint32_t* b, uint8_t c){
   switch(c){ case 1:*b|=TB_A; break; case 2:*b|=TB_B; break; case 3:*b|=TB_X; break; case 4:*b|=TB_Y; break;
     case 5:*b|=TB_LB; break; case 6:*b|=TB_RB; break; case 7:*b|=TB_L3; break; case 8:*b|=TB_R3; break;
-    case 9:*b|=TB_VIEW; break; case 10:*b|=TB_MENU; break; case 11:*b|=TB_STEAM; break;
-    case 12:*b|=TB_DUP; break; case 13:*b|=TB_DDN; break; case 14:*b|=TB_DLF; break; case 15:*b|=TB_DRT; break; default: break; }
+    case 9:*b|=TB_VIEW; break; case 10:*b|=TB_QAM; break; case 11:*b|=TB_STEAM; break;
+    case 12:*b|=TB_DUP; break; case 13:*b|=TB_DDN; break; case 14:*b|=TB_DLF; break; case 15:*b|=TB_DRT; break;
+    case 16:*b|=TB_TOUCH; break; case 17:*b|=TB_MUTE; break; default: break; }
 }
 uint32_t psButtonsFromSteam(uint32_t raw){
   uint32_t b=raw;
+  if(g_qamMap && (b&TB_QAM)){ b &= ~(uint32_t)TB_QAM; b |= tritonFromCode(g_qamMap); }
   if((b&CHORD_BACK4)==CHORD_BACK4) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
   if(b&TB_L4) psOrBackCode(&b, g_back[0]); if(b&TB_R4) psOrBackCode(&b, g_back[1]);
   if(b&TB_L5) psOrBackCode(&b, g_back[2]); if(b&TB_R5) psOrBackCode(&b, g_back[3]);

+ 11 - 1
OpenPuck/gamepad_util.h

@@ -6,6 +6,7 @@
 // are here.
 #pragma once
 #include <stdint.h>
+#include "triton.h"
 
 // int16 stick (center 0) -> uint8 (center 0x80), optional axis invert (HID Y is down-positive).
 uint8_t swStick(int16_t v, bool invert);
@@ -22,8 +23,17 @@ void touchPackPads(uint8_t* pts, bool lTouch, bool rTouch, uint16_t lx, uint16_t
 void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
     uint16_t* lx, uint16_t* ly, uint16_t* rx, uint16_t* ry);
 
+// Convert configurable button code -> TB_* flag (shared across modes).
+static inline uint32_t tritonFromCode(uint8_t c){
+  switch(c){ case 1:return TB_A; case 2:return TB_B; case 3:return TB_X; case 4:return TB_Y;
+    case 5:return TB_LB; case 6:return TB_RB; case 7:return TB_L3; case 8:return TB_R3;
+    case 9:return TB_VIEW; case 10:return TB_QAM; case 11:return TB_STEAM;
+    case 12:return TB_DUP; case 13:return TB_DDN; case 14:return TB_DLF; case 15:return TB_DRT;
+    case 16:return TB_TOUCH; case 17:return TB_MUTE; default:return 0; }
+}
+
 // PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
-uint32_t psButtonsFromSteam(uint32_t raw);   // apply back-paddle + chord-guard remapping
+uint32_t psButtonsFromSteam(uint32_t raw);   // apply back-paddle + chord-guard + QAM remapping
 uint8_t  psShouldersByte(uint32_t b);        // L1..R3 + Create/Options byte
 uint8_t  psHatNibble(uint32_t b);            // d-pad -> 8-way hat (8 = neutral)
 uint8_t  psFaceNibble(uint32_t b);           // face buttons (with A/B + X/Y swap)

+ 2 - 2
OpenPuck/mode_hidgyro.cpp

@@ -32,11 +32,11 @@ static void hidGyroBuild(uint8_t out[63]){
   out[2]=swStick(g_in.rx,false); out[3]=swStick(g_in.ry,true);
   out[4]=psHatNibble(b)|psFaceNibble(b);
   out[5]=psShouldersByte(b);
-  static uint8_t ctr=0; out[6]=((ctr++&0x0F)<<4)|((b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_STEAM)?0x01:0);
+  static uint8_t ctr=0; out[6]=((ctr++&0x0F)<<4)|((b&TB_TOUCH||b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_STEAM)?0x01:0);
   out[7]=g_in.lt; out[8]=g_in.rt;
   out[12]=g_in.gx&0xFF; out[13]=g_in.gx>>8;
   out[14]=g_in.gy&0xFF; out[15]=g_in.gy>>8;
-  out[16]=g_in.gz&0xFF; out[17]=g_in.gz>>8;
+  out[16]=(-g_in.gz)&0xFF; out[17]=(-g_in.gz)>>8;
   out[18]=g_in.ax&0xFF; out[19]=g_in.ax>>8;
   out[20]=g_in.ay&0xFF; out[21]=g_in.ay>>8;
   out[22]=g_in.az&0xFF; out[23]=g_in.az>>8;

+ 14 - 2
OpenPuck/mode_lizard.cpp

@@ -4,6 +4,7 @@
 
 void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev, uint8_t mrid, uint8_t krid){
   uint32_t b=btnsOf(r);
+  bool qamHeld = (b & TB_QAM) != 0;
   // --- right pad -> mouse motion with glide (mirrors mode_xinput's rfXboxMouse) ---
   static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0;
   bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
@@ -39,14 +40,25 @@ void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev
   LZK(b&TB_B,    HID_KEY_ESCAPE);
   LZK(b&TB_X,    HID_KEY_PAGE_UP);
   LZK(b&TB_Y,    HID_KEY_PAGE_DOWN);
-  LZK(b&TB_VIEW, HID_KEY_TAB);
-  LZK(b&TB_MENU, HID_KEY_ESCAPE);
+  LZK(b&TB_VIEW, (g_usbMode==MODE_LIZARD)?HID_KEY_ESCAPE:HID_KEY_TAB);
+  LZK(b&TB_MENU, (g_usbMode==MODE_LIZARD)?HID_KEY_TAB:HID_KEY_ESCAPE);
   int sx=s16off(r,8), sy=s16off(r,10);   // left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
   LZK((b&TB_DUP)||sy> 12000, HID_KEY_ARROW_UP);
   LZK((b&TB_DDN)||sy<-12000, HID_KEY_ARROW_DOWN);
   LZK((b&TB_DLF)||sx<-12000, HID_KEY_ARROW_LEFT);
   LZK((b&TB_DRT)||sx> 12000, HID_KEY_ARROW_RIGHT);
   #undef LZK
+  if(g_usbMode == MODE_LIZARD){
+    static bool prevL5=false, prevR5=false;
+    bool mh = (b & TB_STEAM) || qamHeld;
+    bool nL5 = mh && (b & TB_L5), nR5 = mh && (b & TB_R5);
+    if (nL5 && !prevL5){ uint8_t cc=0x02; if(mdev->ready()) mdev->sendReport(0x03,&cc,1); }
+    if (nR5 && !prevR5){ uint8_t cc=0x01; if(mdev->ready()) mdev->sendReport(0x03,&cc,1); }
+    if ((!nL5 && prevL5) || (!nR5 && prevR5)){ uint8_t cc=0x00; if(mdev->ready()) mdev->sendReport(0x03,&cc,1); }
+    prevL5=nL5; prevR5=nR5;
+    if(mh && (b & TB_X)){ mod=KEYBOARD_MODIFIER_LEFTGUI|KEYBOARD_MODIFIER_LEFTCTRL; kc[0]=HID_KEY_O; kc[1]=0; kc[2]=0; kc[3]=0; kc[4]=0; kc[5]=0; nk=1; }
+    if(mh && (b & TB_L4)){ mod=KEYBOARD_MODIFIER_LEFTCTRL|KEYBOARD_MODIFIER_LEFTALT; kc[0]=HID_KEY_DELETE; kc[1]=0; kc[2]=0; kc[3]=0; kc[4]=0; kc[5]=0; nk=1; }
+  }
   static uint8_t pmod=0, pkc[6]={0,0,0,0,0,0};
   bool chg=(mod!=pmod); for(int i=0;i<6;i++) if(kc[i]!=pkc[i]) chg=true;
   if(chg){ pmod=mod; for(int i=0;i<6;i++) pkc[i]=kc[i];

+ 2 - 2
OpenPuck/mode_ps5.cpp

@@ -42,9 +42,9 @@ static void ps5Build(uint8_t out[63]){
   static uint8_t seq=0; out[6]=seq++;
   out[7]=psHatNibble(b)|psFaceNibble(b);
   out[8]=psShouldersByte(b);
-  out[9]=((b&TB_STEAM)?0x01:0)|((b&TB_LPADC||b&TB_RPADC)?0x02:0);
+  out[9]=((b&TB_STEAM)?0x01:0)|((b&TB_TOUCH||b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_MUTE)?0x04:0);
   out[15]=g_in.gx&0xFF; out[16]=g_in.gx>>8;
-  out[17]=g_in.gz&0xFF; out[18]=g_in.gz>>8;
+  out[17]=(-g_in.gz)&0xFF; out[18]=(-g_in.gz)>>8;
   out[19]=g_in.gy&0xFF; out[20]=g_in.gy>>8;
   out[21]=g_in.ax&0xFF; out[22]=g_in.ax>>8;
   out[23]=g_in.ay&0xFF; out[24]=g_in.ay>>8;

+ 1 - 0
OpenPuck/mode_switch_hori.cpp

@@ -38,6 +38,7 @@ static inline void backCodeToHatDirs(uint8_t c, bool& u, bool& d, bool& l, bool&
 // HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
 static void switchBuildHoripad(uint8_t out[8]){
   uint32_t b=g_in.buttons; uint16_t btn=0;
+  if(g_qamMap && (b&TB_QAM)){ b &= ~(uint32_t)TB_QAM; b |= tritonFromCode(g_qamMap); }
   // Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
   if((b&CHORD_BACK4)==CHORD_BACK4) b &= ~(uint32_t)(TB_A|TB_X|TB_Y);
   // face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)

+ 2 - 0
OpenPuck/mode_switch_pro.cpp

@@ -1,5 +1,6 @@
 #include "mode_switch_pro.h"
 #include "triton.h"
+#include "gamepad_util.h"
 #include "config.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
@@ -64,6 +65,7 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y){   // pack two 12-bi
 static uint8_t g_jcTimer=0;
 static void jcInputPrefix(uint8_t* out){
   uint32_t b=g_in.buttons;
+  if(g_qamMap && (b&TB_QAM)){ b &= ~(uint32_t)TB_QAM; b |= tritonFromCode(g_qamMap); }
   if((b&CHORD_BACK4)==CHORD_BACK4) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
   uint32_t fA=g_abSwap?JC_BTN_B:JC_BTN_A, fB=g_abSwap?JC_BTN_A:JC_BTN_B;
   uint32_t fX=g_abSwap?JC_BTN_Y:JC_BTN_X, fY=g_abSwap?JC_BTN_X:JC_BTN_Y;

+ 2 - 0
OpenPuck/mode_xinput.cpp

@@ -1,5 +1,6 @@
 #include "mode_xinput.h"
 #include "triton.h"
+#include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
@@ -105,6 +106,7 @@ static uint16_t codeToXB(uint8_t c){
 }
 static void rfXboxGamepad(const uint8_t* r){
   uint32_t b=btnsOf(r);
+  if(g_qamMap && (b&TB_QAM)){ b &= ~(uint32_t)TB_QAM; b |= tritonFromCode(g_qamMap); }
   uint16_t btn=0;                                   // map Triton buttons -> XInput button word
   if(b&TB_DUP)btn|=XB_DUP;   if(b&TB_DDN)btn|=XB_DDOWN; if(b&TB_DLF)btn|=XB_DLEFT; if(b&TB_DRT)btn|=XB_DRIGHT;
   if(b&TB_VIEW)btn|=XB_START; if(b&TB_MENU)btn|=XB_BACK; if(b&TB_STEAM)btn|=XB_GUIDE;

+ 35 - 2
OpenPuck/puck_hid.cpp

@@ -42,6 +42,37 @@ static const uint8_t PUCK_HID_DESC[] = {
   0x01,0xB1,0x02,0xC0
 };
 
+// Pure MODE_LIZARD only: same puck/lizard descriptor plus a tiny Consumer Control report for media keys.
+// MODE_STEAM must keep PUCK_HID_DESC verbatim so Steam's controller/haptics path sees the normal puck shape.
+static const uint8_t PUCK_LIZARD_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,
+  0x81,0x01,0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7F,0x75,0x08,0x95,0x02,
+  0x81,0x06,0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0C,0x0A,0x38,0x02,0x95,0x01,0x81,
+  0x06,0xC0,0xC0,0x05,0x01,0x09,0x06,0xA1,0x01,0x85,0x41,0x05,0x07,0x19,0xE0,0x29,
+  0xE7,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,
+  0x65,0x15,0x00,0x25,0x65,0x75,0x08,0x95,0x06,0x81,0x00,0xC0,0x06,0x00,0xFF,0x09,
+  0x01,0xA1,0x01,0x85,0x42,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x35,0x09,0x42,
+  0x81,0x02,0x85,0x44,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x05,0x09,0x44,0x81,
+  0x02,0x85,0x79,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x01,0x09,0x79,0x81,0x02,
+  0x85,0x43,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x0E,0x09,0x43,0x81,0x02,0x85,
+  0x7B,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x0C,0x09,0x7B,0x81,0x02,0x85,0x45,
+  0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x2D,0x09,0x45,0x81,0x02,0x85,0x80,0x15,
+  0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x09,0x09,0x80,0x91,0x02,0x85,0x81,0x15,0x00,
+  0x26,0xFF,0x00,0x75,0x08,0x95,0x07,0x09,0x81,0x91,0x02,0x85,0x82,0x15,0x00,0x26,
+  0xFF,0x00,0x75,0x08,0x95,0x03,0x09,0x82,0x91,0x02,0x85,0x83,0x15,0x00,0x26,0xFF,
+  0x00,0x75,0x08,0x95,0x09,0x09,0x83,0x91,0x02,0x85,0x84,0x15,0x00,0x26,0xFF,0x00,
+  0x75,0x08,0x95,0x08,0x09,0x84,0x91,0x02,0x85,0x85,0x15,0x00,0x26,0xFF,0x00,0x75,
+  0x08,0x95,0x03,0x09,0x85,0x91,0x02,0x85,0x86,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,
+  0x95,0x03,0x09,0x86,0x91,0x02,0x85,0x87,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,
+  0x3F,0x09,0x87,0x91,0x02,0x85,0x89,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x3F,
+  0x09,0x89,0x91,0x02,0x85,0x88,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x3F,0x09,
+  0x88,0x91,0x02,0x85,0x01,0x95,0x3F,0x09,0x01,0xB1,0x02,0x85,0x02,0x95,0x3F,0x09,
+  0x01,0xB1,0x02,0xC0,
+  0x05,0x0C,0x09,0x01,0xA1,0x01,0x85,0x03,0x15,0x00,0x25,0x01,0x09,0xE9,0x09,0xEA,
+  0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,0xC0
+};
+
 static Adafruit_USBD_HID hid[NSLOT];
 
 // ===================== seamless LIZARD decision =====================
@@ -190,11 +221,13 @@ void SteamPuckController::begin(){
   // tied to the real Steam Controller (28DE:1304), which has no WebUSB interface. The normal (wake-mouse) and
   // one-shot debug (CDC) boots present DIFFERENT interface sets, so they use DIFFERENT bcdDevice values --
   // otherwise Windows would serve one's cached descriptor for the other when you reboot between them.
-  USBDevice.setDeviceVersion(g_debugCdcThisBoot ? 0x0212 : 0x0211);
+  USBDevice.setDeviceVersion(g_debugCdcThisBoot ? 0x0212 : (g_usbMode == MODE_LIZARD ? 0x0213 : 0x0211));
   USBDevice.setManufacturerDescriptor("Valve Software");
   USBDevice.setProductDescriptor("Steam Controller Puck");
+  const uint8_t* desc = (g_usbMode == MODE_LIZARD) ? PUCK_LIZARD_HID_DESC : PUCK_HID_DESC;
+  const uint16_t descLen = (g_usbMode == MODE_LIZARD) ? sizeof PUCK_LIZARD_HID_DESC : sizeof PUCK_HID_DESC;
   for (int i = 0; i < NSLOT; i++) {
-    hid[i].setReportDescriptor(PUCK_HID_DESC, sizeof PUCK_HID_DESC);
+    hid[i].setReportDescriptor(desc, descLen);
     hid[i].setReportCallback(GETCB[i], SETCB[i]);
     hid[i].setPollInterval(1);       // 1ms USB poll (was default 10ms = 100/s cap -> choppy)
     hid[i].begin();

+ 2 - 1
OpenPuck/serial_console.cpp

@@ -59,7 +59,8 @@ void serialConsolePoll(){
       else if (line[0]=='E'){ g_mDiv=strtoul(line+1,0,10); if(g_mDiv<4)g_mDiv=4; saveCfg(); Serial.printf("# xbox-mouse sensitivity divisor=%d (lower=faster)\n",g_mDiv); }
       else if (line[0]=='F'){ g_mFric=strtoul(line+1,0,10); if(g_mFric>99)g_mFric=99; saveCfg(); Serial.printf("# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",g_mFric); }
       else if (line[0]=='W'){ g_abSwap=!g_abSwap; saveCfg(); Serial.printf("# A/B + X/Y swap %s (Nintendo layout)\n",g_abSwap?"ON":"off"); }
-      else if (line[0]=='K'){ int i=line[1]-'0'; uint8_t code=strtoul(line+2,0,10); if(i>=0&&i<4){ g_back[i]=code; saveCfg(); Serial.printf("# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright]\n",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } else Serial.println("# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)"); }
+      else if (line[0]=='K'){ int i=line[1]-'0'; uint8_t code=strtoul(line+2,0,10); if(i>=0&&i<4){ g_back[i]=code; saveCfg(); Serial.printf("# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } else Serial.println("# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)"); }
+      else if (line[0]=='Q'){ uint8_t code=strtoul(line+1,0,10); g_qamMap=code; saveCfg(); Serial.printf("# QAM -> code %u  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",code); }
       else if (line[0]=='J'){ char* sp=0; uint8_t id=strtoul(line+1,&sp,0); uint16_t val=sp?strtoul(sp,0,0):0;  // inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
         uint8_t pl[3]={id,(uint8_t)(val&0xFF),(uint8_t)(val>>8)}; relayEnqueue(0x87,pl,3);
         Serial.printf("# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",id,val); }

+ 3 - 0
OpenPuck/triton.h

@@ -12,6 +12,7 @@
 #define TB_B      0x2u
 #define TB_X      0x4u
 #define TB_Y      0x8u
+#define TB_QAM    0x10u
 #define TB_R3     0x20u
 #define TB_VIEW   0x40u
 #define TB_R4     0x80u
@@ -29,6 +30,8 @@
 #define TB_LB     0x80000u
 #define TB_RPADT  0x200000u
 #define TB_RPADC  0x400000u
+#define TB_TOUCH  0x100000u      // virtual: PS Touch Click (back-paddle/QAM target, not a real Triton button)
+#define TB_MUTE   0x1000000u     // virtual: PS5 Mute button (back-paddle/QAM target, not a real Triton button)
 #define TB_LPADT  0x2000000u
 #define TB_LPADC  0x4000000u
 

+ 2 - 1
OpenPuck/webusb_config.cpp

@@ -23,7 +23,7 @@ static void webusbSendBlob(){
   uint8_t p[2+WB_PAYLEN];
   p[0]=0xA5; p[1]=WB_PAYLEN;
   p[2]=4;                          // protocol version (4 = +battery/rssi/git; 2 = chordBtn[3])
-  p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=0 /*rsvd: ex-padSmooth*/; p[7]=g_abSwap;
+  p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=g_qamMap; p[7]=g_abSwap;
   p[8]=g_back[0]; p[9]=g_back[1]; p[10]=g_back[2]; p[11]=g_back[3];
   p[12]=(g_connSlot>=0)?(uint8_t)g_connSlot:0xFF;
   p[13]=up?1:0;
@@ -116,6 +116,7 @@ void webusbPoll(){
           case 16: g_persistMode = v?true:false; break;   // persist last mode across reboots (else always boot Steam)
           case 17: case 18: case 19: if(modeValid(v)) g_chordBtn[f-17]=v; break;   // back4+B/X/Y mode assignments
           case 20: armDebugCdcNextBoot(); usb_web.flush(); delay(40); NVIC_SystemReset(); break;  // reboot once WITH the CDC serial console (puck mode), then auto-revert
+          case 21: g_qamMap = v; break;   // QAM physical button remap code (0=default/unmapped)
         }
         if(persist) saveCfg();
         webusbSendBlob();

+ 31 - 0
docs/BUILD_AND_DEPLOY.md

@@ -14,6 +14,7 @@ Software:
 
 - `arduino-cli`
 - Adafruit nRF52 Arduino core
+- `adafruit-nrfutil` (Python package, for DFU packaging - see "Build the firmware")
 - Chrome or Edge for the WebUSB app
 
 ## 2. Install Arduino CLI
@@ -37,6 +38,7 @@ Use [choco](https://chocolatey.org/) and do a `choco install arduino-cli`. If yo
 Run once on any platform:
 
 ```bash
+pip install adafruit-nrfutil         # DFU packaging helper (required by the Adafruit nRF52 build recipe)
 arduino-cli config init
 arduino-cli core update-index
 arduino-cli core install adafruit:nrf52 --additional-urls https://adafruit.github.io/arduino-board-index/package_adafruit_index.json
@@ -95,6 +97,35 @@ arduino-cli upload `
 
 Replace `COM5` with the actual board port.
 
+## 5b. Upload via DFU (nRF52840 UF2 bootloader)
+
+If the board has the **Adafruit nRF52 UF2 bootloader** (common on Pro Micro nRF52840 boards), you can upload by dragging the compiled `.uf2` file onto the board's mass-storage volume:
+
+1. **Double-tap the RST button**. The board mounts as a **UF2BOOT** / **NRF52BOOT** drive.
+2. Locate the compiled `.uf2` file:
+
+   ```bash
+   # After a successful `arduino-cli compile`, find the .uf2 in the build directory:
+   ls /tmp/arduino/cores/adafruit_nrf52_adafruit52840/*.uf2
+   # or, on Windows, look in %TEMP%\arduino\...
+   ```
+
+3. **Copy the `.uf2` file** onto the UF2BOOT drive. The board auto-ejects and reboots with the new firmware.
+
+Alternatively, use the **adafruit-nrfutil DFU** Python tool with the board in DFU mode (bootloader LED pulsing):
+
+```bash
+# Enter DFU mode (double-tap RST). On Linux/macOS:
+adafruit-nrfutil --verbose dfu serial --package OpenPuck/OpenPuck.ino.adafruit_nrf52_feather52840.zip -p /dev/ttyACM0 -b 115200
+
+# On Windows (PowerShell):
+adafruit-nrfutil --verbose dfu serial --package OpenPuck/OpenPuck.ino.adafruit_nrf52_feather52840.zip -p COM5 -b 115200
+```
+
+Replace the port (`/dev/ttyACM0` / `COM5`) with the actual board port.
+
+> **Note:** The `.zip` package is generated automatically by `arduino-cli compile` when the Adafruit nRF52 core is used. If it is missing, ensure `adafruit-nrfutil` is installed and recompile.
+
 ## 6. Factory reset (erase persistent storage)
 
 Re-flashing firmware does **not** erase the board's internal LittleFS. The paired-controller bond (`bonds.bin`) and every saved setting (`cfg.bin`: USB mode, chord assignments, back-paddle map, mouse sensitivity) survive a fresh build and upload. To bring a board up in a truly clean state — a new unit, a hand-me-down with a stale bond, or a corrupted config — wipe the filesystem with one of:

+ 10 - 3
docs/index.html

@@ -121,7 +121,8 @@
       <div class="row"><label>R4 (back upper-right)</label><select class="back" data-i="1"></select></div>
       <div class="row"><label>L5 (back lower-left)</label><select class="back" data-i="2"></select></div>
       <div class="row"><label>R5 (back lower-right)</label><select class="back" data-i="3"></select></div>
-      <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes.</p>
+      <div class="row"><label>QAM (3 dots)</label><select id="qamMap"></select></div>
+      <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes. QAM remap applies to all modes (0 = default per-mode behavior).</p>
     </div>
 
     <div class="card logonly">
@@ -143,7 +144,7 @@
 <script>
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)"];
 const CHORD_FIELD = [17, 18, 19];
-const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Start"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"]];
+const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Quick Access Menu"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"],[16,"PS Touch Click"],[17,"PS5 Mute"]];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
@@ -152,6 +153,10 @@ function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString
 for(const sel of document.querySelectorAll("select.back")){
   for(const [c,n] of BTN_CODES){ const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
 }
+{ const sel = document.getElementById("qamMap");
+  const d = document.createElement("option"); d.value=0; d.textContent="Default (varies by mode)"; sel.appendChild(d);
+  for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
+}
 for(const sel of document.querySelectorAll("select.chord")){
   MODE_NAMES.forEach((n,i)=>{ const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
 }
@@ -211,7 +216,7 @@ async function readBlob(){
 }
 function applyBlob(p){
   // [ver][mode][mDiv][mFric][rsvd][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100(unused)][newlo][newhi]
-  const mode=p[1], mDiv=p[2], mFric=p[3], abSwap=p[5];   // p[4] reserved (ex-padSmooth)
+  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];   // p[4] = QAM remap code (0 = default)
   const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
   const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,2];   // v2 blob: chordBtn after qos/persist
@@ -251,6 +256,7 @@ function applyBlob(p){
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
+  { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
   for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
 }
 function fmtSlider(id,v){ return ""+v; }
@@ -364,6 +370,7 @@ for(const id of ["mDiv","mFric"]){
 for(const sel of document.querySelectorAll("select.back")){
   sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
 }
+$("#qamMap").addEventListener("change", ()=>setField(21, +$("#qamMap").value));
 for(const sel of document.querySelectorAll("select.chord")){
   sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
 }