소스 검색

Merge pull request #1 from safijari/initial_commit

Initial commit with docs and sketch
Jari 4 달 전
부모
커밋
4866f00a74
7개의 변경된 파일과 1960개의 추가작업 그리고 2개의 파일을 삭제
  1. 4 0
      .gitignore
  2. 1182 0
      OpenPuck.ino
  3. 5 2
      README.md
  4. 1 0
      docs/.nojekyll
  5. 151 0
      docs/BUILD_AND_DEPLOY.md
  6. 361 0
      docs/PROTOCOL.md
  7. 256 0
      docs/index.html

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+.DS_Store
+.idea
+*.log
+tmp/

+ 1182 - 0
OpenPuck.ino

@@ -0,0 +1,1182 @@
+// OpenPuck.ino -- Steam Controller 2 ("Triton") puck reimplementation for an nRF52840.
+//
+// This sketch impersonates the Valve puck over USB, maintains puck-style bond slots, speaks the
+// reverse-engineered RF protocol to the controller, and can re-enumerate into Steam, Xbox, Switch,
+// or lizard modes. The real puck exposes four separate HID control interfaces; interface N owns
+// bond slot N, so this sketch does the same. Build with -DCFG_TUD_HID=4.
+
+#include <Adafruit_TinyUSB.h>
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+
+// custom class-driver API (usbd_class_driver_t, usbd_edpt_*) for the XInput interface
+extern "C" {
+#include "device/usbd_pvt.h"
+}
+
+// Bounded radio-disable wait: NEVER spin forever. A wedged RADIO peripheral would otherwise hang the whole
+// main loop (and USB stops being serviced -> device "dies" until replug). On timeout we bail and continue;
+// the next rfConfig re-inits the radio.
+#define RWAIT_DISABLED() do{ uint32_t _w=micros(); while(!NRF_RADIO->EVENTS_DISABLED && (uint32_t)(micros()-_w)<3000){} }while(0)
+using namespace Adafruit_LittleFS_Namespace;
+
+#if CFG_TUD_HID < 4
+#error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
+#endif
+
+// ---- 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. ----
+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,
+  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
+};
+
+#define NSLOT 4
+Adafruit_USBD_HID hid[NSLOT];
+// 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
+// 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.
+static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
+Adafruit_USBD_HID g_mouse;   // Xbox/Lizard-mode mouse interface (right trackpad)
+static const uint8_t KEYBOARD_HID_DESC[] = { TUD_HID_REPORT_DESC_KEYBOARD() };
+Adafruit_USBD_HID g_keyboard;   // Lizard-mode keyboard interface (buttons -> keys)
+static uint8_t g_usbMode = 0;   // 0=STEAM(puck) 1=XBOX(xinput) 2=SWITCH 3=LIZARD(kbd+mouse); loaded from flash at boot
+static bool    g_xbox = false;   // == (g_usbMode != 0); kept for the existing puck-vs-other checks (true for xbox/switch/lizard)
+// Mode persistence policy: by DEFAULT every fresh power-on/reconnect lands in STEAM mode (0). An explicit
+// mode switch still works for the session via a ONE-SHOT bootMode (honored once, then cleared, so the next
+// cold boot reverts to Steam). The WebUI "persist last mode" toggle (g_persistMode) instead remembers the
+// last selected mode across reboots.
+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)
+volatile uint8_t g_rumble = 0;   // Xbox-mode rumble strength from the host's XInput OUT packet (0=off); drives the haptic relay
+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):
+static int     g_mDiv = 64, g_mFric = 94, g_padSmooth = 35;  // xbox mouse sens/friction%, steam pad smoothing%
+static int     g_lizDiv = 320;   // LIZARD right-pad mouse sensitivity divisor (higher=slower; desktop cursor wants way less than Xbox aim). CDC 'n'.
+static int     g_lizFric = 88;   // LIZARD right-pad mouse friction% (lower=less glide/crisper, higher=more coast). Tuned by feel (divisor 320 / friction 88). CDC 'm'.
+static uint8_t g_abSwap = 0;                 // 1 = swap A/B and X/Y (Nintendo face-button layout)
+static uint8_t g_back[4] = {5,6,7,8};        // back paddles L4,R4,L5,R5 -> button codes (see codeToXB; 5=LB 6=RB 7=L3 8=R3)
+static uint32_t g_pollUs = 800;              // RF poll cadence (us). LOCKED default: 800us is the measured peak (~706 polls/s -> ~400 new/s with PID cycling; below 800 = RX-turnaround-limited, above 800 declines). NOT persisted — WebUSB/CDC 'U' tune it for the session only, reverts to 800 on reboot.
+                                             // but too-aggressive over-polls -> reply collapse during trackpad. Live-tunable.
+static uint32_t g_rxWin = 1200;              // poll RX-window (us): max wait for the reply. This (not pollUs) caps poll rate
+                                             // (~1e6/rxWin). Shorter=more polls/s but may miss the controller's DELAYED replies
+                                             // during trackpad. Tunable 'r<us>'. The reply normally arrives fast; 1200 is the worst-case wait.
+static uint8_t g_fwdNewOnly = 1;             // Steam mode: forward report 0x45 only when seq advanced (dedupe like the real puck).
+                                             // Sending stale repeats makes Steam's trackpad smoothing stair-step. Toggle to A/B.
+// QoS adaptive channel hopping: track link badness (crcfail+noRx) on the current channel; when it degrades,
+// hop to the next clean candidate (controller follows via rfHopTo). Conservative hysteresis to avoid blip-storms.
+static uint8_t  g_qos = 0;                   // 0=off (static g_sessCh), 1=auto-hop on degradation
+static const uint8_t g_hopCand[] = {18,46,76,22,68};  // clean, spread channels (from the puck's RSSI/PER scan)
+static uint8_t  g_hopIdx = 0;
+static volatile uint16_t g_qosBad = 0;       // crcfail+noRx accumulated since last QoS check
+static unsigned long g_qosCheckMs = 0, g_qosLastHopMs = 0;
+
+// WebUSB config channel — a dedicated vendor interface present in every mode so the browser panel
+// can read/set the tunables above. Protocol + poll in webusbPoll() below.
+// NOTE: no setLandingPage() — that would make Chrome pop a "device detected, open <url>" notification
+// every plug-in. The interface works without it; the user just opens the published WebUSB page themselves.
+Adafruit_USBD_WebUSB usb_web;
+
+// ===================== XInput (Xbox 360 wired) device — Xbox-mode gamepad =====================
+// Real Xbox 360 pads are NOT HID: vendor interface class 0xFF/sub 0x5D/proto 0x01 + 20-byte XInput report.
+// Custom TinyUSB class driver (registered via usbd_app_driver_get_cb) + an Adafruit_USBD_Interface subclass
+// that emits the interface+0x21-blob+2 endpoints. Steam/SDL bind it as "Xbox 360 Controller" by VID/PID +
+// the FF/5D/01 interface. (Verified against Adafruit nRF52 1.7.0 / TinyUSB 0.18.)  XInput button bits:
+enum { XB_DUP=0x0001,XB_DDOWN=0x0002,XB_DLEFT=0x0004,XB_DRIGHT=0x0008, XB_START=0x0010,XB_BACK=0x0020,
+       XB_L3=0x0040,XB_R3=0x0080, XB_LB=0x0100,XB_RB=0x0200,XB_GUIDE=0x0400, XB_A=0x1000,XB_B=0x2000,XB_X=0x4000,XB_Y=0x8000 };
+
+#define XINPUT_DESC_LEN (9 + 17 + 7 + 7)   // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
+static uint8_t g_xiItf=0xFF, g_xiEpIn=0, g_xiEpOut=0;
+static uint8_t g_xiInBuf[32], g_xiOutBuf[32];
+static void xi_init(void){}
+static bool xi_deinit(void){return true;}
+static void xi_reset(uint8_t rhport){(void)rhport; g_xiEpIn=g_xiEpOut=0;}
+
+static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const* itf, uint16_t max_len){
+  if(!(itf->bInterfaceClass==0xFF && itf->bInterfaceSubClass==0x5D && itf->bInterfaceProtocol==0x01)) return 0;
+  g_xiItf=itf->bInterfaceNumber;
+  uint8_t const* p=(uint8_t const*)itf; uint8_t const* end=p+max_len; uint16_t used=itf->bLength; p+=itf->bLength;
+  uint8_t opened=0;
+  while(p<end && opened<itf->bNumEndpoints){
+    uint8_t blen=p[0], btype=p[1];
+    if(btype==TUSB_DESC_ENDPOINT){ tusb_desc_endpoint_t const* ep=(tusb_desc_endpoint_t const*)p;
+      usbd_edpt_open(rhport,ep);
+      if(tu_edpt_dir(ep->bEndpointAddress)==TUSB_DIR_IN) g_xiEpIn=ep->bEndpointAddress; else g_xiEpOut=ep->bEndpointAddress;
+      opened++; }
+    used+=blen; p+=blen;
+  }
+  if(g_xiEpOut) usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);  // arm OUT (rumble/LED)
+  return used;
+}
+
+static bool xi_ctrl(uint8_t rhport,uint8_t stage,tusb_control_request_t const* req){(void)rhport;(void)req; return stage!=CONTROL_STAGE_SETUP;}
+
+static bool xi_xfer(uint8_t rhport,uint8_t ep,xfer_result_t res,uint32_t n){(void)res;
+  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
+    }
+    usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);    // re-arm OUT
+  }
+  return true; }
+
+static const usbd_class_driver_t g_xiDriver = {
+#if CFG_TUSB_DEBUG>=2
+  .name="XINPUT",
+#endif
+  .init=xi_init, .deinit=xi_deinit, .reset=xi_reset, .open=xi_open,
+  .control_xfer_cb=xi_ctrl, .xfer_cb=xi_xfer, .sof=NULL };
+extern "C" const usbd_class_driver_t* usbd_app_driver_get_cb(uint8_t* count){ *count=1; return &g_xiDriver; }
+class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
+ public:
+  uint16_t getInterfaceDescriptor(uint8_t, uint8_t* buf, uint16_t bufsize) override {
+    if(!buf) return XINPUT_DESC_LEN; if(bufsize<XINPUT_DESC_LEN) return 0;
+    uint8_t itfnum=TinyUSBDevice.allocInterface(1);
+    uint8_t epin=TinyUSBDevice.allocEndpoint(TUSB_DIR_IN), epout=TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+    const uint8_t t[XINPUT_DESC_LEN]={
+      9,TUSB_DESC_INTERFACE,itfnum,0x00,0x02,0xFF,0x5D,0x01,_strid,
+      0x11,0x21,0x00,0x01,0x01,0x25,epin,0x14,0x00,0x00,0x00,0x00,0x13,epout,0x08,0x00,0x00,
+      7,TUSB_DESC_ENDPOINT,epin, TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),1,   // bInterval 1ms (1000Hz) so the RF rate is the only limit
+      7,TUSB_DESC_ENDPOINT,epout,TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),8 };
+    memcpy(buf,t,XINPUT_DESC_LEN); return XINPUT_DESC_LEN;
+  }
+  bool begin(){ return TinyUSBDevice.addInterface(*this); }
+};
+
+Adafruit_USBD_XInput g_xinput;
+static void xinputSend(uint16_t buttons,uint8_t lt,uint8_t rt,int16_t lx,int16_t ly,int16_t rx,int16_t ry){
+  if(!tud_mounted() || g_xiEpIn==0 || usbd_edpt_busy(0,g_xiEpIn)) return;
+  uint8_t* r=g_xiInBuf;
+  r[0]=0x00; r[1]=0x14; r[2]=buttons&0xFF; r[3]=buttons>>8; r[4]=lt; r[5]=rt;
+  r[6]=lx&0xFF; r[7]=lx>>8; r[8]=ly&0xFF; r[9]=ly>>8; r[10]=rx&0xFF; r[11]=rx>>8; r[12]=ry&0xFF; r[13]=ry>>8;
+  memset(r+14,0,6);
+  if(usbd_edpt_claim(0,g_xiEpIn)){ if(!usbd_edpt_xfer(0,g_xiEpIn,r,20)) usbd_edpt_release(0,g_xiEpIn); }
+}
+
+// ---- identity: a UNIQUE serial from the FICR DEVICEID (never clashes with a real puck) ----
+static char g_unit[16];   // "FXB99602xxxxx"
+static char g_board[16];  // "MXB99602xxxxx"
+static const uint8_t ATTR83[] = {     // 0x83 attributes (product 0x1304 = 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 };
+
+// ---- per-slot state (record = [8 uuid][16 serial]) + per-interface response staging ----
+struct Slot { uint8_t rec[24]; bool used; uint8_t resp[63]; uint16_t resp_len; };
+static Slot g_slot[NSLOT];
+// connection tracking (defined here so the USB feature handler can report B4 connection state)
+static int           g_connSlot = -1;    // bonded slot being polled (== the USB-connected slot)
+static unsigned long g_connReplyMs = 0;  // millis of last RF reply (link-alive timestamp)
+// host->controller relay: Steam writes feature report id 0x01 (raw passthrough) carrying a controller
+// feature report [report-id][len][payload...] (e.g. 87 03 09 00 00 = settings: lizard-mode off). The real
+// dongle forwards it to the controller as a SET sub-TLV in the E3 poll; OpenPuck does the same.
+static volatile bool g_relayPend = false;
+static uint8_t       g_relayBuf[24];
+static volatile uint8_t g_relayN = 0;
+static uint8_t g_relayOp  = 0xE3;   // relay frame opcode (E3 poll)
+static uint8_t g_relaySub = 0x05;   // relay sub-TLV type byte = SET (confirmed on hardware: E3 + [len][05][rid][payload] makes the controller act on it — haptics buzz + lizard-off lands). GET is 01.
+static volatile uint8_t g_testHaptic = 0;   // 't<n>' injects n test haptics (output 0x82 [01 01 F7]) for the buzz hunt
+// Haptic watchdog — stops the "stuck high-freq buzz" when the host's stop command is lost over RF.
+// Xbox rumble is a persistent LEVEL with no keepalive: a dropped stop packet leaves g_rumble set and the
+// relay re-buzzes at 40Hz forever (until a host event like the Steam button clears it). Steam glide haptics
+// 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 bool          g_haptic82On = false;        // a non-zero 0x82 haptic is currently active (awaiting host stop)
+#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)
+// Auto-lizard: Steam, while running, re-sends settings report 0x87 id 0x09 (lizard-off) every ~3s as a
+// heartbeat (captured on hardware). While that heartbeat is alive we forward the gamepad report 0x45 to
+// Steam; when it stops (Steam closed) we fall back to lizard — mouse(0x40)+keyboard(0x41) on the SAME puck
+// interface, exactly like the real controller. g_steamAliveMs=0 at boot => lizard active until Steam appears.
+static unsigned long g_steamAliveMs = 0;   // millis of last 0x87 settings write from Steam (its lizard-off heartbeat)
+#define LIZARD_WD_MS 7000u   // Steam-mode lizard kicks in this long after the 0x87 heartbeat stops (>2x the 3s cadence, so a single missed beat won't trip it mid-session)
+#define BOND_FILE "/bonds.bin"
+static volatile bool g_dirty = false;
+static bool g_pairing = false;
+
+static bool recEmpty(const uint8_t *r) { for (int i = 0; i < 24; i++) if (r[i]) return false; return true; }
+
+static void genSerial() {
+  uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
+  snprintf(g_unit,  sizeof g_unit,  "FXB99602%05lX", (unsigned long)(id & 0xFFFFF));
+  snprintf(g_board, sizeof g_board, "MXB99602%05lX", (unsigned long)(id & 0xFFFFF));
+}
+static void saveBonds() {
+  InternalFS.remove(BOND_FILE);
+  File f(InternalFS);
+  if (f.open(BOND_FILE, FILE_O_WRITE)) {
+    for (int i = 0; i < NSLOT; i++) { uint8_t u = g_slot[i].used ? 1 : 0; f.write(&u, 1); f.write(g_slot[i].rec, 24); }
+    f.close();
+  }
+}
+
+#define CFG_FILE "/cfg.bin"
+#define CFG_MAGIC 0xC6   // bumped (added persistMode+bootMode): old cfg ignored -> clean defaults on first boot
+struct Cfg { uint8_t magic, mode, mDiv, mFric, padSmooth, abSwap, back[4], pollU100, persistMode, bootMode; };
+static void saveCfg(){
+  Cfg c={CFG_MAGIC,g_usbMode,(uint8_t)g_mDiv,(uint8_t)g_mFric,(uint8_t)g_padSmooth,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};
+  InternalFS.remove(CFG_FILE); File f(InternalFS);
+  if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
+}
+static void loadCfg(){
+  Cfg c; File f(InternalFS); bool consume=false;
+  if(f.open(CFG_FILE,FILE_O_READ)){
+    if(f.read((uint8_t*)&c,sizeof c)==(int)sizeof c && c.magic==CFG_MAGIC){
+      g_mDiv=c.mDiv?c.mDiv:64; g_mFric=c.mFric; g_padSmooth=c.padSmooth?c.padSmooth:35;
+      g_abSwap=c.abSwap; for(int i=0;i<4;i++) g_back[i]=c.back[i];
+      g_persistMode = c.persistMode?true:false;
+      // boot-mode policy: a one-shot bootMode (set by an explicit switch when !persist) wins once and is then
+      // cleared; otherwise persist->last mode, else->Steam. (poll rate stays the locked 800us default — never restored.)
+      if(c.bootMode!=0xFF){ g_usbMode=(c.bootMode<=3)?c.bootMode:0; consume=true; }
+      else                 g_usbMode = g_persistMode ? ((c.mode<=3)?c.mode:0) : 0;
+    }
+    f.close();
+  }
+  if(consume){ g_bootMode=0xFF; saveCfg(); }   // clear the one-shot so the NEXT cold boot reverts to the default/persist policy
+}
+
+// Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
+// the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
+static void saveMode(uint8_t m){
+  if(g_persistMode){ g_usbMode=m; g_bootMode=0xFF; }
+  else             { g_bootMode=m; }
+  saveCfg();
+}
+static void loadBonds() {
+  File f(InternalFS);
+  if (f.open(BOND_FILE, FILE_O_READ))
+    for (int i = 0; i < NSLOT; i++) { uint8_t u = 0; if (f.read(&u, 1) == 1) { g_slot[i].used = u; f.read(g_slot[i].rec, 24); } }
+  f.close();
+}
+
+// ---- 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) {
+  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
+      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
+    }
+    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);
+      for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
+      Serial.println();
+    }
+    return;
+  }
+  if (type != HID_REPORT_TYPE_FEATURE || n < 1) return;
+  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 == 0x87) g_steamAliveMs = millis();   // Steam's lizard-off heartbeat (settings 0x87, ~every 3s) -> keep gamepad mode, suppress auto-lizard
+  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;
+  }
+  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);
+    for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
+    Serial.println();
+  }
+  memset(S.resp, 0, sizeof S.resp); S.resp_len = 0;
+  switch (cmd) {
+  case 0x83:
+    S.resp[0] = 0x83; S.resp[1] = sizeof ATTR83; memcpy(S.resp + 2, ATTR83, sizeof ATTR83); S.resp_len = 63; break;
+  case 0xAE: {
+    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
+    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;
+  case 0xAD:
+    g_pairing = (pln > 0 && pl[0] != 0); Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
+    S.resp[0] = 0xAD; S.resp[1] = 0; S.resp_len = 63; break;
+  case 0xA2:                                   // write/clear THIS interface's slot
+    if (len >= 24 && pln >= 24) {
+      if (recEmpty(pl)) { S.used = false; memset(S.rec, 0, 24); }
+      else { memcpy(S.rec, pl, 24); S.used = true; }
+      g_dirty = true; Serial.printf("# slot %d %s\n", slot, recEmpty(pl) ? "cleared" : "bonded");
+    }
+    S.resp[0] = 0xA2; S.resp[1] = 0; S.resp_len = 63; break;
+  case 0xA3:                                   // read THIS interface's slot
+    S.resp[0] = 0xA3; S.resp[1] = 0x18; memset(S.resp + 2, 0, 24);
+    if (S.used) memcpy(S.resp + 2, S.rec, 24); S.resp_len = 63; break;
+  default:
+    S.resp[0] = cmd; S.resp[1] = len; if (pln) memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln); S.resp_len = 63; break;
+  }
+}
+
+static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type, uint8_t *buf, uint16_t reqlen) {
+  if (type != HID_REPORT_TYPE_FEATURE) return 0;
+  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;
+}
+
+// one callback pair per interface (the Adafruit core routes by interface to the matching object)
+#define SLOTCB(N)                                                       \
+  static void setcb##N(uint8_t r, hid_report_type_t t, uint8_t const *b, uint16_t n) { handleSet(N, r, t, b, n); } \
+  static uint16_t getcb##N(uint8_t r, hid_report_type_t t, uint8_t *bf, uint16_t rl) { return handleGet(N, r, t, bf, rl); }
+SLOTCB(0) SLOTCB(1) SLOTCB(2) SLOTCB(3)
+typedef uint16_t (*getcb_t)(uint8_t, hid_report_type_t, uint8_t *, uint16_t);
+typedef void (*setcb_t)(uint8_t, hid_report_type_t, uint8_t const *, uint16_t);
+static getcb_t GETCB[NSLOT] = { getcb0, getcb1, getcb2, getcb3 };
+static setcb_t SETCB[NSLOT] = { setcb0, setcb1, setcb2, setcb3 };
+
+// ===================== RF link (firmware-derived) =====================
+// Register-confirmed ESB config (RESOLVED.md): 2Mbit, 5-byte addr (BALEN=4), dynamic length
+// (LFLEN=8,S1LEN=3), 16-bit preamble, big-endian, whitening off, CRC16 poly 0x11021 init 0xFFFF
+// (address included). On-air = bitrev8(stored); RADIO BASE = byteswap32(bitrev8(stored)).
+// Pairing rendezvous: ch2 (2402MHz), base 91 A2 A7 93 (FUN_0000b8b8 / PTR_DAT_0000ba30).
+// Dongle protocol (protocol/RF_CONNECTED.md): beacon [01][seq][..][E2@5]; handshake 0xF0/0xE0/0xF4.
+// Bare-metal RADIO (no SoftDevice — never start BLE); coexists with TinyUSB. Stage A: listen+log.
+// Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
+//   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
+// (The 0x91A2A793 bytes in IBEX rodata precede the key string but are a *different* key,
+//  NOT the discovery address — confirmed via PTR_s_ibexesb_common=0x56f99 -> "ibex...".)
+static const uint8_t PAIR_BASE[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
+static uint8_t  g_rfPrefix = 0x10;     // discovery prefix (rodata byte @0x56f98)
+static uint8_t  g_rfCh = 2;
+static uint8_t  g_sessCh = 18;   // connected-session channel: a CLEAN data channel, off the congested BLE-adv
+                                 // ch2 (=adv37). ch18 measured clean (RSSI 95/90, PER 0) on the REAL PUCK's own
+                                 // channel scan and is in its active hop set {18,2,80}; ch52 (old) was mediocre.
+                                 // Trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
+static bool     g_rfListen = false;
+static uint8_t  g_rfBase[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
+static uint8_t  rfrx[80], rftx[80];
+static uint32_t g_rfRxCount = 0;
+
+static uint8_t rfBitrev8(uint8_t x){ uint8_t y=0; for(int i=0;i<8;i++){y=(y<<1)|(x&1);x>>=1;} return y; }
+
+// HYBRID puck-link config (firmware-derived, definitive):
+//   PHY    = Ble_1Mbit (IBEX FUN_0000f108 MODE=3 is the only RADIO MODE write; ESB inherits it)
+//   PCNF0  = 0x00100108  (S0LEN=1 [buf[0]=0x12 IS the S0 byte], LFLEN=8, S1INCL=1, 8-bit preamble)
+//   PCNF1  = 0x02030040  (WHITEEN ON, BALEN=3 [4-byte addr], little-endian, MAXLEN=64)
+//   CRC    = CRC16 poly 0x11021, init 0xFFFF, CRCCNF=2 (address INCLUDED) -- ESB override
+//            (FUN_00037000); NOT the Bluetooth radio's CRC24 0x65b/CRCCNF=0x103.
+//   whiten = ON, DATAWHITEIV=37 (RADIO FREQUENCY=2 = 2402MHz = BLE adv channel 37)
+//   addr   = ESB addr_conv (FUN_00027a78 bitrev8 each byte): "ibex" -> BASE0=0x9646A61E.
+// All tunable via CDC (M/0/2/i/w + I=crcinit) for hardware iteration.
+// DECODED from real-puck capture (definitive): puck TX = Ble_2Mbit, ENDIAN=Big (MSB-first), NO whitening,
+// addr "ibex", CRC16 0x11021/0xFFFF. Frame = FUN_00027e9a (static length). My Little-endian capture saw
+// it bit-reversed; decode (shift3+bitrev8) recovered 0xE1 + proteus_uuid_LE + ibex_uuid_LE for both bonds.
+static uint32_t g_crcinit = 0xFFFF;
+static uint8_t  g_whiteiv = 37;
+static uint8_t  g_mode    = RADIO_MODE_MODE_Ble_2Mbit;   // <-- was the bug (1Mbit). Real puck = 2Mbit.
+static uint32_t g_pcnf0   = 0x00030008UL;                // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+static uint8_t  g_statlen = 0x20;                        // (static-mode payload length; tunable 'T')
+// PCNF1: ENDIAN=Big (puck transmits MSB-first), WHITEEN=0, BALEN3, MAXLEN64.
+static uint32_t g_pcnf1   = 0x01040040;  // ENDIAN=Big, BALEN4, MAXLEN64 - CRC-VALIDATED
+static uint32_t g_crcpoly = 0x11021UL;
+static uint16_t g_crccnf  = 0x2;          // CRC16, address included
+static uint8_t  g_pid     = 0;
+// Connected-mode poll (Phase 2b): puck (PTX) sends 0xE7 protocol-version then 0xE3 input polls on the
+// session addr/ch; controller responds in the ACK/reply with its input report (byte5==0xF1 + TLV,
+// parsed by IBEX FUN_0000ba38). Tunable for on-hardware iteration (j=type, Q=len, k=enable).
+static bool     g_connOn   = true;   // auto-start poll on boot (so a USB replug resumes acting as a puck)
+static uint8_t  g_connType = 0xE7;   // start with protocol-version handshake, then 0xE3
+static uint8_t  g_e7b = 0;           // E7 payload B-byte: 0=current(slow/awake), 1=test protocol-version-1 (ODR agent: B!=0 sets controller 'v1 fast' latch -> ~100Hz?). 'V<n>' to toggle. RISK: original RE called [00][01] "suspended" — revert to 0 if input stops.
+static uint8_t  g_connLen  = 0x08;
+static uint32_t g_stPoll=0, g_stF1=0, g_stF3=0; static unsigned long g_stMs=0;   // per-second poll/F1/F3 stat counters
+static uint8_t  g_connF3v=0xFF;   // last protocol version the controller reported in an F3 reply (0xFF=none seen)
+static uint16_t g_f1ps=0;   // last completed second's F1 rate (for the WebUSB status readout)
+static uint8_t  g_lastSeq=0; static uint32_t g_stNew=0; static uint16_t g_newps=0;  // genuine new-report rate (report 0x45 seq byte changes)
+static uint32_t g_stCrc=0, g_stNoRx=0;   // diagnostics: replies w/ bad CRC, and polls with no reply at all (timing vs RF-quality)
+static uint32_t g_lastPollUs=0;   // (g_pollUs declared in the config block near the top; 'U' or WebUSB to tune)
+static unsigned long g_lastSessBeacon=0;          // session keepalive beacon timer
+static uint32_t g_connRx   = 0;
+static uint8_t  g_balen   = 4;            // ESB 5-byte addr
+// Access address (IBEX FUN_00037530 + func_0x00049e5a): BASE0 = bitrev8 each base byte, packed
+// big-endian (= byteswap(bitrev8(uint32))); PREFIX0 = raw prefix (func_0x0003756c copies it). BALEN=3
+// uses BASE0[31:8] + PREFIX0. For "ibex" 69 62 65 78 -> BASE0=0x9646A61E, PREFIX0=0x10.
+static bool g_prefixRaw = false;   // false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+static void rfSetAddr(const uint8_t b4[4], uint8_t prefix){
+  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
+  NRF_RADIO->BASE0   = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
+  NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
+  NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 1u<<0;
+}
+static void rfConfig(uint8_t ch){
+  NRF_RADIO->TASKS_DISABLE = 1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED = 0;
+  NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);   // Ble_1Mbit (tunable 'M')
+  NRF_RADIO->FREQUENCY = ch;
+  NRF_RADIO->TXPOWER   = (RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+#if defined(RADIO_MODECNF0_RU_Fast)
+  NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+#endif
+  NRF_RADIO->PCNF0 = g_pcnf0;       // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
+  NRF_RADIO->PCNF1 = g_pcnf1 ? g_pcnf1 :
+    ((1u<<RADIO_PCNF1_ENDIAN_Pos)              // ENDIAN=Big (puck transmits MSB-first)
+     |((uint32_t)g_balen<<RADIO_PCNF1_BALEN_Pos)
+     |((uint32_t)g_statlen<<RADIO_PCNF1_STATLEN_Pos)
+     |((uint32_t)g_statlen<<RADIO_PCNF1_MAXLEN_Pos));   // WHITEEN=0
+  NRF_RADIO->CRCCNF = g_crccnf;     // CRC16, address included
+  NRF_RADIO->CRCPOLY = g_crcpoly; NRF_RADIO->CRCINIT = g_crcinit;
+  NRF_RADIO->DATAWHITEIV = g_whiteiv;
+  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+}
+static void rfListenStart(){
+  rfConfig(g_rfCh); rfSetAddr(g_rfBase, g_rfPrefix);
+  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+  NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_START_Msk;
+  NRF_RADIO->EVENTS_END = 0; NRF_RADIO->TASKS_RXEN = 1;
+  g_rfListen = true;
+  Serial.printf("# RF listen ch%u base %02X%02X%02X%02X prefix %02X\n",
+                g_rfCh, g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3], g_rfPrefix);
+}
+static void rfPoll(){
+  if (!g_rfListen) return;
+  if (NRF_RADIO->EVENTS_END){
+    NRF_RADIO->EVENTS_END = 0;
+    bool crcok = NRF_RADIO->CRCSTATUS & 1;
+    uint8_t len = rfrx[0];
+    if (len && len < 64){
+      g_rfRxCount++;
+      Serial.printf("RF#%lu ch%u crc%d len%u: ", (unsigned long)g_rfRxCount, g_rfCh, crcok, len);
+      for (uint8_t i=0;i<=len && i<40;i++) Serial.printf("%02X ", rfrx[i]);
+      Serial.println();
+    }
+  }
+}
+// Dongle is PTX: TX a beacon [01][seq][..][E2@5] then RX the controller's response (0xF0...).
+// RAM layout / payload length are byte-level unknowns to pin live (S1INCL, plen tweakable).
+static bool    g_rfBeacon = false;
+static uint8_t g_seq = 0, g_plen = 0x18, g_s1incl = 0;
+static unsigned long g_lastBeacon = 0;
+static unsigned long g_lastDisc = 0;   // last discovery beacon (ch2) time
+static bool    g_rfHost = true;   // auto-start host beacon on boot (resumes puck role after a USB replug)
+static unsigned long g_lastHop = 0;
+
+// HOST FRAME the bonded controller waits for (IBEX FUN_00019000 verify: b[0]=0x12,b[5]=0xE1,
+// b[6..10]=proteus_uuid, b[10..14]=ibex_uuid). Built like PROTEUS FUN_00027e9a. Sent on the shared
+// rendezvous addr; the controller filters by the uuids in the payload, then connects.
+static void rfHostFrameOnce(int slot){
+  if (slot<0||slot>=NSLOT||!g_slot[slot].used) return;
+  uint8_t *rec = g_slot[slot].rec;                       // [proteus_uuid 4][ibex_uuid 4][serial 16]
+  // CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)].
+  // payload: [0]=0xE1, [1..5]=proteus_uuid LE, [5..9]=ibex_uuid LE, [9]=session channel,
+  // [10..13]=0, [13..17]=session base, [17]=session prefix. Radio auto-appends CRC16 0x11021.
+  memset(rftx,0,sizeof rftx);
+  rftx[0]=0x12;                        // LENGTH = 18 (controller's buf[0]==0x12 check validates this)
+  rftx[1]=(uint8_t)((g_pid++&3)<<1);   // S1 = PID<<1 | noack0  (matches real puck 00/02/04/06)
+  rftx[2]=0xE1;                        // payload[0] marker
+  memcpy(rftx+3, rec+0, 4);            // payload[1..5] proteus_uuid (LE, as bonded)
+  memcpy(rftx+7, rec+4, 4);            // payload[5..9] ibex_uuid
+  rftx[11]=g_sessCh;                   // payload[9] session channel: tell the controller to run the session on
+                                       // the clean channel (it adopts buf[0xe]); discovery beacon still TXes on ch2
+  memcpy(rftx+15, g_rfBase, 4);        // payload[13..17] session base
+  rftx[19]=g_rfPrefix;                 // payload[17] session prefix
+  rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
+  NRF_RADIO->PACKETPTR=(uint32_t)rftx;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
+  NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
+  RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<800){}
+  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;   // ANY reception = controller answered our frame
+    g_rfRxCount++; bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
+    if (Serial.availableForWrite()>90){                  // non-blocking: don't stall the loop on CDC backpressure
+      Serial.printf("*** RESP#%lu ch%u crc%d rxmatch%lu len%u: ",(unsigned long)g_rfRxCount,
+                    g_rfCh,crcok,(unsigned long)NRF_RADIO->RXMATCH,len);
+      for(uint8_t i=0;i<(len<40?len+2:40);i++)Serial.printf("%02X ",rfrx[i]); Serial.println(); } }
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+}
+// Mid-session channel hop: advertise newCh in the host frame, transmitted on the CURRENT session channel
+// (where the controller is listening), a few times so it reliably adopts it; then move our poll to newCh.
+// This is the foundational test for channel-hopping/QoS — does the controller follow a live channel change?
+static void rfHopTo(uint8_t newCh){
+  if(g_connSlot<0 || newCh==g_sessCh) return;
+  uint8_t cur=g_sessCh, savedRfCh=g_rfCh;
+  g_sessCh=newCh; g_rfCh=cur;                // host frame now advertises newCh but is TXed on cur
+  for(int k=0;k<6;k++){ rfHostFrameOnce(g_connSlot); delayMicroseconds(700); }
+  g_rfCh=savedRfCh;                          // poll + session beacon now run on g_sessCh=newCh
+}
+// CONNECTED MODE: OpenPuck is the PTX/master. It already chose the session base/prefix/channel
+// (advertised in the host frame the controller connected on), so no discovery/re-sync is needed. After
+// connect: send E7 (proto-version 00 01), send E4 (channel map {ch,2,80}), then loop E3 input-poll (+E2
+// keepalive periodically) hopping the map; RX the controller's reply each cycle (0xF1 = full input report
+// w/ TLVs, 0xF3 = short status). Captured PUCK->controller bytes [LEN][S1][payload], payload[0]=type:
+//   E7 00 01 (S1=01) | E4 <ch> 02 50 (S1=05) | E2 (S1=07) | E3 (S1=07).  See FUN_0000ba38 / FUN_0001b960.
+// One TX->RX cycle: TX the connected packet, SHORTS straight into a short RX window to catch the reply.
+// ---- XBOX-mode output: map report 0x45 -> standard gamepad (g_pad) + right-trackpad -> mouse (report 0x40) ----
+// report 0x45 layout: [0]=0x45 [1]=seq [2..5]=buttons u32; analog offsets are from buttons-low-byte (rep[2]).
+static int s16off(const uint8_t* r,int off){ int v=r[2+off]|(r[2+off+1]<<8); return (v&0x8000)?v-0x10000:v; }
+static int u16off(const uint8_t* r,int off){ return r[2+off]|(r[2+off+1]<<8); }
+static uint32_t btnsOf(const uint8_t* r){ return (uint32_t)r[2]|((uint32_t)r[3]<<8)|((uint32_t)r[4]<<16)|((uint32_t)r[5]<<24); }
+// Triton button masks
+#define TB_A 0x1u
+#define TB_B 0x2u
+#define TB_X 0x4u
+#define TB_Y 0x8u
+#define TB_R3 0x20u
+#define TB_VIEW 0x40u
+#define TB_R4 0x80u
+#define TB_R5 0x100u
+#define TB_RB 0x200u
+#define TB_DDN 0x400u
+#define TB_DRT 0x800u
+#define TB_DLF 0x1000u
+#define TB_DUP 0x2000u
+#define TB_MENU 0x4000u
+#define TB_L3 0x8000u
+#define TB_STEAM 0x10000u
+#define TB_L4 0x20000u
+#define TB_L5 0x40000u
+#define TB_LB 0x80000u
+#define TB_RPADT 0x200000u
+#define TB_RPADC 0x400000u
+#define TB_LPADT 0x2000000u
+#define TB_LPADC 0x4000000u
+// Trackpad coords genuinely refresh only ~46Hz (capsense floor) but reports ship ~300Hz -> stale-repeats.
+// Forwarding those raw to Steam defeats its interpolation (it sees no-motion then a jump). Smooth the pad
+// X/Y in the forwarded report 0x45 (exponential LERP toward the latest genuine value each report) so Steam
+// sees continuous motion. g_padSmooth = alpha% (100 = off, lower = smoother/laggier). 'D<pct>' tunes it.
+// (g_padSmooth declared at top with the persisted config)
+static void smoothPad(uint8_t* rep){     // rep = report 0x45; smooth LPad(@18/20) + RPad(@24/26) in place
+  if(g_padSmooth>=100) return;
+  float a=g_padSmooth/100.0f; uint32_t b=btnsOf(rep);
+  static float lsx=0,lsy=0,rsx=0,rsy=0; static bool lpt=false,rpt=false;
+  bool lt=b&TB_LPADT; int16_t lx=rep[18]|(rep[19]<<8), ly=rep[20]|(rep[21]<<8);
+  if(lt){ if(!lpt){lsx=lx;lsy=ly;} else {lsx+=(lx-lsx)*a; lsy+=(ly-lsy)*a;}
+    int16_t ox=(int16_t)lsx,oy=(int16_t)lsy; rep[18]=ox;rep[19]=ox>>8;rep[20]=oy;rep[21]=oy>>8; }
+  lpt=lt;
+  bool rt=b&TB_RPADT; int16_t rx=rep[24]|(rep[25]<<8), ry=rep[26]|(rep[27]<<8);
+  if(rt){ if(!rpt){rsx=rx;rsy=ry;} else {rsx+=(rx-rsx)*a; rsy+=(ry-rsy)*a;}
+    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;
+}
+// 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
+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;
+  case 5:return XB_LB; case 6:return XB_RB; case 7:return XB_L3; case 8:return XB_R3;
+  case 9:return XB_BACK; case 10:return XB_START; case 11:return XB_GUIDE; default:return 0; }
+}
+static void rfXboxGamepad(const uint8_t* r){
+  uint32_t b=btnsOf(r);
+  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_MENU)btn|=XB_START; if(b&TB_VIEW)btn|=XB_BACK; if(b&TB_STEAM)btn|=XB_GUIDE;
+  if(b&TB_LB)btn|=XB_LB;  if(b&TB_RB)btn|=XB_RB;
+  if(b&TB_L3)btn|=XB_L3;  if(b&TB_R3)btn|=XB_R3;
+  // face buttons, with optional A/B + X/Y swap (Nintendo layout)
+  uint16_t fA=g_abSwap?XB_B:XB_A, fB=g_abSwap?XB_A:XB_B, fX=g_abSwap?XB_Y:XB_X, fY=g_abSwap?XB_X:XB_Y;
+  if(b&TB_A)btn|=fA; if(b&TB_B)btn|=fB; if(b&TB_X)btn|=fX; if(b&TB_Y)btn|=fY;
+  // back paddles -> configurable mapping (default L4->LB, R4->RB, L5->L3, R5->R3)
+  if(b&TB_L4)btn|=codeToXB(g_back[0]); if(b&TB_R4)btn|=codeToXB(g_back[1]);
+  if(b&TB_L5)btn|=codeToXB(g_back[2]); if(b&TB_R5)btn|=codeToXB(g_back[3]);
+  uint8_t lt=u16off(r,4)>>8, rt=u16off(r,6)>>8;     // triggers u16 -> u8
+  xinputSend(btn, lt, rt, (int16_t)s16off(r,8), (int16_t)s16off(r,10),   // L stick X/Y
+             (int16_t)s16off(r,12), (int16_t)s16off(r,14)); // R stick X/Y
+}
+// Right-pad -> mouse, modeled on the real puck's lizard mouse: pad deltas feed a VELOCITY that is emitted
+// each report and decays with friction. This (a) smooths the stale-repeat bursts, (b) gives momentum/flick
+// (a fast swipe injects high velocity -> cursor keeps sliding after lift), (c) sub-pixel remainder carry so
+// slow drags still move. Tunable live: M<div>=sensitivity (lower=faster), F<pct>=friction% (higher=more glide).
+// (g_mDiv, g_mFric declared at top with the persisted config)
+static void rfXboxMouse(const uint8_t* r){
+  uint32_t b=btnsOf(r);
+  static int px=0,py=0; static bool pt=false; static float vx=0,vy=0,rmx=0,rmy=0; static uint8_t pmb=0;
+  bool touch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
+  if(touch){ if(pt){ vx+=(rx-px); vy+=(ry-py); } px=rx; py=ry; }   // inject genuine pad move into velocity
+  pt=touch;
+  float mxf = vx/(float)g_mDiv + rmx, myf = -(vy/(float)g_mDiv) + rmy;   // Y inverted for screen coords
+  int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy;                  // fractional carry (sub-pixel)
+  if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
+  float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0;  // friction (glide/decay)
+  uint8_t mb=((b&TB_RPADC)?1:0)|((b&TB_LPADC)?2:0);  // RPad click=left, LPad click=right
+  if(dx||dy||mb!=pmb){ pmb=mb;
+    hid_mouse_report_t m; m.buttons=mb; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=0; m.pan=0;
+    if(g_mouse.ready()) g_mouse.sendReport(0,&m,sizeof m);
+  }
+}
+// ===================== LIZARD mode (mode 3) — desktop keyboard + mouse =====================
+// The controller's native "lizard" desktop behavior, reproduced on the OpenPuck side so the controller is a
+// driverless keyboard+mouse when Steam isn't running. Canonical Valve Steam Controller firmware map (SC1,
+// the documented reference; SC2 presumed-similar): right pad -> mouse (trackball glide), left pad -> scroll
+// wheel + middle-click on press, R-trigger / R-pad-click -> left mouse, L-trigger -> right mouse;
+// A=Enter B=Esc X=PgUp Y=PgDn, D-pad & left-stick -> arrow keys, LB=LeftCtrl RB=LeftAlt, Back/View=Tab,
+// Start/Menu=Esc, Steam=none. The mouse reuses the SAME velocity+friction+sub-pixel GLIDE model as Xbox mode
+// (g_mDiv = sensitivity divisor, g_mFric = friction% — both live-tunable), which is the "custom smoothing
+// and gliding" the real lizard mouse has (cursor coasts after lift, smooth sub-pixel slow drags).
+static 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);
+  // --- right pad -> mouse motion with glide (mirrors 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);
+  if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
+  float mxf=vx/(float)g_lizDiv+rmx, myf=-(vy/(float)g_lizDiv)+rmy;   // Y inverted for screen coords; g_lizDiv = lizard sensitivity (separate from Xbox aim)
+  int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy;          // sub-pixel carry
+  if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
+  float f=g_lizFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0;   // friction = glide/decay (lizard has its own, crisper than Xbox)
+  // --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
+  static int ply=0; static bool plt=false; static float sacc=0;
+  bool ltouch=b&TB_LPADT; int ly=s16off(r,18);
+  if(ltouch){ if(plt){ sacc += (ly-ply)/(float)(g_lizDiv*8); } ply=ly; } else sacc=0; plt=ltouch;
+  int dw=(int)sacc; sacc-=dw; if(dw>15)dw=15; if(dw<-15)dw=-15;   // finger up = wheel up (positive)
+  // --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
+  uint8_t rtrig=u16off(r,6)>>8, ltrig=u16off(r,4)>>8;
+  uint8_t mbtn=0;
+  if((b&TB_RPADC)||rtrig>180) mbtn|=1;
+  if(ltrig>180)               mbtn|=2;
+  if(b&TB_LPADC)              mbtn|=4;
+  static uint8_t pmbtn=0;
+  if(dx||dy||dw||mbtn!=pmbtn){ pmbtn=mbtn;
+    hid_mouse_report_t m; m.buttons=mbtn; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=(int8_t)dw; m.pan=0;
+    if(mdev->ready()) mdev->sendReport(mrid,&m,sizeof m);
+  }
+  // --- keyboard: modifiers + up to 6 keycodes ---
+  uint8_t mod=0, kc[6]={0,0,0,0,0,0}, nk=0;
+  if(b&TB_LB) mod|=KEYBOARD_MODIFIER_LEFTCTRL;
+  if(b&TB_RB) mod|=KEYBOARD_MODIFIER_LEFTALT;
+#define LZK(cond,code) do{ if((cond)&&nk<6) kc[nk++]=(code); }while(0)
+  LZK(b&TB_A,    HID_KEY_ENTER);
+  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);
+  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
+  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];
+    uint8_t krep[8]={mod,0,kc[0],kc[1],kc[2],kc[3],kc[4],kc[5]};
+    if(kdev->ready()) kdev->sendReport(krid,krep,8);
+  }
+}
+// ===================== SWITCH mode (mode 2) — HORIPAD / Pokkén Tournament Pro Pad =====================
+// Targets a REAL Switch console: a plain HID gamepad (HORI 0F0D:0092) the console accepts on plug-in with
+// NO handshake / SPI calibration / encryption — unlike the genuine Pro Controller (057E:2009), whose USB
+// init the console handles fussily. This is the descriptor every DIY Switch controller copies (the LUFA
+// "Splatoon printer" descriptor). Also recognized by SDL/Steam on PC. 8-byte input report streamed at
+// ~125Hz: [btn_lo][btn_hi][hat][LX][LY][RX][RY][vendor], sticks uint8 center 0x80.
+static const uint8_t SWITCH_HID_DESC[]={
+  0x05,0x01, 0x09,0x05, 0xA1,0x01,           // Usage Page (Generic Desktop), Usage (Game Pad), Collection (App)
+  0x15,0x00, 0x25,0x01, 0x35,0x00, 0x45,0x01, 0x75,0x01, 0x95,0x10,   // 16 x 1-bit buttons
+  0x05,0x09, 0x19,0x01, 0x29,0x10, 0x81,0x02,
+  0x05,0x01, 0x25,0x07, 0x46,0x3B,0x01, 0x75,0x04, 0x95,0x01, 0x65,0x14, 0x09,0x39, 0x81,0x42,  // hat switch (4 bits)
+  0x65,0x00, 0x95,0x01, 0x81,0x01,           // 4-bit padding to a byte
+  0x26,0xFF,0x00, 0x46,0xFF,0x00, 0x09,0x30, 0x09,0x31, 0x09,0x32, 0x09,0x35, 0x75,0x08, 0x95,0x04, 0x81,0x02, // X,Y,Z,Rz
+  0x06,0x00,0xFF, 0x09,0x20, 0x95,0x01, 0x81,0x02,           // vendor byte (input)
+  0x0A,0x21,0x26, 0x95,0x08, 0x91,0x02,                      // vendor (output, 8 bytes; unused)
+  0xC0
+};
+static uint32_t g_swBtns=0; static int16_t g_swLX=0,g_swLY=0,g_swRX=0,g_swRY=0;  // latest decoded RF frame
+static unsigned long g_swLastMs=0;
+Adafruit_USBD_HID g_switch;   // HORIPAD gamepad HID interface (mode 2)
+// 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 inline uint8_t swStick(int16_t v,bool invert){   // int16 (center 0) -> uint8 (center 0x80)
+  int32_t a = 0x80 + (invert ? -((int32_t)v>>8) : ((int32_t)v>>8));
+  if(a<0)a=0; if(a>255)a=255; return (uint8_t)a;
+}
+static void switchBuildHoripad(uint8_t out[8]){
+  uint32_t b=g_swBtns; uint16_t btn=0;
+  // face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)
+  uint16_t fY = g_abSwap?0x08:0x01, fB = g_abSwap?0x04:0x02, fA = g_abSwap?0x02:0x04, fX = g_abSwap?0x01:0x08;
+  if(b&TB_Y)btn|=fY; if(b&TB_B)btn|=fB; if(b&TB_A)btn|=fA; if(b&TB_X)btn|=fX;
+  if(b&TB_LB)btn|=0x10; if(b&TB_RB)btn|=0x20;                 // L, R
+  if(b&0x8000000u)btn|=0x40; if(b&0x800000u)btn|=0x80;        // ZL(LTrigClick), ZR(RTrigClick)
+  if(b&TB_VIEW)btn|=0x100; if(b&TB_MENU)btn|=0x200;           // Minus, Plus
+  if(b&TB_L3)btn|=0x400; if(b&TB_R3)btn|=0x800;               // LClick, RClick
+  if(b&TB_STEAM)btn|=0x1000;                                  // Home
+  bool u=b&TB_DUP,d=b&TB_DDN,l=b&TB_DLF,r=b&TB_DRT;           // hat: 0=N..7=NW, 8=neutral
+  uint8_t hat=8;
+  if(u&&r)hat=1; else if(r&&d)hat=3; else if(d&&l)hat=5; else if(l&&u)hat=7;
+  else if(u)hat=0; else if(r)hat=2; else if(d)hat=4; else if(l)hat=6;
+  out[0]=btn&0xFF; out[1]=btn>>8; out[2]=hat;
+  out[3]=swStick(g_swLX,false); out[4]=swStick(g_swLY,true);  // HID Y is down-positive -> invert
+  out[5]=swStick(g_swRX,false); out[6]=swStick(g_swRY,true);
+  out[7]=0;
+}
+static void swStream(){   // call from loop(); stream the 8-byte HORIPAD report at ~125Hz (no handshake needed)
+  if(g_usbMode!=2 || !g_switch.ready()) return;
+  if(millis()-g_swLastMs < 8) return; g_swLastMs=millis();
+  uint8_t p[8]; switchBuildHoripad(p);
+  g_switch.sendReport(0, p, sizeof p);   // report-id-less descriptor
+}
+
+// RECIPE (IBEX disasm @0x18d80): controller streams 0xF1 input report ONLY when (a) connstate==3 [host
+// frame E1 establishes it], (b) we mark host-awake via 0xE7 payload [00][00] (vs [00][01]=suspended,
+// handler @0x193aa), and (c) EACH 0xE3 poll carries a GET-report-0x45 sub-TLV [len=02][subtype=01][id=45]
+// [param] (parser @0x1948c; F1 builder @0x20ca4 arms a one-shot latch @0x2000d36e cleared after each F1 ->
+// no free-run stream, 1 F1 per GET). F1 reply = type 0xF1 @rfrx[2] then TLV type4(analog/buttons)+
+// type6(per-module gyro/accel/sticks).
+static uint8_t  g_connSt    = 0;         // 0=announce awake, 1=poll loop
+static uint8_t  g_connStep  = 0;         // repeat counter within a state
+static uint16_t g_connPoll  = 0;         // poll counter (re-assert awake every 64th)
+static uint32_t g_connF1    = 0;         // count of 0xF1 input reports seen
+static uint8_t  g_getParam  = 0x00;      // GET report 0x45 param byte (try 0x00, fall back 0x2D) - 'q' cmd
+static uint8_t  g_e3mode = 1, g_e3pid = 0;  // E3-poll PID/S1 mode: 0=fixed 0x07, 1=cycle PID(noack1), 2=cycle PID(noack0). DEFAULT 1: cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID) — THE rate fix. 'e<n>' to A/B.
+static bool     g_connVerbose = false;   // true=full multi-line debug dump; false=compact "I45 <hex>" stream
+static unsigned long g_lastStream = 0;   // throttle for compact UI stream
+static unsigned long g_connCooldown = 0; // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
+// (g_connReplyMs, g_connSlot declared near g_slot so the USB feature handler can read them)
+// TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx. Decodes 0xF1.
+static uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen){
+  memset(rftx,0,sizeof rftx);
+  rftx[0]=plen;                          // LENGTH = payload byte count
+  rftx[1]=s1;                            // S1 (type-specific)
+  memcpy(rftx+2, payload, plen);         // payload[0]=type byte, then data/TLVs
+  rfConfig(ch); rfSetAddr(g_rfBase,g_rfPrefix);
+  NRF_RADIO->PACKETPTR=(uint32_t)rftx;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
+  NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
+  RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;       // RXEN->READY->START; catch the reply
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+  g_stPoll++;
+  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<g_rxWin){}  // RX window (tunable 'r'): caps poll rate ~1e6/rxWin
+  uint8_t rxlen=0;
+  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
+    bool crcok=NRF_RADIO->CRCSTATUS&1; rxlen=rfrx[0];
+    if(!crcok){ g_stCrc++; g_qosBad++; }                // reply arrived but CRC failed -> RF quality (channel/interference)
+    if (crcok && rxlen && rxlen<=64){                   // F1 report ~46B, so allow up to MAXLEN
+      g_connRx++; g_connReplyMs=millis();               // link alive -> loop() suppresses the redundant E1 beacon
+      if(rfrx[2]==0xF1) g_stF1++;
+      uint8_t rtype=rfrx[2];                            // reply type byte (proven offset from captures)
+      if(rtype==0xF2) g_connCooldown=millis();          // controller disconnecting/powering off -> back off 2.5s
+      if(rtype==0xF3){                                  // F3 = controller status/version reply (ODR agent: reply to E7 version handshake, byte[6]=version)
+        g_stF3++; g_connF3v=rfrx[6];
+        if(g_connVerbose && Serial.availableForWrite()>40){ Serial.print("  F3 "); for(uint8_t i=0;i<(rxlen+2<32?rxlen+2:32);i++)Serial.printf("%02X",rfrx[i]); Serial.println(); }
+      }
+      bool isF1=(rtype==0xF1);
+      if(isF1){
+        g_connF1++;
+        uint8_t idx=3, end=rxlen+2;                     // walk ALL type6 TLVs (= HID report 0x45)
+        const uint8_t* lastRep=nullptr; uint8_t lastTlen=0;  // the controller PACKS several queued reports per F1;
+        while(idx+1<end){                               // draining all of them (not just the first) is what keeps
+          uint8_t tlen=rfrx[idx], ttype=rfrx[idx+1];    // pace with the real puck — taking only [0] halved our rate.
+          if(tlen==0) break;
+          if(ttype==6 && tlen>=2 && rfrx[idx+2]==0x45){
+            const uint8_t* rep=&rfrx[idx+2];            // report 0x45: [0x45][seq][buttons u32]...
+            bool fresh=(rep[1]!=g_lastSeq); if(fresh){ g_stNew++; g_lastSeq=rep[1]; }  // genuine new report vs stale poll-repeat
+            uint32_t bb=btnsOf(rep);
+            // cache the latest decoded frame for the Switch streamer + Xbox/Steam paths
+            g_swBtns=bb; g_swLX=(int16_t)s16off(rep,8); g_swLY=(int16_t)s16off(rep,10);
+            g_swRX=(int16_t)s16off(rep,12); g_swRY=(int16_t)s16off(rep,14);
+            if(g_usbMode==2){                           // SWITCH: streamed from loop() (swStream); nothing to send here
+            } else if(g_usbMode==3){                    // LIZARD: translate report 0x45 -> keyboard + mouse (forced)
+              rfLizard(rep, &g_mouse, &g_keyboard, 0, 0);
+            } else if(g_xbox){                          // XBOX: standard gamepad + right-pad mouse
+              rfXboxGamepad(rep); rfXboxMouse(rep);
+            } else if(g_connSlot>=0 && g_connSlot<NSLOT){  // STEAM (puck interface)
+              // Auto-lizard: while Steam's 0x87 heartbeat is alive -> forward gamepad report 0x45; once it
+              // stops (Steam closed) -> emit lizard mouse(0x40)+keyboard(0x41) on the same interface, like the puck.
+              bool steamAlive = g_steamAliveMs && (millis()-g_steamAliveMs < LIZARD_WD_MS);
+              if(!steamAlive){
+                rfLizard(rep, &hid[g_connSlot], &hid[g_connSlot], 0x40, 0x41);
+              } else {
+                uint8_t blen=tlen-1; if(blen>45)blen=45;  // body after the 0x45 id byte
+                smoothPad((uint8_t*)rep);                 // interpolate the 46Hz pad coords -> continuous motion for Steam
+                // forward only 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"
+              }
+            }
+            lastRep=rep; lastTlen=tlen;
+          }
+          idx+=tlen+2;
+        }
+        // mode-switch chord (back4 R4|L4|R5|L5 + A/X/Y), checked ONCE per frame + DEBOUNCED: a single
+        // garbled/misaligned report must not trigger saveMode+reset (that looked like a crash under heavy input).
+        { static uint8_t chWant=0xFF, chCnt=0;
+          const uint32_t BACK4=TB_R4|TB_L4|TB_R5|TB_L5; uint8_t want=0xFF;
+          if((g_swBtns&BACK4)==BACK4){ if(g_swBtns&TB_A)want=0; else if(g_swBtns&TB_X)want=1; else if(g_swBtns&TB_Y)want=2; else if(g_swBtns&TB_B)want=3; }  // back4 + A=Steam X=Xbox Y=Switch B=Lizard
+          if(want!=0xFF && want==chWant){ if(++chCnt>=12 && want!=g_usbMode){ saveMode(want); delay(40); NVIC_SystemReset(); } }
+          else { chWant=want; chCnt=(want!=0xFF)?1:0; }
+        }
+        // compact stream for rf_controller_ui.py — NON-BLOCKING: skip if CDC TX is backed up (a blocking
+        // Serial.print stalls the RF+USB loop -> jaggy input). One line/frame using the last record.
+        if(lastRep && !g_connVerbose && Serial.availableForWrite()>110 && millis()-g_lastStream>=4){
+          g_lastStream=millis(); Serial.print("I45 ");
+          for(uint8_t i=0;i<lastTlen;i++)Serial.printf("%02X",lastRep[i]); Serial.println();
+        }
+      }
+      if(g_connVerbose){
+        Serial.printf("%s CRX#%lu txtype%02X ch%u len%u: ",isF1?"<<<F1":(rtype==0xF3?"  F3":"  rx"),
+                      (unsigned long)g_connRx,payload[0],ch,rxlen);
+        for(uint8_t i=0;i<(rxlen+2<=66?rxlen+2:66);i++)Serial.printf("%02X",rfrx[i]); Serial.println();
+      }
+    } else rxlen=0;
+  } else { g_stNoRx++; g_qosBad++; }                   // RX window expired with no packet at all -> turnaround/timing or controller silent
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+  return rxlen;
+}
+// Drive the connected-mode sequence one step per call (from loop() while g_connOn). Camps on g_rfCh (the
+// host-frame channel the controller connected on); the E1 beacon (cmd 'H') runs in parallel as keepalive.
+static void rfConnStep(){
+  g_connSlot=-1; for(int s=0;s<NSLOT;s++) if(g_slot[s].used){g_connSlot=s;break;}
+  if(g_connSlot<0) return;               // need a bonded slot (session established by host frame)
+  if(g_connSt==0){                       // announce HOST AWAKE: E7 00 00, a few times
+    uint8_t p[3]={0xE7,0x00,g_e7b}; rfConnTx(g_sessCh,0x01,p,3);
+    if(++g_connStep>=4){ g_connSt=1; g_connStep=0; Serial.println("# CONN: awake announced -> polling GET report 0x45"); }
+  } else {                               // poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
+    if((uint32_t)(micros()-g_lastPollUs) < g_pollUs) return;   // CONTROLLED CADENCE: poll ~every g_pollUs,
+    g_lastPollUs=micros();                                     // not as-fast-as-possible (over-polling starves replies)
+    uint8_t ch=g_sessCh;                                       // controller stays on the session channel (no hop)
+    if((g_connPoll & 0x1F)==0){ uint8_t pa[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,pa,3); }   // re-assert awake/version
+    if(!g_relayPend && g_testHaptic){                // buzz-hunt: synthesize a haptic relay (output 0x82 [01 01 F7])
+      g_relayBuf[0]=0x82; g_relayBuf[1]=3; g_relayBuf[2]=0x01; g_relayBuf[3]=0x01; g_relayBuf[4]=0xF7; g_relayPend=true; g_testHaptic--;
+    }
+    if(g_relayPend){                                 // relay host cmd: [op][len][sub][rid][payload] (op/sub tunable for the buzz hunt)
+      uint8_t rid=g_relayBuf[0], rl=g_relayBuf[1]; if(rl>18)rl=18;
+      uint8_t p[24]; p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=g_relaySub; p[3]=rid;   // len = sub-id + payload bytes
+      for(uint8_t i=0;i<rl;i++) p[4+i]=g_relayBuf[2+i];
+      rfConnTx(ch,0x07,p,(uint8_t)(4+rl));
+      g_relayPend=false;
+    } else {
+      uint8_t p[5]={0xE3,0x02,0x01,0x45,g_getParam}; // E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
+      uint8_t s1 = (g_e3mode==1) ? (uint8_t)((((g_e3pid++)&3)<<1)|1)   // cycle PID (S1 1,3,5,7), NO_ACK=1
+        : (g_e3mode==2) ? (uint8_t)(((g_e3pid++)&3)<<1)       // cycle PID (S1 0,2,4,6), NO_ACK=0
+        : 0x07;                                               // fixed (current default; matches captured puck poll)
+      rfConnTx(ch,s1,p,5);
+    }
+    g_connPoll++;
+  }
+}
+// SCAN-THEN-RESPOND (dongle role): listen on ch2/91A2A793 for the controller's advertisement,
+// and the instant one arrives, TX the host frame (its ack). Matches IBEX 'scan-before-initiating'.
+static uint8_t g_hostTx[48]; static int g_hostLen=0; static bool g_rfRespond=false; static uint32_t g_advCount=0;
+static void buildHostTx(){
+  int slot=-1; for(int i=0;i<NSLOT;i++) if(g_slot[i].used){slot=i;break;}
+  memset(g_hostTx,0,sizeof g_hostTx); g_hostLen=0; if(slot<0) return;
+  // S0LEN=1 RAM layout: [0]=S0=0x12, [1]=LENGTH, raw RAM offsets validated by controller.
+  memset(g_hostTx,0,sizeof g_hostTx);
+  g_hostTx[0]=0x12; g_hostTx[1]=0x20; g_hostTx[3]=1; g_hostTx[5]=0xE1;
+  memcpy(g_hostTx+6,g_slot[slot].rec,4); memcpy(g_hostTx+10,g_slot[slot].rec+4,4);
+  g_hostTx[0xe]=2; memcpy(g_hostTx+0x12,g_rfBase,4); g_hostTx[0x16]=g_rfPrefix;
+  g_hostLen=0x22;
+}
+static void rfRespondStart(){
+  buildHostTx();
+  rfConfig(2); rfSetAddr(g_rfBase,g_rfPrefix);            // RX ch2 / 91A2A793
+  NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1; g_rfRespond=true;
+  Serial.printf("# RESPOND mode: RX ch2/91A2A793, reply host frame (len%d)\n", g_hostLen);
+}
+static void rfRespondPoll(){
+  if(!g_rfRespond) return;
+  if(NRF_RADIO->EVENTS_END){
+    NRF_RADIO->EVENTS_END=0; uint8_t len=rfrx[0];
+    if(len && len<64){
+      g_advCount++;
+      // reply: TX the host frame immediately (ack)
+      NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+      NRF_RADIO->PACKETPTR=(uint32_t)g_hostTx;
+      NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
+      NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
+      RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+      Serial.printf("ADV#%lu len%u: ",(unsigned long)g_advCount,len);
+      for(uint8_t i=0;i<=len&&i<32;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
+      // re-arm RX
+      NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
+      NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
+      NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+    }
+  }
+}
+static void rfBeaconOnce(){
+  uint8_t pl[48]; memset(pl,0,sizeof pl);
+  pl[0]=0x01; pl[1]=g_seq++; pl[5]=0xE2;                 // dongle beacon (FUN_00027ed8 format)
+  rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
+  if (g_s1incl) NRF_RADIO->PCNF0 |= (1u<<RADIO_PCNF0_S1INCL_Pos);
+  // RAM: [LENGTH][ (S1 if S1INCL) ][payload]
+  rftx[0]=0; rftx[1]=g_plen; memcpy(rftx+2,pl,g_plen);
+  NRF_RADIO->PACKETPTR=(uint32_t)rftx;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
+  NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
+  RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+  // RX window for the controller's response
+  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
+  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;
+  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<500){}
+  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
+    bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
+    if (len && len<64){ g_rfRxCount++;
+      Serial.printf("RESP#%lu crc%d len%u: ",(unsigned long)g_rfRxCount,crcok,len);
+      for(uint8_t i=0;i<=len&&i<40;i++)Serial.printf("%02X ",rfrx[i]); Serial.println();
+    }
+  }
+  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+}
+
+// ===================== WebUSB config channel =====================
+// Browser panel <-> firmware, binary framed.
+//   host->dev:  0x01                  GET  -> reply status blob
+//               0x02 <field> <value>  SET one byte field (1=mDiv 2=mFric 3=padSmooth 4=abSwap 5..8=back[0..3] 9=pollU100 10=e7b 11=relayOp 12=relaySub 13=testHaptic)
+//               0x03 <mode>           switch USB mode (0=steam 1=xbox 2=switch): persist + reboot
+//   dev->host:  0xA5 <len> <payload>  payload = [ver=1][mode][mDiv][mFric][padSmooth][abSwap]
+//                                                [back0..3][connSlot(0xFF=none)][linkUp][f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub]
+#define WB_PAYLEN 23
+static void webusbSendBlob(){
+  if(!usb_web.connected()) return;
+  bool up = (g_connSlot>=0 && (millis()-g_connReplyMs) < 300);
+  uint8_t p[2+WB_PAYLEN];
+  p[0]=0xA5; p[1]=WB_PAYLEN;
+  p[2]=1;                          // protocol version
+  p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=(uint8_t)g_padSmooth; 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;
+  p[14]=(uint8_t)g_f1ps; p[15]=(uint8_t)(g_f1ps>>8);
+  p[16]=(uint8_t)(g_pollUs/100);
+  p[17]=(uint8_t)g_newps; p[18]=(uint8_t)(g_newps>>8);
+  p[19]=g_e7b;
+  p[20]=g_relayOp; p[21]=g_relaySub; p[22]=g_fwdNewOnly; p[23]=g_qos; p[24]=g_persistMode?1:0;
+  usb_web.write(p,sizeof p); usb_web.flush();
+}
+static void webusbPoll(){
+  static uint8_t buf[16]; static uint8_t n=0;
+  while(usb_web.available()){
+    int c=usb_web.read(); if(c<0) break;
+    if(n<sizeof buf) buf[n++]=(uint8_t)c;
+    // process complete commands from the front of buf
+    for(;;){
+      if(n==0) break;
+      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03)?2 : (op==0x01)?1 : 1;
+      if(op!=0x01 && op!=0x02 && op!=0x03){ // resync: drop one byte
+        memmove(buf,buf+1,--n); continue;
+      }
+      if(n<need) break;                      // wait for more bytes
+      if(op==0x01){ webusbSendBlob(); }
+      else if(op==0x02){
+        uint8_t f=buf[1], v=buf[2];
+        bool persist=true;   // most fields persist; the rate knobs are session-only (locked default on reboot)
+        switch(f){
+        case 1: g_mDiv = v<4?4:v; break;
+        case 2: g_mFric = v>99?99:v; break;
+        case 3: g_padSmooth = v<5?5:(v>100?100:v); break;
+        case 4: g_abSwap = v?1:0; break;
+        case 5: case 6: case 7: case 8: g_back[f-5]=v; break;
+        case 9: g_pollUs = (uint32_t)(v<1?1:v)*100; persist=false; break;   // pollU100 -> us. SESSION-ONLY: poll rate is locked; this tunes it live but reverts to 800 on reboot.
+        case 10: g_e7b = v?1:0; break;                       // E7 protocol-version B-byte (experimental v1 fast)
+        case 11: g_relayOp = v; break;                       // haptic-relay opcode (buzz hunt)
+        case 12: g_relaySub = v; break;                      // haptic-relay sub-type (buzz hunt)
+        case 13: g_testHaptic = v?v:40; break;               // inject v test haptics (0->40)
+        case 14: g_fwdNewOnly = v?1:0; break;                // Steam: forward only fresh reports (dedupe)
+        case 15: g_qos = v?1:0; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); break;  // QoS adaptive channel hopping
+        case 16: g_persistMode = v?true:false; break;   // persist last mode across reboots (else always boot Steam)
+        }
+        if(persist) saveCfg();
+        webusbSendBlob();
+      } else if(op==0x03){
+        uint8_t m=buf[1]; if(m<=3){ webusbSendBlob(); usb_web.flush(); saveMode(m); delay(40); NVIC_SystemReset(); }
+      }
+      memmove(buf,buf+need,n-need); n-=need;
+    }
+  }
+}
+
+// CDC commands: operational controls only.
+static void rfSerialPoll(){
+  static char line[24]; static uint8_t li=0;
+  while (Serial.available()){
+    char c=Serial.read();
+    if (c=='\n'||c=='\r'){ line[li]=0;
+      if (line[0]=='l') rfListenStart();
+      else if (line[0]=='B'){ g_rfBeacon=!g_rfBeacon; g_rfListen=false; Serial.printf("# beacon %s ch%u\n",g_rfBeacon?"ON":"off",g_rfCh); }
+      else if (line[0]=='L'){ g_plen=strtol(line+1,0,16); Serial.printf("# plen=%02X\n",g_plen); }
+      else if (line[0]=='1'){ g_s1incl=!g_s1incl; Serial.printf("# s1incl=%u\n",g_s1incl); }
+      else if (line[0]=='H'){ g_rfHost=!g_rfHost; g_rfBeacon=false; g_rfListen=false; g_rfCh=2;
+        Serial.printf("# HOSTFRAME beacon %s base %02X%02X%02X%02X (slot0 uuids %02X%02X%02X%02X/%02X%02X%02X%02X)\n",
+                      g_rfHost?"ON":"off",g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3],
+                      g_slot[0].rec[0],g_slot[0].rec[1],g_slot[0].rec[2],g_slot[0].rec[3],
+                      g_slot[0].rec[4],g_slot[0].rec[5],g_slot[0].rec[6],g_slot[0].rec[7]); }
+      else if (line[0]=='C'){ g_rfBeacon=g_rfListen=g_rfHost=false; rfRespondStart(); }
+      else if (line[0]=='s'){ g_rfListen=false; g_rfBeacon=false; g_rfHost=false; g_rfRespond=false; NRF_RADIO->TASKS_DISABLE=1; Serial.println("# RF off"); }
+      else if (line[0]=='c'){ g_rfCh=atoi(line+1); Serial.printf("# ch=%u\n",g_rfCh); if(g_rfListen) rfListenStart(); }
+      else if (line[0]=='p'){ g_rfPrefix=strtol(line+1,0,16); Serial.printf("# prefix=%02X\n",g_rfPrefix); if(g_rfListen) rfListenStart(); }
+      else if (line[0]=='i'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%06lX\n",(unsigned long)g_crcinit); }
+      else if (line[0]=='w'){ g_whiteiv=atoi(line+1); Serial.printf("# whiteiv=%u\n",g_whiteiv); }
+      else if (line[0]=='0'){ g_pcnf0=strtoul(line+1,0,16); Serial.printf("# pcnf0=%08lX\n",(unsigned long)g_pcnf0); }
+      else if (line[0]=='2'){ g_pcnf1=strtoul(line+1,0,16); Serial.printf("# pcnf1=%08lX\n",(unsigned long)g_pcnf1); }
+      else if (line[0]=='M'){ g_mode=strtoul(line+1,0,16); Serial.printf("# mode=%u (3=Ble1M,4=Ble2M,0/2=Nrf)\n",g_mode); }
+      else if (line[0]=='I'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%08lX\n",(unsigned long)g_crcinit); }
+      else if (line[0]=='P'){ g_crcpoly=strtoul(line+1,0,16); Serial.printf("# crcpoly=%lX\n",(unsigned long)g_crcpoly); }
+      else if (line[0]=='N'){ g_crccnf=strtoul(line+1,0,16); Serial.printf("# crccnf=%X\n",g_crccnf); }
+      else if (line[0]=='T'){ g_statlen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# statlen=%02X\n",g_statlen); }
+      else if (line[0]=='k'){ g_connOn=!g_connOn; if(g_connOn){ g_connSt=0; g_connStep=0; g_connPoll=0; g_connF1=0; } Serial.printf("# CONN mode %s: E7-awake[00 00] -> E3+GET-report-0x45 poll on ch%u (param=%02X). F1 seen=%lu\n",g_connOn?"ON":"off",g_rfCh,g_getParam,(unsigned long)g_connF1); }
+      else if (line[0]=='q'){ g_getParam = g_getParam ? 0x00 : 0x2D; Serial.printf("# GET-0x45 param=%02X\n",g_getParam); }
+      else if (line[0]=='v'){ g_connVerbose=!g_connVerbose; Serial.printf("# conn verbose %s (F1 seen=%lu)\n",g_connVerbose?"ON":"off",(unsigned long)g_connF1); }
+      else if (line[0]=='C'){ g_sessCh=strtoul(line+1,0,10); Serial.printf("# session channel=%u (re-pair/reconnect to apply)\n",g_sessCh); }
+      else if (line[0]=='U'){ g_pollUs=strtoul(line+1,0,10); if(g_pollUs<50)g_pollUs=50; Serial.printf("# poll interval=%lu us (~%lu/s) SESSION-ONLY (locked default 800us on reboot)\n",(unsigned long)g_pollUs,(unsigned long)(1000000/g_pollUs)); }
+      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]=='n'){ g_lizDiv=strtoul(line+1,0,10); if(g_lizDiv<8)g_lizDiv=8; Serial.printf("# lizard-mouse sensitivity divisor=%d (higher=slower)\n",g_lizDiv); }
+      else if (line[0]=='m'){ g_lizFric=strtoul(line+1,0,10); if(g_lizFric>99)g_lizFric=99; Serial.printf("# lizard-mouse friction=%d%% (lower=less glide/crisper)\n",g_lizFric); }
+      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]=='D'){ g_padSmooth=strtoul(line+1,0,10); if(g_padSmooth<5)g_padSmooth=5; if(g_padSmooth>100)g_padSmooth=100; saveCfg(); Serial.printf("# steam pad smoothing alpha=%d%% (100=off, lower=smoother/laggier)\n",g_padSmooth); }
+      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]\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]=='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]
+        g_relayBuf[0]=0x87; g_relayBuf[1]=3; g_relayBuf[2]=id; g_relayBuf[3]=val&0xFF; g_relayBuf[4]=val>>8; g_relayN=5; g_relayPend=true;
+        Serial.printf("# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",id,val); }
+      else if (line[0]=='V'){ g_e7b=strtoul(line+1,0,10); Serial.printf("# E7 protocol-version B-byte=%u (0=awake/slow, 1=try v1-fast; watch new=/s; revert if input stops)\n",g_e7b); }
+      else if (line[0]=='O'){ g_relayOp=strtoul(line+1,0,16); Serial.printf("# relay opcode=%02X\n",g_relayOp); }
+      else if (line[0]=='o'){ g_relaySub=strtoul(line+1,0,16); Serial.printf("# relay sub-type=%02X\n",g_relaySub); }
+      else if (line[0]=='h'){ uint8_t nc=strtoul(line+1,0,10); Serial.printf("# HOP %u->%u (advertise on current ch, then poll new). Watch F1=/s\n",g_sessCh,nc); rfHopTo(nc); }
+      else if (line[0]=='z'){ g_qos=!g_qos; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); Serial.printf("# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",g_qos?"ON":"off"); }
+      else if (line[0]=='r'){ g_rxWin=strtoul(line+1,0,10); if(g_rxWin<150)g_rxWin=150; Serial.printf("# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",(unsigned long)g_rxWin,(unsigned long)(1000000/g_rxWin)); }
+      else if (line[0]=='e'){ g_e3mode=strtoul(line+1,0,10); Serial.printf("# E3 poll PID mode=%u (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0) - watch new=/s\n",g_e3mode); }
+      else if (line[0]=='t'){ uint8_t n=line[1]?strtoul(line+1,0,10):40;     // inject n test haptics (output 0x82 [01 01 F7]) over the relay
+        g_testHaptic=n; Serial.printf("# test-haptic burst x%u queued (relay 0x82 01 01 F7 via op=%02X sub=%02X)\n",n,g_relayOp,g_relaySub); }
+      else if (line[0]=='j'){ g_connType=strtoul(line+1,0,16); Serial.printf("# connType=%02X\n",g_connType); }
+      else if (line[0]=='Q'){ g_connLen=strtoul(line+1,0,16); Serial.printf("# connLen=%02X\n",g_connLen); }
+      else if (line[0]=='A'){ g_balen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# balen=%u\n",g_balen); }
+      else if (line[0]=='a'){ uint32_t v=strtoul(line+1,0,16); g_rfBase[0]=v>>24;g_rfBase[1]=v>>16;g_rfBase[2]=v>>8;g_rfBase[3]=v; Serial.printf("# base=%08lX\n",(unsigned long)v); if(g_rfListen) rfListenStart(); }
+      else if (line[0]=='b'){ for(int i=0;i<NSLOT;i++) if(g_slot[i].used){Serial.printf("slot%d ",i); for(int j=0;j<24;j++)Serial.printf("%02X",g_slot[i].rec[j]); Serial.println();} }
+      li=0;
+    } else if (li<sizeof line-1) line[li++]=c;
+  }
+}
+
+void setup() {
+  genSerial();
+  InternalFS.begin();
+  loadCfg(); g_xbox = (g_usbMode != 0);   // load persisted config + decide USB presentation BEFORE registering interfaces
+  USBDevice.setSerialDescriptor(g_unit);
+  if (g_usbMode == 1) {                // XBOX presentation: XInput gamepad + mouse only (no puck)
+    USBDevice.setID(0x045E, 0x028E);   // Microsoft Xbox 360 wired controller (so Steam/SDL bind it as Xbox 360)
+    USBDevice.setVersion(0x0200); USBDevice.setDeviceVersion(0x0114);
+    USBDevice.setManufacturerDescriptor("Microsoft");
+    USBDevice.setProductDescriptor("Controller");
+    g_xinput.setStringDescriptor("Controller"); g_xinput.begin();   // XInput vendor interface (FF/5D/01)
+    g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC); g_mouse.setPollInterval(1); g_mouse.begin();
+  } else if (g_usbMode == 2) {         // SWITCH presentation: HORIPAD/Pokkén pad (console-compatible)
+    USBDevice.setID(0x0F0D, 0x0092);   // HORI Pokkén Tournament Pro Pad (accepted by a real Switch console)
+    USBDevice.setVersion(0x0200); USBDevice.setDeviceVersion(0x0200);
+    USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
+    USBDevice.setProductDescriptor("POKKEN CONTROLLER");
+    g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
+    g_switch.setPollInterval(8);       // 8ms (~125Hz); we stream the 8-byte report (no handshake needed)
+    g_switch.begin();
+  } else if (g_usbMode == 3) {         // LIZARD presentation: plain desktop keyboard + mouse (driverless)
+    USBDevice.setID(0x28DE, 0x1102);   // Valve VID, distinct PID; KB+mouse are boot-protocol so the IDs don't matter functionally
+    USBDevice.setManufacturerDescriptor("Valve Software");
+    USBDevice.setProductDescriptor("Steam Controller (Desktop)");
+    g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC); g_mouse.setPollInterval(1); g_mouse.begin();
+    g_keyboard.setReportDescriptor(KEYBOARD_HID_DESC, sizeof KEYBOARD_HID_DESC); g_keyboard.setPollInterval(1); g_keyboard.begin();
+  } else {                             // STEAM presentation: the 4 puck slot interfaces
+    USBDevice.setID(0x28DE, 0x1304);
+    USBDevice.setManufacturerDescriptor("Valve Software");
+    USBDevice.setProductDescriptor("Steam Controller Puck");
+    for (int i = 0; i < NSLOT; i++) {
+      hid[i].setReportDescriptor(PUCK_HID_DESC, sizeof PUCK_HID_DESC);
+      hid[i].setReportCallback(GETCB[i], SETCB[i]);
+      hid[i].setPollInterval(1);       // 1ms USB poll (was default 10ms = 100/s cap -> choppy)
+      hid[i].begin();
+    }
+  }
+  usb_web.begin();   // config panel (present in every mode); no landing page -> no Chrome auto-notification
+  Serial.begin(115200);
+  while (!USBDevice.mounted()) delay(10);
+  loadBonds();
+  Serial.printf("# openpuck up: unit=%s board=%s, mode=%s\n", g_unit, g_board, g_usbMode==1?"XBOX(gamepad+mouse)":g_usbMode==2?"SWITCH(pro controller)":g_usbMode==3?"LIZARD(keyboard+mouse)":"STEAM(puck)");
+}
+
+void loop() {
+  if (g_dirty) { g_dirty = false; saveBonds(); }
+  webusbPoll();
+  swStream();        // SWITCH mode: stream 0x30 input reports once the host enabled mode 0x30
+  rfSerialPoll();
+  rfPoll();
+  rfRespondPoll();
+  if (g_rfBeacon && millis() - g_lastBeacon >= 5) { g_lastBeacon = millis(); rfBeaconOnce(); }
+  // Host-frame beacon: sent continuously, INCLUDING while connected. The controller uses the periodic E1
+  // (the real puck's per-hop-cycle announce) to stay synced and keep answering polls at full rate;
+  // suppressing it drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect
+  // cooldown so a controller that's powering off isn't immediately re-woken/reconnected.
+  if (g_rfHost && millis()-g_connCooldown > 2500) {
+    bool connNow = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
+    // session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
+    // (every-loop beaconing also hammers ch52 and steals reply slots from the poll)
+    if (millis()-g_lastSessBeacon >= (connNow ? 25u : 0u)) { g_lastSessBeacon=millis(); g_rfCh=g_sessCh; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s); }
+    // discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
+    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
+  // 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.
+  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) {
+    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
+      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
+  }
+  // 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) {
+    uint16_t bad=g_qosBad; g_qosBad=0; g_qosCheckMs=millis();
+    if (bad>20 && millis()-g_qosLastHopMs>2000) {
+      for(int k=0;k<(int)sizeof g_hopCand;k++){ g_hopIdx=(g_hopIdx+1)%(sizeof g_hopCand); if(g_hopCand[g_hopIdx]!=g_sessCh) break; }
+      if(Serial.availableForWrite()>60) Serial.printf("# QoS: ch%u bad=%u -> hop ch%u\n",g_sessCh,bad,g_hopCand[g_hopIdx]);
+      rfHopTo(g_hopCand[g_hopIdx]); g_qosLastHopMs=millis();
+    }
+  }
+  // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
+  // edge-triggered, + periodic 0x7B status. Live-captured: this is what Steam reads to mark the controller
+  // connected. Without it Steam shows disconnected even though 0x45 input is streaming.
+  if(!g_xbox){ static bool usbConn=false; static unsigned long last79=0, last7B=0;   // puck-only USB presentation
+    bool conn = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
+    if (g_connSlot>=0 && g_connSlot<NSLOT && hid[g_connSlot].ready()){
+      // 0x79 connection state: on edge AND repeated every 750ms while connected (Steam can miss the edge,
+      // and the 0x45 stream can starve the endpoint at the transition instant -> resend until it sticks).
+      if (conn!=usbConn || (conn && millis()-last79>=750)){
+        uint8_t st=conn?0x02:0x01; hid[g_connSlot].sendReport(0x79,&st,1); usbConn=conn; last79=millis();
+      } else if (conn && millis()-last7B>=2000){
+        static const uint8_t s7b[12]={0xF7,0x01,0x89,0x00,0x00,0x00,0x03,0x00,0xDD,0x00,0x3A,0x02};
+        hid[g_connSlot].sendReport(0x7B,s7b,12); last7B=millis();
+      }
+    } else if(!conn) usbConn=false;
+  }
+  if (g_connOn && millis()-g_stMs>=1000){ g_f1ps=g_stF1; g_newps=g_stNew; if(Serial.availableForWrite()>70) Serial.printf("# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",(unsigned long)g_stPoll,(unsigned long)g_stF1,(unsigned long)g_stNew,(unsigned long)g_stF3,(int8_t)g_connF3v,g_e7b,(unsigned long)g_stCrc,(unsigned long)g_stNoRx,g_connSlot); g_stPoll=0; g_stF1=0; g_stNew=0; g_stF3=0; g_stCrc=0; g_stNoRx=0; g_stMs=millis(); }
+  if (g_rfRespond && millis() - g_lastHop >= 25) {          // scan the 3 BLE adv channels w/ matched whitening
+    g_lastHop = millis();
+    static const uint8_t advf[3]={2,26,80}, advw[3]={37,38,39}; static uint8_t ai=0; ai=(ai+1)%3;
+    g_rfCh=advf[ai]; g_whiteiv=advw[ai];
+    rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix); NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
+    NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk; NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+  }
+}

+ 5 - 2
README.md

@@ -23,11 +23,14 @@ At any point you can hold all 4 back buttons and press X to switch over to ***Xb
 
 Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to a **Switch mode**. This emulates a Pokken controller and is very limited (no gyro, no haptics, etc) but it's functional enough for my kids to play Animal Crossing :D.
 
+# How to use it
+You will need to flash OpenPuck.ino onto a compatible microcontroller. See [build instructions document](./docs/BUILD_AND_DEPLOY.md) for more details.
+
 # Configuration
-A webusb based configuration UI is available at <link>. It allows Switching the mode manually and changing the back button mapping for Xbox mode among other things. This will likely only work in Chrome and Edge and needs the pro micro to be connected via USB to the same computer for it to function.
+A webusb based configuration UI is available [here](https://safijari.github.io/openpuck). It allows Switching the mode manually and changing the back button mapping for Xbox mode among other things. This will likely only work in Chrome and Edge and needs the pro micro to be connected via USB to the same computer for it to function.
 
 # Contributions
-I have tested this software fairly extensively but I have limited resources. I only have one controller so I actually don't know if multiple controllers even work correctly. Please submit issues with any issues you find. PRs also welcome of course.
+I have tested this software fairly extensively but I have limited resources. I only have one controller so I actually don't know if multiple controllers even work correctly (in fact I'm almost sure they will not). Please submit issues with any issues you find. PRs also welcome of course.
 
 # Acknowledgements
 - Valve for putting out the amazing controller

+ 1 - 0
docs/.nojekyll

@@ -0,0 +1 @@
+

+ 151 - 0
docs/BUILD_AND_DEPLOY.md

@@ -0,0 +1,151 @@
+# Build And Deploy
+
+These instructions cover firmware builds for macOS, Linux, and Windows, plus static hosting of the WebUSB app through GitHub Pages.
+
+## 1. Prerequisites
+
+Hardware:
+
+- nRF52840 board supported by the Adafruit nRF52 Arduino core
+- USB cable
+- Steam Controller 2 controller
+
+Software:
+
+- `arduino-cli`
+- Adafruit nRF52 Arduino core
+- Chrome or Edge for the WebUSB app
+
+## 2. Install Arduino CLI
+
+### macOS
+
+```bash
+brew install arduino-cli
+```
+
+### Linux
+
+Use your package manager if it ships a recent `arduino-cli`, or download the release archive from Arduino and place `arduino-cli` on `PATH`.
+
+### Windows
+
+Install the official Arduino CLI zip or MSI, then ensure `arduino-cli.exe` is on `PATH`.
+
+## 3. Install the board core
+
+Run once on any platform:
+
+```bash
+arduino-cli config init
+arduino-cli core update-index
+arduino-cli core install adafruit:nrf52
+```
+
+If your CLI config does not already include the Adafruit package index, add it first:
+
+```yaml
+board_manager:
+  additional_urls:
+    - https://www.adafruit.com/package_adafruit_index.json
+```
+
+Then rerun `arduino-cli core update-index`.
+
+## 4. Build the firmware
+
+From the repository root:
+
+```bash
+arduino-cli compile \
+  -b adafruit:nrf52:feather52840 \
+  --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
+  firmware/OpenPuck
+```
+
+This sketch requires `CFG_TUD_HID=4` because Steam mode exposes four HID interfaces.
+
+## 5. Upload the firmware
+
+### macOS / Linux
+
+Find the board port:
+
+```bash
+arduino-cli board list
+```
+
+Upload:
+
+```bash
+arduino-cli upload \
+  -b adafruit:nrf52:feather52840 \
+  -p /dev/ttyACM0 \
+  firmware/OpenPuck
+```
+
+Replace `/dev/ttyACM0` with the actual port. On macOS it is usually `/dev/cu.usbmodem*`.
+
+### Windows
+
+Find the COM port:
+
+```powershell
+arduino-cli board list
+```
+
+Upload:
+
+```powershell
+arduino-cli upload `
+  -b adafruit:nrf52:feather52840 `
+  -p COM5 `
+  firmware/OpenPuck
+```
+
+Replace `COM5` with the actual board port.
+
+## 6. Pair and verify
+
+1. Flash the board.
+2. Plug it into the host.
+3. In Steam mode it enumerates as a puck-compatible device.
+4. Pair the controller to one of the bond slots.
+5. Verify that the slot returns `0xB4 = 0x02` when connected.
+
+## 7. Run the WebUSB app locally
+
+WebUSB requires a secure context. `http://localhost` qualifies.
+
+### macOS / Linux
+
+```bash
+cd docs
+python3 -m http.server 8008
+```
+
+Open:
+
+```text
+http://localhost:8008
+```
+
+### Windows
+
+```powershell
+cd docs
+py -m http.server 8008
+```
+
+Open:
+
+```text
+http://localhost:8008
+```
+
+## 8. Known operational details
+
+- Chrome or Edge is required for WebUSB.
+- The board re-enumerates on USB mode switches.
+- Poll interval tuning in the WebUSB app is session-only by design.
+- Bonds persist in the board's internal filesystem.

+ 361 - 0
docs/PROTOCOL.md

@@ -0,0 +1,361 @@
+# OpenPuck Controller Protocol
+
+This document describes the complete controller-to-puck protocol implemented by `OpenPuck.ino`.
+
+## 1. Scope
+
+OpenPuck emulates the Valve Steam Controller 2 "puck" dongle. There are two separate protocols:
+
+1. USB host-facing protocol between the puck and the computer.
+2. 2.4 GHz radio protocol between the puck and the controller.
+
+Both are required for a fully compatible implementation.
+
+## 2. Bond record
+
+A bond slot is 24 bytes:
+
+```text
+offset  size  meaning
+0x00    4     proteus_uuid / token, little-endian
+0x04    4     ibex_uuid, little-endian
+0x08    16    controller serial, ASCII, not null-terminated if fully filled
+```
+
+The puck stores four independent bond slots. Slot `N` is exposed as USB HID interface `N`.
+
+An empty slot is all zeroes.
+
+## 3. USB protocol
+
+### 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.
+
+Relevant report IDs:
+
+- Input: `0x40`, `0x41`, `0x42`, `0x43`, `0x44`, `0x45`, `0x79`, `0x7B`
+- Output: `0x80` to `0x89`
+- Feature: `0x01`, `0x02`
+
+### 3.2 Feature commands
+
+Feature payload framing is:
+
+```text
+[cmd][len][payload...]
+```
+
+Implemented commands:
+
+- `0x83`: attribute block
+- `0xAE`: string attribute
+- `0xB4`: connection state for the interface's slot
+- `0xAD`: pairing mode enable/disable
+- `0xA2`: write or clear the interface's 24-byte bond slot
+- `0xA3`: read the interface's 24-byte bond slot
+
+#### `0xB4` response
+
+```text
+[0xB4][0x01][state]
+```
+
+- `0x02`: controller connected on this slot
+- `0x01`: not connected
+
+#### `0xA2` write
+
+`len` must be at least `24`. A zero-filled 24-byte payload clears the slot. Any non-zero valid payload writes the bond verbatim.
+
+#### `0xA3` read
+
+Returns:
+
+```text
+[0xA3][0x18][24-byte bond]
+```
+
+### 3.3 Host-to-controller relay
+
+When Steam writes feature report `0x01`, OpenPuck forwards it over RF to the controller as an `E3` frame containing a SET sub-TLV:
+
+```text
+[E3][sub_len][0x05][report_id][payload...]
+```
+
+`0x05` is the confirmed SET subtype. `0x01` is the GET subtype.
+
+This is how settings writes, haptics, and lizard-disable requests reach the controller.
+
+## 4. Radio physical layer
+
+OpenPuck uses Nordic raw radio mode configured to match the puck firmware:
+
+- PHY: `Ble_2Mbit`
+- Dynamic-length packet format
+- `PCNF0 = 0x00030008`
+- `PCNF1 = 0x01040040`
+- 5-byte address (`BALEN=4`)
+- Big-endian
+- Whitening disabled
+- CRC16, address included
+- Polynomial `0x11021`
+- Init `0xFFFF`
+
+CRC is standard CRC-16-CCITT over address plus payload, MSB-first.
+
+### 4.1 Address transform
+
+Stored address bytes are converted to on-air address bytes by bit-reversing each byte.
+
+For the nRF `BASE0` register, the bit-reversed base bytes are then packed big-endian:
+
+```text
+BASE0 = bitrev8(base[0]) << 24 |
+        bitrev8(base[1]) << 16 |
+        bitrev8(base[2]) <<  8 |
+        bitrev8(base[3])
+
+PREFIX0 = bitrev8(prefix)
+```
+
+## 5. Discovery / reconnect
+
+Discovery uses a fixed address and fixed channel:
+
+- Base bytes: ASCII `"ibex"` = `69 62 65 78`
+- Prefix: `0x10`
+- Channel: `2`
+
+The puck transmits a host frame on that address until the controller connects.
+
+## 6. Host frame (`E1`)
+
+OpenPuck's discovery host frame is a dynamic-length ESB payload:
+
+```text
+RAM buffer:
+[0]  = 0x12                     ; payload length
+[1]  = S1 byte                  ; PID in bits 1..2, no-ack bit clear
+[2]  = 0xE1                     ; host-frame opcode
+[3..6]   = proteus_uuid         ; 4 bytes, little-endian
+[7..10]  = ibex_uuid            ; 4 bytes, little-endian
+[11]     = session channel
+[12..14] = 0x00 0x00 0x00
+[15..18] = session base         ; 4 bytes, stored form
+[19]     = session prefix
+```
+
+Semantics:
+
+- `proteus_uuid` is the first 4 bytes of the bond record.
+- `ibex_uuid` is the next 4 bytes of the bond record.
+- The host chooses the connected-session channel and address and advertises them here.
+
+## 7. Connected session
+
+After discovery, the puck becomes the poll master.
+
+### 7.1 Controller wake / version frame (`E7`)
+
+OpenPuck sends:
+
+```text
+E7 00 00
+```
+
+This is the normal "host awake" form.
+
+### 7.2 Input poll (`E3`)
+
+OpenPuck polls for report `0x45` using:
+
+```text
+E3 [len] 0x01 0x45 [param]
+```
+
+- `0x01`: GET subtype
+- `0x45`: controller input report
+- `param`: implementation tunable, usually `0x00` or `0x2D`
+
+The controller replies in the ACK window.
+
+### 7.3 Controller replies
+
+Observed reply opcodes:
+
+- `0xF1`: full TLV container carrying input data
+- `0xF2`: disconnect / shutdown
+- `0xF3`: status
+
+#### `0xF1` container
+
+The TLV scan starts at byte 6. Each record is:
+
+```text
+[len][tag][value...]
+```
+
+Known tags:
+
+- `0x02`: 4-byte control/status field
+- `0x04`: bulk data blob
+- `0x06`: embedded report wrapper
+
+For controller input, the embedded report is `0x45`.
+
+## 8. Report `0x45` layout
+
+Report `0x45` is 46 bytes:
+
+```text
+offset  size  meaning
+0x00    1     report id = 0x45
+0x01    1     sequence
+0x02    4     buttons, little-endian u32
+0x06    2     left trigger, u16
+0x08    2     right trigger, u16
+0x0A    2     left stick X, s16
+0x0C    2     left stick Y, s16
+0x0E    2     right stick X, s16
+0x10    2     right stick Y, s16
+0x12    2     left pad X, s16
+0x14    2     left pad Y, s16
+0x16    2     left pad press, s16
+0x18    2     right pad X, s16
+0x1A    2     right pad Y, s16
+0x1C    2     right pad press, s16
+0x1E    4     IMU timestamp, u32
+0x22    2     accel X, s16
+0x24    2     accel Y, s16
+0x26    2     accel Z, s16
+0x28    2     gyro X, s16
+0x2A    2     gyro Y, s16
+0x2C    2     gyro Z, s16
+```
+
+### 8.1 Button bitfield
+
+```text
+0x00000001 A
+0x00000002 B
+0x00000004 X
+0x00000008 Y
+0x00000010 QAM
+0x00000020 R3
+0x00000040 View
+0x00000080 R4
+0x00000100 R5
+0x00000200 RB
+0x00000400 D-pad down
+0x00000800 D-pad right
+0x00001000 D-pad left
+0x00002000 D-pad up
+0x00004000 Menu
+0x00008000 L3
+0x00010000 Steam
+0x00020000 L4
+0x00040000 L5
+0x00080000 LB
+0x00100000 right stick touch
+0x00200000 right pad touch
+0x00400000 right pad click
+0x00800000 right trigger click
+0x01000000 left stick touch
+0x02000000 left pad touch
+0x04000000 left pad click
+0x08000000 left trigger click
+0x10000000 right grip touch
+0x20000000 left grip touch
+```
+
+## 9. USB presentation modes
+
+The RF side stays the same across modes. Only USB enumeration changes.
+
+### 9.1 Steam mode
+
+- VID:PID `28DE:1304`
+- Four puck HID interfaces
+- `0x45` reports are forwarded to the connected slot's HID interface
+
+### 9.2 Xbox mode
+
+- VID:PID `045E:028E`
+- Custom XInput-compatible vendor interface
+- Separate HID mouse for right-pad mouse emulation
+- `0x45` is converted into a 20-byte XInput report
+
+### 9.3 Switch mode
+
+- VID:PID `057E:2009`
+- Single HID interface compatible with Switch Pro host expectations
+
+### 9.4 Lizard mode
+
+- Keyboard and mouse USB presentation
+
+## 10. WebUSB control channel
+
+The WebUSB vendor interface is present in every mode.
+
+Messages:
+
+- Host to device:
+  - `0x01`: get status blob
+  - `0x02 <field> <value>`: set one field
+  - `0x03 <mode>`: switch mode and reboot
+- Device to host:
+  - `0xA5 <len> <payload>`
+
+Status blob payload:
+
+```text
+[0]  version
+[1]  usb mode
+[2]  xbox mouse sensitivity divisor
+[3]  xbox mouse friction
+[4]  steam pad smoothing
+[5]  A/B + X/Y swap
+[6]  back mapping 0
+[7]  back mapping 1
+[8]  back mapping 2
+[9]  back mapping 3
+[10] connected slot or 0xFF
+[11] link up flag
+[12] delivered reports/s low
+[13] delivered reports/s high
+[14] poll interval / 100
+[15] new reports/s low
+[16] new reports/s high
+[17] E7 mode byte
+[18] relay opcode
+[19] relay subtype
+[20] forward-new-only flag
+[21] QoS auto-hop flag
+[22] persist-mode flag
+```
+
+## 11. Timing notes
+
+- Default poll interval: `800` microseconds
+- Default RX window: `1200` microseconds
+- Discovery beacon continues on channel 2 even after a connected session exists
+- Connected session can be moved to a cleaner channel such as 18 or 52
+
+## 12. Reimplementation checklist
+
+To build a compatible puck from scratch:
+
+1. Expose four independent bond slots over USB.
+2. Implement `0x83`, `0xAE`, `0xB4`, `0xAD`, `0xA2`, `0xA3`.
+3. Persist 24-byte bond records exactly.
+4. Configure the radio exactly as described above.
+5. Beacon `E1` host frames on `"ibex"` / prefix `0x10` / channel 2.
+6. Poll with `E7` then `E3 GET 0x45`.
+7. Parse `F1` and unpack report `0x45`.
+8. Relay host feature writes to the controller as `E3` SET sub-TLVs.
+9. Re-enumerate USB cleanly when switching Steam/Xbox/Switch/Lizard modes.
+
+If those pieces match, the controller-to-puck protocol is fully reimplemented.

+ 256 - 0
docs/index.html

@@ -0,0 +1,256 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>OpenPuck Config</title>
+<style>
+  :root{ --bg:#11131a; --card:#1b1f2b; --fg:#e7ecf3; --mut:#8a93a6; --acc:#5bd6a0; --acc2:#5b9bd6; --bad:#d65b6e; }
+  *{box-sizing:border-box}
+  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg);padding:24px}
+  h1{font-size:20px;margin:0 0 4px}
+  .sub{color:var(--mut);margin:0 0 20px}
+  .wrap{max-width:680px;margin:0 auto}
+  .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:18px 20px;margin-bottom:16px}
+  .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
+  .row{display:flex;align-items:center;gap:14px;margin:12px 0}
+  .row label{flex:0 0 150px;color:var(--fg)}
+  .row .val{flex:0 0 64px;text-align:right;color:var(--acc);font-variant-numeric:tabular-nums}
+  input[type=range]{flex:1;accent-color:var(--acc)}
+  select{background:#0e1017;color:var(--fg);border:1px solid #313a4f;border-radius:8px;padding:6px 8px;flex:1}
+  button{background:#222a3a;color:var(--fg);border:1px solid #36405a;border-radius:8px;padding:9px 14px;cursor:pointer;font:inherit}
+  button:hover{border-color:var(--acc2)}
+  button.primary{background:var(--acc2);border-color:var(--acc2);color:#06121f;font-weight:600}
+  button.active{background:var(--acc);border-color:var(--acc);color:#062018;font-weight:600}
+  button:disabled{opacity:.4;cursor:default}
+  .pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600}
+  .pill.up{background:#143626;color:var(--acc)} .pill.dn{background:#3a1822;color:var(--bad)}
+  .statgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
+  .stat{background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px}
+  .stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
+  .stat .v{font-size:18px;margin-top:2px}
+  .modes{display:flex;gap:10px}
+  .note{color:var(--mut);font-size:12px;margin-top:10px}
+  #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:120px;overflow:auto}
+  .hide{display:none}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <h1>OpenPuck Config</h1>
+  <p class="sub">nRF52840 Steam Controller 2 puck reimplementation — WebUSB. Chrome/Edge only.</p>
+
+  <div class="card" id="connectCard">
+    <div class="row" style="margin:0">
+      <button id="connectBtn" class="primary">Connect to OpenPuck</button>
+      <span id="connState" class="pill dn">disconnected</span>
+    </div>
+    <p class="note">If nothing appears in the device picker, OpenPuck may be busy, already claimed by another app, or needs a replug. This page also works when published through GitHub Pages because WebUSB only requires a secure context.</p>
+  </div>
+
+  <div id="panel" class="hide">
+    <div class="card">
+      <h2>USB mode</h2>
+      <div class="modes">
+        <button data-mode="0" class="modebtn">Steam (puck)</button>
+        <button data-mode="1" class="modebtn">Xbox 360</button>
+        <button data-mode="2" class="modebtn">Switch Pro</button>
+        <button data-mode="3" class="modebtn">Lizard (kbd+mouse)</button>
+      </div>
+      <p class="note">Switching reboots OpenPuck and re-enumerates USB (~2 s). The RF link reconnects automatically.</p>
+      <div class="row"><label>Persist last mode</label>
+        <button id="persistMode">off</button>
+        <span class="note" style="flex:1">Off (default): every restart / fresh reconnect boots into Steam mode. On: remembers the last mode you selected.</span>
+      </div>
+    </div>
+
+    <div class="card">
+      <h2>Link status</h2>
+      <div class="statgrid">
+        <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
+        <div class="stat"><div class="k">Slot</div><div class="v" id="stSlot">—</div></div>
+        <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
+        <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
+      </div>
+    </div>
+
+    <div class="card">
+      <h2>RF poll rate</h2>
+      <div class="row"><label>Poll interval</label><input type="range" id="pollUs" min="1" max="120" step="1"><span class="val" id="pollUsV"></span></div>
+      <p class="note">Microseconds between RF polls (×100). Lower = poll harder = more fresh reports/s (peaks ~60/s near 700&nbsp;µs). Too aggressive can collapse the reply rate during heavy trackpad use — watch the “New reports” stat and trackpad feel as you tune.</p>
+    </div>
+
+    <div class="card">
+      <h2>Steam-mode report dedupe</h2>
+      <div class="row"><label>Forward only new</label>
+        <button id="fwdNew">on</button>
+        <span class="note" style="flex:1">Only send report 0x45 to Steam when the data actually changed (like the real puck).</span>
+      </div>
+      <p class="note">We were sending ~400 stale-repeat reports/s on top of ~100 real ones — Steam's trackpad smoothing stair-steps on the duplicates. ON = dedupe (puck-like). Toggle OFF to compare the old jittery behavior.</p>
+    </div>
+
+    <div class="card">
+      <h2>QoS adaptive channel hopping</h2>
+      <div class="row"><label>Auto-hop</label>
+        <button id="qos">off</button>
+        <span class="note" style="flex:1">When the RF link degrades (crcfail/no-reply spikes during trackpad), hop to a clean channel — like the real puck.</span>
+      </div>
+      <p class="note">Candidates {18,46,76,22,68}. Conservative (hops at most every ~2s) to avoid blip-storms; each hop has a brief ~0.5–1s re-sync blip. A/B it during heavy trackpad — watch crcfail and the feel.</p>
+    </div>
+
+
+    <div class="card" id="mouseCard">
+      <h2>Xbox-mode trackpad mouse</h2>
+      <div class="row"><label>Sensitivity</label><input type="range" id="mDiv" min="4" max="200" step="1"><span class="val" id="mDivV"></span></div>
+      <div class="row"><label>Glide / friction</label><input type="range" id="mFric" min="0" max="99" step="1"><span class="val" id="mFricV"></span></div>
+      <p class="note">Sensitivity is a divisor — lower = faster pointer. Glide = how long the cursor coasts after a flick.</p>
+    </div>
+
+    <div class="card" id="steamCard">
+      <h2>Steam-mode trackpad smoothing</h2>
+      <div class="row"><label>Smoothing</label><input type="range" id="padSmooth" min="5" max="100" step="1"><span class="val" id="padSmoothV"></span></div>
+      <p class="note">Interpolation alpha. 100 = off (raw 46 Hz capsense), lower = smoother but slightly laggier.</p>
+    </div>
+
+    <div class="card">
+      <h2>Button mapping</h2>
+      <div class="row"><label>A/B + X/Y swap</label>
+        <button id="abSwap">off</button>
+        <span class="note" style="flex:1">Nintendo layout (affects Xbox &amp; Switch modes)</span>
+      </div>
+      <div class="row"><label>L4 (back upper-left)</label><select class="back" data-i="0"></select></div>
+      <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 mode.</p>
+    </div>
+
+    <div id="log"></div>
+  </div>
+</div>
+
+<script>
+const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch Pro","Lizard (kbd+mouse)"];
+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"]];
+let dev=null, epIn=0, epOut=0, ifNum=0, polling=false;
+const $ = s=>document.querySelector(s);
+function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
+
+// populate back-paddle dropdowns
+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); }
+}
+
+async function connect(){
+  try{
+    dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E}]});
+    await dev.open();
+    if(dev.configuration===null) await dev.selectConfiguration(1);
+    // find the WebUSB vendor interface: class 0xFF with one bulk-IN + one bulk-OUT (XInput is 0xFF but interrupt; CDC data is 0x0A)
+    let found=null;
+    for(const itf of dev.configuration.interfaces){
+      const a=itf.alternate;
+      if(a.interfaceClass!==0xFF) continue;
+      let bin=0,bout=0;
+      for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
+      if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
+    }
+    if(!found){ log("no WebUSB interface found on this device"); return; }
+    ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
+    await dev.claimInterface(ifNum);
+    // WebUSB "connect" handshake: Adafruit_USBD_WebUSB only enables write() / connected()
+    // after the host sends SET_CONTROL_LINE_STATE (bRequest 0x22, value=1) on this interface.
+    await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
+    $("#connState").textContent="connected"; $("#connState").className="pill up";
+    $("#connectBtn").textContent="Reconnect";
+    $("#panel").classList.remove("hide");
+    log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
+    dev.onUSBConnectionEvent;
+    navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
+    startPolling();
+  }catch(e){ log("connect failed: "+e.message); }
+}
+function onGone(){
+  polling=false; dev=null;
+  $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
+  $("#panel").classList.add("hide");
+  log("device disconnected (likely a mode-switch reboot — reconnect)");
+}
+async function send(bytes){
+  if(!dev) return;
+  try{ await dev.transferOut(epOut, new Uint8Array(bytes)); }
+  catch(e){ log("write err: "+e.message); }
+}
+async function readBlob(){
+  try{
+    const r = await dev.transferIn(epIn, 64);
+    if(r.status!=="ok"||r.data.byteLength<2) return null;
+    const d=new Uint8Array(r.data.buffer);
+    // find 0xA5 frame
+    let i=0; while(i<d.length && d[i]!==0xA5) i++;
+    if(i+2>d.length) return null;
+    const len=d[i+1]; const p=d.slice(i+2, i+2+len);
+    return p.length>=12 ? p : null;
+  }catch(e){ if(dev) log("read err: "+e.message); return null; }
+}
+function applyBlob(p){
+  // [ver][mode][mDiv][mFric][padSmooth][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100][newlo][newhi]
+  const mode=p[1], mDiv=p[2], mFric=p[3], padSmooth=p[4], abSwap=p[5];
+  const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
+  const pollU100=p[14], newps=(p.length>16?(p[15]|(p[16]<<8)):0), fwdNew=(p.length>20?p[20]:1), qos=(p.length>21?p[21]:0), persistMode=(p.length>22?p[22]:0);
+  // mode buttons
+  for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
+  $("#mouseCard").style.opacity = mode===1?1:0.5;
+  $("#steamCard").style.opacity = mode===0?1:0.5;
+  // status
+  $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
+  $("#stSlot").textContent = slot===0xFF? "—" : slot;
+  $("#stRate").textContent = up? f1+" /s" : "—";
+  $("#stNew").textContent = up? newps+" /s" : "—";
+  // sliders (only if not being dragged)
+  setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("padSmooth",padSmooth);
+  if(pollU100) setSlider("pollUs",pollU100);
+  $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
+  $("#fwdNew").textContent = fwdNew? "on":"off"; $("#fwdNew").classList.toggle("active",!!fwdNew);
+  $("#qos").textContent = qos? "on":"off"; $("#qos").classList.toggle("active",!!qos);
+  $("#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]; }
+}
+function fmtSlider(id,v){ return id==="pollUs" ? (v*100)+"µs" : (""+v); }
+function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
+
+let inflight=false;
+async function refresh(){
+  if(!dev||inflight) return; inflight=true;
+  await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
+  inflight=false;
+}
+async function setField(field,value){
+  await send([0x02, field, value&0xff]); const p=await readBlob(); if(p) applyBlob(p);
+}
+async function startPolling(){
+  polling=true; await refresh();
+  while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
+}
+
+// UI wiring
+$("#connectBtn").onclick=connect;
+$("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
+$("#fwdNew").onclick=()=>{ const on=$("#fwdNew").classList.contains("active"); setField(14, on?0:1); };
+$("#qos").onclick=()=>{ const on=$("#qos").classList.contains("active"); setField(15, on?0:1); };
+$("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
+for(const id of ["mDiv","mFric","padSmooth","pollUs"]){
+  const field={mDiv:1,mFric:2,padSmooth:3,pollUs:9}[id];
+  $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
+  $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
+}
+for(const sel of document.querySelectorAll("select.back")){
+  sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
+}
+for(const b of document.querySelectorAll(".modebtn")){
+  b.onclick=()=>{ if(confirm("Switch to "+MODE_NAMES[+b.dataset.mode]+"? OpenPuck will reboot.")){ send([0x03, +b.dataset.mode]); log("mode switch requested — device will reboot"); } };
+}
+if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
+</script>
+</body>
+</html>