Ver Fonte

Merge branch 'main' into modern-standby-wake-fix

# Conflicts:
#	OpenPuck/puck_hid.cpp
#	OpenPuck/wake_hid.h
Jari há 4 meses atrás
pai
commit
6fdbb43fcc

+ 26 - 1
.github/workflows/build.yml

@@ -2,7 +2,8 @@ name: Build
 
 on:
   push:
-  pull_request:
+    branches:
+      - main
 
 jobs:
   compile:
@@ -32,9 +33,33 @@ jobs:
           arduino-cli core update-index
           arduino-cli core install adafruit:nrf52
 
+      - name: Generate version header
+        run: ./gen_version.sh
+
       - name: Compile OpenPuck
         run: |
+          mkdir -p build/openpuck build/cache/openpuck
           arduino-cli compile \
             -b adafruit:nrf52:feather52840 \
+            --clean \
+            --build-path build/cache/openpuck \
+            --output-dir build/openpuck \
             --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
             OpenPuck
+
+      - name: List build outputs
+        run: |
+          find build/openpuck -maxdepth 1 -type f -print | sort
+
+      - name: Upload build artifacts
+        uses: actions/upload-artifact@v4
+        with:
+          name: openpuck-firmware
+          retention-days: 7
+          path: |
+            build/openpuck/*.uf2
+            build/openpuck/*.bin
+            build/openpuck/*.hex
+            build/openpuck/*.elf
+            build/openpuck/*.map
+            build/openpuck/*.zip

+ 2 - 1
.github/workflows/format.yml

@@ -2,7 +2,8 @@ name: Format
 
 on:
   push:
-  pull_request:
+    branches:
+      - main
 
 jobs:
   clang-format:

+ 105 - 0
.github/workflows/release.yml

@@ -0,0 +1,105 @@
+name: Release
+
+on:
+  push:
+    branches:
+      - main
+
+permissions:
+  contents: write
+
+jobs:
+  build-release:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Check out repository
+        uses: actions/checkout@v4
+
+      - name: Set up Arduino CLI
+        uses: arduino/setup-arduino-cli@v2
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: '3.x'
+
+      - name: Install adafruit-nrfutil
+        run: pip install adafruit-nrfutil
+
+      - name: Install Adafruit nRF52 core
+        run: |
+          arduino-cli config init --overwrite
+          arduino-cli config set board_manager.additional_urls https://adafruit.github.io/arduino-board-index/package_adafruit_index.json
+          arduino-cli core update-index
+          arduino-cli core install adafruit:nrf52
+
+      - name: Generate version header
+        run: ./gen_version.sh
+
+      - name: Compile OpenPuck
+        run: |
+          mkdir -p build/openpuck build/cache/openpuck
+          arduino-cli compile \
+            -b adafruit:nrf52:feather52840 \
+            --clean \
+            --build-path build/cache/openpuck \
+            --output-dir build/openpuck \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
+            OpenPuck
+
+      - name: Compile recovery build
+        run: |
+          mkdir -p build/openpuck-recovery build/cache/openpuck-recovery
+          arduino-cli compile \
+            -b adafruit:nrf52:feather52840 \
+            --clean \
+            --build-path build/cache/openpuck-recovery \
+            --output-dir build/openpuck-recovery \
+            --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4 -DOPK_FACTORY_RESET=1" \
+            OpenPuck
+
+      - name: Collect artifacts
+        run: |
+          mkdir -p release_assets
+          REF="${GITHUB_REF_NAME}"
+          SAFE_REF="$(printf '%s' "$REF" | tr -c 'A-Za-z0-9._-' '-')"
+          SHA_SHORT="$(git rev-parse --short=8 HEAD)"
+          VERSION="${SAFE_REF}-${SHA_SHORT}"
+
+          find build/openpuck build/openpuck-recovery \
+            -maxdepth 1 -type f \
+            \( -name '*.uf2' -o -name '*.zip' \) \
+            -print | while read -r f; do
+            base="$(basename "$f")"
+            dir="$(basename "$(dirname "$f")")"
+            if [ "$dir" = "openpuck-recovery" ]; then
+              flavor="factory-reset"
+            else
+              flavor="standard"
+            fi
+            cp "$f" "release_assets/OpenPuck-${VERSION}-${flavor}-${base}"
+          done
+          test -n "$(find release_assets -maxdepth 1 -type f -print -quit)"
+          ls -la release_assets
+
+      - name: Create GitHub Release
+        uses: softprops/action-gh-release@v2
+        with:
+          tag_name: ${{ format('build-{0}', github.sha) }}
+          target_commitish: ${{ github.sha }}
+          name: ${{ format('Build {0}', github.sha) }}
+          prerelease: true
+          files: release_assets/*
+          body: |
+            OpenPuck Firmware Release
+
+            Build: ${{ github.sha }}
+            Ref: ${{ github.ref_name }}
+
+            ### Files included:
+
+            - `.zip` files for Adafruit DFU tool
+            - `.uf2` files for drag-and-drop flashing, if generated by the board core
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 2 - 0
.gitignore

@@ -3,6 +3,8 @@
 *.log
 *.json
 tmp/
+build/
+release_assets/
 # pairtui helper binary (build from scmd.c: clang -framework IOKit -framework CoreFoundation -o scmd scmd.c)
 pairtui/scmd
 __pycache__/

+ 125 - 50
OpenPuck/OpenPuck.ino

@@ -34,6 +34,9 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "serial_console.h"
 #include "wake_hid.h"
 #include "status_led.h"
+#include "usb_mount.h"
+#include "identity.h"
+#include <stdio.h>
 
 #if CFG_TUD_HID < 4
 #error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
@@ -42,13 +45,45 @@ using namespace Adafruit_LittleFS_Namespace;
 // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
 static uint8_t g_usbCfgDesc[512];
 
+// Per-mode USB serial suffix (modes 1..8: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game D=ds4game).
+static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D' };
+// Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
+static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
+
+// Dynamic re-enumeration (NO MCU reboot): tear down + rebuild the config descriptor presenting `k` connected-
+// controller slots, replaying the fixed interfaces in locked HID-instance order (wake mouse = instance 0,
+// before the slot pool; WebUSB is vendor-class so order-free), then re-attach. RF/firmware state survives.
+// g_usbToBond must already be built for k connected controllers. Called at boot and by usb_mount's watcher.
+void usbReenumerate(uint8_t k)
+{
+	USBDevice.detach();
+	delay(20);
+	USBDevice.clearConfiguration();
+	USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
+	g_active->usbIdentity(); // clearConfiguration reset VID/PID/strings -- restore them
+	// serial carries the mounted count so the host invalidates its cached config descriptor on a change
+	snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%u", g_unit,
+		 MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= 8) ? g_usbMode - 1 :
+							         0],
+		 (unsigned)k);
+	USBDevice.setSerialDescriptor(g_usbSerial);
+	if (s_dynWantWakeMouse)
+		wakeHidAddInterface(); // HID instance 0
+	g_active->mountSlots(k); // mode's fixed HIDs (if any) + k slot interfaces
+	if (s_dynWantWebusb)
+		USBDevice.addInterface(usb_web);
+	USBDevice.setConfigurationAttribute(0x80 | 0x20);
+	USBDevice.attach();
+}
+
 void setup()
 {
 	genSerial();
 	ledInit();
 
-	// per-device unique RF session address (advertised in the host frame; isolates pucks)
-	rfGenSessionAddr();
+	// seed defaults so unbonded slots don't share the discovery address
+	for (int s = 0; s < NSLOT; s++)
+		rfGenSessionAddr(s);
 	InternalFS.begin();
 #if OPK_FACTORY_RESET
 	// Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
@@ -56,6 +91,11 @@ void setup()
 	factoryResetOnce(OPK_GIT_HASH);
 #endif
 	loadCfg();
+	loadBonds();
+	// regenerate per-slot session addresses from each bond UUID (deterministic, stable across reboots)
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used)
+			rfGenSessionAddr(s);
 
 	// decide USB presentation BEFORE registering interfaces
 	g_xbox = !modeIsPuck(g_usbMode);
@@ -75,57 +115,79 @@ void setup()
 	// clean-PS modes skip BOTH the wake mouse and WebUSB -- no config panel / host-wake; chord back to Steam
 	// (back-paddle 4 + A) to reach the panel. Normal MODE_PS5 / MODE_HIDGYRO keep wake + panel.
 	const bool psClean = modeIsCleanPS(g_usbMode);
-	USBDevice.detach();
-	delay(30);
-	if (keepCdc) {
+	const bool dynamic = g_active->dynamicMount();
+
+	if (dynamic) {
+		// Dynamic mount: present only ACTIVELY-CONNECTED controllers; usbReenumerate re-attaches (no reboot)
+		// as the set changes. Emulated modes are never puck; clean-PS drops the wake mouse + WebUSB.
+		s_dynWantWakeMouse = !psClean;
+		s_dynWantWebusb = !psClean;
+		USBDevice.detach();
+		delay(30);
+		USBDevice.clearConfiguration();
 		USBDevice.setConfigurationBuffer(g_usbCfgDesc,
 						 sizeof g_usbCfgDesc);
+		// Lock TinyUSB HID instance indices ONCE, canonical order: wake mouse (HID 0) then the mode's pool.
+		if (s_dynWantWakeMouse)
+			wakeHidBegin();
+		g_active->beginPool();
+		if (s_dynWantWebusb)
+			usb_web.begin();
+		usbMountEnable(true, g_active->maxSlots());
+		usbMountRebuildMap(); // initial connected set (usually empty at cold boot)
+		usbReenumerate(g_usbMountCount); // build the live descriptor + attach
 	} else {
-		USBDevice.clearConfiguration();
+		USBDevice.detach();
+		delay(30);
+		if (keepCdc) {
+			USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+							 sizeof g_usbCfgDesc);
+		} else {
+			USBDevice.clearConfiguration();
 
-		// headroom over the default 256 B cap
-		USBDevice.setConfigurationBuffer(g_usbCfgDesc,
-						 sizeof g_usbCfgDesc);
-	}
+			// headroom over the default 256 B cap
+			USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+							 sizeof g_usbCfgDesc);
+		}
 
-	// Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
-	// identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
-	// identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
-	static const char MODE_SUFFIX[] = {
-		'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D'
-	}; // modes 1..8 (Q=PS5 game, D=DS4 game)
-	if (puckMode) {
-		USBDevice.setSerialDescriptor(g_unit);
-	} else {
-		snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit,
-			 MODE_SUFFIX[g_usbMode - 1]);
-		USBDevice.setSerialDescriptor(g_usbSerial);
-	}
+		// Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
+		// identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
+		// identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
+		if (puckMode) {
+			USBDevice.setSerialDescriptor(g_unit);
+		} else {
+			// Bond count in serial so Windows invalidates its cached config descriptor if the count changes.
+			snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%d",
+				 g_unit, MODE_SUFFIX[g_usbMode - 1],
+				 bondedSlotCount() > 0 ? bondedSlotCount() : 1);
+			USBDevice.setSerialDescriptor(g_usbSerial);
+		}
 
-	// SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
-	// and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
-	if (puckMode)
-		usb_web.begin();
-	if (puckMode && !keepCdc)
-		wakeHidBegin();
-
-	g_active->begin();
-
-	// Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
-	// no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
-	// modes so the device stays a single clean HID gamepad (see psClean above).
-	if (!puckMode && !keepCdc && !psClean)
-		wakeHidBegin();
-
-	// WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
-	// slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
-	if (!puckMode && !psClean)
-		usb_web.begin();
-	// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
-	USBDevice.setConfigurationAttribute(0x80 | 0x20);
+		// SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
+		// and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
+		if (puckMode)
+			usb_web.begin();
+		if (puckMode && !keepCdc)
+			wakeHidBegin();
 
-	// re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
-	USBDevice.attach();
+		g_active->begin();
+
+		// Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
+		// no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
+		// modes so the device stays a single clean HID gamepad (see psClean above).
+		if (!puckMode && !keepCdc && !psClean)
+			wakeHidBegin();
+
+		// WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
+		// slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
+		if (!puckMode && !psClean)
+			usb_web.begin();
+		// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
+		USBDevice.setConfigurationAttribute(0x80 | 0x20);
+
+		// re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
+		USBDevice.attach();
+	}
 	Serial.begin(115200);
 	for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
 		delay(10); // wait up to 3s for USB mount, but NEVER hang
@@ -133,7 +195,6 @@ void setup()
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
-	loadBonds();
 	hapticInit();
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
@@ -152,9 +213,21 @@ void setup()
 				"DEBUG boot (CDC console on, wake mouse off; reverts next boot)" :
 				"normal (CDC off, wake mouse on)");
 	Serial.printf(
-		"# session addr %02X%02X%02X%02X/%02X ch%u (discovery on ibex/ch2)\n",
-		g_sessBase[0], g_sessBase[1], g_sessBase[2], g_sessBase[3],
-		g_sessPrefix, g_sessCh);
+		"# session ch%u (discovery on ibex/ch2); per-slot session addrs:\n",
+		g_sessCh);
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used) {
+			Serial.printf(
+				"#   slot %d: %02X%02X%02X%02X/%02X (uuid %02X%02X%02X%02X %02X%02X%02X%02X)\n",
+				s, g_sessBase[s][0], g_sessBase[s][1],
+				g_sessBase[s][2], g_sessBase[s][3],
+				g_sessPrefix[s], g_slot[s].rec[0],
+				g_slot[s].rec[1], g_slot[s].rec[2],
+				g_slot[s].rec[3], g_slot[s].rec[4],
+				g_slot[s].rec[5], g_slot[s].rec[6],
+				g_slot[s].rec[7]);
+		}
+	}
 	// Hardware watchdog: if loop() ever stops feeding it (wedged radio busy-wait, HardFault spin, blocked CDC
 	// write) the WDT resets the nRF52 after ~8s, re-enumerating USB + re-initialising RF, so a hang no longer
 	// needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.
@@ -211,6 +284,7 @@ void loop()
 	t = micros();
 	ledTask();
 	acc[6] += (uint32_t)(micros() - t);
+	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	loops++;
 	if (millis() - secMs >= 1000) {
 		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
@@ -237,5 +311,6 @@ void loop()
 	rfLinkTask();
 	hapticTask();
 	ledTask();
+	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 #endif
 }

+ 10 - 2
OpenPuck/bonds.cpp

@@ -4,8 +4,7 @@
 using namespace Adafruit_LittleFS_Namespace;
 
 Slot g_slot[NSLOT];
-int g_connSlot = -1;
-unsigned long g_connReplyMs = 0;
+unsigned long g_connReplyMs[NSLOT] = { 0 };
 volatile bool g_dirty = false;
 bool g_pairing = false;
 
@@ -46,3 +45,12 @@ void loadBonds()
 		}
 	f.close();
 }
+
+int bondedSlotCount()
+{
+	int n = 0;
+	for (int i = 0; i < NSLOT; i++)
+		if (g_slot[i].used)
+			n++;
+	return n;
+}

+ 8 - 6
OpenPuck/bonds.h

@@ -3,8 +3,8 @@
 // The real puck holds four controller bonds and exposes one HID control interface per slot (interface N owns
 // slot N). We mirror that: g_slot[N] holds the bond record [8 uuid][16 serial] plus a staging buffer for that
 // interface's pending feature-report reply (filled by puck_hid.cpp's command channel). Bonds persist to
-// flash (bonds.bin). g_connSlot / g_connReplyMs are the live RF link state, shared here so the USB feature
-// handler can report per-slot connection status without depending on the RF layer.
+// flash (bonds.bin). g_connReplyMs (per-slot, rf_link sets the active one) is the live RF link state, shared
+// here so the USB feature handler can report per-slot connection status without depending on the RF layer.
 #pragma once
 #include <stdint.h>
 
@@ -19,13 +19,15 @@ struct Slot {
 };
 extern Slot g_slot[NSLOT];
 
-// bonded slot being polled (== the USB-connected slot); -1 = none
-extern int g_connSlot;
-extern unsigned long
-	g_connReplyMs; // millis of last RF reply (link-alive timestamp)
+// Per-slot link state. g_connReplyMs[i] is the millis() of the last F-type reply on slot i (the
+// "controller is alive" timestamp). g_linkRssi (rf_link.h) is also per-slot. Steam reads each slot's
+// B4/0x79/0x7B against its own entry here, so the four interfaces present four independent controllers
+// (each marked connected on its own).
+extern unsigned long g_connReplyMs[NSLOT];
 extern volatile bool g_dirty; // bonds changed -> flush to flash from loop()
 extern bool g_pairing;
 
 bool recEmpty(const uint8_t *r);
 void loadBonds();
 void saveBonds();
+int bondedSlotCount();

+ 49 - 16
OpenPuck/config.cpp

@@ -18,12 +18,44 @@ bool g_debugCdcThisBoot = false;
 static uint8_t g_debugCdc = 0;
 
 int g_mDiv = 64, g_mFric = 94;
+
+// Per-type button config. back default {5,6,7,8} = L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad,
+// 16/17 PS touch/mute, 18 Switch Capture). Switch differs: QAM defaults to Capture(18), A/B swap on, and
+// trackpad haptics off. qamMap 0 = unmapped (hardcoded per-mode behavior).
+TypeCfg g_type[ET_COUNT] = {
+	/* ET_XBOX   */ { { 5, 6, 7, 8 }, 0, 0, 1 },
+	/* ET_SWITCH */ { { 5, 6, 7, 8 }, 18, 1, 0 },
+	/* ET_DS4    */ { { 5, 6, 7, 8 }, 0, 0, 1 },
+	/* ET_DS5    */ { { 5, 6, 7, 8 }, 0, 0, 1 },
+};
+uint8_t g_etype = ET_NONE;
+
+// Live mirrors of the active type (puck modes use the harmless defaults below).
 uint8_t g_abSwap = 0;
-uint8_t g_back[4] = {
-	5, 6, 7, 8
-}; // L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad U/D/L/R)
-// 0 = default (unmapped, uses hardcoded behavior per mode)
+uint8_t g_back[4] = { 5, 6, 7, 8 };
 uint8_t g_qamMap = 0;
+uint8_t g_padHaptics = 1;
+
+void applyActiveType()
+{
+	g_etype = etypeForMode(g_usbMode);
+	if (g_etype >= ET_COUNT) { // puck mode (Steam/Lizard): no remap, haptics on
+		g_back[0] = 5;
+		g_back[1] = 6;
+		g_back[2] = 7;
+		g_back[3] = 8;
+		g_qamMap = 0;
+		g_abSwap = 0;
+		g_padHaptics = 1;
+		return;
+	}
+	const TypeCfg &t = g_type[g_etype];
+	for (int i = 0; i < 4; i++)
+		g_back[i] = t.back[i];
+	g_qamMap = t.qamMap;
+	g_abSwap = t.abSwap;
+	g_padHaptics = t.padHaptics;
+}
 // rumble strength % (200 = double); adjustable from the WebUSB panel
 uint8_t g_rumbleScale = 200;
 
@@ -32,11 +64,12 @@ uint8_t g_rumbleScale = 200;
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-// bumped (rumbleScale): old cfg ignored -> clean defaults on first boot
-#define CFG_MAGIC 0xC9
+// bumped (per-type TypeCfg blob replaces scalar abSwap/back/qamMap): old cfg ignored -> clean defaults on first boot
+#define CFG_MAGIC 0xCA
 struct Cfg {
-	uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100,
-		persistMode, bootMode, chordBtn[3], qamMap, rumbleScale;
+	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode, bootMode,
+		chordBtn[3], rumbleScale;
+	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 void saveCfg()
@@ -46,14 +79,14 @@ void saveCfg()
 		  (uint8_t)g_mDiv,
 		  (uint8_t)g_mFric,
 		  g_debugCdc,
-		  g_abSwap,
-		  { g_back[0], g_back[1], g_back[2], g_back[3] },
 		  (uint8_t)(g_pollUs / 100),
 		  (uint8_t)(g_persistMode ? 1 : 0),
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
-		  g_qamMap,
-		  g_rumbleScale };
+		  g_rumbleScale,
+		  {} };
+	for (int i = 0; i < ET_COUNT; i++)
+		c.type[i] = g_type[i];
 	InternalFS.remove(CFG_FILE);
 	File f(InternalFS);
 	if (f.open(CFG_FILE, FILE_O_WRITE)) {
@@ -72,9 +105,8 @@ void loadCfg()
 		    c.magic == CFG_MAGIC) {
 			g_mDiv = c.mDiv ? c.mDiv : 64;
 			g_mFric = c.mFric;
-			g_abSwap = c.abSwap;
-			for (int i = 0; i < 4; i++)
-				g_back[i] = c.back[i];
+			for (int i = 0; i < ET_COUNT; i++)
+				g_type[i] = c.type[i];
 			g_persistMode = c.persistMode ? true : false;
 			// one-shot debug-CDC (Cfg.rsvd0): honor for THIS boot, then consume so the next boot reverts to normal.
 			g_debugCdcThisBoot = c.rsvd0 ? true : false;
@@ -103,13 +135,14 @@ void loadCfg()
 				g_chordBtn[i] = modeValid(c.chordBtn[i]) ?
 							c.chordBtn[i] :
 							CHORD_DEF[i];
-			g_qamMap = c.qamMap;
 
 			// 0 is a valid setting (rumble off)
 			g_rumbleScale = c.rumbleScale;
 		}
 		f.close();
 	}
+	// resolve the active emulated type's settings into the live mirrors the mode builders read
+	applyActiveType();
 	// clear the one-shot so the NEXT cold boot reverts to the default/persist policy
 	if (consume) {
 		g_bootMode = 0xFF;

+ 53 - 3
OpenPuck/config.h

@@ -56,6 +56,35 @@ static inline bool modeValid(uint8_t m)
 	return m <= MODE_MAX;
 }
 
+// ---- Emulated controller TYPES. Button-remap config (back paddles / QAM / A-B swap / trackpad haptics) is kept
+//      per-type, not per-mode: the right mapping depends on what controller we present, and the variant modes of
+//      one controller share it. Steam + Lizard are native puck presentations -- they forward raw input to Steam,
+//      which does its own remapping -- so they are NOT emulated types (ET_NONE) and ignore this config. ----
+#define ET_XBOX 0
+#define ET_SWITCH 1
+#define ET_DS4 2
+#define ET_DS5 3
+#define ET_COUNT 4
+#define ET_NONE 0xFF
+static inline uint8_t etypeForMode(uint8_t m)
+{
+	switch (m) {
+	case MODE_XBOX:
+		return ET_XBOX;
+	case MODE_SW_HORI:
+	case MODE_SW_PRO:
+		return ET_SWITCH;
+	case MODE_HIDGYRO:
+	case MODE_DS4_GAME:
+		return ET_DS4;
+	case MODE_PS5:
+	case MODE_PS5_GAME:
+		return ET_DS5;
+	default:
+		return ET_NONE; // Steam / Lizard
+	}
+}
+
 extern uint8_t g_usbMode; // loaded from flash at boot
 
 // true for all non-puck presentations (cached !modeIsPuck(g_usbMode))
@@ -79,12 +108,29 @@ void armDebugCdcNextBoot(); // persist the one-shot (caller reboots)
 
 // persisted, runtime-tunable config:
 extern int g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
-extern uint8_t g_abSwap; // 1 = swap A/B and X/Y (Nintendo face-button layout)
 
-// back paddles L4,R4,L5,R5 -> button codes (0..15 standard, 16=PS Touch Click, 17=PS5 Mute)
+// Per-emulated-type button config. One entry per ET_* type. back[] = paddle L4,R4,L5,R5 -> button codes
+// (0..15 standard, 16=PS Touch Click, 17=PS5 Mute, 18=Switch Capture/Screenshot). qamMap = QAM (3 dots)
+// physical button -> same code space (0 = default/unmapped). abSwap = swap A/B and X/Y (Nintendo layout).
+// padHaptics = 1 keeps the controller's autonomous trackpad haptics, 0 disables them for this type.
+struct TypeCfg {
+	uint8_t back[4];
+	uint8_t qamMap;
+	uint8_t abSwap;
+	uint8_t padHaptics;
+};
+extern TypeCfg g_type[ET_COUNT];
+extern uint8_t g_etype; // etypeForMode(g_usbMode), resolved at boot (ET_NONE for puck modes)
+
+// Live, resolved-active-type mirrors of g_type[g_etype], read by the hot-path mode builders. Refreshed by
+// applyActiveType() at boot and after any edit to the active type.
+extern uint8_t g_abSwap; // 1 = swap A/B and X/Y (Nintendo face-button layout)
 extern uint8_t g_back[4];
-// QAM (3 dots) physical button -> same code space (0 = default/unmapped)
 extern uint8_t g_qamMap;
+extern uint8_t g_padHaptics; // 1 = trackpad haptics on (default), 0 = disabled for the active type
+
+// Copy g_type[g_etype] into the live mirrors above (safe defaults when g_etype == ET_NONE).
+void applyActiveType();
 // rumble strength, percent of decoded amplitude (100 = 1x, 200 = 2x default), all modes
 extern uint8_t g_rumbleScale;
 // Switch Pro motion settings. Persisted in their OWN flash file (mode_switch_pro.cpp), NOT in Cfg -- so changing
@@ -101,6 +147,10 @@ void swProSaveCfg();
 #define POLL_US_DEFAULT 4000u
 // host-side HID stream cadence for translated modes (~250 Hz)
 #define USB_STREAM_MS 4u
+// How long a USB suspend must PERSIST before we power the controllers off. A brief selective-suspend
+// (host idle power-management) resumes in <1s and must not trigger a self-inflicted power-off -> the
+// resulting disconnect/reconnect churn looked like random controller drops. Real host sleep persists.
+#define SUSPEND_OFF_MS 4000u
 // RF poll cadence (us). FIXED -- not configurable (see loadCfg).
 extern const uint32_t g_pollUs;
 

+ 40 - 5
OpenPuck/controllers.h

@@ -5,9 +5,11 @@
 //   begin()       -- called once from setup(): set the USB VID/PID/strings and register this mode's
 //                    interface(s). Shared USB lifecycle (detach, clear/keep CDC, serial suffix, WebUSB,
 //                    attach) stays in setup(); begin() only adds what's specific to this controller.
-//   onReport45()  -- called from rf_link.cpp each time a controller input report 0x45 is decoded. PUSH-style
-//                    modes (Xbox, puck/lizard) build + send their host report here. STREAM-style modes ignore
-//                    it and emit at a fixed cadence from task(); they read the decoded g_in directly.
+//   onReport45()  -- called from rf_link.cpp each time a controller input report 0x45 is decoded. The `slot`
+//                    argument is the bond slot the report arrived on (0..NSLOT-1, or -1 for the pre-bond
+//                    catch-all). PUSH-style modes (Xbox, puck/lizard) build + send their host report here;
+//                    the puck/lizard mode uses `slot` to fan out to hid[slot]. STREAM-style modes ignore
+//                    `slot`; STREAM-style modes emit at a fixed cadence from task() reading g_in[s].
 //   task()        -- called every loop(): streaming emit, handshake/subcommand draining, mode-specific upkeep.
 //   isPuck()      -- true for Steam/Lizard (keep the boot CDC composite; different USB lifecycle in setup()).
 //
@@ -22,9 +24,10 @@ class IController {
 	{
 	}
 	virtual void begin() = 0;
-	virtual void onReport45(const uint8_t *rep, bool fresh,
+	virtual void onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)
 	{
+		(void)slot;
 		(void)rep;
 		(void)fresh;
 		(void)bodyTlen;
@@ -32,8 +35,10 @@ class IController {
 	// Other controller->host input reports decoded from the F1 reply (NOT 0x45): power/battery status report
 	// 0x43, status event 0x44. The real puck forwards these to Steam verbatim -- that's how Steam reads battery.
 	// rid = report id, data/n = body after the id. Default no-op (clean modes don't expose these reports).
-	virtual void onAuxReport(uint8_t rid, const uint8_t *data, uint8_t n)
+	virtual void onAuxReport(int slot, uint8_t rid, const uint8_t *data,
+				 uint8_t n)
 	{
+		(void)slot;
 		(void)rid;
 		(void)data;
 		(void)n;
@@ -50,6 +55,36 @@ class IController {
 	{
 		return false;
 	}
+
+	// ---- Dynamic USB mounting (see usb_mount.h). Emulated modes that present only CONNECTED controllers
+	//      override these; the defaults keep the legacy "begin() mounts everything" behavior for puck/lizard
+	//      and any not-yet-converted mode. ----
+	// true => this mode uses the dynamic mount/unmount path (usbReenumerate driven by the connectivity watcher)
+	// rather than registering its slot interfaces in begin(). maxSlots() then caps the mounted count.
+	virtual bool dynamicMount() const
+	{
+		return false;
+	}
+	// HID-instance budget for slot interfaces (CFG_TUD_HID minus any fixed HID like the wake mouse).
+	virtual uint8_t maxSlots() const
+	{
+		return 0;
+	}
+	// Re-apply this mode's USB identity (VID/PID, manufacturer/product strings, bcdDevice). Called by
+	// usbReenumerate after clearConfiguration (which resets them). begin() also calls it.
+	virtual void usbIdentity()
+	{
+	}
+	// One-time at boot: create the slot interface/HID object pool and lock their TinyUSB instance indices
+	// (begin() each). Adds them to the throwaway descriptor; usbReenumerate then presents the live prefix.
+	virtual void beginPool()
+	{
+	}
+	// (Re)add the first k slot interfaces (a dense prefix of the pool) to the freshly-cleared config descriptor.
+	virtual void mountSlots(uint8_t k)
+	{
+		(void)k;
+	}
 };
 
 // The singleton for a given mode (nullptr if the mode is unknown). Defined in controllers.cpp.

+ 31 - 4
OpenPuck/gamepad_util.cpp

@@ -2,6 +2,26 @@
 #include "triton.h"
 #include "config.h"
 
+void psNeutralCalib(uint8_t *buf)
+{
+	// Payload offsets = kernel buf[] index minus 1. buf[0..5] gyro bias stays zero (caller memset). Symmetric
+	// ranges -> non-zero divisors on the host.
+	le16(buf + 6, 2844);
+	le16(buf + 8, -2844); // gyro pitch +/-
+	le16(buf + 10, 2844);
+	le16(buf + 12, -2844); // gyro yaw +/-
+	le16(buf + 14, 2844);
+	le16(buf + 16, -2844); // gyro roll +/-
+	le16(buf + 18, 2844);
+	le16(buf + 20, 2844); // gyro speed +/- (sum != 0)
+	le16(buf + 22, 8192);
+	le16(buf + 24, -8192); // accel X +/-
+	le16(buf + 26, 8192);
+	le16(buf + 28, -8192); // accel Y +/-
+	le16(buf + 30, 8192);
+	le16(buf + 32, -8192); // accel Z +/-
+}
+
 uint8_t swStick(int16_t v, bool invert)
 {
 	int32_t a = 0x80 + (invert ? -((int32_t)v >> 8) : ((int32_t)v >> 8));
@@ -138,6 +158,12 @@ static void psOrBackCode(uint32_t *b, uint8_t c)
 	case 17:
 		*b |= TB_MUTE;
 		break;
+	case 19:
+		*b |= TB_L2; // left trigger (L2)
+		break;
+	case 20:
+		*b |= TB_R2; // right trigger (R2)
+		break;
 	default:
 		break;
 	}
@@ -161,12 +187,13 @@ uint32_t psButtonsFromSteam(uint32_t raw)
 		psOrBackCode(&b, g_back[3]);
 	return b;
 }
-// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start)
-uint8_t psShouldersByte(uint32_t b)
+// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start). The analog trigger values come from
+// the per-slot `g_in[slot]` (pass lt/rt explicitly so a single shoulder byte never leaks across slots).
+uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt)
 {
 	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
-	       ((g_in.lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
-	       ((g_in.rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
+	       ((lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
+	       ((rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
 	       ((b & TB_MENU) ? 0x10 : 0) |
 	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
 	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);

+ 21 - 1
OpenPuck/gamepad_util.h

@@ -11,6 +11,20 @@
 // int16 stick (center 0) -> uint8 (center 0x80), optional axis invert (HID Y is down-positive).
 uint8_t swStick(int16_t v, bool invert);
 
+// Write a signed 16-bit value little-endian.
+static inline void le16(uint8_t *p, int16_t v)
+{
+	p[0] = (uint8_t)(v & 0xFF);
+	p[1] = (uint8_t)((v >> 8) & 0xFF);
+}
+
+// Fill a DS4/DualSense motion-calibration feature report PAYLOAD. The TinyUSB GET_REPORT path already wrote the
+// report id and hands us the buffer PAST it, so our offsets = the kernel's buf[] index minus one (bias at 0..5,
+// stays zero from the caller's memset). Zero bias + symmetric non-zero ranges so hid-playstation's/hid-sony's
+// divisor (|plus-bias|+|minus-bias|, and gyro speed_plus+speed_minus) is never zero. Shared by report 0x02 (DS4)
+// and 0x05 (DualSense) -- identical layout; the caller returns size-1 and leaves trailing bytes zero.
+void psNeutralCalib(uint8_t *buf);
+
 // Steam trackpad s16 coords -> absolute touch surface. TOUCH_PAD_W is split into left/right halves so both
 // pads can co-exist as two contacts on a single DualSense/DS4 touchpad.
 #define TOUCH_PAD_W 1920u
@@ -64,6 +78,10 @@ static inline uint32_t tritonFromCode(uint8_t c)
 		return TB_TOUCH;
 	case 17:
 		return TB_MUTE;
+	case 19:
+		return TB_L2; // left trigger (LT / L2 / ZL)
+	case 20:
+		return TB_R2; // right trigger (RT / R2 / ZR)
 	default:
 		return 0;
 	}
@@ -72,6 +90,8 @@ static inline uint32_t tritonFromCode(uint8_t c)
 // PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
 uint32_t psButtonsFromSteam(
 	uint32_t raw); // apply back-paddle + chord-guard + QAM remapping
-uint8_t psShouldersByte(uint32_t b); // L1..R3 + Create/Options byte
+// L1..R3 + Create/Options byte. lt/rt are the per-slot analog trigger values so the digital ZL/ZR trip
+// doesn't leak across slots when several slots are active.
+uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt);
 uint8_t psHatNibble(uint32_t b); // d-pad -> 8-way hat (8 = neutral)
 uint8_t psFaceNibble(uint32_t b); // face buttons (with A/B + X/Y swap)

+ 211 - 165
OpenPuck/haptics.cpp

@@ -14,8 +14,8 @@ uint8_t g_relayOp = 0xE3; // E3 poll
 uint8_t g_relaySub = 0x05;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 0;
-unsigned long g_hapticBlockUntil =
-	0; // drop Steam haptics briefly during reconnect settle
+// Per-slot reconnect block. 0 = idle; non-zero = drop haptics aimed at this slot until millis() catches up.
+unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 
 // Controller power-off. CONFIRMED from a real Windows USB capture of the Valve puck: Steam's "turn off
 // controller" is the single feature-0x01 command 0x9F with payload ASCII "off!" (6F 66 66 21). The dongle
@@ -25,8 +25,11 @@ unsigned long g_hapticBlockUntil =
 void hapticSendShutdown()
 {
 	static const uint8_t OFF[4] = { 0x6f, 0x66, 0x66, 0x21 }; // "off!"
+	// broadcast: every connected controller should power off, not just the slot the shutdown was triggered
+	// from (the trigger can come from a Steam interface, the test button, or host-suspend -- any of which
+	// "logically" means "all controllers off").
 	for (uint8_t i = 0; i < HAPTIC_SHUTDOWN_SHOTS; i++)
-		relayEnqueue(0x9F, OFF, sizeof OFF);
+		relayEnqueue(0x9F, OFF, sizeof OFF, 0xFF);
 }
 
 // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
@@ -35,11 +38,12 @@ static unsigned long g_haptic82Ms = 0;
 // a non-zero 0x82 haptic is currently active (awaiting host stop)
 static bool g_haptic82On = false;
 
-// millis of last translated host rumble (0x80)
-static unsigned long g_rumble80Ms = 0;
+// millis of last translated host rumble (0x80), per-slot (4 XInput interfaces each have their own stream)
+static unsigned long g_rumble80Ms[NSLOT] = { 0 };
 
-// Steam/Triton rumble is latched on until an explicit zero report
-static bool g_rumble80On = false;
+// Steam/Triton rumble is latched on until an explicit zero report; tracked per-slot so each controller's
+// stuck-rumble watchdog is independent
+static bool g_rumble80On[NSLOT] = { false, false, false, false };
 
 // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
 static unsigned long g_reinitAt = 0;
@@ -51,19 +55,18 @@ static uint8_t g_reinitLeft = 0;
 // latch that engaged during/after use, even seconds after connect)
 static bool g_hapClearArmed = false;
 
-// ---- relay ring: multi-producer (USB ISR + loop-context console/xinput), single consumer (poll flush) ----
-// Producers serialize through a brief PRIMASK critical section (copy is <=62 bytes); the consumer only ever
-// touches the tail entry, which no producer writes while the ring isn't full -- so a flush can't be torn.
+// ---- relay rings: one per bond slot. Multi-producer (USB ISR + loop-context console/xinput), one consumer
+// per slot (rfConnFlushRelay on that slot's poll turn). Producers serialize under PRIMASK.
 struct RelayMsg {
 	uint8_t rid, len;
 	uint8_t data[RELAY_MAXP];
 };
 // deep enough to hold a full Steam settings/LED transaction burst without loss
 #define RELAY_QLEN 32
-static RelayMsg g_rq[RELAY_QLEN];
+static RelayMsg g_rq[NSLOT][RELAY_QLEN];
+static volatile uint8_t g_rqHead[NSLOT];
 static volatile uint8_t
-	g_rqHead = 0,
-	g_rqTail = 0; // head=next write, tail=next read; empty when equal
+	g_rqTail[NSLOT]; // head=next write, tail=next read; empty when equal
 static inline uint8_t rqNext(uint8_t i)
 {
 	return (uint8_t)((i + 1) % RELAY_QLEN);
@@ -71,28 +74,35 @@ static inline uint8_t rqNext(uint8_t i)
 
 bool relayPending()
 {
-	return g_rqHead != g_rqTail;
+	// check the current slot's queue; called from rfConnQueueHapticRelay which runs with g_curSlot set
+	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	return g_rqHead[cur] != g_rqTail[cur];
 }
-bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen)
+bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
+		  uint8_t slot)
 {
-	// 60B is the RF frame ceiling; longer can't be relayed
 	if (plen > RELAY_MAXP)
 		plen = RELAY_MAXP;
+	if (slot != 0xFF && slot >= NSLOT)
+		return false;
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
-	uint8_t h = g_rqHead, nx = rqNext(h);
-	// Full -> evict the OLDEST entry, never the newest. Steam sends commands as bursts where the meaningful
-	// frame comes LAST (a settings transaction ends with its commit; a haptic stream ends with its stop), so
-	// dropping the oldest guarantees the most recent command (LED commit, haptic stop) always lands.
-	if (nx == g_rqTail)
-		g_rqTail = rqNext(g_rqTail);
-	g_rq[h].rid = rid;
-	g_rq[h].len = plen;
-	if (plen)
-		memcpy(g_rq[h].data, payload, plen);
-	g_rqHead = nx;
-	// Any haptic relay (Steam OR Xbox rumble OR test) arms the idle-clear and refreshes its timer -- so the
-	// during-use buzz gets cleared in EVERY USB mode. The re-init's own 0x81/0x87 deliberately don't match.
+	// slot=0xFF: broadcast -- enqueue into every slot's ring.
+	// Full queue: evict the oldest entry, never the newest. Steam bursts end with the commit/stop, so
+	// dropping the oldest keeps the most-recent (meaningful) frame.
+	uint8_t s0 = (slot == 0xFF) ? 0 : slot;
+	uint8_t s1 = (slot == 0xFF) ? NSLOT : slot + 1;
+	for (uint8_t s = s0; s < s1; s++) {
+		uint8_t h = g_rqHead[s], nx = rqNext(h);
+		if (nx == g_rqTail[s])
+			g_rqTail[s] = rqNext(g_rqTail[s]);
+		g_rq[s][h].rid = rid;
+		g_rq[s][h].len = plen;
+		if (plen)
+			memcpy(g_rq[s][h].data, payload, plen);
+		g_rqHead[s] = nx;
+	}
+	// Any haptic relay arms the idle-clear (so the during-use latch gets cleared in every mode).
 	if (rid == 0x82 || rid == 0x80) {
 		g_haptic82Ms = millis();
 		g_hapClearArmed = true;
@@ -138,8 +148,8 @@ void hapticDumpLog()
 {
 	const uint16_t N = HAPLOG_N;
 	uint32_t now = millis();
-	Serial.printf("# --- capture history (now=%lu, connSlot=%d) ---\n",
-		      (unsigned long)now, g_connSlot);
+	Serial.printf("# --- capture history (now=%lu, curSlot=%d) ---\n",
+		      (unsigned long)now, g_curSlot);
 	for (uint16_t i = 0; i < N; i++) {
 		HapLog &e = g_hapLog[(uint16_t)((g_hapHead + i) % N)];
 		if (!e.ms && !e.rid)
@@ -182,19 +192,28 @@ bool hapLogPull(uint32_t *logMs, uint8_t *slot, uint8_t *rid, uint8_t *n,
 }
 #endif // OPK_LOG
 
-bool hapticLinkUp()
+// Per-slot helpers. slot==-1 (default) checks the CURRENT poll slot (g_curSlot), used by flush-time code
+// paths that don't have a slot in hand. Callers with a real slot pass it in.
+bool hapticLinkUp(int slot)
 {
-	return g_connSlot >= 0 && (millis() - g_connReplyMs) < 300;
+	int s = (slot >= 0) ? slot : g_curSlot;
+	if (s < 0 || s >= NSLOT)
+		return false;
+	return g_slot[s].used && (millis() - g_connReplyMs[s]) < 300;
 }
-bool haptic82Blocked()
+bool haptic82Blocked(int slot)
 {
-	return !hapticLinkUp() ||
-	       (g_hapticBlockUntil &&
-		(int32_t)(millis() - g_hapticBlockUntil) < 0);
+	int s = (slot >= 0) ? slot : g_curSlot;
+	if (s < 0 || s >= NSLOT)
+		return true;
+	return !hapticLinkUp(s) ||
+	       (g_hapticBlockUntil[s] &&
+		(int32_t)(millis() - g_hapticBlockUntil[s]) < 0);
 }
+// "Is haptics from this USB interface's slot allowed through?" -- the slot must be currently connected.
 bool hapticRelaySlotOk(int slot)
 {
-	return g_connSlot >= 0 && slot == g_connSlot;
+	return slot >= 0 && slot < NSLOT && hapticLinkUp(slot);
 }
 static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
 {
@@ -208,30 +227,32 @@ static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
 }
 static void hapticCancelPendingOn()
 {
-	// void queued ON entries (stale Steam haptics / translated rumble across a reconnect)
+	// void queued ON entries across all slot queues (stale haptics / rumble across a reconnect)
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
-	for (uint8_t i = g_rqTail; i != g_rqHead; i = rqNext(i)) {
-		RelayMsg &m = g_rq[i];
-		if (m.rid == 0x82) {
-			bool on = false;
-			for (uint8_t j = 2; j < m.len; j++)
-				if (m.data[j]) {
-					on = true;
-					break;
-				}
-			if (on)
-				m.rid = 0;
-		}
-		if (m.rid == 0x80) {
-			bool on = false;
-			for (uint8_t j = 0; j < m.len; j++)
-				if (m.data[j]) {
-					on = true;
-					break;
-				}
-			if (on)
-				m.rid = 0;
+	for (int s = 0; s < NSLOT; s++) {
+		for (uint8_t i = g_rqTail[s]; i != g_rqHead[s]; i = rqNext(i)) {
+			RelayMsg &m = g_rq[s][i];
+			if (m.rid == 0x82) {
+				bool on = false;
+				for (uint8_t j = 2; j < m.len; j++)
+					if (m.data[j]) {
+						on = true;
+						break;
+					}
+				if (on)
+					m.rid = 0;
+			}
+			if (m.rid == 0x80) {
+				bool on = false;
+				for (uint8_t j = 0; j < m.len; j++)
+					if (m.data[j]) {
+						on = true;
+						break;
+					}
+				if (on)
+					m.rid = 0;
+			}
 		}
 	}
 	__set_PRIMASK(pm);
@@ -248,8 +269,10 @@ void haptic82HostReport(const uint8_t *p, uint16_t n)
 	// 0x82 is a discrete pad click -- the spurious end-of-movement "click"/buzz the real puck never produces.
 	g_haptic82On = haptic82PayloadOn(p, n);
 }
-bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 {
+	if (slot >= NSLOT)
+		return false;
 	// user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
 	if (g_rumbleScale != 100) {
 		uint32_t l = (uint32_t)lowFreq * g_rumbleScale / 100,
@@ -258,9 +281,12 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 	}
 	bool on = lowFreq || highFreq;
-	if (on && haptic82Blocked())
-		return false; // same settle gate as native Steam haptics
-	if (!on && !hapticLinkUp())
+	// Per-slot settle gate (the per-slot reconnect block + link-up check). 0x82 haptics in Steam mode use the
+	// same gate; for XInput, the host only sends a stream while a controller is connected, so this also doubles
+	// as "no controller here, no relay".
+	if (on && haptic82Blocked(slot))
+		return false;
+	if (!on && !hapticLinkUp(slot))
 		return false;
 
 	// SDL's current Steam/Triton structs define output report 0x80 as:
@@ -279,13 +305,16 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
 	p[6] = (uint8_t)(highFreq & 0xFF);
 	p[7] = (uint8_t)(highFreq >> 8);
 	p[8] = 0;
-	if (!relayEnqueue(0x80, p, sizeof p))
+	if (!relayEnqueue(0x80, p, sizeof p, slot))
 		return false;
-	g_rumble80Ms = millis();
-	g_rumble80On = on;
+	g_rumble80Ms[slot] = millis();
+	g_rumble80On[slot] = on;
 	return true;
 }
-// Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate).
+// Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate). Test
+// haptics broadcast to all connected slots (slot 0xFF); the stop frame is broadcast too (a stuck latch can
+// affect any controller, and the haptic-engine clear-re-init is settings-only so it's harmless on healthy
+// ones).
 void rfConnQueueHapticRelay()
 {
 	if (relayPending())
@@ -293,34 +322,34 @@ void rfConnQueueHapticRelay()
 	static const uint8_t HAP_ON[3] = { 0x01, 0x01, 0xF7 };
 	static const uint8_t HAP_OFF[3] = { 0x01, 0x01, 0x00 };
 	if (g_testHaptic) {
-		if (relayEnqueue(0x82, HAP_ON, 3))
+		if (relayEnqueue(0x82, HAP_ON, 3, 0xFF))
 			g_testHaptic--;
 	} else if (g_hapticStop && !g_xbox) {
-		if (relayEnqueue(0x82, HAP_OFF, 3))
+		if (relayEnqueue(0x82, HAP_OFF, 3, 0xFF))
 			g_hapticStop--;
 	}
 }
+// rfConnFlushRelay(ch, s1): drain one entry from the current slot's relay queue and TX it. Each slot's queue
+// is independent, so each controller only sees its own commands. With N connected slots the per-slot relay
+// rate is 1/N of the per-cycle rate; sustained buzz streams are still 1 packet/cycle/slot.
 void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 {
-	while (g_rqTail != g_rqHead) {
-		RelayMsg &m = g_rq[g_rqTail];
+	int cur = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	while (g_rqTail[cur] != g_rqHead[cur]) {
+		RelayMsg &m = g_rq[cur][g_rqTail[cur]];
 
-		// rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
+		// rid 0 = entry voided by hapticCancelPendingOn -> skip
 		if (m.rid) {
 			uint8_t rl = m.len;
 			if (rl > RELAY_MAXP)
 				rl = RELAY_MAXP;
-			// On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs (puck_sniffer): a command LANDS on
-			// the controller only with the type-01 + inner-len form  E3 [2+rl][01][rid][innerlen][data]; with the legacy
-			// form  E3 [1+rl][05][rid][data]  (no inner-len) the controller DISCARDS any 0x87+ command (it reads data[0]
-			// as the length).
+			// On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs: a command LANDS on
+			// the controller only with the type-01 + inner-len form E3 [2+rl][01][rid][innerlen][data];
+			// the legacy form E3 [1+rl][05][rid][data] makes the controller DISCARD any 0x87+ command.
 			//
-			// WHITELIST the landing form to EXACTLY the two commands that need it; everything else keeps legacy form:
-			//   * LED brightness  -- report 0x87 whose first register byte is 0x2D
-			//   * controller power-off -- report 0x9F ("off!")
-			// Steam ALSO sends other 0x87 passthrough writes during play -- the haptic-config block (reg 0x30 =
-			// IMU/subsystem enable, 0x34/0x35 = haptic amplitude). Landing those regresses BOTH: a landed 0x30 FREEZES
-			// the IMU (gyro stops) and landed 0x34/0x35 = the connect buzz. So keep this list tight.
+			// Whitelist type-01 to only two commands: LED brightness (0x87 reg 0x2D) and power-off
+			// (0x9F). Other 0x87 writes (e.g. reg 0x30 IMU enable, 0x34/0x35 haptic amplitude) must
+			// stay on legacy form -- landing 0x30 freezes the gyro; landing 0x34/0x35 causes the buzz.
 			bool land01 =
 				(m.rid == 0x9F) ||
 				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
@@ -341,32 +370,41 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				memcpy(p + 4, m.data, rl);
 				plen = (uint8_t)(4 + rl);
 			}
-			// log what we actually TX to the controller (slot 0xFE) for the buzz hunt
 			hapLogAdd(0xFE, m.rid, m.data, rl);
-
-			// copied out -> release the slot before the TX
-			g_rqTail = rqNext(g_rqTail);
-			// s1 carries a PID distinct from the GET poll that follows (caller cycles it), so the controller's ESB
-			// dedup never mistakes the GET for a retransmit of this relay. 80us RX window: the relay is NO-ACK, so
-			// don't burn the full ~1.2ms reply window (that halves the poll rate during haptics).
+			// release slot before TX
+			g_rqTail[cur] = rqNext(g_rqTail[cur]);
+			// s1 carries a PID distinct from the GET poll (caller cycles it) so the controller's ESB
+			// dedup never treats the GET as a retransmit of this relay. 80us RX: relay is NO-ACK.
 			rfConnTx(ch, s1, p, plen, 80);
-
-			// ONE relay per poll cycle (matches the real puck's pacing)
-			return;
+			return; // one relay per poll cycle
 		}
-		g_rqTail = rqNext(g_rqTail);
+		g_rqTail[cur] = rqNext(g_rqTail[cur]);
 	}
 }
 
-// Haptic-subsystem RE-INIT: the exact sequence Steam sends (captured on hardware) when it (re)takes control,
-// which clears a stuck haptic script on the controller -- a 0x81 reset action plus 0x87 writes to the haptic
-// registers (30/07/08/31/52, 18/2e/34/35). Replayed to recover from the latched-buzz the controller falls
-// into across a reconnect. Brightness (0x87 reg 2d) is deliberately OMITTED so we don't stomp the LED.
-//
-// The 0x87 frames below go out on LEGACY (type-05) framing (the whitelist lands 0x87 only for brightness
-// 0x2D), so the controller DISCARDS them -- kept verbatim only to match the captured sequence. The effective
-// re-init is the three 0x81 frames. (Landing the 0x30 here would freeze the gyro.)
-void hapticReinit()
+// Haptic-subsystem re-init: the captured sequence Steam sends when it (re)takes control (0x81 reset + 0x87
+// register writes). Brightness (reg 0x2D) is omitted to avoid stomping the LED. The 0x87 frames go out on
+// legacy framing so the controller discards them; the three 0x81 frames are the effective reset. slot=0xFF
+// broadcasts to all connected controllers (the re-init is settings-only and harmless on healthy controllers).
+// One-shot SET_SETTINGS (0x87) write of the controller's GLOBAL trackpad-haptics enable. The controller plays
+// touchpad haptic sequences autonomously (haptics-sequencer-touchpad) gated by settings/haptics/enabled; in the
+// emulated (non-Steam) modes the host never sends SC haptic reports, so the only way to silence the pad buzz is
+// to write this setting. Wire format [id][val u16 LE] -- confirmed from the controller's SET_SETTINGS handler
+// (decomp FUN_0001f61c; id 0x30 == settings/sensors/imu/mode is the cross-check anchor in the same id space).
+// The exact id for settings/haptics/enabled needs a one-time `scmd labels` HW read (candidates from the captured
+// reinit: 0x18 / 0x2e / 0x34 / 0x35). Until it is set, SETTING_HAPTICS_ENABLED stays 0xFF and this is inert.
+#define SETTING_HAPTICS_ENABLED 0xFF
+void hapticSetPadEnabled(uint8_t slot, bool on)
+{
+#if SETTING_HAPTICS_ENABLED != 0xFF
+	uint8_t pl[3] = { SETTING_HAPTICS_ENABLED, (uint8_t)(on ? 1 : 0), 0 };
+	relayEnqueue(0x87, pl, 3, slot);
+#else
+	(void)slot;
+	(void)on;
+#endif
+}
+void hapticReinit(uint8_t slot)
 {
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
 				       0x00, 0x08, 0x07, 0x00, 0x31,
@@ -382,100 +420,108 @@ void hapticReinit()
 		0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
 	};
 	// reset action (FUN_0001f554) -- Steam sends this first
-	relayEnqueue(0x81, nullptr, 0);
-	relayEnqueue(0x87, H30, sizeof H30);
+	relayEnqueue(0x81, nullptr, 0, slot);
+	relayEnqueue(0x87, H30, sizeof H30, slot);
 
 	// haptic config (enabled/amplifier/gain): the part that clears a latch
-	relayEnqueue(0x87, H18, sizeof H18);
-	relayEnqueue(0x87, H35, sizeof H35);
-	relayEnqueue(0x81, T81A, sizeof T81A);
-	relayEnqueue(0x81, T81B, sizeof T81B);
+	relayEnqueue(0x87, H18, sizeof H18, slot);
+	relayEnqueue(0x87, H35, sizeof H35, slot);
+	relayEnqueue(0x81, T81A, sizeof T81A, slot);
+	relayEnqueue(0x81, T81B, sizeof T81B, slot);
+	// Re-apply the active emulated type's trackpad-haptics preference last (after the haptic-config writes
+	// above, which would otherwise re-enable it). Default-on types send "enable"; Switch (padHaptics=0)
+	// disables. Inert until the setting id is captured.
+	hapticSetPadEnabled(slot, g_padHaptics != 0);
 }
 void hapticInit()
 {
-	g_rqHead = g_rqTail = 0;
 	g_haptic82On = false;
-	g_rumble80On = false;
-	// boot: block stale Steam 0x82 until link stable
-	g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;
-	// NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
-	// zero-gain [01 01 00] "stop". An invented stop frame at boot/connect is a likelier cause of the connect-time
-	// buzz than a cure. Be a pure pass-through of Steam's haptics, like the real puck.
 	g_hapticStop = 0;
+	for (int s = 0; s < NSLOT; s++) {
+		g_rqHead[s] = g_rqTail[s] = 0;
+		g_rumble80On[s] = false;
+		g_rumble80Ms[s] = 0;
+		// block stale Steam 0x82 until the link is stable after boot
+		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	}
 }
-// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called from rf_link the moment a
-// controller reply arrives after a gap (the reliable reconnect signal), and as a backup on hapticTask's
-// link-up edge. Idempotent -- safe to call repeatedly.
-void hapticOnReconnect()
+// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called on the reliable first-reply
+// signal from rf_link and again from hapticTask's link-up edge detector. Per-slot: only the reconnected
+// slot is blocked; the broadcast re-init covers all slots.
+void hapticOnReconnect(int slot)
 {
-	// no haptics relayed for the next 3s
-	g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_haptic82On = false;
-	g_rumble80On = false;
-
-	// drop any haptic ON queued before the link came up
+	g_rumble80On[slot] = false;
+	g_rumble80Ms[slot] = 0;
 	hapticCancelPendingOn();
-	// Re-init the haptic engine repeatedly across the settle window rather than waiting for the block to end: the
-	// brief connect buzz engages early (during the block, controller-internal), so a single late shot misses it.
-	// The re-init is settings only (no haptic play) and Steam haptics are blocked throughout, so it can't buzz.
-	g_reinitAt = millis() + 200u; // first reset ~200ms after (re)connect
-
-	// then every HAPTIC_REINIT_GAP_MS across the window
+	// Re-init repeatedly across the settle window: the connect buzz engages early (during the block,
+	// controller-internal), so a single late shot misses it.
+	g_reinitAt = millis() + 200u;
 	g_reinitLeft = HAPTIC_REINIT_SHOTS;
 	uint8_t mk = 2;
-
-	// capture marker: RECONNECT detected (block+reinit armed)
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 }
 void hapticTask()
 {
-	static bool wasHapticLinkUp = false;
-	bool up = hapticLinkUp();
-	// Link-edge markers are diagnostic only -- the block/re-init is armed reliably from rf_link
-	// (hapticOnReconnect) on the first reply after a gap, which fires even when this 300ms edge doesn't.
-	if (up && !wasHapticLinkUp) {
-		uint8_t mk = 1;
-		hapLogAdd(0xFD, 0xEE, &mk, 1);
-		hapticOnReconnect();
-	}
-	if (!up && wasHapticLinkUp) {
-		uint8_t mk = 0;
-		hapLogAdd(0xFD, 0xEE, &mk, 1);
+	// Per-slot link-edge detect (backup for hapticOnReconnect in rf_link).
+	static bool wasHapticLinkUp[NSLOT] = { 0 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_slot[s].used)
+			continue;
+		bool up = hapticLinkUp(s);
+		if (up && !wasHapticLinkUp[s]) {
+			uint8_t mk = 1;
+			hapLogAdd(0xFD, 0xEE, &mk, 1);
+			hapticOnReconnect(s);
+		}
+		if (!up && wasHapticLinkUp[s]) {
+			uint8_t mk = 0;
+			hapLogAdd(0xFD, 0xEE, &mk, 1);
+		}
+		wasHapticLinkUp[s] = up;
 	}
-	wasHapticLinkUp = up;
-	if (g_reinitAt && up &&
-	    (int32_t)(millis() - g_reinitAt) >=
-		    0) { // proactive haptic re-init across the connect window
+	if (g_reinitAt && anySlotLinkUp() &&
+	    (int32_t)(millis() - g_reinitAt) >= 0) {
 		hapticReinit();
 		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
 				     (millis() + HAPTIC_REINIT_GAP_MS) :
 				     0;
 	}
-	// Controller power-off on host SLEEP: send the power-off command (0x9F "off!") the instant the USB bus
-	// suspends, like the real puck. BUT only when USB power (VBUS) is still present -- i.e. a genuine host sleep,
-	// NOT a cable unplug. Pulling the dongle ALSO trips the suspend edge (in the brief window it runs on residual
-	// power), and we must NOT kill the controller then; it should only power off on a shutdown command or a real
-	// host sleep. VBUSDETECT is 1 while the cable still delivers 5V, 0 once unplugged. wasSusp starts true so a
-	// boot-into-suspended state never false-fires.
+	// Power-off on host sleep: only when VBUS is present (genuine sleep, not a cable unplug which also
+	// trips the suspend edge briefly) AND the suspend has PERSISTED >= SUSPEND_OFF_MS. A brief USB
+	// selective-suspend (host idle power-management) resumes in <1s; firing the power-off on its edge
+	// powered the controllers off ourselves -> random drop/reconnect churn. Arm only on a genuine
+	// resume->suspend edge (wasSusp=true at boot suppresses a false fire on boot-into-suspended).
 	static bool wasSusp = true;
+	static unsigned long suspSinceMs = 0;
+	static bool suspArmed = false;
 	bool susp = USBDevice.suspended();
 	bool vbus = (NRF_POWER->USBREGSTATUS &
 		     POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0;
-	if (susp && !wasSusp && vbus)
+	if (susp && !wasSusp) {
+		suspSinceMs = millis();
+		suspArmed = true;
+	}
+	if (!susp)
+		suspArmed = false;
+	if (suspArmed && vbus && (millis() - suspSinceMs) >= SUSPEND_OFF_MS) {
 		hapticSendShutdown();
+		suspArmed = false; // fire once per suspend
+	}
 	wasSusp = susp;
-	// Steam-mode: host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics
-	// are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
-	// click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.
+	// Steam-mode quiet timeout: mark 0x82 stream inactive. No synthesized stop -- Steam forwards its own.
 	if (!g_xbox && g_haptic82On &&
 	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
 		g_haptic82On = false;
-	if (g_rumble80On && millis() - g_rumble80Ms > 2500u)
-		hapticSteamRumble(0, 0);
-	// Haptic activity has gone idle for a while -> fire one re-init to clear any latch it left behind (the buzz
-	// that engages during/after use and won't self-clear, incl. after a mode switch). Fires only after a quiet
-	// gap, so it never interrupts active haptics; the brightness-less re-init is silent (settings, no play, no
-	// LED). Runs in ALL modes (the controller-side latch is mode-independent).
+	// Per-slot stuck-rumble watchdog: force zero after 2.5s without a refresh.
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)
+			hapticSteamRumble(0, 0, (uint8_t)s);
+	}
+	// Idle-clear: after haptic activity goes quiet, re-init once to clear any latch left behind.
 	if (g_hapClearArmed &&
 	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
 		g_hapClearArmed = false;

+ 24 - 17
OpenPuck/haptics.h

@@ -4,11 +4,9 @@
 // a SET sub-TLV inside the E3 poll. We do the same: handleSet()/the console enqueue with relayEnqueue(), and
 // rfConnFlushRelay() emits ONE entry per poll cycle (rf_link.cpp), never at raw loop rate.
 //
-// The queue is a small ring, NOT a single buffer: producers run in USB-ISR context while the consumer may be
-// mid-flush in loop context, and Steam writes settings/calibration as bursts of back-to-back feature reports.
-// A single buffer LOSES reports arriving before the previous flush and can be TORN mid-copy by the ISR.
-// Entries carry up to RELAY_MAXP payload bytes (the RF frame fits 60: [E3][len][05][rid] + payload <= MAXLEN
-// 64); a smaller cap chops multi-register 0x87 settings blocks (LED brightness) and calibration writes.
+// One ring per bond slot: the consumer (rfConnFlushRelay) only drains the current slot's queue, so commands
+// addressed to other slots are never consumed out of turn. ISR producers write under PRIMASK; the consumer
+// runs in loop context and never races the same slot's head pointer.
 //
 // The controller's haptic LATCHES until told to stop. If the host's stop is lost over RF (or the link drops
 // mid-buzz) the actuator whines forever -- so we send 0x82-zero stop bursts on reconnect, but ONLY when a
@@ -18,6 +16,7 @@
 #pragma once
 #include <stdint.h>
 #include "config.h" // OPK_LOG
+#include "bonds.h" // NSLOT
 
 // after this much host silence, consider the current 0x82 haptic stream inactive
 #define HAPTIC_QUIET_MS 300u
@@ -42,9 +41,10 @@
 #define HAPTIC_SHUTDOWN_SHOTS 3u
 
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
-// Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
-// goes on the air). ISR-safe (brief PRIMASK critical section). Returns false (dropped) when the ring is full.
-bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen);
+// Enqueue one host->controller report. `slot` = bond slot (0..NSLOT-1) or 0xFF to broadcast to every
+// connected controller (used by hapticSendShutdown / hapticReinit / test haptics). ISR-safe (PRIMASK).
+bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
+		  uint8_t slot = 0xFF);
 
 // anything still queued (xinput uses it to pace rumble re-queues)
 bool relayPending();
@@ -53,7 +53,8 @@ extern uint8_t g_relaySub; // relay sub-TLV type byte = SET
 extern volatile uint8_t g_testHaptic; // 't<n>' injects n test haptics
 // pending haptic-STOP frames to relay (kill a latched whine)
 extern volatile uint8_t g_hapticStop;
-extern unsigned long g_hapticBlockUntil;
+// Per-slot block: arm after a (re)connect, drop haptics aimed at the slot for HAPTIC_RECONNECT_BLOCK_MS.
+extern unsigned long g_hapticBlockUntil[NSLOT];
 
 // relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
 void hapticSendShutdown();
@@ -85,13 +86,14 @@ static inline bool hapLogPull(uint32_t *, uint8_t *, uint8_t *, uint8_t *,
 }
 #endif
 
-bool hapticLinkUp();
-bool haptic82Blocked();
+bool hapticLinkUp(int slot = -1);
+bool haptic82Blocked(int slot = -1);
 bool hapticRelaySlotOk(int slot);
 void haptic82HostReport(const uint8_t *p, uint16_t n);
-bool hapticSteamRumble(
-	uint16_t lowFreq,
-	uint16_t highFreq); // queue Steam/Triton output report 0x80 rumble
+// queue a Steam/Triton 0x80 rumble frame. `slot` = bond slot of the originating controller (0..NSLOT-1);
+// defaults to 0 for the legacy single-controller callers. Per-slot so each connected controller can have its
+// own active rumble stream when the host presents multiple gamepads (e.g. 4 XInput devices).
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
 
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
@@ -105,9 +107,14 @@ void hapticInit();
 // per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
 void hapticTask();
 
-// replay Steam's haptic-subsystem re-init to the controller -> clears a latched/stuck buzz
-void hapticReinit();
+// replay Steam's haptic-subsystem re-init to the controller -> clears a latched/stuck buzz.
+// `slot` defaults to 0xFF (broadcast to all connected) -- the re-init is a settings-only reset and is
+// harmless on healthy controllers, so it's worth re-initializing every slot the firmware knows about.
+void hapticReinit(uint8_t slot = 0xFF);
 // Called from rf_link the instant a controller (re)connect is detected (an F-reply after a gap): blocks haptic
 // relays for HAPTIC_RECONNECT_BLOCK_MS and schedules a re-init just after, to keep the freshly-booted
 // controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
-void hapticOnReconnect();
+// Per-slot: only the slot that just reconnected is blocked, the others keep relaying.
+void hapticOnReconnect(int slot);
+// Write the controller's global trackpad-haptics enable setting (per active emulated type). slot 0xFF = broadcast.
+void hapticSetPadEnabled(uint8_t slot, bool on);

+ 176 - 62
OpenPuck/mode_hidgyro.cpp

@@ -3,6 +3,8 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -10,25 +12,78 @@
 HidGyroController g_hidGyroCtl;
 
 static const uint8_t GYRO_HID_DESC[] = {
-	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
-	0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
-	0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46,
-	0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00,
-	0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
-	0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
-	0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
-	0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
-	0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
-	0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
-	0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0xC0
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09,
+	0x31, 0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x25, 0x07, 0x75, 0x04,
+	0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05, 0x09, 0x19, 0x01, 0x29,
+	0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0E, 0x81, 0x02,
+	0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95, 0x01, 0x15, 0x00,
+	0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09, 0x34, 0x15,
+	0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, 0x06,
+	0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
+	0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24,
+	0xB1, 0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0xC0
 };
 #define DS4_TOUCH_H 942
 #define DS4_STATUS_USB 0x1B // cable + level 11 (full)
-static unsigned long g_gyroLastMs = 0;
-static Adafruit_USBD_HID g_hidGyro;
+static unsigned long g_gyroLastMs[NSLOT] = { 0 };
+// NSLOT HID instances: 4 DS4 gamepads on the wire, one per bond slot. The host enumerates each as a
+// separate input device (hid-playstation, hid-sony, SDL, Steam Input).
+static Adafruit_USBD_HID g_hidGyro[NSLOT];
 
-static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		       uint16_t n)
+// Per-slot MAC base: 4 distinct NICs so the host sees 4 different devices (real DS4s have unique MACs).
+// OUI 0x001BDC is Sony's; vary the last byte per slot.
+static const uint8_t DS4_MAC_BASE[5] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55 };
+static uint8_t g_ds4Mac[NSLOT][6];
+static bool g_ds4MacInit = false;
+static void initDs4Macs()
+{
+	if (g_ds4MacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_ds4Mac[s], DS4_MAC_BASE, 5);
+		g_ds4Mac[s][5] = (uint8_t)(0x50 + s); // 0x50, 0x51, 0x52, 0x53
+	}
+	g_ds4MacInit = true;
+}
+
+// GET_FEATURE handler. Per-slot dispatch via per-instance callback (ps5Get##N passes the slot index).
+// Sizes per drivers/hid/hid-playstation.c: 0x02=37, 0x12=16, 0xA3=49; legacy hid-sony MAC 0x81=7.
+static uint16_t hidGyroGetCommon(uint8_t slot, uint8_t rid,
+				 hid_report_type_t type, uint8_t *buf,
+				 uint16_t reqlen)
+{
+	(void)slot;
+	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
+		return 0;
+	memset(buf, 0, reqlen);
+	switch (rid) {
+	case 0x02: // motion calibration (37 incl id)
+		if (reqlen < 36)
+			return 0;
+		psNeutralCalib(buf);
+		return 36;
+	case 0x12: // pairing info / MAC, hid-playstation (16 incl id)
+		if (reqlen < 15)
+			return 0;
+		memcpy(buf, g_ds4Mac[slot], 6);
+		return 15;
+	case 0x81: // MAC, legacy hid-sony USB (7 incl id)
+		if (reqlen < 6)
+			return 0;
+		memcpy(buf, g_ds4Mac[slot], 6);
+		return 6;
+	case 0xA3: // firmware / hardware info (49 incl id)
+		if (reqlen < 48)
+			return 0;
+		// non-zero version (contents not validated over USB)
+		buf[0] = 0x01;
+		return 48;
+	default:
+		return 0;
+	}
+}
+static void hidGyroSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			     uint8_t const *b, uint16_t n)
 {
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
@@ -47,48 +102,82 @@ static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	// DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
 	if (id != 0x05 || pn < 5)
 		return;
-	hapticSteamRumble((uint16_t)p[4] * 257u,
-			  (uint16_t)p[3] * 257u); // DS4: left=low, right=high
+	// `slot` is the USB slot the report arrived on -> route rumble to its mapped bond slot.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
+	hapticSteamRumble((uint16_t)p[4] * 257u, (uint16_t)p[3] * 257u,
+			  (uint8_t)bond); // DS4: left=low, right=high
 }
+// Per-slot callback trampolines -- the Adafruit HID class has ONE _get/_set pair shared across instances,
+// and the lib's tud_hid_*_cb doesn't pass the interface index to the user callback. Generate a
+// per-instance trampoline that closes over the slot index, matching the pattern in puck_hid.cpp.
+#define HIDGYROCB(N)                                                  \
+	static uint16_t hidGyroGet##N(uint8_t r, hid_report_type_t t, \
+				      uint8_t *bf, uint16_t rl)       \
+	{                                                             \
+		return hidGyroGetCommon(N, r, t, bf, rl);             \
+	}                                                             \
+	static void hidGyroSet##N(uint8_t r, hid_report_type_t t,     \
+				  uint8_t const *b, uint16_t n)       \
+	{                                                             \
+		hidGyroSetCommon(N, r, t, b, n);                      \
+	}
+// clang-format off
+HIDGYROCB(0)
+HIDGYROCB(1)
+HIDGYROCB(2)
+HIDGYROCB(3)
+// clang-format on
+typedef uint16_t (*ds4_getcb_t)(uint8_t, hid_report_type_t, uint8_t *,
+				uint16_t);
+typedef void (*ds4_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			    uint16_t);
+static ds4_getcb_t const DS4_GETCB[NSLOT] = { hidGyroGet0, hidGyroGet1,
+					      hidGyroGet2, hidGyroGet3 };
+static ds4_setcb_t const DS4_SETCB[NSLOT] = { hidGyroSet0, hidGyroSet1,
+					      hidGyroSet2, hidGyroSet3 };
 
-static void hidGyroBuild(uint8_t out[63])
+// usbSlot drives the per-HID counters; slot (bond) is the controller whose decoded input feeds the report.
+static void hidGyroBuild(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 {
-	uint32_t b = psButtonsFromSteam(g_in.buttons);
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	memset(out, 0, 63);
-	out[0] = swStick(g_in.lx, false);
-	out[1] = swStick(g_in.ly, true);
-	out[2] = swStick(g_in.rx, false);
-	out[3] = swStick(g_in.ry, true);
+	out[0] = swStick(g_in[slot].lx, false);
+	out[1] = swStick(g_in[slot].ly, true);
+	out[2] = swStick(g_in[slot].rx, false);
+	out[3] = swStick(g_in[slot].ry, true);
 	out[4] = psHatNibble(b) | psFaceNibble(b);
-	out[5] = psShouldersByte(b);
-	static uint8_t ctr = 0;
-	out[6] = ((ctr++ & 0x0F) << 4) |
+	out[5] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
+	static uint8_t ctr[NSLOT] = { 0 };
+	out[6] = ((ctr[usbSlot]++ & 0x0F) << 4) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_STEAM) ? 0x01 : 0);
-	out[7] = g_in.lt;
-	out[8] = g_in.rt;
-	out[12] = g_in.gx & 0xFF;
-	out[13] = g_in.gx >> 8;
-	out[14] = g_in.gy & 0xFF;
-	out[15] = g_in.gy >> 8;
-	out[16] = (-g_in.gz) & 0xFF;
-	out[17] = (-g_in.gz) >> 8;
-	out[18] = g_in.ax & 0xFF;
-	out[19] = g_in.ax >> 8;
-	out[20] = g_in.ay & 0xFF;
-	out[21] = g_in.ay >> 8;
-	out[22] = g_in.az & 0xFF;
-	out[23] = g_in.az >> 8;
+	out[7] = g_in[slot].lt;
+	out[8] = g_in[slot].rt;
+	out[12] = g_in[slot].gx & 0xFF;
+	out[13] = g_in[slot].gx >> 8;
+	out[14] = g_in[slot].gz & 0xFF;
+	out[15] = g_in[slot].gz >> 8;
+	out[16] = (-g_in[slot].gy) & 0xFF;
+	out[17] = (-g_in[slot].gy) >> 8;
+	out[18] = g_in[slot].ax & 0xFF;
+	out[19] = g_in[slot].ax >> 8;
+	out[20] = g_in[slot].ay & 0xFF;
+	out[21] = g_in[slot].ay >> 8;
+	out[22] = g_in[slot].az & 0xFF;
+	out[23] = g_in[slot].az >> 8;
 	out[29] = DS4_STATUS_USB;
 	if (lTouch || rTouch) {
 		uint16_t lx, ly, rx, ry;
-		steamPadsToTouch(b, DS4_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx,
-				 g_in.rpy, &lx, &ly, &rx, &ry);
-		static uint8_t tstamp = 0;
+		steamPadsToTouch(b, DS4_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+				 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx,
+				 &ry);
+		static uint8_t tstamp[NSLOT] = { 0 };
 		out[32] = 1;
-		out[33] = tstamp++;
+		out[33] = tstamp[usbSlot]++;
 		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
 	} else {
 		out[32] = 0;
@@ -98,30 +187,55 @@ static void hidGyroBuild(uint8_t out[63])
 	}
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void HidGyroController::begin()
+{
+}
+// HID budget: clean DS4 (MODE_DS4_GAME) has no wake mouse; normal HIDGYRO keeps it (1 HID).
+uint8_t HidGyroController::maxSlots() const
+{
+	uint8_t cap = modeIsCleanPS(g_usbMode) ? (uint8_t)CFG_TUD_HID :
+						 (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void HidGyroController::usbIdentity()
 {
 	USBDevice.setID(0x054C, 0x05C4);
-
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor
-	USBDevice.setDeviceVersion(0x0104);
+	USBDevice.setDeviceVersion(0x0120);
 	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
 	USBDevice.setProductDescriptor("Wireless Controller");
-	g_hidGyro.enableOutEndpoint(true);
-	g_hidGyro.setReportCallback(NULL, hidGyroSet);
-	g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
-
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_hidGyro.setPollInterval(1);
-	g_hidGyro.begin();
+}
+void HidGyroController::beginPool()
+{
+	initDs4Macs();
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_hidGyro[s].enableOutEndpoint(true);
+		g_hidGyro[s].setReportCallback(DS4_GETCB[s], DS4_SETCB[s]);
+		g_hidGyro[s].setReportDescriptor(GYRO_HID_DESC,
+						 sizeof GYRO_HID_DESC);
+		g_hidGyro[s].setPollInterval(1);
+		g_hidGyro[s].begin();
+	}
+}
+void HidGyroController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_hidGyro[u]);
 }
 void HidGyroController::task()
 {
-	if (!g_hidGyro.ready())
-		return;
-	if (millis() - g_gyroLastMs < USB_STREAM_MS)
-		return;
-	g_gyroLastMs = millis();
-	uint8_t p[63];
-	hidGyroBuild(p);
-	g_hidGyro.sendReport(0x01, p, sizeof p);
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_hidGyro[u].ready())
+			continue;
+		if (millis() - g_gyroLastMs[u] < USB_STREAM_MS)
+			continue;
+		int bond = g_usbToBond[u];
+		if (bond < 0)
+			continue;
+		g_gyroLastMs[u] = millis();
+		uint8_t p[63];
+		hidGyroBuild(u, (uint8_t)bond, p);
+		g_hidGyro[u].sendReport(0x01, p, sizeof p);
+	}
 }

+ 8 - 0
OpenPuck/mode_hidgyro.h

@@ -10,5 +10,13 @@ class HidGyroController : public IController {
     public:
 	void begin() override;
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern HidGyroController g_hidGyroCtl;

+ 148 - 73
OpenPuck/mode_ps5.cpp

@@ -3,6 +3,8 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -36,17 +38,38 @@ static const uint8_t PS5_HID_DESC[] = {
 };
 #define PS5_TOUCH_H 1080
 #define PS5_STATUS_USB 0x1A // charging + level 10 (~100%)
-static unsigned long g_ps5LastMs = 0;
-static Adafruit_USBD_HID g_ps5;
+static unsigned long g_ps5LastMs[NSLOT] = { 0 };
+// NSLOT DualSense HID instances, one per bond slot. The host enumerates each as a separate DualSense
+// (hid-playstation on Linux/SteamOS, SDL on Windows).
+static Adafruit_USBD_HID g_ps5[NSLOT];
 
-static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
-		       uint16_t reqlen)
+// Per-slot MAC base: 4 distinct NICs. OUI 0x001BDC is Sony's; vary the last byte per slot.
+static const uint8_t PS5_MAC_BASE[5] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55 };
+static uint8_t g_ps5Mac[NSLOT][6];
+static bool g_ps5MacInit = false;
+static void initPs5Macs()
 {
+	if (g_ps5MacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_ps5Mac[s], PS5_MAC_BASE, 5);
+		g_ps5Mac[s][5] = (uint8_t)(0x60 + s); // 0x60, 0x61, 0x62, 0x63
+	}
+	g_ps5MacInit = true;
+}
+
+// GET_FEATURE handler. Per-slot dispatch via per-instance callback. Sizes per drivers/hid/hid-playstation.c:
+// 0x05=41, 0x09=20, 0x20=64. TinyUSB writes the report id itself and hands us the buffer PAST it, so we
+// fill only the PAYLOAD and return size-1.
+static uint16_t ps5GetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			     uint8_t *buf, uint16_t reqlen)
+{
+	(void)slot;
 	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
 		return 0;
 	memset(buf, 0, reqlen);
 	switch (rid) {
-	// capabilities: identify as DualSense-capable (SDL checks byte 2 == 0x28)
+	// capabilities: identify as DualSense-capable (SDL-only probe; hid-playstation never reads 0x03)
 	case 0x03: {
 		if (reqlen < 47)
 			return 0;
@@ -57,32 +80,29 @@ static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		buf[4] = 0x0E; // sensors + lightbar + vibration capability bits
 		return 47;
 	}
-
-	// calibration: neutral-ish non-zero block so SDL's read succeeds
-	case 0x05: {
-		uint16_t n = reqlen < 40 ? reqlen : 40;
-		for (uint16_t i = 0; i < n; i++)
-			buf[i] = 0;
-		return n;
-	}
-	case 0x09: { // serial number feature
-		uint16_t n = reqlen < 20 ? reqlen : 20;
-		for (uint16_t i = 0; i < n && i < 12; i++)
-			buf[i] = (uint8_t)("010203040506"[i]);
-		return n;
-	}
-	case 0x20: { // firmware info
-		uint16_t n = reqlen < 63 ? reqlen : 63;
-		buf[0] = 0x01;
-		buf[1] = 0x00; // minimal non-zero firmware version
-		return n;
-	}
+	case 0x05: // motion calibration (41 incl id)
+		if (reqlen < 40)
+			return 0;
+		psNeutralCalib(buf);
+		return 40;
+	case 0x09: // pairing info / MAC (20 incl id)
+		if (reqlen < 19)
+			return 0;
+		// MAC at kernel buf[1..6] = payload[0..5]
+		memcpy(buf, g_ps5Mac[slot], 6);
+		return 19;
+	case 0x20: // firmware info (64 incl id)
+		if (reqlen < 63)
+			return 0;
+		buf[23] = 0x01; // hw_version (le32 @ kernel buf[24]) non-zero
+		buf[27] = 0x01; // fw_version (le32 @ kernel buf[28]) non-zero
+		return 63;
 	default:
 		return 0;
 	}
 }
-static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		   uint16_t n)
+static void ps5SetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			 uint8_t const *b, uint16_t n)
 {
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
@@ -105,73 +125,128 @@ static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	}
 	if (id != 0x02 || pn < 4)
 		return;
-	hapticSteamRumble((uint16_t)p[3] * 257u,
-			  (uint16_t)p[2] *
-				  257u); // DualSense: left=low, right=high
+	// `slot` here is the USB slot the report arrived on -> route rumble to the bond slot it's mapped to.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
+	hapticSteamRumble((uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u,
+			  (uint8_t)bond);
+	// DualSense: left=low, right=high
 }
+#define PS5CB(N)                                                               \
+	static uint16_t ps5Get##N(uint8_t r, hid_report_type_t t, uint8_t *bf, \
+				  uint16_t rl)                                 \
+	{                                                                      \
+		return ps5GetCommon(N, r, t, bf, rl);                          \
+	}                                                                      \
+	static void ps5Set##N(uint8_t r, hid_report_type_t t,                  \
+			      uint8_t const *b, uint16_t n)                    \
+	{                                                                      \
+		ps5SetCommon(N, r, t, b, n);                                   \
+	}
+// clang-format off
+PS5CB(0)
+PS5CB(1)
+PS5CB(2)
+PS5CB(3)
+// clang-format on
+typedef uint16_t (*ps5_getcb_t)(uint8_t, hid_report_type_t, uint8_t *,
+				uint16_t);
+typedef void (*ps5_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			    uint16_t);
+static ps5_getcb_t const PS5_GETCB[NSLOT] = { ps5Get0, ps5Get1, ps5Get2,
+					      ps5Get3 };
+static ps5_setcb_t const PS5_SETCB[NSLOT] = { ps5Set0, ps5Set1, ps5Set2,
+					      ps5Set3 };
 
-static void ps5Build(uint8_t out[63])
+// usbSlot drives the per-HID sequence counter; bond is the controller whose decoded input feeds the report.
+static void ps5Build(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 {
-	uint32_t b = psButtonsFromSteam(g_in.buttons);
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	memset(out, 0, 63);
-	out[0] = swStick(g_in.lx, false);
-	out[1] = swStick(g_in.ly, true);
-	out[2] = swStick(g_in.rx, false);
-	out[3] = swStick(g_in.ry, true);
-	out[4] = g_in.lt;
-	out[5] = g_in.rt;
-	static uint8_t seq = 0;
-	out[6] = seq++;
+	out[0] = swStick(g_in[slot].lx, false);
+	out[1] = swStick(g_in[slot].ly, true);
+	out[2] = swStick(g_in[slot].rx, false);
+	out[3] = swStick(g_in[slot].ry, true);
+	out[4] = g_in[slot].lt;
+	out[5] = g_in[slot].rt;
+	static uint8_t seq[NSLOT] = { 0 };
+	out[6] = seq[usbSlot]++;
 	out[7] = psHatNibble(b) | psFaceNibble(b);
-	out[8] = psShouldersByte(b);
+	out[8] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
 	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_MUTE) ? 0x04 : 0);
-	out[15] = g_in.gx & 0xFF;
-	out[16] = g_in.gx >> 8;
-	out[17] = (-g_in.gz) & 0xFF;
-	out[18] = (-g_in.gz) >> 8;
-	out[19] = g_in.gy & 0xFF;
-	out[20] = g_in.gy >> 8;
-	out[21] = g_in.ax & 0xFF;
-	out[22] = g_in.ax >> 8;
-	out[23] = g_in.ay & 0xFF;
-	out[24] = g_in.ay >> 8;
-	out[25] = g_in.az & 0xFF;
-	out[26] = g_in.az >> 8;
+	out[15] = g_in[slot].gx & 0xFF;
+	out[16] = g_in[slot].gx >> 8;
+	out[17] = g_in[slot].gz & 0xFF;
+	out[18] = g_in[slot].gz >> 8;
+	out[19] = (-g_in[slot].gy) & 0xFF;
+	out[20] = (-g_in[slot].gy) >> 8;
+	out[21] = g_in[slot].ax & 0xFF;
+	out[22] = g_in[slot].ax >> 8;
+	out[23] = g_in[slot].ay & 0xFF;
+	out[24] = g_in[slot].ay >> 8;
+	out[25] = g_in[slot].az & 0xFF;
+	out[26] = g_in[slot].az >> 8;
 	uint16_t lx, ly, rx, ry;
-	steamPadsToTouch(b, PS5_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx, g_in.rpy,
-			 &lx, &ly, &rx, &ry);
+	steamPadsToTouch(b, PS5_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+			 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx, &ry);
 	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
 	out[52] = PS5_STATUS_USB;
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void Ps5Controller::begin()
+{
+}
+// HID budget: clean PS modes have no wake mouse (CFG_TUD_HID slots); normal PS5 keeps the wake mouse (1 HID).
+uint8_t Ps5Controller::maxSlots() const
+{
+	uint8_t cap = modeIsCleanPS(g_usbMode) ? (uint8_t)CFG_TUD_HID :
+						 (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void Ps5Controller::usbIdentity()
 {
 	USBDevice.setID(0x054C, 0x0CE6);
-
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor
-	USBDevice.setDeviceVersion(0x0104);
+	USBDevice.setDeviceVersion(0x0110);
 	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
 	USBDevice.setProductDescriptor("DualSense Wireless Controller");
-	g_ps5.enableOutEndpoint(true);
-	g_ps5.setReportCallback(ps5Get, ps5Set);
-	g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
-
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_ps5.setPollInterval(1);
-	g_ps5.begin();
+}
+// One-time: create the DualSense HID pool and lock instance indices (wake mouse, if any, was begun first).
+void Ps5Controller::beginPool()
+{
+	initPs5Macs();
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_ps5[s].enableOutEndpoint(true);
+		g_ps5[s].setReportCallback(PS5_GETCB[s], PS5_SETCB[s]);
+		g_ps5[s].setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
+		g_ps5[s].setPollInterval(1);
+		g_ps5[s].begin();
+	}
+}
+void Ps5Controller::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_ps5[u]);
 }
 void Ps5Controller::task()
 {
-	if (!g_ps5.ready())
-		return;
-	if (millis() - g_ps5LastMs < USB_STREAM_MS)
-		return;
-	g_ps5LastMs = millis();
-	uint8_t p[63];
-	ps5Build(p);
-	g_ps5.sendReport(0x01, p, sizeof p);
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_ps5[u].ready())
+			continue;
+		if (millis() - g_ps5LastMs[u] < USB_STREAM_MS)
+			continue;
+		int bond = g_usbToBond[u];
+		if (bond < 0)
+			continue;
+		g_ps5LastMs[u] = millis();
+		uint8_t p[63];
+		ps5Build(u, (uint8_t)bond, p);
+		g_ps5[u].sendReport(0x01, p, sizeof p);
+	}
 }

+ 8 - 0
OpenPuck/mode_ps5.h

@@ -10,5 +10,13 @@ class Ps5Controller : public IController {
     public:
 	void begin() override;
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern Ps5Controller g_ps5Ctl;

+ 73 - 33
OpenPuck/mode_switch_hori.cpp

@@ -2,6 +2,8 @@
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
+#include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 
@@ -26,10 +28,11 @@ static const uint8_t SWITCH_HID_DESC[] = {
 	// vendor (output, 8 bytes; unused)
 	0x0A, 0x21, 0x26, 0x95, 0x08, 0x91, 0x02, 0xC0
 };
-static Adafruit_USBD_HID g_switch; // HORIPAD gamepad HID interface
-static unsigned long g_swLastMs = 0;
+// NSLOT HORIPAD HID instances -- one per bond slot. The Switch console binds each as a separate gamepad.
+static Adafruit_USBD_HID g_switch[NSLOT];
+static unsigned long g_swLastMs[NSLOT] = { 0 };
 
-// back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
+// back-paddle / QAM code (g_back[], g_qamMap) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home) 18=Capture
 static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
 			     uint16_t fY)
 {
@@ -56,6 +59,12 @@ static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
 		return 0x200;
 	case 11:
 		return 0x1000;
+	case 18:
+		return 0x2000; // Capture / Screenshot (Switch-only target)
+	case 19:
+		return 0x40; // ZL (left trigger)
+	case 20:
+		return 0x80; // ZR (right trigger)
 	default:
 		return 0;
 	}
@@ -74,14 +83,14 @@ static inline void backCodeToHatDirs(uint8_t c, bool &u, bool &d, bool &l,
 		r = true;
 }
 // HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
-static void switchBuildHoripad(uint8_t out[8])
+// Per-slot: each controller's decoded input is in g_in[slot].
+static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 {
-	uint32_t b = g_in.buttons;
+	uint32_t b = g_in[slot].buttons;
 	uint16_t btn = 0;
-	if (g_qamMap && (b & TB_QAM)) {
-		b &= ~(uint32_t)TB_QAM;
-		b |= tritonFromCode(g_qamMap);
-	}
+	// QAM (3 dots) remap: the configured code is applied to the Switch output just like a back paddle below
+	// (so Capture(18) and every Switch target work). 0 = unmapped -> QAM does nothing on Switch.
+	bool qam = g_qamMap && (b & TB_QAM);
 	// Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
 	if ((b & CHORD_BACK4) == CHORD_BACK4)
 		b &= ~(uint32_t)(TB_A | TB_X | TB_Y);
@@ -101,9 +110,9 @@ static void switchBuildHoripad(uint8_t out[8])
 	if (b & TB_RB)
 		btn |= 0x20; // L, R
 	// ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
-	if ((g_in.lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		btn |= 0x40;
-	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		btn |= 0x80;
 	if (b & TB_MENU)
 		btn |= 0x100;
@@ -124,6 +133,8 @@ static void switchBuildHoripad(uint8_t out[8])
 		btn |= codeToSwitch(g_back[2], fA, fB, fX, fY);
 	if (b & TB_R5)
 		btn |= codeToSwitch(g_back[3], fA, fB, fX, fY);
+	if (qam)
+		btn |= codeToSwitch(g_qamMap, fA, fB, fX, fY);
 	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF,
 	     r = b & TB_DRT; // hat: 0=N..7=NW, 8=neutral
 	if (b & TB_L4)
@@ -134,6 +145,8 @@ static void switchBuildHoripad(uint8_t out[8])
 		backCodeToHatDirs(g_back[2], u, d, l, r);
 	if (b & TB_R5)
 		backCodeToHatDirs(g_back[3], u, d, l, r);
+	if (qam)
+		backCodeToHatDirs(g_qamMap, u, d, l, r);
 	uint8_t hat = 8;
 	if (u && r)
 		hat = 1;
@@ -154,36 +167,63 @@ static void switchBuildHoripad(uint8_t out[8])
 	out[0] = btn & 0xFF;
 	out[1] = btn >> 8;
 	out[2] = hat;
-	out[3] = swStick(g_in.lx, false);
-	out[4] = swStick(g_in.ly, true); // HID Y is down-positive -> invert
-	out[5] = swStick(g_in.rx, false);
-	out[6] = swStick(g_in.ry, true);
+	out[3] = swStick(g_in[slot].lx, false);
+	out[4] = swStick(g_in[slot].ly,
+			 true); // HID Y is down-positive -> invert
+	out[5] = swStick(g_in[slot].rx, false);
+	out[6] = swStick(g_in[slot].ry, true);
 	out[7] = 0;
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void SwitchHoriController::begin()
+{
+}
+// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the HORIPAD pool.
+uint8_t SwitchHoriController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SwitchHoriController::usbIdentity()
 {
 	USBDevice.setID(0x0F0D, 0x0092);
-
-	// Windows caches config by VID:PID:bcdDevice, so any interface change MUST bump this
-	USBDevice.setDeviceVersion(0x0202);
+	USBDevice.setDeviceVersion(0x0210);
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
-	g_switch.enableOutEndpoint(true);
-	g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
-
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_switch.setPollInterval(1);
-	g_switch.begin();
+}
+void SwitchHoriController::beginPool()
+{
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_switch[s].enableOutEndpoint(true);
+		g_switch[s].setReportDescriptor(SWITCH_HID_DESC,
+						sizeof SWITCH_HID_DESC);
+		g_switch[s].setPollInterval(1);
+		g_switch[s].begin();
+	}
+}
+void SwitchHoriController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_switch[u]);
 }
 void SwitchHoriController::task()
-{ // stream the 8-byte HORIPAD report at ~250Hz (no handshake needed)
-	if (!g_switch.ready())
-		return;
-	if (millis() - g_swLastMs < USB_STREAM_MS)
-		return;
-	g_swLastMs = millis();
-	uint8_t p[8];
-	switchBuildHoripad(p);
-	g_switch.sendReport(0, p, sizeof p); // report-id-less descriptor
+{
+	// stream the 8-byte HORIPAD report at ~250Hz per CONNECTED controller. usbSlot u -> bond slot for input;
+	// switchBuildHoripad reads g_in[bond] (no per-USB-slot state in the HORIPAD report).
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_switch[u].ready())
+			continue;
+		if (millis() - g_swLastMs[u] < USB_STREAM_MS)
+			continue;
+		int bond = g_usbToBond[u];
+		if (bond < 0)
+			continue;
+		g_swLastMs[u] = millis();
+		uint8_t p[8];
+		switchBuildHoripad((uint8_t)bond, p);
+		g_switch[u].sendReport(0, p,
+				       sizeof p); // report-id-less descriptor
+	}
 }

+ 8 - 0
OpenPuck/mode_switch_hori.h

@@ -11,5 +11,13 @@ class SwitchHoriController : public IController {
     public:
 	void begin() override;
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern SwitchHoriController g_switchHori;

+ 274 - 149
OpenPuck/mode_switch_pro.cpp

@@ -3,6 +3,8 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -21,7 +23,7 @@ SwitchProController g_switchPro;
 #define SW_ACCEL_DIV 4
 
 uint8_t g_swProRate =
-	1; // 0 = 66Hz, 1 = 120Hz (default), 2 = full (~250Hz / USB_STREAM_MS)
+	2; // 0 = 66Hz, 1 = 120Hz, 2 = full (~250Hz / USB_STREAM_MS, default)
 uint8_t g_swGyroScale10 = 10; // gyro sensitivity x10 (10 = 1.0x)
 
 // Persist the two Switch Pro motion settings in their own tiny file so they never trigger a global-Cfg reset.
@@ -43,8 +45,8 @@ static void swProLoadCfg()
 	uint8_t b[3];
 	if (f.open(SWPRO_CFG_FILE, FILE_O_READ)) {
 		if (f.read(b, 3) == 3 && b[0] == 0x01) {
-			// 0=66Hz, 1=120Hz, 2=full; bad value -> default 120Hz
-			g_swProRate = (b[1] <= 2) ? b[1] : 1;
+			// 0=66Hz, 1=120Hz, 2=full; bad value -> default full
+			g_swProRate = (b[1] <= 2) ? b[1] : 2;
 			g_swGyroScale10 =
 				(b[2] >= 5 && b[2] <= 30) ?
 					b[2] :
@@ -96,6 +98,7 @@ static const uint8_t SWPRO_HID_DESC[] = {
 #define JC_BTN_RSTICK (1u << 10)
 #define JC_BTN_LSTICK (1u << 11)
 #define JC_BTN_HOME (1u << 12)
+#define JC_BTN_CAPTURE (1u << 13)
 #define JC_BTN_DOWN (1u << 16)
 #define JC_BTN_UP (1u << 17)
 #define JC_BTN_RIGHT (1u << 18)
@@ -128,6 +131,12 @@ static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
 		return JC_BTN_PLUS;
 	case 11:
 		return JC_BTN_HOME;
+	case 18:
+		return JC_BTN_CAPTURE; // Capture / Screenshot (Switch-only target)
+	case 19:
+		return JC_BTN_ZL; // left trigger
+	case 20:
+		return JC_BTN_ZR; // right trigger
 	case 12:
 		return JC_BTN_UP;
 	case 13:
@@ -140,13 +149,37 @@ static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
 		return 0;
 	}
 }
-static unsigned long g_swProLastMs = 0;
-static Adafruit_USBD_HID g_swPro;
+// NSLOT Pro-Controller HIDs (one per bond slot) + per-slot handshake state (timer, report-mode gate,
+// subcommand-reply FIFO), per-slot reply queue indices, per-slot last-stream millis, per-slot MAC, and
+// per-slot user-cal SPI mirror.
+static Adafruit_USBD_HID g_swPro[NSLOT];
+static unsigned long g_swProLastMs[NSLOT] = { 0 };
 // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
-static uint8_t g_swProReportMode = 0;
-static const uint8_t JC_MAC[6] = {
-	0x7C, 0xBB, 0x8A, 0x00, 0x00, 0x01
-}; // synthetic but stable controller MAC
+static uint8_t g_swProReportMode[NSLOT] = { 0 };
+static uint8_t g_jcTimer[NSLOT] = { 0 };
+// Per-slot MAC. The console uses the BT addr it reads from subcommand 0x02 to identify the controller;
+// real Pro Controllers each have their own. Last byte varies per slot.
+static const uint8_t JC_MAC_BASE[5] = { 0x7C, 0xBB, 0x8A, 0x00, 0x00 };
+static uint8_t g_jcMac[NSLOT][6];
+static bool g_jcMacInit = false;
+static void initJcMacs()
+{
+	if (g_jcMacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_jcMac[s], JC_MAC_BASE, 5);
+		g_jcMac[s][5] = (uint8_t)(0x10 + s);
+	}
+	g_jcMacInit = true;
+}
+// Dynamic mount: the handshake/state arrays are keyed by USB slot, but the controller INPUT (and rumble target)
+// belongs to the bond slot the USB slot is mapped to. This resolves usbSlot -> bondSlot (identity fallback if
+// somehow unmapped, e.g. legacy paths).
+static inline uint8_t jcBondOf(uint8_t usbSlot)
+{
+	int b = (usbSlot < NSLOT) ? g_usbToBond[usbSlot] : -1;
+	return (b >= 0) ? (uint8_t)b : usbSlot;
+}
 static uint16_t jcRumbleAmp(const uint8_t r[4])
 {
 	// Nintendo packs a high band (r[0],r[1]) and low band (r[2],r[3]) of frequency+amplitude. We only need a
@@ -158,19 +191,21 @@ static uint16_t jcRumbleAmp(const uint8_t r[4])
 	uint8_t a = hf > lf ? hf : lf; // 0..0x7F
 	return (uint16_t)a << 9; // scale to ~16-bit motor speed
 }
-static void jcRumble(const uint8_t *p, uint16_t pn)
+// Per-slot: each Pro Controller has its own rumble stream, so the "last" relay tracking must be per-slot.
+static uint16_t g_jcLastLo[NSLOT] = { 0 };
+static uint16_t g_jcLastHi[NSLOT] = { 0 };
+static void jcRumble(uint8_t slot, const uint8_t *p, uint16_t pn)
 {
 	if (pn < 9)
 		return; // [timer][left rumble x4][right rumble x4]
 	uint16_t lo = jcRumbleAmp(p + 1), hi = jcRumbleAmp(p + 5);
-	static uint16_t lastLo = 0, lastHi = 0;
 	// only relay on change: the Switch streams rumble every frame; re-sending
 	// unchanged values would flood the RF relay and loop the motor
-	if (lo == lastLo && hi == lastHi)
+	if (lo == g_jcLastLo[slot] && hi == g_jcLastHi[slot])
 		return;
-	lastLo = lo;
-	lastHi = hi;
-	hapticSteamRumble(lo, hi);
+	g_jcLastLo[slot] = lo;
+	g_jcLastHi[slot] = hi;
+	hapticSteamRumble(lo, hi, jcBondOf(slot)); // route to the mapped controller
 }
 static int jcStick12(int16_t v, bool inv)
 { // steam int16 (center 0) -> 12-bit (center 0x800), clamped
@@ -187,14 +222,12 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
 }
 // Standard input-report prefix [0..11] (timer, battery/conn, 3 button bytes, both packed sticks, vibrator),
 // shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
-static uint8_t g_jcTimer = 0;
-static void jcInputPrefix(uint8_t *out)
+static void jcInputPrefix(uint8_t slot, uint8_t *out)
 {
-	uint32_t b = g_in.buttons;
-	if (g_qamMap && (b & TB_QAM)) {
-		b &= ~(uint32_t)TB_QAM;
-		b |= tritonFromCode(g_qamMap);
-	}
+	uint8_t bond = jcBondOf(slot); // input data comes from the mapped controller; timer/state stay per USB slot
+	uint32_t b = g_in[bond].buttons;
+	// QAM (3 dots) remap -> applied via codeToJc below like a back paddle (so Capture(18)/any target work).
+	bool qam = g_qamMap && (b & TB_QAM);
 	if ((b & CHORD_BACK4) == CHORD_BACK4)
 		b &= ~(uint32_t)(TB_A | TB_B | TB_X | TB_Y);
 	uint32_t fA = g_abSwap ? JC_BTN_B : JC_BTN_A,
@@ -214,9 +247,9 @@ static void jcInputPrefix(uint8_t *out)
 		jc |= JC_BTN_L;
 	if (b & TB_RB)
 		jc |= JC_BTN_R;
-	if ((g_in.lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[bond].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		jc |= JC_BTN_ZL;
-	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[bond].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		jc |= JC_BTN_ZR;
 	if (b & TB_VIEW)
 		jc |= JC_BTN_PLUS;
@@ -244,7 +277,9 @@ static void jcInputPrefix(uint8_t *out)
 		jc |= codeToJc(g_back[2], fA, fB, fX, fY);
 	if (b & TB_R5)
 		jc |= codeToJc(g_back[3], fA, fB, fX, fY);
-	out[0] = g_jcTimer++;
+	if (qam)
+		jc |= codeToJc(g_qamMap, fA, fB, fX, fY);
+	out[0] = g_jcTimer[slot]++;
 
 	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
 	// A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too.
@@ -256,15 +291,16 @@ static void jcInputPrefix(uint8_t *out)
 	// charging_grip bit (button "common" byte, bit7): genuine Pro Controller always sets it on USB; real
 	// Switch uses it to recognise a wired controller. Not a button, so hid-nintendo ignores it.
 	out[3] |= 0x80;
-	jcPackStick(out + 5, g_in.lx, g_in.ly);
-	jcPackStick(out + 8, g_in.rx, g_in.ry);
+	jcPackStick(out + 5, g_in[bond].lx, g_in[bond].ly);
+	jcPackStick(out + 8, g_in[bond].rx, g_in[bond].ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	out[11] = 0x09;
 }
-static void switchProBuild(uint8_t out[63])
+static void switchProBuild(uint8_t slot, uint8_t out[63])
 {
+	uint8_t bond = jcBondOf(slot); // IMU data comes from the mapped controller
 	memset(out, 0, 63);
-	jcInputPrefix(out);
+	jcInputPrefix(slot, out);
 	// Gyro slot order follows hid-nintendo: raw+6 = ROLL, raw+8 = PITCH, raw+10 = YAW. Source routing (a proper
 	// rotation, det +1): SwitchX<-+gy, SwitchY<--gx, SwitchZ<-+gz.
 	//
@@ -278,14 +314,14 @@ static void switchProBuild(uint8_t out[63])
 	// ~1g. Without this the console reads gravity as ~4g, REJECTS the accel for drift correction (it must be linear
 	// accel, not gravity), and gyro roll error accumulates into a slow ~45deg lean. Gyro is left at native scale.
 	// accel X <- +ay, accel Y <- -ax, accel Z <- +az (signs match gyro)
-	int16_t aX = (int16_t)(g_in.ay / SW_ACCEL_DIV);
-	int16_t aY = (int16_t)((-(int16_t)g_in.ax) / SW_ACCEL_DIV);
-	int16_t aZ = (int16_t)(g_in.az / SW_ACCEL_DIV);
+	int16_t aX = (int16_t)(g_in[bond].ay / SW_ACCEL_DIV);
+	int16_t aY = (int16_t)((-(int16_t)g_in[bond].ax) / SW_ACCEL_DIV);
+	int16_t aZ = (int16_t)(g_in[bond].az / SW_ACCEL_DIV);
 
 	// gyro outputs scaled by the user sensitivity factor
-	int16_t groll = gscale((int16_t)g_in.gy);
-	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in.gx));
-	int16_t gyaw = gscale((int16_t)g_in.gz);
+	int16_t groll = gscale((int16_t)g_in[bond].gy);
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[bond].gx));
+	int16_t gyaw = gscale((int16_t)g_in[bond].gz);
 	for (int k = 0; k < 3; k++) {
 		int o = 12 + k * 12;
 		out[o + 0] = aX & 0xFF;
@@ -317,7 +353,8 @@ static const uint8_t SPI_PARAMS2[18] = { 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
 					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
 static const uint8_t SPI_COLOR[13] = { 0x32, 0x32, 0x32, 0xE6, 0xE6, 0xE6, 0x32,
 				       0x32, 0x32, 0x32, 0x32, 0x32, 0xFF };
-// built at boot: left[9]+right[9] factory stick calibration (packed 12-bit)
+// built at boot: left[9]+right[9] factory stick calibration (packed 12-bit). Shared across slots -- it's a
+// factory stub the host reads to seed its cal, and we present the same neutral stub for all controllers.
 static uint8_t g_spiStickCal[18];
 static void jcPack12(uint8_t *o9, const uint16_t v[6])
 { // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
@@ -346,14 +383,6 @@ static void jcBuildStickCal()
 // Manual-pairing (subcommand 0x01) reply payloads. A real Switch runs this 3-stage BT key exchange over USB so it
 // can register the pad (Steam/hid-nintendo do not). The Switch only validates the shape, not the key contents. Each
 // is the 31-byte reply body following the 0x21 input prefix + ack(0x81) + echoed-subcommand(0x01).
-// type 1: echo type + controller BT addr + "Pro Controller" name
-static const uint8_t BT_PAIR_1[31] = {
-	0x01, JC_MAC[0], JC_MAC[1], JC_MAC[2], JC_MAC[3], JC_MAC[4], JC_MAC[5],
-	0x00, 0x25,	 0x08,	    0x50,      0x72,	  0x6F,	     0x20,
-	0x43, 0x6F,	 0x6E,	    0x74,      0x72,	  0x6F,	     0x6C,
-	0x6C, 0x65,	 0x72, // "Pro Controller"
-	0x00, 0x00,	 0x00,	    0x00,      0x00,	  0x68
-};
 static const uint8_t BT_PAIR_2[31] = { // type 2: LTK exchange
 	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
 	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -364,53 +393,77 @@ static const uint8_t BT_PAIR_3[31] = {
 }; // type 3: save pairing (all zero body)
 // User-calibration SPI mirror (0x8000-0x80FF). A real Pro Controller stores the gyro/accel (and stick) calibration
 // the Switch writes during "Calibrate Motion Controls" here, then reads it back and applies it -- that's how a
-// resting IMU/neutral offset gets cancelled. We store writes and serve them back, persisted to flash so it survives
-// unplug/replug. 0xFF = blank -> Switch falls back to our factory block at 0x6020 (default until cal'd).
-#define SWCAL_FILE "/swimucal.bin"
-static uint8_t g_userCal[0x100];
+// resting IMU/neutral offset gets cancelled. Per-slot: each Pro Controller has its own SPI mirror, so each
+// gets its own persisted file (NSLOT files; the same 0x100 bytes as before, partitioned by slot index).
+#define SWCAL_FILE_BASE "/swimucal"
+static uint8_t g_userCal[NSLOT][0x100];
+static bool g_userCalLoaded[NSLOT] = { false, false, false, false };
+static void swCalFileName(uint8_t slot, char *out)
+{
+	// "/swimucal0.bin" .. "/swimucal3.bin"
+	const char *base = SWCAL_FILE_BASE;
+	int i = 0;
+	while (base[i]) {
+		out[i] = base[i];
+		i++;
+	}
+	out[i++] = (char)('0' + slot);
+	out[i++] = '.';
+	out[i++] = 'b';
+	out[i++] = 'i';
+	out[i++] = 'n';
+	out[i] = '\0';
+}
 static void loadUserCal()
 {
-	memset(g_userCal, 0xFF, sizeof g_userCal);
-	File f(InternalFS);
-	if (f.open(SWCAL_FILE, FILE_O_READ)) {
-		f.read(g_userCal, sizeof g_userCal);
-		f.close();
+	for (int s = 0; s < NSLOT; s++) {
+		memset(g_userCal[s], 0xFF, sizeof g_userCal[s]);
+		char fn[16];
+		swCalFileName((uint8_t)s, fn);
+		File f(InternalFS);
+		if (f.open(fn, FILE_O_READ)) {
+			f.read(g_userCal[s], sizeof g_userCal[s]);
+			f.close();
+		}
+		g_userCalLoaded[s] = true;
 	}
 }
-static void saveUserCal()
+static void saveUserCal(uint8_t slot)
 {
-	InternalFS.remove(SWCAL_FILE);
+	char fn[16];
+	swCalFileName(slot, fn);
+	InternalFS.remove(fn);
 	File f(InternalFS);
-	if (f.open(SWCAL_FILE, FILE_O_WRITE)) {
-		f.write(g_userCal, sizeof g_userCal);
+	if (f.open(fn, FILE_O_WRITE)) {
+		f.write(g_userCal[slot], sizeof g_userCal[slot]);
 		f.close();
 	}
 }
 // jcSpiWrite runs in the USB ISR (via jcSet). NEVER do flash I/O here -- a blocking LittleFS erase/write in the
 // interrupt wedges USB + the RF poll and corrupts state (device drops into a bad state needing a replug). Only
 // update the RAM mirror and flag it dirty; task() (main loop) does the actual save, debounced so a calibration
-// write-burst coalesces into one flash write.
-static volatile bool g_userCalDirty = false;
-static volatile unsigned long g_userCalDirtyMs = 0;
-static void jcSpiWrite(uint32_t addr, uint8_t len, const uint8_t *data,
-		       uint16_t avail)
+// write-burst coalesces into one flash write. Per-slot: each Pro Controller's ISR write targets its own mirror.
+static volatile bool g_userCalDirty[NSLOT] = { false, false, false, false };
+static volatile unsigned long g_userCalDirtyMs[NSLOT] = { 0 };
+static void jcSpiWrite(uint8_t slot, uint32_t addr, uint8_t len,
+		       const uint8_t *data, uint16_t avail)
 {
 	bool changed = false;
 	for (uint8_t i = 0; i < len && i < avail; i++) {
 		uint32_t a = addr + i;
 		if (a >= 0x8000 && a < 0x8100) {
-			if (g_userCal[a - 0x8000] != data[i]) {
-				g_userCal[a - 0x8000] = data[i];
+			if (g_userCal[slot][a - 0x8000] != data[i]) {
+				g_userCal[slot][a - 0x8000] = data[i];
 				changed = true;
 			}
 		}
 	}
 	if (changed) {
-		g_userCalDirty = true;
-		g_userCalDirtyMs = millis();
+		g_userCalDirty[slot] = true;
+		g_userCalDirtyMs[slot] = millis();
 	} // defer the flash write to task()
 }
-static void spiRead(uint32_t addr, uint8_t len, uint8_t *dst)
+static void spiRead(uint8_t slot, uint32_t addr, uint8_t len, uint8_t *dst)
 {
 	for (uint8_t i = 0; i < len; i++) {
 		uint32_t a = addr + i;
@@ -427,52 +480,74 @@ static void spiRead(uint32_t addr, uint8_t len, uint8_t *dst)
 			v = SPI_PARAMS2[a - 0x6098];
 		else if (a >= 0x8000 && a < 0x8100)
 			// persisted user cal (0xFF blank -> factory fallback)
-			v = g_userCal[a - 0x8000];
+			v = g_userCal[slot][a - 0x8000];
 		dst[i] = v;
 	}
 }
 // Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
-// 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Every reply
-// is sent as a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter
-// than the descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
+// 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Per-slot: each
+// Pro Controller runs its own handshake state machine, so each has its own reply queue. Every reply is sent as
+// a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter than the
+// descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
 #define JC_REPLEN 63
 struct JcRep {
 	uint8_t rid;
 	uint8_t data[JC_REPLEN];
 };
 #define JCQ_N 8
-static volatile uint8_t g_jcQh = 0, g_jcQt = 0;
-static JcRep g_jcQ[JCQ_N];
-static void jcEnq(uint8_t rid, const uint8_t *d, uint8_t len)
+static volatile uint8_t g_jcQh[NSLOT] = { 0, 0, 0, 0 };
+static volatile uint8_t g_jcQt[NSLOT] = { 0, 0, 0, 0 };
+static JcRep g_jcQ[NSLOT][JCQ_N];
+static void jcEnq(uint8_t slot, uint8_t rid, const uint8_t *d, uint8_t len)
 {
-	uint8_t nt = (uint8_t)((g_jcQt + 1) % JCQ_N);
-	if (nt == g_jcQh)
+	uint8_t t = g_jcQt[slot];
+	uint8_t nt = (uint8_t)((t + 1) % JCQ_N);
+	if (nt == g_jcQh[slot])
 		return; // full -> drop; the host re-requests on timeout
 	if (len > JC_REPLEN)
 		len = JC_REPLEN;
-	g_jcQ[g_jcQt].rid = rid;
-	memset(g_jcQ[g_jcQt].data, 0, JC_REPLEN);
-	memcpy(g_jcQ[g_jcQt].data, d, len); // zero-pad to full report length
-	g_jcQt = nt;
+	g_jcQ[slot][t].rid = rid;
+	memset(g_jcQ[slot][t].data, 0, JC_REPLEN);
+	memcpy(g_jcQ[slot][t].data, d, len); // zero-pad to full report length
+	g_jcQt[slot] = nt;
 }
 // Build the 0x21 reply (standard input prefix + ACK + echoed subcommand id + reply data) for a report-0x01 subcommand.
-static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
+static void jcSubcmd(uint8_t slot, uint8_t sub, const uint8_t *args,
+		     uint16_t alen)
 {
 	uint8_t p[JC_REPLEN];
 	memset(p, 0, sizeof p);
-	jcInputPrefix(p);
+	jcInputPrefix(slot, p);
 	p[13] = sub;
 	switch (sub) {
 	// manual BT pairing: 3-stage key exchange a real Switch runs over USB
 	case 0x01: {
-		const uint8_t *d = BT_PAIR_3;
 		uint8_t t = (alen >= 1) ? args[0] : 3;
-		if (t == 1)
-			d = BT_PAIR_1;
-		else if (t == 2)
-			d = BT_PAIR_2;
 		p[12] = 0x81; // pairing ACK
-		memcpy(&p[14], d, 31);
+		if (t == 1) {
+			// echo type + this slot's MAC + "Pro Controller" name
+			p[14] = 0x01;
+			memcpy(&p[15], g_jcMac[slot], 6);
+			p[21] = 0x00;
+			p[22] = 0x25;
+			p[23] = 0x08;
+			// "Pro Controller" = 50 72 6F 20 43 6F 6E 74 72 6F 6C 6C 65 72
+			static const uint8_t NAME[14] = {
+				0x50, 0x72, 0x6F, 0x20, 0x43, 0x6F, 0x6E,
+				0x74, 0x72, 0x6F, 0x6C, 0x6C, 0x65, 0x72
+			};
+			memcpy(&p[24], NAME, 14);
+			p[38] = 0x00;
+			p[39] = 0x00;
+			p[40] = 0x00;
+			p[41] = 0x00;
+			p[42] = 0x00;
+			p[43] = 0x68;
+		} else if (t == 2) {
+			memcpy(&p[14], BT_PAIR_2, 31);
+		} else {
+			memcpy(&p[14], BT_PAIR_3, 31);
+		}
 		break;
 	}
 	case 0x02: // request device info
@@ -483,7 +558,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		p[15] = 0x48;
 		p[16] = 0x03; // controller type: Pro Controller
 		p[17] = 0x02; // fixed
-		memcpy(&p[18], JC_MAC, 6);
+		memcpy(&p[18], g_jcMac[slot], 6);
 		p[24] = 0x01; // colors stored in SPI (0x6050)
 		p[25] = 0x01; // fixed
 		break;
@@ -504,7 +579,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		p[16] = args[2];
 		p[17] = args[3];
 		p[18] = rl;
-		spiRead(a, rl, &p[19]);
+		spiRead(slot, a, rl, &p[19]);
 		break;
 	}
 	// SPI flash write -> persist user calibration so Switch motion-cal sticks
@@ -514,7 +589,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 				     ((uint32_t)args[1] << 8) |
 				     ((uint32_t)args[2] << 16) |
 				     ((uint32_t)args[3] << 24);
-			jcSpiWrite(a, args[4], &args[5],
+			jcSpiWrite(slot, a, args[4], &args[5],
 				   (alen > 5) ? (uint16_t)(alen - 5) : 0);
 		}
 		p[12] = 0x80; // write ACK
@@ -523,7 +598,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 	// set input report mode (0x30 = standard full) -> begin streaming
 	case 0x03:
 		if (alen >= 1 && args[0] == 0x30)
-			g_swProReportMode = 0x30;
+			g_swProReportMode[slot] = 0x30;
 		p[12] = 0x80;
 		break;
 	// trigger buttons elapsed time -> canned reply (genuine pad returns data)
@@ -545,13 +620,12 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		// 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
 		break;
 	}
-	jcEnq(0x21, p, JC_REPLEN);
+	jcEnq(slot, 0x21, p, JC_REPLEN);
 }
-// USB-ISR callback for the Pro Controller's OUT endpoint. Data arrives via the interrupt OUT endpoint, where
-// this TinyUSB core passes report_id=0 and leaves the real report id in buffer[0] (it does NOT strip it). Also
-// accept the control SET_REPORT shape (id already in `rid`) for safety. report 0x80 = USB handshake; 0x01 = subcommand.
-static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		  uint16_t n)
+// USB-ISR callback for the Pro Controller's OUT endpoint. Per-slot dispatch via per-instance callback
+// (jcSet##N closes over the slot index). Report 0x80 = USB handshake; 0x01 = subcommand.
+static void jcSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			uint8_t const *b, uint16_t n)
 {
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
@@ -572,18 +646,24 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 		if (pn < 1)
 			return;
 		if (p[0] == 0x01) {
-			uint8_t d[9] = { 0x01,	    0x00,      0x03,
-					 JC_MAC[0], JC_MAC[1], JC_MAC[2],
-					 JC_MAC[3], JC_MAC[4], JC_MAC[5] };
-			jcEnq(0x81, d, 9);
+			uint8_t d[9] = { 0x01,
+					 0x00,
+					 0x03,
+					 g_jcMac[slot][0],
+					 g_jcMac[slot][1],
+					 g_jcMac[slot][2],
+					 g_jcMac[slot][3],
+					 g_jcMac[slot][4],
+					 g_jcMac[slot][5] };
+			jcEnq(slot, 0x81, d, 9);
 		} // device type + MAC
 		else if (p[0] == 0x02) {
 			uint8_t d[1] = { 0x02 };
-			jcEnq(0x81, d, 1);
+			jcEnq(slot, 0x81, d, 1);
 		} // handshake
 		else if (p[0] == 0x03) {
 			uint8_t d[1] = { 0x03 };
-			jcEnq(0x81, d, 1);
+			jcEnq(slot, 0x81, d, 1);
 		} // set baudrate
 		// 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
 		return;
@@ -591,68 +671,113 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	if (id == 0x01) { // [timer][rumble x8][subcmd][args...]
 		if (pn < 10)
 			return;
-		jcRumble(p, pn);
-		jcSubcmd(p[9], p + 10, (pn > 10) ? (uint16_t)(pn - 10) : 0);
+		jcRumble(slot, p, pn);
+		jcSubcmd(slot, p[9], p + 10,
+			 (pn > 10) ? (uint16_t)(pn - 10) : 0);
 		return;
 	}
 	if (id == 0x10) {
-		jcRumble(p, pn);
+		jcRumble(slot, p, pn);
 		return;
 	}
 	// report 0x82: ignored
 }
+#define JCCB(N)                                                                \
+	static void jcSet##N(uint8_t r, hid_report_type_t t, uint8_t const *b, \
+			     uint16_t n)                                       \
+	{                                                                      \
+		jcSetCommon(N, r, t, b, n);                                    \
+	}
+// clang-format off
+JCCB(0)
+JCCB(1)
+JCCB(2)
+JCCB(3)
+// clang-format on
+typedef void (*jc_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			   uint16_t);
+static jc_setcb_t const JC_SETCB[NSLOT] = { jcSet0, jcSet1, jcSet2, jcSet3 };
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void SwitchProController::begin()
+{
+}
+// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the Pro Controller pool.
+uint8_t SwitchProController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SwitchProController::usbIdentity()
 {
 	USBDevice.setID(0x057E, 0x2009);
-	// bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
-	USBDevice.setDeviceVersion(0x0212);
+	USBDevice.setDeviceVersion(0x0220);
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setProductDescriptor("Pro Controller");
+}
+void SwitchProController::beginPool()
+{
 	jcBuildStickCal();
-
-	// restore any persisted Switch motion calibration (per-unit IMU bias correction)
 	loadUserCal();
-	swProLoadCfg(); // restore report-rate + gyro-scale settings
-	g_swPro.enableOutEndpoint(true);
-	// answer the Nintendo USB handshake + subcommands (else Steam never binds it)
-	g_swPro.setReportCallback(NULL, jcSet);
-	g_swPro.setReportDescriptor(SWPRO_HID_DESC, sizeof SWPRO_HID_DESC);
-
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_swPro.setPollInterval(1);
-	g_swPro.begin();
+	swProLoadCfg();
+	initJcMacs();
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
+		g_swPro[s].enableOutEndpoint(true);
+		g_swPro[s].setReportCallback(NULL, JC_SETCB[s]);
+		g_swPro[s].setReportDescriptor(SWPRO_HID_DESC,
+					       sizeof SWPRO_HID_DESC);
+		g_swPro[s].setPollInterval(1);
+		g_swPro[s].begin();
+	}
 }
-void SwitchProController::task()
+void SwitchProController::mountSlots(uint8_t k)
 {
-	if (!g_swPro.ready())
-		return;
-	// Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst is one save).
-	if (g_userCalDirty &&
-	    (unsigned long)(millis() - g_userCalDirtyMs) > 250u) {
-		g_userCalDirty = false;
-		saveUserCal();
+	for (uint8_t u = 0; u < k; u++) {
+		// Each re-enumeration restarts the host handshake -- clear this USB slot's gate + reply FIFO so we
+		// don't stream 0x30 before the (new) host has re-selected report mode.
+		g_swProReportMode[u] = 0;
+		g_jcQh[u] = g_jcQt[u] = 0;
+		USBDevice.addInterface(g_swPro[u]);
 	}
-	if (g_jcQh !=
-	    g_jcQt) { // drain handshake/subcommand replies first (ordered)
-		JcRep *r = &g_jcQ[g_jcQh];
-		if (g_swPro.sendReport(r->rid, r->data, JC_REPLEN))
-			g_jcQh = (uint8_t)((g_jcQh + 1) % JCQ_N);
-		return; // one report per call; the rest next loop
+}
+void SwitchProController::task()
+{
+	for (uint8_t s = 0; s < g_usbMountCount; s++) {
+		if (!g_swPro[s].ready())
+			continue;
+		// Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst
+		// is one save). Per-slot: each Pro Controller saves its own mirror.
+		if (g_userCalDirty[s] &&
+		    (unsigned long)(millis() - g_userCalDirtyMs[s]) > 250u) {
+			g_userCalDirty[s] = false;
+			saveUserCal((uint8_t)s);
+		}
+		// drain handshake/subcommand replies first (ordered) -- one report per slot per call so a
+		// bursty host-init doesn't starve the streamed 0x30
+		if (g_jcQh[s] != g_jcQt[s]) {
+			JcRep *r = &g_jcQ[s][g_jcQh[s]];
+			if (g_swPro[s].sendReport(r->rid, r->data, JC_REPLEN))
+				g_jcQh[s] = (uint8_t)((g_jcQh[s] + 1) % JCQ_N);
+			// one report per slot per call; the rest next loop
+			continue;
+		}
+		// not until the host has finished init + selected 0x30
+		if (g_swProReportMode[s] != 0x30)
+			continue;
+		// The Switch integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores
+		// the timer byte and assumes a 3-samples-per-15ms genuine cadence). Streaming faster (e.g. 250 Hz x 3
+		// = 750 samples/s) over-credits gyro rotation ~3.75x, so residual bias accumulates into the slow
+		// orientation lean that builds over minutes and resets on replug. 15 ms matches the genuine push,
+		// making integration 1:1.
+		uint32_t interval = (g_swProRate == 2) ? USB_STREAM_MS :
+				    (g_swProRate == 1) ? SW_PRO_REPORT_MS_120 :
+							 SW_PRO_REPORT_MS;
+		if (millis() - g_swProLastMs[s] < interval)
+			continue;
+		g_swProLastMs[s] = millis();
+		uint8_t p[63];
+		switchProBuild((uint8_t)s, p);
+		g_swPro[s].sendReport(0x30, p, sizeof p);
 	}
-	if (g_swProReportMode != 0x30)
-		return; // not until the host has finished init + selected 0x30
-	// The Switch integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores the timer
-	// byte and assumes a 3-samples-per-15ms genuine cadence). Streaming faster (e.g. 250 Hz x 3 = 750 samples/s)
-	// over-credits gyro rotation ~3.75x, so residual bias accumulates into the slow orientation lean that builds over
-	// minutes and resets on replug. 15 ms matches the genuine push, making integration 1:1.
-	uint32_t interval = (g_swProRate == 2) ? USB_STREAM_MS :
-			    (g_swProRate == 1) ? SW_PRO_REPORT_MS_120 :
-						 SW_PRO_REPORT_MS;
-	if (millis() - g_swProLastMs < interval)
-		return;
-	g_swProLastMs = millis();
-	uint8_t p[63];
-	switchProBuild(p);
-	g_swPro.sendReport(0x30, p, sizeof p);
 }

+ 8 - 0
OpenPuck/mode_switch_pro.h

@@ -12,5 +12,13 @@ class SwitchProController : public IController {
     public:
 	void begin() override;
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern SwitchProController g_switchPro;

+ 161 - 52
OpenPuck/mode_xinput.cpp

@@ -1,9 +1,21 @@
+// mode_xinput.cpp -- Xbox 360 wired (XInput) personality (MODE_XBOX): one XInput gamepad per bonded controller.
+//
+// Real Xbox 360 pads are NOT HID: they use a vendor interface (class 0xFF / sub 0x5D / proto 0x01) carrying a
+// 20-byte XInput report. A custom TinyUSB class driver serves that interface plus a second boot-mouse interface
+// for the right trackpad. Host binds by VID/PID (045E:028E) + the FF/5D/01 interface. The OUT endpoint carries
+// rumble, relayed to the controller as a haptic by task().
+//
+// Multi-controller: one XInput interface per CONNECTED controller (dynamic mount -- see usb_mount.h; the device
+// re-enumerates without rebooting as controllers connect/disconnect). The single class driver routes xfer/open
+// callbacks by endpoint address into the per-bond-slot state; xi_open claims slots in connection order. The
+// right-pad mouse is a single shared interface (only bond slot 0's right-pad drives it).
 #include "mode_xinput.h"
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -35,21 +47,33 @@ enum {
 
 // ===================== XInput custom TinyUSB class driver =====================
 // Custom class driver + an Adafruit_USBD_Interface subclass emitting the interface + 0x21-blob + 2 endpoints.
+// Per-slot state lives in g_xiSlot[]: each XInput interface gets one. The class driver dispatches open/xfer
+// by endpoint address; xinputSend() targets a specific slot's IN endpoint.
 #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];
-
-// XInput rumble strengths from the host's OUT packet; translated to Steam/Triton report 0x80.
-static volatile uint16_t g_rumbleLow = 0, g_rumbleHigh = 0;
-static volatile unsigned long g_rumbleMs =
-	0; // millis of last rumble OUT packet (stuck-rumble watchdog)
 
+// per-slot state. `inUse` is set in xi_open from the USB ISR and read in xinputSend from the loop -- mark
+// volatile so the loop doesn't observe a stale 0 across a freshly-attached interface.
+struct XiSlot {
+	uint8_t itf, epIn, epOut;
+	uint8_t inBuf[32], outBuf[32];
+	volatile uint16_t rumbleLow,
+		rumbleHigh; // last host rumble values, x 257
+	volatile unsigned long
+		rumbleMs; // millis of last OUT packet (stuck-rumble watchdog)
+	volatile bool inUse;
+};
+static XiSlot g_xiSlot[NSLOT];
+// Dynamic mount: xi_open is called once per XInput interface in descriptor order; this counts them so the
+// u-th interface claims the u-th connected controller (g_usbToBond[u]). Reset on every (re)enumeration.
+static uint8_t g_xiOpenU = 0;
 // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
 #define RUMBLE_STUCK_MS 2500u
 
 static void xi_init(void)
 {
+	for (int s = 0; s < NSLOT; s++)
+		g_xiSlot[s].inUse = false;
 }
 static bool xi_deinit(void)
 {
@@ -58,15 +82,37 @@ static bool xi_deinit(void)
 static void xi_reset(uint8_t rhport)
 {
 	(void)rhport;
-	g_xiEpIn = g_xiEpOut = 0;
+	g_xiOpenU = 0; // restart per-interface claim ordering for this enumeration
+	for (int s = 0; s < NSLOT; s++) {
+		g_xiSlot[s].itf = 0;
+		g_xiSlot[s].epIn = g_xiSlot[s].epOut = 0;
+		g_xiSlot[s].inUse = false;
+	}
 }
+// TinyUSB calls xi_open once per XInput interface in the config descriptor. Claim the first free BONDED
+// slot so the xiSlot index matches the bond-slot index (begin() registers them in ascending bond-slot order).
+// Fallback to any free slot when there are no bonds (fresh device).
 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;
+	// The u-th XInput interface in the descriptor serves the u-th connected controller. g_xiSlot is keyed by
+	// BOND slot so onReport45(bond)/rumble route straight through; only connected bonds get an interface.
+	uint8_t u = g_xiOpenU++;
+	int slot = (u < g_usbMountCount) ? g_usbToBond[u] : -1;
+	if (slot < 0) { // fallback (e.g. map not built): first free slot
+		for (int s = 0; s < NSLOT; s++)
+			if (!g_xiSlot[s].inUse) {
+				slot = s;
+				break;
+			}
+	}
+	if (slot < 0 || slot >= NSLOT || g_xiSlot[slot].inUse)
+		return 0;
+	XiSlot &S = g_xiSlot[slot];
+	S.itf = itf->bInterfaceNumber;
 	uint8_t const *p = (uint8_t const *)itf;
 	uint8_t const *end = p + max_len;
 	uint16_t used = itf->bLength;
@@ -79,17 +125,18 @@ static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 				(tusb_desc_endpoint_t const *)p;
 			usbd_edpt_open(rhport, ep);
 			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
-				g_xiEpIn = ep->bEndpointAddress;
+				S.epIn = ep->bEndpointAddress;
 			else
-				g_xiEpOut = ep->bEndpointAddress;
+				S.epOut = 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)
+	S.inUse = true;
+	// arm OUT (rumble/LED)
+	if (S.epOut)
+		usbd_edpt_xfer(rhport, S.epOut, S.outBuf, sizeof S.outBuf);
 	return used;
 }
 static bool xi_ctrl(uint8_t rhport, uint8_t stage,
@@ -99,21 +146,30 @@ static bool xi_ctrl(uint8_t rhport, uint8_t stage,
 	(void)req;
 	return stage != CONTROL_STAGE_SETUP;
 }
+// Route an endpoint xfer callback to the slot that owns that endpoint.
 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_rumbleLow = (uint16_t)big * 257u;
-			g_rumbleHigh = (uint16_t)sml * 257u;
-			g_rumbleMs =
-				millis(); // stamp for the stuck-rumble watchdog
-			hapticSteamRumble(g_rumbleLow, g_rumbleHigh);
+	for (int s = 0; s < NSLOT; s++) {
+		XiSlot &S = g_xiSlot[s];
+		if (!S.inUse)
+			continue;
+		if (ep == S.epOut) {
+			// XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
+			if (n >= 5 && S.outBuf[0] == 0x00 &&
+			    S.outBuf[1] == 0x08) {
+				uint8_t big = S.outBuf[3], sml = S.outBuf[4];
+				uint16_t lo = (uint16_t)big * 257u;
+				uint16_t hi = (uint16_t)sml * 257u;
+				S.rumbleLow = lo;
+				S.rumbleHigh = hi;
+				S.rumbleMs = millis();
+				hapticSteamRumble(lo, hi, (uint8_t)s);
+			}
+			usbd_edpt_xfer(rhport, S.epOut, S.outBuf,
+				       sizeof S.outBuf);
+			return true;
 		}
-		usbd_edpt_xfer(rhport, g_xiEpOut, g_xiOutBuf,
-			       sizeof g_xiOutBuf); // re-arm OUT
 	}
 	return true;
 }
@@ -166,13 +222,18 @@ class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
 		return TinyUSBDevice.addInterface(*this);
 	}
 };
-static 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)
+// NSLOT instances: each registers its own XInput interface (own itfnum + IN/OUT ep pair) during begin().
+// begin() in XboxController::begin() runs in order 0..NSLOT-1, so xi_open's first-free assignment lines up.
+static Adafruit_USBD_XInput g_xinput[NSLOT];
+static void xinputSend(uint8_t slot, 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))
+	if (slot >= NSLOT || !g_xiSlot[slot].inUse)
 		return;
-	uint8_t *r = g_xiInBuf;
+	XiSlot &S = g_xiSlot[slot];
+	if (!tud_mounted() || S.epIn == 0 || usbd_edpt_busy(0, S.epIn))
+		return;
+	uint8_t *r = S.inBuf;
 	r[0] = 0x00;
 	r[1] = 0x14;
 	r[2] = buttons & 0xFF;
@@ -188,15 +249,16 @@ static void xinputSend(uint16_t buttons, uint8_t lt, uint8_t rt, int16_t lx,
 	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);
+	if (usbd_edpt_claim(0, S.epIn)) {
+		if (!usbd_edpt_xfer(0, S.epIn, r, 20))
+			usbd_edpt_release(0, S.epIn);
 	}
 }
 
 // ===================== right-pad mouse interface =====================
 static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
-static Adafruit_USBD_HID g_mouse; // Xbox-mode mouse interface (right trackpad)
+static Adafruit_USBD_HID
+	g_mouse; // Xbox-mode mouse interface (right trackpad, slot 0 only)
 
 // ===================== report 0x45 -> XInput + mouse =====================
 // button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright
@@ -237,7 +299,7 @@ static uint16_t codeToXB(uint8_t c)
 		return 0;
 	}
 }
-static void rfXboxGamepad(const uint8_t *r)
+static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 {
 	uint32_t b = btnsOf(r);
 	if (g_qamMap && (b & TB_QAM)) {
@@ -290,13 +352,31 @@ static void rfXboxGamepad(const uint8_t *r)
 	uint8_t lt = trigU8(u16off(r, 4)),
 		rt = trigU8(u16off(
 			r, 6)); // triggers u16 (half-scale) -> full-range u8
-	xinputSend(btn, lt, rt, (int16_t)s16off(r, 8),
+	// Trigger remaps (codes 19=LT, 20=RT): XInput triggers are analog bytes, not buttons, so a back paddle /
+	// QAM mapped to a trigger pulls it full. QAM arrives as TB_L2/TB_R2 (folded into b via tritonFromCode);
+	// back paddles are matched by their configured code.
+	if (b & TB_L2)
+		lt = 0xFF;
+	if (b & TB_R2)
+		rt = 0xFF;
+	const uint8_t bc[4] = { g_back[0], g_back[1], g_back[2], g_back[3] };
+	const uint32_t bm[4] = { TB_L4, TB_R4, TB_L5, TB_R5 };
+	for (int i = 0; i < 4; i++) {
+		if (!(b & bm[i]))
+			continue;
+		if (bc[i] == 19)
+			lt = 0xFF;
+		else if (bc[i] == 20)
+			rt = 0xFF;
+	}
+	xinputSend(slot, 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 on a second HID-mouse interface alongside the XInput gamepad. Same glide model as Lizard's
-// right pad; RPad click = left button, LPad click = right.
+// right pad; RPad click = left button, LPad click = right. SINGLE shared mouse: the desktop can only consume
+// one mouse. Slot 0's right-pad drives it; other slots' right-pad input is intentionally ignored here.
 static void rfXboxMouse(const uint8_t *r)
 {
 	uint32_t b = btnsOf(r);
@@ -354,39 +434,68 @@ static void rfXboxMouse(const uint8_t *r)
 }
 
 // ===================== IController =====================
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void XboxController::begin()
 {
-	g_rumbleLow = g_rumbleHigh = 0;
-
+}
+// XInput slot interfaces are a custom (non-HID) class, so they don't draw on the CFG_TUD_HID budget (only the
+// wake mouse + right-pad mouse do). Slots are limited by NSLOT / USB endpoints, not HID instances.
+uint8_t XboxController::maxSlots() const
+{
+	return (uint8_t)NSLOT;
+}
+void XboxController::usbIdentity()
+{
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
-	// Windows caches the config descriptor by VID:PID:bcdDevice, so any interface change here MUST bump bcdDevice
-	// or Windows serves a stale descriptor.
-	USBDevice.setDeviceVersion(0x0115);
+	USBDevice.setDeviceVersion(0x0120);
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");
-	g_xinput.setStringDescriptor("Controller");
-	g_xinput.begin();
+}
+void XboxController::beginPool()
+{
+	// Lock the right-pad mouse HID instance (wake mouse was begun first by setup -> this is HID instance 1).
 	g_mouse.setStringDescriptor("OpenPuck Mouse");
 	g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
 	g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC);
 	g_mouse.setPollInterval(1);
 	g_mouse.begin();
+	for (int s = 0; s < NSLOT; s++)
+		g_xinput[s].setStringDescriptor("Controller");
 }
-void XboxController::onReport45(const uint8_t *rep, bool fresh,
+void XboxController::mountSlots(uint8_t k)
+{
+	// Right-pad mouse first (fixed HID), then one XInput interface per connected controller (claimed in
+	// order by xi_open). The wake mouse + WebUSB are added around this by usbReenumerate.
+	USBDevice.addInterface(g_mouse);
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_xinput[u]);
+}
+void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)
 {
 	(void)fresh;
 	(void)bodyTlen;
-	rfXboxGamepad(rep);
-	rfXboxMouse(rep);
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	rfXboxGamepad((uint8_t)slot, rep);
+	// Shared desktop mouse: slot 0's right-pad only. Other slots' right-pad is still part of the
+	// per-slot XInput (TB_RPADT in the gamepad report is unused today but kept for forward-compat).
+	if (slot == 0)
+		rfXboxMouse(rep);
 }
-// Lost-stop watchdog: Steam/Triton rumble is latched, so force a zero report if the host stops refreshing.
+// Lost-stop watchdog per slot: Steam/Triton rumble is latched, so force a zero report if the host stops
+// refreshing on a particular XInput. The haptics.cpp global watchdog fires too, but it skips while the slot
+// is in the post-reconnect block -- this catches a host that kept streaming while we were blocked.
 void XboxController::task()
 {
-	if ((g_rumbleLow || g_rumbleHigh) &&
-	    millis() - g_rumbleMs > RUMBLE_STUCK_MS) {
-		g_rumbleLow = g_rumbleHigh = 0;
-		hapticSteamRumble(0, 0);
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_xiSlot[s].inUse)
+			continue;
+		if ((g_xiSlot[s].rumbleLow || g_xiSlot[s].rumbleHigh) &&
+		    millis() - g_xiSlot[s].rumbleMs > RUMBLE_STUCK_MS) {
+			g_xiSlot[s].rumbleLow = g_xiSlot[s].rumbleHigh = 0;
+			hapticSteamRumble(0, 0, (uint8_t)s);
+		}
 	}
 }

+ 9 - 1
OpenPuck/mode_xinput.h

@@ -11,10 +11,18 @@
 class XboxController : public IController {
     public:
 	void begin() override;
-	void onReport45(const uint8_t *rep, bool fresh,
+	void onReport45(int slot, const uint8_t *rep, bool fresh,
 			uint8_t bodyTlen) override;
 
 	// legacy XInput rumble -> haptic relay + stuck-rumble watchdog
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern XboxController g_xboxCtl;

+ 117 - 65
OpenPuck/puck_hid.cpp

@@ -154,11 +154,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 		if (rid >= 0x80 && rid <= 0x86 && n >= 1 &&
 		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
-			if (!haptic82Blocked()) {
+			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
 					     (uint8_t)(n > RELAY_MAXP ?
 							       RELAY_MAXP :
-							       n));
+							       n),
+					     (uint8_t)slot);
 				// Track on/off from what was actually RELAYED (= the controller's believed state): a
 				// BLOCKED stop must leave "on" set (controller may be latched -> reconnect stop-burst),
 				// a blocked ON must not set it (nothing reached the controller -> no spurious clicks).
@@ -205,7 +206,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
 		bool relayOk = hapticRelaySlotOk(slot) &&
 			       !(haptic82 && (lizardActive() || muted));
-		if (relayOk && (!haptic82 || !haptic82Blocked())) {
+		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
 			// Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's
 			// multi-register 0x87 settings blocks (LED brightness) and calibration writes exceed the old
 			// 18B cap, and the chopped frames were why those settings never landed on the controller.
@@ -214,7 +215,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				Serial.printf(
 					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
 					cmd, len, (unsigned)RELAY_MAXP);
-			relayEnqueue(cmd, pl, rl);
+			relayEnqueue(cmd, pl, rl, (uint8_t)slot);
 
 			// track from RELAYED frames only (see the OUTPUT path)
 			if (haptic82)
@@ -257,8 +258,9 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		hostStampAlive();
 		S.resp[0] = 0xB4;
 		S.resp[1] = 0x01;
-		S.resp[2] = (slot == g_connSlot && !g_xbox &&
-			     (millis() - g_connReplyMs < 500)) ?
+		S.resp[2] = (slot >= 0 && slot < NSLOT && !g_xbox &&
+			     g_slot[slot].used &&
+			     (millis() - g_connReplyMs[slot] < 500)) ?
 				    0x02 :
 				    0x01;
 		S.resp_len = 63;
@@ -377,10 +379,15 @@ void SteamPuckController::begin()
 }
 
 // Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. PURELY a
-// USB-side decision -- changes nothing about the RF poll or the host->controller relay.
-void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
+// USB-side decision -- changes nothing about the RF poll or the host->controller relay. Per-slot: each
+// controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard remains
+// single-controller (slot 0); the other slots stay quiet in lizard mode -- one mouse + one keyboard is
+// the only thing a desktop can consume, and the comment in mode_lizard.h documents this.
+void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				     uint8_t bodyTlen)
 {
+	(void)fresh;
+	(void)bodyTlen;
 	// Host asleep -> forward NOTHING. While suspended, every sendReport attempt can translate into a host wake,
 	// making the PC wake on any controller movement. Waking is an explicit gesture only (Steam-button short
 	// press / controller connect), handled in rf_link.cpp via the device-level USB resume signal.
@@ -390,10 +397,15 @@ void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
 	// the freshly-woken desktop.
 	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
 		return;
-	if (g_connSlot < 0 || g_connSlot >= NSLOT)
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	if (!g_slot[slot].used)
 		return;
 	if (lizardActive()) {
-		rfLizard(rep, &hid[g_connSlot], &hid[g_connSlot], 0x40, 0x41);
+		// Lizard single-controller: only slot 0's interface drives the mouse/keyboard. Other slots
+		// stay quiet in lizard mode.
+		if (slot == 0)
+			rfLizard(rep, &hid[0], &hid[0], 0x40, 0x41);
 	} else {
 		uint8_t blen = bodyTlen - 1;
 		if (blen > 45)
@@ -401,9 +413,8 @@ void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
 		// forward the puck's raw pad coords untouched (Steam does its own interpolation/smoothing). Forward
 		// only FRESH reports: the real puck dedupes, so stale repeats make 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(
+		if ((fresh || !g_fwdNewOnly) && hid[slot].ready())
+			hid[slot].sendReport(
 				0x45, rep + 1,
 				blen); // Steam/SDL Triton: input report 0x45
 	}
@@ -411,69 +422,100 @@ void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
 
 // Forward the controller's NON-input status reports (0x43 power/battery, 0x44) to Steam verbatim -- the real
 // puck does this and it's how Steam reads battery. Same host-asleep / post-resume gating as 0x45; no lizard
-// path (status reports aren't input, so they forward regardless of the lizard decision).
-void SteamPuckController::onAuxReport(uint8_t rid, const uint8_t *data,
-				      uint8_t n)
+// path (status reports aren't input, so they forward regardless of the lizard decision). Per-slot: each
+// controller's status goes to its own hid[slot].
+void SteamPuckController::onAuxReport(int slot, uint8_t rid,
+				      const uint8_t *data, uint8_t n)
 {
 	if (USBDevice.suspended())
 		return;
 	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
 		return;
-	if (g_connSlot < 0 || g_connSlot >= NSLOT)
+	if (slot < 0 || slot >= NSLOT)
 		return;
-	if (g_slot[g_connSlot].used && hid[g_connSlot].ready())
-		hid[g_connSlot].sendReport(rid, data, n);
+	// Forward the controller's status report VERBATIM (the real puck does this; it's how the host reads
+	// battery). Padding the report to the descriptor-declared length broke battery in both lizard and
+	// Steam, so it's reverted -- send exactly what the controller sent.
+	if (g_slot[slot].used && hid[slot].ready())
+		hid[slot].sendReport(rid, data, n);
 }
 
 // wake nudge: a bare USB resume signal is NOT enough to wake some hosts (Windows in particular) -- they only
 // wake when actual mouse/keyboard input follows. So on a deliberate wake gesture we play a HARMLESS mouse
 // JIGGLE (move a few px right, then back -- NET ZERO cursor, NO button): real mouse activity wakes the host
 // but clicks/activates nothing (an open Start menu stays open). Queued by wakeEvent() (rf_link, on a Steam
-// short press / controller connect while suspended); delivered once the suspended bus has resumed.
-static uint8_t g_nudgeStep = 0; // 0=idle; 1=jiggle+, 2=jiggle-
-static unsigned long g_nudgeMs = 0;
+// short press / controller connect while suspended); delivered once the suspended bus has resumed. Per-slot
+// so every connected interface gets the jiggle (Windows can credit any of them with the wake).
+static uint8_t g_nudgeStep[NSLOT] = { 0 }; // 0=idle; 1=jiggle+, 2=jiggle-
+static unsigned long g_nudgeMs[NSLOT] = { 0 };
 #define NUDGE_JIGGLE_PX 10
 void SteamPuckController::wakeEvent()
 {
-	g_nudgeStep = 1;
-	g_nudgeMs = millis();
+	// arm the jiggle on every connected slot -- any one of them is enough to wake Windows
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_slot[s].used) {
+			g_nudgeStep[s] = 1;
+			g_nudgeMs[s] = millis();
+		}
+	}
 }
 static void wakeNudgeTask()
 {
-	if (!g_nudgeStep)
-		return;
-	if (millis() - g_nudgeMs > 5000) {
-		g_nudgeStep = 0;
-		return;
-	} // bus never resumed -> drop the nudge
 	if (USBDevice.suspended())
 		return; // wait for resume; reports can't cross a suspended bus
-	// gate on the interface we'll actually send on: boot mouse normally, slot HID only on the debug-CDC fallback
+	// Expire stale arms (bus never resumed) and see if any slot still wants a wake nudge.
+	bool armed = false;
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_nudgeStep[s])
+			continue;
+		if (millis() - g_nudgeMs[s] > 5000) {
+			g_nudgeStep[s] = 0;
+			continue;
+		}
+		armed = true;
+	}
+	if (!armed)
+		return;
+	// Ride the BOOT MOUSE -- the interface Windows armed as the wake source; a gamepad-slot report does not
+	// wake Modern Standby. A single jiggle on it wakes the host regardless of how many slots are connected.
 	if (wakeHidPresent()) {
 		if (!wakeHidReady())
 			return;
-	} else if (g_connSlot < 0 || g_connSlot >= NSLOT ||
-		   !hid[g_connSlot].ready())
+		static unsigned long stepMs = 0;
+		static uint8_t step = 1;
+		if (millis() - stepMs < 15)
+			return; // pace the edges
+		stepMs = millis();
+		wakeHidMove((step == 1) ? NUDGE_JIGGLE_PX : -NUDGE_JIGGLE_PX, 0);
+		if (step >= 2) { // jiggle (right, then back) delivered -> disarm every slot
+			step = 1;
+			for (int s = 0; s < NSLOT; s++)
+				g_nudgeStep[s] = 0;
+		} else
+			step++;
 		return;
-	static unsigned long stepMs = 0;
-	if (millis() - stepMs < 15)
-		return; // pace the edges
-	stepMs = millis();
-	int8_t dx = (g_nudgeStep == 1) ? NUDGE_JIGGLE_PX : -NUDGE_JIGGLE_PX;
-	// Ride the boot mouse -- the interface the host armed as the wake source. Only fall back to the gamepad slot
-	// on the debug-CDC boot, where the wake mouse isn't registered (and the host can't wake from it anyway).
-	if (wakeHidPresent())
-		wakeHidMove(dx, 0); // jiggle right, then back
-	else {
+	}
+	// Fallback (debug-CDC boot: no wake mouse): per-slot jiggle on the gamepad slot HIDs.
+	static unsigned long stepMs[NSLOT] = { 0 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_nudgeStep[s])
+			continue;
+		if (!hid[s].ready())
+			continue;
+		if (millis() - stepMs[s] < 15)
+			continue; // pace the edges
+		stepMs[s] = millis();
 		hid_mouse_report_t m;
 		m.buttons = 0;
-		m.x = dx;
+		m.x = (g_nudgeStep[s] == 1) ? NUDGE_JIGGLE_PX : -NUDGE_JIGGLE_PX;
 		m.y = 0;
 		m.wheel = 0;
 		m.pan = 0;
-		hid[g_connSlot].sendReport(0x40, &m, sizeof m);
+		hid[s].sendReport(0x40, &m, sizeof m); // jiggle right, then back
+		g_nudgeStep[s] = (g_nudgeStep[s] >= 2) ?
+					 0 :
+					 (uint8_t)(g_nudgeStep[s] + 1);
 	}
-	g_nudgeStep = (g_nudgeStep >= 2) ? 0 : (uint8_t)(g_nudgeStep + 1);
 }
 
 // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
@@ -495,26 +537,33 @@ void SteamPuckController::task()
 	// no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
 	if (USBDevice.suspended())
 		return;
-	static bool usbConn = false;
-	static unsigned long last79 = 0, last7B = 0, connEdgeMs = 0;
-	bool conn = (g_connSlot >= 0 && millis() - g_connReplyMs < 300);
-	if (g_connSlot >= 0 && g_connSlot < NSLOT && hid[g_connSlot].ready()) {
+	// Per-slot 0x79/0x7B: each connected slot reports its OWN edge and its OWN status. State arrays are
+	// per-slot so each controller's "connected" edge fires once and is re-sent only until Steam acks THAT
+	// slot. The real puck's per-slot edge-triggered 0x79 prevents re-triggering Steam's connect-chime loop.
+	static bool usbConn[NSLOT] = { 0 };
+	static unsigned long last79[NSLOT] = { 0 }, last7B[NSLOT] = { 0 },
+			     connEdgeMs[NSLOT] = { 0 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_slot[s].used || !hid[s].ready())
+			continue;
+		bool conn = (millis() - g_connReplyMs[s] < 300);
 		// 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
 		// settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; an
 		// unconditional forever-resend re-triggers Steam's connect handling (connect chime) every 750ms before
 		// Steam consumes 0x45 -> a loop of connect-time haptic buzzes. Resending until acked still covers
 		// "Steam missed the edge".
 		bool steamAcked = g_steamAliveMs &&
-				  (int32_t)(g_steamAliveMs - connEdgeMs) >= 0;
-		if (conn != usbConn ||
-		    (conn && !steamAcked && millis() - last79 >= 750)) {
-			if (conn && !usbConn)
-				connEdgeMs = millis();
+				  (int32_t)(g_steamAliveMs - connEdgeMs[s]) >=
+					  0;
+		if (conn != usbConn[s] ||
+		    (conn && !steamAcked && millis() - last79[s] >= 750)) {
+			if (conn && !usbConn[s])
+				connEdgeMs[s] = millis();
 			uint8_t st = conn ? 0x02 : 0x01;
-			hid[g_connSlot].sendReport(0x79, &st, 1);
-			usbConn = conn;
-			last79 = millis();
-		} else if (conn && millis() - last7B >= 2000) {
+			hid[s].sendReport(0x79, &st, 1);
+			usbConn[s] = conn;
+			last79[s] = millis();
+		} else if (conn && millis() - last7B[s] >= 2000) {
 			// 0x7B status, live-captured template. Byte 8 is the controller->puck signal strength as signed
 			// dBm (capture showed 0xDD = -35) -- patch in the smoothed RSSI the radio samples on each
 			// controller reply (rf_link). 0 = no sample yet -> keep the capture value rather than garbage.
@@ -526,17 +575,20 @@ void SteamPuckController::task()
 			// so the bar tracks usable range, not antenna gain. Clamp keeps it in a sane window.
 			uint8_t s7b[12] = { 0xF7, 0x01, 0x89, 0x00, 0x00, 0x00,
 					    0x03, 0x00, 0xDD, 0x00, 0x3A, 0x02 };
-			if (g_linkRssi) {
-				int mag = (int)g_linkRssi - RSSI_DBM_OFFSET;
+			if (g_linkRssi[s]) {
+				int mag = (int)g_linkRssi[s] - RSSI_DBM_OFFSET;
 				if (mag < 25)
 					mag = 25;
 				else if (mag > 95)
 					mag = 95;
 				s7b[8] = (uint8_t)(0u - (uint8_t)mag);
 			}
-			hid[g_connSlot].sendReport(0x7B, s7b, 12);
-			last7B = millis();
+			hid[s].sendReport(0x7B, s7b, 12);
+			last7B[s] = millis();
 		}
-	} else if (!conn)
-		usbConn = false;
+	}
+	// Reset edge state for slots that are no longer used/ready (so a re-bond sees a fresh edge).
+	for (int s = 0; s < NSLOT; s++)
+		if (!g_slot[s].used || !hid[s].ready())
+			usbConn[s] = false;
 }

+ 2 - 2
OpenPuck/puck_hid.h

@@ -21,9 +21,9 @@ extern uint8_t g_fwdNewOnly;
 class SteamPuckController : public IController {
     public:
 	void begin() override;
-	void onReport45(const uint8_t *rep, bool fresh,
+	void onReport45(int slot, const uint8_t *rep, bool fresh,
 			uint8_t bodyTlen) override;
-	void onAuxReport(uint8_t rid, const uint8_t *data, uint8_t n)
+	void onAuxReport(int slot, uint8_t rid, const uint8_t *data, uint8_t n)
 		override; // forward controller 0x43/0x44 status to Steam
 	void task() override;
 

+ 51 - 16
OpenPuck/radio.cpp

@@ -1,4 +1,5 @@
 #include "radio.h"
+#include "bonds.h" // g_slot[]
 
 // Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key preceding "ibex" is a DIFFERENT key,
 // NOT the discovery address.
@@ -10,32 +11,66 @@ uint8_t g_rfBase[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
 // data bursts -> reply-rate crash. Tunable 'C'.
 uint8_t g_sessCh = 18;
 
-// Safe default; rfGenSessionAddr() overwrites with the per-device value at boot.
-uint8_t g_sessBase[4] = { 0x69, 0x62, 0x65, 0x78 };
-uint8_t g_sessPrefix = 0x10;
-
-void rfGenSessionAddr()
+// Safe defaults; rfGenSessionAddr(slot) overwrites each used slot at boot. Discovery base "ibex" as a
+// degenerate starting point means an UN-bonded slot (and a not-yet-initialized one) won't accidentally
+// cross-talk with itself before the real address is set.
+uint8_t g_sessBase[NSLOT][4];
+uint8_t g_sessPrefix[NSLOT];
+static void sessSetDefault(int slot)
+{
+	for (int i = 0; i < 4; i++)
+		g_sessBase[slot][i] = g_rfBase[i];
+	g_sessPrefix[slot] = g_rfPrefix;
+}
+void rfGenSessionAddr(int slot)
 {
-	// Mix the 64-bit FICR DEVICEID into a 4-byte base + prefix: deterministic per chip, unique across chips
-	// -> two OpenPucks never collide on the connected session. Avoid degenerate address bytes (0x00/0xFF) for
-	// clean preamble correlation, and never reproduce the shared discovery base "ibex" (else isolation is lost).
-	uint32_t h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^
+	if (slot < 0 || slot >= NSLOT) {
+		sessSetDefault(0);
+		return;
+	}
+	sessSetDefault(slot);
+	// Per-bond UUID (rec[0..8]) + FICR DEVICEID (8 bytes) -> 5-byte on-air address. Mixing the bond UUID in
+	// makes the address deterministic per (puck, controller) pair: a slot's address is stable across reboots
+	// without storing anything new in bonds.bin, and the same controller bonded to two OpenPucks gets two
+	// different session addresses (DEVICEID differs) so the two pucks never collide on-air. The original
+	// generator (UUID-free) is preserved as the no-UUID fallback for an empty/zero bond record.
+	const uint8_t *uuid = g_slot[slot].rec;
+	bool uuidLive = false;
+	for (int i = 0; i < 8; i++)
+		if (uuid[i]) {
+			uuidLive = true;
+			break;
+		}
+	uint32_t h, h2;
+	if (uuidLive) {
+		h = (uint32_t)uuid[0] * 0x9E3779B1u ^ (uint32_t)uuid[2] * 0x85EBCA6Bu ^
+		    (uint32_t)uuid[4] * 0xC2B2AE35u ^ (uint32_t)uuid[6] ^
+		    NRF_FICR->DEVICEID[0];
+		h2 = (uint32_t)uuid[1] * 0x27D4EB2Fu ^ (uint32_t)uuid[3] * 0x165667B1u ^
+		     (uint32_t)uuid[5] ^ (uint32_t)uuid[7] * 0x9E3779B1u ^
 		     NRF_FICR->DEVICEID[1];
-	uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^
-		      NRF_FICR->DEVICEID[0];
+	} else {
+		h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
+		h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^ NRF_FICR->DEVICEID[0];
+	}
+	// Scrub degenerate bytes (0x00/0xFF) for clean preamble correlation.
 	for (int i = 0; i < 4; i++) {
 		uint8_t b = (uint8_t)(h >> (i * 8));
 		if (b == 0x00 || b == 0xFF)
 			b ^= 0x5A;
-		g_sessBase[i] = b;
+		g_sessBase[slot][i] = b;
 	}
 	uint8_t p = (uint8_t)(h2 >> 16);
 	if (p == 0x00 || p == 0xFF)
 		p = 0x5C;
-	g_sessPrefix = p;
-	if (g_sessBase[0] == g_rfBase[0] && g_sessBase[1] == g_rfBase[1] &&
-	    g_sessBase[2] == g_rfBase[2] && g_sessBase[3] == g_rfBase[3])
-		g_sessBase[0] ^= 0x80; // collided with discovery base "ibex"
+	g_sessPrefix[slot] = p;
+	// Never reproduce the shared discovery base "ibex" (else the on-air address is shared with every other
+	// controller scanning the rendezvous -> isolation is lost).
+	if (g_sessBase[slot][0] == g_rfBase[0] &&
+	    g_sessBase[slot][1] == g_rfBase[1] &&
+	    g_sessBase[slot][2] == g_rfBase[2] &&
+	    g_sessBase[slot][3] == g_rfBase[3])
+		g_sessBase[slot][0] ^= 0x80;
 }
 
 uint8_t rfrx[100], rftx[100];

+ 11 - 8
OpenPuck/radio.h

@@ -27,16 +27,19 @@ extern const uint8_t PAIR_BASE[4]; // "ibex"
 extern uint8_t g_rfPrefix; // discovery prefix (rodata byte @0x56f98)
 extern uint8_t g_rfCh; // current TX/RX channel (hopped during beacon/poll)
 extern uint8_t g_rfBase[4]; // "ibex"
-// connected-session channel: a CLEAN data channel off the congested adv ch2
+// connected-session channel: a CLEAN data channel off the congested adv ch2 (shared by all slots on a puck)
 extern uint8_t g_sessCh;
 
-// Per-device SESSION address (base+prefix). Discovery stays on the SHARED "ibex" address (g_rfBase) so any
-// controller can find us; the host frame advertises THIS unique address and the controller adopts it for the
-// session. Two OpenPucks therefore never share an on-air session address -> no crosstalk, no cross-wake.
-// Derived from the FICR DEVICEID (stable per chip, unique across chips).
-extern uint8_t g_sessBase[4];
-extern uint8_t g_sessPrefix;
-void rfGenSessionAddr();
+// Per-bond SESSION address (base+prefix). Discovery stays on the SHARED "ibex" address (g_rfBase) so any
+// controller can find us; the host frame advertises THIS slot's unique address and the controller adopts it
+// for the session. Two OpenPucks never share an on-air session address, and two slots on the SAME puck have
+// distinct addresses so each controller only hears its own polls -> no crosstalk, no cross-wake, no per-slot
+// relay broadcast. Derived from the bond UUID (8 bytes) + FICR DEVICEID (8 bytes) so the address is
+// deterministic per (puck, controller) pair and stable across reboots without a bonds.bin schema bump.
+#include "bonds.h" // NSLOT
+extern uint8_t g_sessBase[NSLOT][4];
+extern uint8_t g_sessPrefix[NSLOT];
+void rfGenSessionAddr(int slot);
 
 // RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
 extern uint8_t rfrx[100], rftx[100];

+ 333 - 168
OpenPuck/rf_link.cpp

@@ -20,6 +20,23 @@ uint8_t g_connLen = 0x08;
 uint8_t g_getParam = 0x00;
 // cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID). 'e<n>' selects.
 uint8_t g_e3mode = 1;
+
+// ---- real-puck alignment (sniff1.json: a bonded controller RECONNECTING) ----
+// The live air capture of a real puck<->controller reconnect shows the controller streams 0xF1 input in
+// response to a BARE 0xE3 poll (1-byte payload, just the opcode) with NO 0xE7 awake-announce and NO
+// GET-report-0x45 sub-TLV -- 1857 of the puck's 2003 polls were bare E3, and the very first session frame
+// (a bare E3) was answered by F1 immediately. That contradicts the earlier RE "recipe" (rf_link.h) which
+// assumed E7 + GET-0x45 were required. So these now default to the real-puck behavior; flip them at runtime
+// ('d'/'n' console cmds) to fall back to the legacy GET/E7 path for an A/B comparison on hardware.
+bool g_pollGet = false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
+bool g_e7announce = false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
+// Session-channel E1 host-frame keepalive. The real puck sends NO E1 on its session channel (the bonded
+// controller already knows the per-bond address and just resumes). OpenPuck still needs E1 because it runs
+// the SHARED "ibex" address, not the (un-reversed) per-bond address -- E1 is how the controller learns this
+// puck's session base/prefix/channel. So this defaults ON; turn it off ('m') to test the real-puck "no
+// session E1" model once per-bond addressing exists. Discovery on ch2 is separate and always runs.
+bool g_e1keepalive = true;
+
 bool g_connVerbose = false;
 // poll RX-window (us); shorter=more polls/s but may miss DELAYED replies. Tunable 'r'.
 uint32_t g_rxWin = 1200;
@@ -46,19 +63,33 @@ uint16_t g_pollsps = 0;
 uint16_t g_pollPeriodUs = 0;
 static uint32_t g_pollDtSum = 0;
 static uint16_t g_pollDtCnt = 0;
-// smoothed |dBm| of the controller's replies (0 = none yet)
-volatile uint8_t g_linkRssi = 0;
-// battery % from the controller's report 0x43 (body[1]); 0 = none yet
-volatile uint8_t g_battery = 0;
+// smoothed |dBm| of the controller's replies, per slot (0 = none yet)
+volatile uint8_t g_linkRssi[NSLOT] = { 0 };
+// battery % from the controller's report 0x43 (body[1]); 0 = none yet. Per-slot -- the active controller's
+// battery is the most recently seen one (other slots' values stay in their own array slots).
+volatile uint8_t g_battery[NSLOT] = { 0 };
+
+// Slot the poll loop is currently driving. Set by rfConnStep before each E7/relay/E3, consumed by the
+// decode (g_in[g_curSlot]), the haptic flush (per-slot session address), and the per-second stat dump.
+int g_curSlot = -1;
 
 // ---- internal counters / timers ----
-static uint8_t g_e3pid = 0;
+// ESB PID is 2 bits. The controller dequeues a FRESH report only when the poll's PID differs from the
+// last one it saw on that pipe; a repeated PID reads as a retransmit and returns the SAME (stale) report.
+// PER SLOT, because a single shared counter advances by (2 * nWarm) per cycle -- with 2 controllers that's
+// +4 per cycle = 0 mod 4, so each slot's GET PID is constant => the controller never dequeues => ~60 new/s
+// instead of ~400. Each slot's counter increments once per poll-of-that-slot so it cycles 0,1,2,3 cleanly.
+static uint8_t g_pollPid[NSLOT] = {};
+static uint8_t g_relayPid[NSLOT] = {};
 static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
 static unsigned long g_stMs = 0;
-static uint8_t g_lastSeq = 0;
+// Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
+// slots into a single sequence makes one controller "swallow" the other's frame).
+static uint8_t g_lastSeq[NSLOT] = { 0 };
 static uint32_t g_stNew = 0;
 static uint32_t g_stCrc = 0, g_stNoRx = 0;
 static uint32_t g_chF1[3] = { 0, 0, 0 };
+// Cycle gate: fires once per g_pollUs; each fire polls every warm slot so all run at ~250 Hz.
 static uint32_t g_lastPollUs = 0;
 static uint32_t g_connRx = 0;
 static unsigned long g_lastSessBeacon = 0, g_lastDisc = 0;
@@ -68,9 +99,20 @@ static unsigned long g_lastStream = 0;
 // 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.
 // Transmit one host frame. `discovery`=true sends it on the SHARED rendezvous address ("ibex"/ch2) where a
-// searching controller looks; =false sends it on this puck's unique SESSION address (the keepalive once the
-// controller has adopted the session). EITHER way the payload advertises the session base/prefix/channel, so
-// the controller always learns the unique address to connect on.
+// searching controller looks; =false sends it on this slot's unique SESSION address (the keepalive once the
+// controller has adopted the session). EITHER way the payload advertises the session base/prefix/channel,
+// so the controller always learns the unique address to connect on.
+
+// Any-slot link helper: true if ANY bonded slot is currently hearing F-type replies (within 300 ms).
+// Used for the "we're connected to at least one controller" decisions (beacon pacing, wake detect).
+bool anySlotLinkUp()
+{
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && millis() - g_connReplyMs[s] < 300)
+			return true;
+	return false;
+}
+
 static void rfHostFrameOnce(int slot, bool discovery)
 {
 	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
@@ -93,13 +135,14 @@ static void rfHostFrameOnce(int slot, bool discovery)
 	// payload[9] session channel: controller runs the session on this clean
 	// channel (adopts buf[0xe]); discovery beacon still TXes on ch2
 	rftx[11] = g_sessCh;
-	// payload[13..17] session base  (the per-device UNIQUE address)
-	memcpy(rftx + 15, g_sessBase, 4);
-	rftx[19] = g_sessPrefix; // payload[17] session prefix
-	// TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses our unique address
-	// (where the controller now listens). The advertised session params (above) are identical either way.
-	const uint8_t *txBase = discovery ? g_rfBase : g_sessBase;
-	uint8_t txPfx = discovery ? g_rfPrefix : g_sessPrefix;
+	// payload[13..17] session base  (the per-bond UNIQUE address; each controller adopts its own)
+	memcpy(rftx + 15, g_sessBase[slot], 4);
+	rftx[19] = g_sessPrefix[slot]; // payload[17] session prefix
+	// TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses this slot's
+	// unique address (where THIS controller now listens). The advertised session params (above) are
+	// identical either way -- so the discovery frame can also double as a re-advertisement if needed.
+	const uint8_t *txBase = discovery ? g_rfBase : g_sessBase[slot];
+	uint8_t txPfx = discovery ? g_rfPrefix : g_sessPrefix[slot];
 	rfConfig(g_rfCh);
 	rfSetAddr(txBase, txPfx);
 	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
@@ -145,19 +188,27 @@ static void rfHostFrameOnce(int slot, bool discovery)
 
 void rfHopTo(uint8_t newCh)
 {
-	if (g_connSlot < 0 || newCh == g_sessCh)
+	// QoS hop is shared across all slots -- we run all connected sessions on the same channel for simplicity.
+	// The per-slot session ADDRESS is what isolates the controllers from each other; the channel is global.
+	if (g_curSlot < 0 || newCh == g_sessCh)
 		return;
 	uint8_t cur = g_sessCh, savedRfCh = g_rfCh;
 	g_sessCh = newCh;
-	// host frame now advertises newCh but is TXed on cur (session addr)
+	// host frame now advertises newCh but is TXed on cur (per-slot session addr)
 	g_rfCh = cur;
-	for (int k = 0; k < 6; k++) {
-		rfHostFrameOnce(g_connSlot, false);
-		delayMicroseconds(700);
-	}
+	for (int s = 0; s < NSLOT; s++)
+		for (int k = 0; k < 6; k++) {
+			rfHostFrameOnce(s, false);
+			delayMicroseconds(700);
+		}
 	g_rfCh = savedRfCh; // poll + session beacon now run on g_sessCh=newCh
 }
 
+// TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
+// rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect
+// no reply, so they don't burn a full ~1.2ms window of dead air per haptic. Per-slot: the connected poll runs
+// on this slot's UNIQUE session address (one address per bonded controller); replies are demuxed by which
+// address the controller answers on (each controller only hears its own).
 uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 		 uint16_t rxWinUs)
 {
@@ -168,8 +219,10 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 	rftx[1] = s1; // S1 (type-specific)
 	memcpy(rftx + 2, payload, plen); // payload[0]=type byte, then data/TLVs
 	rfConfig(ch);
-	// connected poll runs on this puck's UNIQUE session addr
-	rfSetAddr(g_sessBase, g_sessPrefix);
+	// connected poll runs on this slot's UNIQUE session addr (per-bond). g_curSlot is set by rfConnStep
+	// before each call; fall back to slot 0 if not (e.g. rf_diag paths).
+	int slot = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	rfSetAddr(g_sessBase[slot], g_sessPrefix[slot]);
 	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
 	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
 			    RADIO_SHORTS_END_DISABLE_Msk;
@@ -210,24 +263,27 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			// 0xE1 beacon (e.g. one just plugged into another computer), bumps g_connReplyMs, and the "new RF
 			// connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
 			if (rtype >= 0xF0) {
+				int s = (g_curSlot >= 0 && g_curSlot < NSLOT) ?
+						g_curSlot :
+						0;
 				// A reply after a long gap (or the first ever) = a (re)connect. Arm the haptic block + re-init HERE,
 				// directly off the reply stream -- reliable even when hapticTask's 300ms link-up edge doesn't fire
 				// (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
-				if (g_connReplyMs == 0 ||
-				    (uint32_t)(millis() - g_connReplyMs) >
+				if (g_connReplyMs[s] == 0 ||
+				    (uint32_t)(millis() - g_connReplyMs[s]) >
 					    1500u)
-					hapticOnReconnect();
+					hapticOnReconnect(s);
 				g_connRx++;
 				// link alive -> loop() suppresses the redundant E1 beacon
-				g_connReplyMs = millis();
+				g_connReplyMs[s] = millis();
 				// |dBm| of this reply (started by the ADDRESS short)
 				uint8_t rs =
 					(uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
-				// EWMA, ~8-sample horizon
+				// EWMA, ~8-sample horizon, per-slot
 				if (rs)
-					g_linkRssi =
-						g_linkRssi ?
-							(uint8_t)((g_linkRssi *
+					g_linkRssi[s] =
+						g_linkRssi[s] ?
+							(uint8_t)((g_linkRssi[s] *
 									   7u +
 								   rs + 4u) /
 								  8u) :
@@ -235,9 +291,20 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			}
 			if (rtype == 0xF1)
 				g_stF1++;
-			// controller disconnecting/powering off -> back off 2.5s
-			if (rtype == 0xF2)
-				g_connCooldown = millis();
+			// controller disconnecting/powering off -> back off 2.5s so we don't immediately re-wake it.
+			// BUT only when no OTHER slot is still live: g_connCooldown is global and gates ALL polling +
+			// beacons, so backing off because ONE controller powered off would drop every other connected
+			// controller for 2.5s (a real multi-controller disconnect). The powering-off slot goes silent
+			// and ages out via SLOT_COLD on its own.
+			if (rtype == 0xF2) {
+				int others = 0;
+				for (int i = 0; i < NSLOT; i++)
+					if (i != g_curSlot && g_slot[i].used &&
+					    millis() - g_connReplyMs[i] < 300)
+						others++;
+				if (others == 0)
+					g_connCooldown = millis();
+			}
 			// F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
 			if (rtype == 0xF3) {
 				g_stF3++;
@@ -276,80 +343,96 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 						bool fresh =
-							(rep[1] != g_lastSeq);
+							(rep[1] !=
+							 g_lastSeq[g_curSlot]);
 						// genuine new report vs stale poll-repeat
 						if (fresh) {
 							g_stNew++;
-							g_lastSeq = rep[1];
+							g_lastSeq[g_curSlot] =
+								rep[1];
 						}
 						uint32_t bb = btnsOf(rep);
 						// USB remote wakeup on Steam button short press (down + up within 1 s). A long press likely means
 						// the user is powering off the controller, so we ignore it.
 						{
-							static bool steamWasDown =
-								false;
-							static unsigned long
-								steamDownMs = 0;
+							// per-slot: with round-robin polling, a shared static gets
+							// reset by other slots' reports between press and release.
+							static bool steamWasDown
+								[NSLOT] = {};
+							static unsigned long steamDownMs
+								[NSLOT] = {};
 							if (fresh) {
 								bool steamNow =
 									(bb &
 									 TB_STEAM) !=
 									0;
-								// rising edge: record press time
 								if (steamNow &&
-								    !steamWasDown)
-									steamDownMs =
+								    !steamWasDown
+									    [g_curSlot])
+									steamDownMs[g_curSlot] =
 										millis();
-								// falling edge within 1 s -> short press -> wake
 								if (!steamNow &&
-								    steamWasDown &&
-								    millis() - steamDownMs <
+								    steamWasDown
+									    [g_curSlot] &&
+								    millis() - steamDownMs[g_curSlot] <
 									    1000u &&
 								    USBDevice
 									    .suspended()) {
 									USBDevice
 										.remoteWakeup();
 									ledWakePulse();
-									// post-resume nudge (host needs real input to actually wake)
 									if (g_active)
 										g_active->wakeEvent();
 								}
-								steamWasDown =
+								steamWasDown[g_curSlot] =
 									steamNow;
 							}
 						}
 						// Decode the report into the shared g_in (one source, read by every IController).
-						g_in.buttons = bb;
+						g_in[g_curSlot].buttons = bb;
 						// Global power-off chord: Steam + Y held 2 s -> shut the controller down (any mode). Detect on the raw
 						// `bb` (pre-mask), time-based (poll rate varies), fires once per hold, re-arms only after release. While
-						// held, mask Steam+Y out of g_in.buttons so the press doesn't leak to the host -- in EVERY mode except
+						// held, mask Steam+Y out of g_in[g_curSlot].buttons so the press doesn't leak to the host -- in EVERY mode except
 						// regular Steam (mode 0 forwards the raw 0x45 to Steam, which owns the Steam button). Runs before
 						// onReport45 below so push modes (Xbox) see the mask too; stream modes read g_in in task() (also masked).
 						{
+							// Per-slot: each controller's Steam+Y hold is independent, so the debounce timer must
+							// be per-slot. With multiple controllers, a shared timer gets reset every time ANOTHER
+							// slot's poll (without the hold) runs -- and the round-robin poll cycles through every
+							// used slot, so the timer never reaches 2s.
 							static unsigned long
-								offHoldMs = 0;
-							static bool offFired =
-								false;
+								offHoldMs[NSLOT] = {
+									0, 0, 0,
+									0
+								};
+							static bool offFired
+								[NSLOT] = {
+									false,
+									false,
+									false,
+									false
+								};
 							if ((bb & (TB_STEAM |
 								   TB_Y)) ==
 							    (TB_STEAM | TB_Y)) {
-								if (offHoldMs ==
+								if (offHoldMs[g_curSlot] ==
 								    0)
-									offHoldMs =
+									offHoldMs[g_curSlot] =
 										millis();
 								else if (
-									!offFired &&
+									!offFired[g_curSlot] &&
 									(unsigned long)(millis() -
-											offHoldMs) >=
+											offHoldMs[g_curSlot]) >=
 										2000u) {
-									offFired =
+									offFired[g_curSlot] =
 										true;
 									hapticSendShutdown();
 								}
 								if (g_usbMode !=
 								    MODE_STEAM) {
 									// stream modes read g_in
-									g_in.buttons &=
+									g_in[g_curSlot]
+										.buttons &=
 										~(uint32_t)(TB_STEAM |
 											    TB_Y);
 									// push modes read btnsOf(rep): TB_Y in rep[2],
@@ -366,32 +449,40 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 											   16);
 								}
 							} else {
-								offHoldMs = 0;
-								offFired =
+								offHoldMs[g_curSlot] =
+									0;
+								offFired[g_curSlot] =
 									false;
 							}
 						}
-						g_in.lx =
+						g_in[g_curSlot].lx =
 							(int16_t)s16off(rep, 8);
-						g_in.ly = (int16_t)s16off(rep,
-									  10);
-						g_in.rx = (int16_t)s16off(rep,
-									  12);
-						g_in.ry = (int16_t)s16off(rep,
-									  14);
-						g_in.lt =
+						g_in[g_curSlot].ly =
+							(int16_t)s16off(rep,
+									10);
+						g_in[g_curSlot].rx =
+							(int16_t)s16off(rep,
+									12);
+						g_in[g_curSlot].ry =
+							(int16_t)s16off(rep,
+									14);
+						g_in[g_curSlot].lt =
 							trigU8(u16off(rep, 4));
 						// for the Switch digital-trigger threshold
-						g_in.rt =
+						g_in[g_curSlot].rt =
 							trigU8(u16off(rep, 6));
-						g_in.lpx = (int16_t)s16off(rep,
-									   16);
-						g_in.lpy = (int16_t)s16off(rep,
-									   18);
-						g_in.rpx = (int16_t)s16off(rep,
-									   22);
-						g_in.rpy = (int16_t)s16off(rep,
-									   24);
+						g_in[g_curSlot].lpx =
+							(int16_t)s16off(rep,
+									16);
+						g_in[g_curSlot].lpy =
+							(int16_t)s16off(rep,
+									18);
+						g_in[g_curSlot].rpx =
+							(int16_t)s16off(rep,
+									22);
+						g_in[g_curSlot].rpy =
+							(int16_t)s16off(rep,
+									24);
 						// IMU lives at report bytes 0x22..0x2D (rep[34..45]). Decode it ONLY when a FULL 46-byte report was
 						// actually received -- bounded by `end` (the received length), NOT sizeof(rfrx). The outer gate is
 						// tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a short 0x45 (button-only, or
@@ -400,13 +491,21 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						if (tlen >= 46 &&
 						    (size_t)(idx + 2) + 46 <=
 							    (size_t)end)
-							imuFrom45(rep, &g_in.ax,
-								  &g_in.ay,
-								  &g_in.az,
-								  &g_in.gx,
-								  &g_in.gy,
-								  &g_in.gz);
-						// Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in.buttons stays
+							imuFrom45(
+								rep,
+								&g_in[g_curSlot]
+									 .ax,
+								&g_in[g_curSlot]
+									 .ay,
+								&g_in[g_curSlot]
+									 .az,
+								&g_in[g_curSlot]
+									 .gx,
+								&g_in[g_curSlot]
+									 .gy,
+								&g_in[g_curSlot]
+									 .gz);
+						// Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in[g_curSlot].buttons stays
 						// intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
 						if ((bb & CHORD_BACK4) ==
 						    CHORD_BACK4)
@@ -419,8 +518,8 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						// g_in); PUSH modes (Xbox, puck/lizard) build + send their host report here.
 						if (g_active)
 							g_active->onReport45(
-								rep, fresh,
-								tlen);
+								g_curSlot, rep,
+								fresh, tlen);
 						lastRep = rep;
 						lastTlen = tlen;
 					} else if (ttype == 6 &&
@@ -436,10 +535,15 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 						// 0x43 body[1] (~0x5e=94) reads as battery % (sniff-derived)
-						if (rep[0] == 0x43 && tlen >= 3)
-							g_battery = rep[2];
+						if (rep[0] == 0x43 &&
+						    tlen >= 3 &&
+						    g_curSlot >= 0 &&
+						    g_curSlot < NSLOT)
+							g_battery[g_curSlot] =
+								rep[2];
 						if (g_active)
 							g_active->onAuxReport(
+								g_curSlot,
 								rep[0], rep + 1,
 								(uint8_t)(tlen -
 									  1));
@@ -448,21 +552,35 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				}
 				// mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.
 				{
-					static uint8_t chWant = 0xFF, chCnt = 0;
+					// Per-slot debounce: the chord input is per-slot (g_in[g_curSlot]), so the debounce counter
+					// must be too. The shared-static form worked with 1 controller because slot 0 polled
+					// back-to-back; with N>1, the round-robin poll cycles through every used slot, and the OTHER
+					// slots' non-chord reports reset the counter on every iteration. The counter could never
+					// reach 12 with multiple controllers, making the chord effectively dead.
+					static uint8_t chWant[NSLOT] = {
+						0xFF, 0xFF, 0xFF, 0xFF
+					};
+					static uint8_t chCnt[NSLOT] = { 0, 0, 0,
+									0 };
 					uint8_t want = 0xFF;
-					if ((g_in.buttons & CHORD_BACK4) ==
-					    CHORD_BACK4) {
-						if (g_in.buttons & TB_A)
+					if ((g_in[g_curSlot].buttons &
+					     CHORD_BACK4) == CHORD_BACK4) {
+						if (g_in[g_curSlot].buttons &
+						    TB_A)
 							want = MODE_STEAM;
-						else if (g_in.buttons & TB_B)
+						else if (g_in[g_curSlot].buttons &
+							 TB_B)
 							want = g_chordBtn[0];
-						else if (g_in.buttons & TB_X)
+						else if (g_in[g_curSlot].buttons &
+							 TB_X)
 							want = g_chordBtn[1];
-						else if (g_in.buttons & TB_Y)
+						else if (g_in[g_curSlot].buttons &
+							 TB_Y)
 							want = g_chordBtn[2];
 					}
-					if (want != 0xFF && want == chWant) {
-						if (++chCnt >= 12 &&
+					if (want != 0xFF &&
+					    want == chWant[g_curSlot]) {
+						if (++chCnt[g_curSlot] >= 12 &&
 						    want != g_usbMode &&
 						    modeValid(want) &&
 						    !USBDevice.suspended()) {
@@ -471,8 +589,9 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							NVIC_SystemReset();
 						}
 					} else {
-						chWant = want;
-						chCnt = (want != 0xFF) ? 1 : 0;
+						chWant[g_curSlot] = want;
+						chCnt[g_curSlot] =
+							(want != 0xFF) ? 1 : 0;
 					}
 				}
 				// compact stream for rf_controller_ui.py -- NON-BLOCKING: skip if CDC TX is backed up (a blocking
@@ -515,82 +634,122 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 	return rxlen;
 }
 
-// Drive the connected-mode sequence one step per call. Camps on g_sessCh (the host-frame channel the
-// controller connected on); the host-frame beacon runs in parallel as keepalive.
+// Throttle: bonded slots that haven't replied in SLOT_COLD_MS are polled at most every SLOT_COLD_RETRY_MS
+// instead of every cycle. This keeps the online controllers at full 250 Hz while barely touching offline ones.
+#define SLOT_COLD_MS 5000u
+#define SLOT_COLD_RETRY_MS 2000u
+static unsigned long g_slotLastAttemptMs[NSLOT] = {};
+
+// Drive the connected-mode sequence. The cycle gate fires once per g_pollUs (250 Hz); each fire
+// polls EVERY warm slot back-to-back so all bonded controllers run at full rate regardless of count.
+// "Cold" means the slot WAS connected but has been silent for > SLOT_COLD_MS; slots that have never
+// replied stay warm so new controllers can connect at any time after boot. Cold slots retry every
+// SLOT_COLD_RETRY_MS.
 static void rfConnStep()
 {
-	g_connSlot = -1;
-	for (int s = 0; s < NSLOT; s++)
-		if (g_slot[s].used) {
-			g_connSlot = s;
+	int firstUsed = -1;
+	for (int k = 0; k < NSLOT; k++)
+		if (g_slot[k].used) {
+			firstUsed = k;
 			break;
 		}
-	// need a bonded slot (session established by host frame)
-	if (g_connSlot < 0)
+	if (firstUsed < 0) {
+		g_curSlot = -1;
 		return;
+	}
+
 	uint8_t ch = g_sessCh;
-	// announce HOST AWAKE: E7 00 00, a few times
+	// announce HOST AWAKE: E7 00 00, a few times. The real puck does NOT do this (the controller streams F1 to a
+	// bare E3 with no E7) -- skipped unless g_e7announce ('n') re-enables the legacy handshake.
 	if (g_connSt == 0) {
-		uint8_t p[3] = { 0xE7, 0x00, g_e7b };
-		rfConnTx(ch, 0x01, p, 3);
-		if (++g_connStep >= 4) {
+		g_curSlot = firstUsed;
+		if (g_e7announce) {
+			uint8_t p[3] = { 0xE7, 0x00, g_e7b };
+			rfConnTx(ch, 0x01, p, 3);
+			if (++g_connStep >= 4) {
+				g_connSt = 1;
+				g_connStep = 0;
+				Serial.println(
+					"# CONN: awake announced -> polling");
+			}
+		} else {
+			// real-puck path: straight to the bare-E3 poll loop, no E7
 			g_connSt = 1;
 			g_connStep = 0;
-			Serial.println(
-				"# CONN: awake announced -> polling GET report 0x45");
 		}
-		// poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
-	} else {
-		// CONTROLLED CADENCE: poll ~every g_pollUs
-		if ((uint32_t)(micros() - g_lastPollUs) < g_pollUs)
-			return;
-		// FIXED-RATE schedule: advance the deadline by exactly one interval (NOT reset-to-now, which would add
-		// each cycle's ~1ms of poll work + loop jitter to the next interval -- the period drifts to ~5000us
-		// instead of the 4000us target). The resync guard prevents a catch-up burst after a real stall.
-		{
-			uint32_t now = micros();
-			static uint32_t lastFire = 0;
-			// MEASURED actual period
-			if (lastFire) {
-				g_pollDtSum += (uint32_t)(now - lastFire);
-				g_pollDtCnt++;
-			}
-			lastFire = now;
-			g_lastPollUs += g_pollUs;
-			// fell >1 interval behind -> resync
-			if ((uint32_t)(now - g_lastPollUs) >= g_pollUs)
-				g_lastPollUs = now;
+		return;
+	}
+
+	// Cycle gate: fires once per g_pollUs (250 Hz). On each fire we poll EVERY warm slot
+	// back-to-back so all bonded controllers run at full rate -- the real puck services all
+	// controllers per cycle. Polling one-slot-per-call instead tied the per-slot rate to the
+	// loop frequency AND split it across slots, collapsing 2 controllers to ~90 Hz.
+	uint32_t nowUs = micros();
+	if ((uint32_t)(nowUs - g_lastPollUs) < (uint32_t)g_pollUs)
+		return;
+	{
+		// Cycle period stat: time between gate fires (= each slot's poll period, intended g_pollUs).
+		static uint32_t lastCycleUs = 0;
+		if (lastCycleUs) {
+			g_pollDtSum += (uint32_t)(nowUs - lastCycleUs);
+			g_pollDtCnt++;
 		}
-		if ((g_connPoll & 0x1F) == 0) {
+		lastCycleUs = nowUs;
+	}
+	g_lastPollUs += g_pollUs;
+	if ((uint32_t)(nowUs - g_lastPollUs) >= (uint32_t)g_pollUs)
+		g_lastPollUs = nowUs; // catch-up reset when a cycle overran
+
+	unsigned long nowMs = millis();
+
+	// Inline poll helper; emits E7 re-assert (every 32 polls, bounded reply window so a
+	// missed F3 doesn't burn the whole slot budget), queues haptics, flushes relay, sends E3 GET.
+	auto doPoll = [&](int k) {
+		g_slotLastAttemptMs[k] = nowMs;
+		g_curSlot = k;
+		// re-assert awake/version every 32 polls (legacy; real puck never sends E7 -- gated by g_e7announce)
+		if (g_e7announce && (g_connPoll & 0x1F) == 0) {
 			uint8_t pa[3] = { 0xE7, 0x00, g_e7b };
-			rfConnTx(ch, 0x01, pa, 3);
-		} // re-assert awake/version
+			rfConnTx(ch, 0x01, pa, 3, 600);
+		}
 		rfConnQueueHapticRelay();
-		// Relay (if any) gets its OWN cycled PID, then the GET poll gets the NEXT one -- so they're always distinct
-		// and the controller never dedups the GET as a retransmit of the relay (that was dropping ~half the replies
-		// during haptics). Advancing g_e3pid even when no relay is pending just skips a PID value (harmless).
 		{
-			uint8_t rs1 = (uint8_t)((((g_e3pid++) & 3) << 1) | 1);
+			uint8_t rs1 =
+				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
 			rfConnFlushRelay(ch, rs1);
 		}
-		{
-			// E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
+		// per-slot PID cycle so each bonded controller's polls stay distinct
+		uint8_t pidv = g_pollPid[k]++;
+		uint8_t s1 = (g_e3mode == 1) ?
+				     (uint8_t)(((pidv & 3) << 1) | 1) :
+			     (g_e3mode == 2) ? (uint8_t)((pidv & 3) << 1) :
+					       0x07;
+		uint8_t rx;
+		if (g_pollGet) {
+			// legacy: E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
 			uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45, g_getParam };
-			// cycle PID (S1 1,3,5,7), NO_ACK=1
-			// cycle PID (S1 0,2,4,6), NO_ACK=0
-			// fixed (matches captured puck poll)
-			uint8_t s1 =
-				(g_e3mode == 1) ?
-					(uint8_t)((((g_e3pid++) & 3) << 1) |
-						  1) :
-				(g_e3mode == 2) ?
-					(uint8_t)(((g_e3pid++) & 3) << 1) :
-					0x07;
-			uint8_t rx = rfConnTx(ch, s1, p, 5);
-			if (rx)
-				g_chF1[0]++;
+			rx = rfConnTx(ch, s1, p, 5);
+		} else {
+			// real puck: BARE E3 (just the opcode) -- the controller streams F1 to any E3 ack
+			uint8_t p[1] = { 0xE3 };
+			rx = rfConnTx(ch, s1, p, 1);
 		}
+		if (rx)
+			g_chF1[0]++;
 		g_connPoll++;
+	};
+
+	// Poll every warm slot this cycle. "Cold" = was connected, now silent > SLOT_COLD_MS;
+	// those are retried only every SLOT_COLD_RETRY_MS. Never-replied slots count as warm so
+	// a controller that connects at any time gets the full poll rate to establish its link.
+	for (int k = 0; k < NSLOT; k++) {
+		if (!g_slot[k].used)
+			continue;
+		bool cold = g_connReplyMs[k] != 0 &&
+			    nowMs - g_connReplyMs[k] > SLOT_COLD_MS;
+		if (cold && nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+			continue;
+		doPoll(k);
 	}
 }
 
@@ -601,17 +760,24 @@ void rfLinkTask()
 	// 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);
+		bool connNow = anySlotLinkUp();
 		// session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
-		// (every-loop beaconing also hammers the session ch and steals reply slots from the poll)
-		if (millis() - g_lastSessBeacon >= (connNow ? 25u : 0u)) {
+		// (every-loop beaconing also hammers the session ch and steals reply slots from the poll). The real puck
+		// sends NO E1 on its session channel; gated by g_e1keepalive ('m') so this can be A/B'd on hardware --
+		// but it stays ON by default because OpenPuck's shared-address model relies on E1 to advertise the session.
+		if (g_e1keepalive &&
+		    millis() - g_lastSessBeacon >= (connNow ? 25u : 0u)) {
 			g_lastSessBeacon = millis();
 			g_rfCh = g_sessCh;
 			for (int s = 0; s < NSLOT; s++)
 				rfHostFrameOnce(s, false);
 		}
-		// discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
+		// discovery beacon on ch2 (where a searching controller looks): every loop when nothing is
+		// connected (fastest cold-boot/late-joiner connect), every 200ms once ANY controller is linked.
+		// Matches main: while a slot is connected, ch2 discovery shares g_sessCh's air budget, so beaconing
+		// faster than 200ms (the old allUp-gated 40ms path) just steals reply windows from the connected
+		// controller -> reply-rate sag -> >1500ms gaps -> spurious hapticOnReconnect re-init. A late joiner
+		// still enumerates within ~1s at 200ms.
 		if (millis() - g_lastDisc >= (connNow ? 200u : 0u)) {
 			g_lastDisc = millis();
 			g_rfCh = 2;
@@ -623,10 +789,9 @@ void rfLinkTask()
 		rfConnStep();
 	} // connected-mode: poll controller, read input
 	{
-		// remote wakeup on new RF controller connection
+		// remote wakeup on new RF controller connection (any slot)
 		static bool wasRfConn = false;
-		bool nowRfConn =
-			(g_connSlot >= 0 && millis() - g_connReplyMs < 300);
+		bool nowRfConn = anySlotLinkUp();
 		if (nowRfConn && !wasRfConn && USBDevice.suspended()) {
 			USBDevice.remoteWakeup();
 			ledWakePulse();
@@ -637,7 +802,7 @@ void rfLinkTask()
 		wasRfConn = nowRfConn;
 	}
 	// QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
-	if (g_qos && g_connSlot >= 0 && millis() - g_qosCheckMs >= 600) {
+	if (g_qos && g_curSlot >= 0 && millis() - g_qosCheckMs >= 600) {
 		uint16_t bad = g_qosBad;
 		g_qosBad = 0;
 		g_qosCheckMs = millis();
@@ -670,7 +835,7 @@ void rfLinkTask()
 				(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_curSlot);
 		g_stPoll = 0;
 		g_stF1 = 0;
 		g_stNew = 0;

+ 33 - 11
OpenPuck/rf_link.h

@@ -7,11 +7,17 @@
 // mode-switch chord, USB remote-wakeup on a Steam short-press, adaptive channel hopping (QoS), and the
 // host-frame keepalive beacons. RE/calibration scaffolding lives separately in rf_diag.cpp.
 //
-// RECIPE (IBEX disasm @0x18d80): the controller streams 0xF1 ONLY when (a) connstate==3 [host frame E1
-// establishes it], (b) we mark host-awake via E7 [00][00], and (c) each E3 poll carries a GET-report-0x45
-// sub-TLV. F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
+// RECIPE: a live air capture of a real puck<->controller RECONNECT (sniff1.json) shows the controller streams
+// 0xF1 input in response to a BARE 0xE3 poll -- with NO 0xE7 awake-announce and NO GET-report-0x45 sub-TLV.
+// So the only thing OpenPuck genuinely needs to (re)establish the stream is connstate==3, which the host frame
+// (0xE1) sets up; once connected, bare E3 polls keep F1 flowing. (The earlier RE hypothesis that E7+GET-0x45
+// were required is contradicted by the capture -- see g_pollGet/g_e7announce.) The real puck also sends no E1
+// on its session channel (its bonded controller resumes on the known per-bond address); OpenPuck still beacons
+// E1 only because it runs the SHARED "ibex" address, not the per-bond one (g_e1keepalive).
+// F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
 #pragma once
 #include <stdint.h>
+#include "bonds.h" // NSLOT
 
 // ---- operational toggles + tunables (set from the CDC console / WebUSB; read here + by webusb status) ----
 // auto-start host beacon on boot (resumes puck role after a USB replug)
@@ -27,6 +33,11 @@ extern uint8_t g_getParam; // GET report 0x45 param byte
 // E3-poll PID/S1 mode (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0)
 extern uint8_t g_e3mode;
 
+// Real-puck alignment toggles (default to the captured real-puck behavior; flip on the CDC console to A/B):
+extern bool g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
+extern bool g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
+extern bool g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
+
 // full multi-line debug dump vs compact "I45 <hex>" stream
 extern bool g_connVerbose;
 extern uint32_t g_rxWin; // poll RX-window (us): caps poll rate (~1e6/rxWin)
@@ -37,11 +48,19 @@ extern unsigned long g_connCooldown;
 // connected-mode state (reset by the 'k' console toggle)
 extern uint8_t g_connSt, g_connStep;
 extern uint16_t g_connPoll;
-extern uint32_t g_connF1; // count of 0xF1 input reports seen
+extern uint32_t g_connF1; // count of 0xF1 input reports seen (aggregate across all slots)
 
-// last protocol version the controller reported in an F3 reply (0xFF=none)
+// last protocol version the controller reported in an F3 reply (0xFF=none) -- aggregate (last writer wins)
 extern uint8_t g_connF3v;
 
+// Slot the poll loop is currently driving (set by rfConnStep before each E7/relay/E3). -1 = idle. Used by
+// the decode path (g_in[g_curSlot]), the haptic flush (per-slot session address), the wake detect, and the
+// stat dump.
+extern int g_curSlot;
+// True if ANY bonded slot is currently link-up (heard an F-reply within 300 ms). Used by the haptic
+// re-init gating (we only re-init on a real link) and the beacon pacing.
+bool anySlotLinkUp();
+
 // QoS adaptive channel hopping
 extern uint8_t g_qos; // 0=off (static g_sessCh), 1=auto-hop on degradation
 extern uint8_t g_hopIdx;
@@ -60,15 +79,18 @@ extern uint16_t g_pollsps;
 extern uint16_t g_pollPeriodUs;
 
 // Smoothed controller->puck signal strength, sampled by the radio (RSSISAMPLE) on each CRC-good controller
-// reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. puck_hid reports it
-// to Steam in status report 0x7B byte 8 (signed dBm), after subtracting RSSI_DBM_OFFSET.
-extern volatile uint8_t g_linkRssi;
+// reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. Per-slot: each
+// controller's RSSI is reported on its own 0x7B byte 8 (puck_hid reports it to Steam, after subtracting
+// RSSI_DBM_OFFSET).
+extern volatile uint8_t g_linkRssi[NSLOT];
 // dB to subtract from our raw RSSI magnitude before reporting, to match the real puck's close-range -35dBm
 // (compensates the Pro Micro antenna vs Valve's front-end). Tune against one known-distance reading.
 #define RSSI_DBM_OFFSET 20
-// Battery percent from the controller's periodic report 0x43 (body[1]); 0 = none yet. Surfaced in the WebUSB
-// panel; the raw 0x43 report is also forwarded to Steam verbatim (puck_hid onAuxReport) so Steam reads it itself.
-extern volatile uint8_t g_battery;
+// Battery percent from the controller's periodic report 0x43 (body[1]); 0 = none yet. Per-slot: each
+// controller's battery is read on its own 0x43 reply and stored against its slot index. The WebUSB panel
+// reads g_battery[g_curSlot] (the most recently active one). The raw 0x43 report is also forwarded to
+// Steam verbatim (puck_hid onAuxReport) so Steam reads it itself.
+extern volatile uint8_t g_battery[NSLOT];
 
 // TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
 // rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect

+ 105 - 19
OpenPuck/serial_console.cpp

@@ -175,9 +175,13 @@ void serialConsolePoll()
 					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);
+					"# CONN mode %s: %s%s poll on ch%u (param=%02X). F1 seen=%lu\n",
+					g_connOn ? "ON" : "off",
+					g_e7announce ? "E7-awake[00 00] -> " : "",
+					g_pollGet ? "E3+GET-report-0x45" :
+						    "bare E3",
+					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",
@@ -210,34 +214,51 @@ void serialConsolePoll()
 					"# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",
 					g_mFric);
 			} else if (line[0] == 'W') {
-				g_abSwap = !g_abSwap;
-				saveCfg();
-				Serial.printf(
-					"# A/B + X/Y swap %s (Nintendo layout)\n",
-					g_abSwap ? "ON" : "off");
+				// A/B swap edits the ACTIVE emulated type (Xbox/Switch/DS4/DS5); puck modes have none.
+				if (g_etype < ET_COUNT) {
+					g_type[g_etype].abSwap =
+						!g_type[g_etype].abSwap;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# A/B + X/Y swap %s (Nintendo layout) [type %u]\n",
+						g_abSwap ? "ON" : "off", g_etype);
+				} else
+					Serial.println(
+						"# A/B swap N/A in this mode (Steam/Lizard are not emulated types)");
 			} 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;
+				// back paddles edit the ACTIVE emulated type. code 18 = Capture/Screenshot (Switch only).
+				if (i >= 0 && i < 4 && g_etype < ET_COUNT) {
+					g_type[g_etype].back[i] = code;
+					applyActiveType();
 					saveCfg();
 					Serial.printf(
-						"# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",
+						"# back[%d] (%s) -> code %u [type %u]  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute 18=Capture(Switch) 19=LT/L2/ZL 20=RT/R2/ZR]\n",
 						i,
 						(const char *[]){ "L4", "R4",
 								  "L5",
 								  "R5" }[i],
-						code);
-				} else
+						code, g_etype);
+				} else if (g_etype >= ET_COUNT)
+					Serial.println(
+						"# back map N/A in this mode (Steam/Lizard are not emulated types)");
+				else
 					Serial.println(
 						"# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)");
 			} else if (line[0] == 'Q') {
 				uint8_t code = strtoul(line + 1, 0, 10);
-				g_qamMap = code;
-				saveCfg();
-				Serial.printf(
-					"# QAM -> code %u  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",
-					code);
+				if (g_etype < ET_COUNT) {
+					g_type[g_etype].qamMap = code;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# QAM -> code %u [type %u]  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute 18=Capture(Switch) 19=LT/L2/ZL 20=RT/R2/ZR]\n",
+						code, g_etype);
+				} else
+					Serial.println(
+						"# QAM map N/A in this mode (Steam/Lizard are not emulated types)");
 			} else if (line[0] == 'J') {
 				char *sp = 0;
 				uint8_t id = strtoul(line + 1, &sp, 0);
@@ -246,7 +267,8 @@ void serialConsolePoll()
 				uint16_t val = sp ? strtoul(sp, 0, 0) : 0;
 				uint8_t pl[3] = { id, (uint8_t)(val & 0xFF),
 						  (uint8_t)(val >> 8) };
-				relayEnqueue(0x87, pl, 3);
+				// console-injected writes go to all connected controllers (no specific slot in scope)
+				relayEnqueue(0x87, pl, 3, 0xFF);
 				Serial.printf(
 					"# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",
 					id, val);
@@ -295,6 +317,54 @@ void serialConsolePoll()
 				Serial.printf(
 					"# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",
 					g_qos ? "ON" : "off");
+			} else if (line[0] == 'U') {
+				// Per-slot debug dump: which controllers are connected, their RSSI, haptic-block
+				// state, and session address. Quick at-a-glance view for multi-controller testing.
+				Serial.printf("# session ch%u, curSlot=%d\n",
+					      g_sessCh, g_curSlot);
+				for (int s = 0; s < NSLOT; s++) {
+					bool live = g_slot[s].used &&
+						    g_connReplyMs[s] != 0;
+					unsigned long age =
+						live ? (unsigned long)(millis() -
+								       g_connReplyMs
+									       [s]) :
+						       0;
+					int rssi =
+						g_linkRssi[s] ?
+							(int)g_linkRssi[s] -
+								RSSI_DBM_OFFSET :
+							0;
+					unsigned long now_ms = millis();
+					unsigned long blk =
+						(g_hapticBlockUntil[s] &&
+						 (int32_t)(g_hapticBlockUntil[s] -
+							   now_ms) > 0) ?
+							(g_hapticBlockUntil[s] -
+							 now_ms) :
+							0;
+					Serial.printf(
+						"# slot %d: %s addr %02X%02X%02X%02X/%02X rssi=%ddBm conn=%lums ago batt=%u%% haptic-block=%lums uuid %02X%02X%02X%02X %02X%02X%02X%02X\n",
+						s,
+						g_slot[s].used ?
+							(live ? "LIVE" :
+								"BONDED") :
+							"empty",
+						g_sessBase[s][0],
+						g_sessBase[s][1],
+						g_sessBase[s][2],
+						g_sessBase[s][3],
+						g_sessPrefix[s], rssi, age,
+						g_battery[s], blk,
+						g_slot[s].rec[0],
+						g_slot[s].rec[1],
+						g_slot[s].rec[2],
+						g_slot[s].rec[3],
+						g_slot[s].rec[4],
+						g_slot[s].rec[5],
+						g_slot[s].rec[6],
+						g_slot[s].rec[7]);
+				}
 			} else if (line[0] == 'r') {
 				g_rxWin = strtoul(line + 1, 0, 10);
 				if (g_rxWin < 150)
@@ -308,6 +378,22 @@ void serialConsolePoll()
 				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] == 'd') {
+				g_pollGet = !g_pollGet;
+				Serial.printf(
+					"# poll = %s (real puck sends BARE E3) - watch F1=/s new=/s\n",
+					g_pollGet ? "E3 + GET-report-0x45 TLV (legacy)" :
+						    "bare E3");
+			} else if (line[0] == 'n') {
+				g_e7announce = !g_e7announce;
+				Serial.printf(
+					"# E7 awake-announce %s (real puck never sends E7) - watch F1=/s\n",
+					g_e7announce ? "ON (legacy)" : "off");
+			} else if (line[0] == 'm') {
+				g_e1keepalive = !g_e1keepalive;
+				Serial.printf(
+					"# session-channel E1 keepalive %s (real puck sends none; needed for shared addr) - watch F1=/s\n",
+					g_e1keepalive ? "ON" : "off");
 			} else if (line[0] == 't') {
 				// inject n test haptics (output 0x82 [01 01 F7]) over the relay
 				uint8_t n = line[1] ? strtoul(line + 1, 0, 10) :

+ 1 - 1
OpenPuck/triton.cpp

@@ -1,6 +1,6 @@
 #include "triton.h"
 
-PuckInput g_in;
+PuckInput g_in[NSLOT];
 
 void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 	       int16_t *gx, int16_t *gy, int16_t *gz)

+ 16 - 7
OpenPuck/triton.h

@@ -30,14 +30,19 @@
 #define TB_LB 0x80000u
 #define TB_RPADT 0x200000u
 #define TB_RPADC 0x400000u
-
-// virtual: PS Touch Click (back-paddle/QAM target, not a real Triton button)
-#define TB_TOUCH 0x100000u
-
-// virtual: PS5 Mute button (back-paddle/QAM target, not a real Triton button)
-#define TB_MUTE 0x1000000u
 #define TB_LPADT 0x2000000u
 #define TB_LPADC 0x4000000u
+// Trigger full-pull / click bits (the controller sets these at a full ZL/ZR pull; also the target of a back-
+// paddle / QAM remap to LT/RT). psShouldersByte / the Switch builders already read these as L2/R2 / ZL/ZR.
+#define TB_R2 0x800000u
+#define TB_L2 0x8000000u
+
+// Virtual PS targets, settable ONLY via a back-paddle/QAM remap (psOrBackCode / tritonFromCode), never by the
+// controller itself. They live on bits 30/31 -- the only bits the SC2 0x45 report does NOT use (see PROTOCOL.md
+// §8.1; bits 0..29 are all real). They originally aliased 0x00100000 (right stick touch) and 0x01000000 (left
+// stick touch), so merely RESTING a thumb on a capacitive stick fired the DualSense trackpad-click / mute.
+#define TB_TOUCH 0x40000000u // PS touchpad click
+#define TB_MUTE 0x80000000u // PS5 mute
 
 // all four back paddles held -> mode-switch chord guard
 #define CHORD_BACK4 (TB_R4 | TB_L4 | TB_R5 | TB_L5)
@@ -72,6 +77,9 @@ void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 	       int16_t *gx, int16_t *gy, int16_t *gz);
 
 // ---- shared decoded input (filled by rf_link.cpp once per fresh report 0x45, read by every mode) ----
+// One PuckInput per bond slot: each controller in a multi-slot puck has its own decoded input. The stream
+// modes (Switch, PS5, DS4) read g_in[s] for each active slot in their task() loop; the puck/lizard mode reads
+// g_in[slot] inside onReport45/onAuxReport to forward to hid[slot].
 struct PuckInput {
 	// raw Triton buttons (TB_*); per-mode builders apply their own chord masking
 	uint32_t buttons;
@@ -81,4 +89,5 @@ struct PuckInput {
 	int16_t ax, ay, az; // accelerometer
 	int16_t gx, gy, gz; // gyroscope
 };
-extern PuckInput g_in;
+#include "bonds.h" // NSLOT
+extern PuckInput g_in[NSLOT];

+ 127 - 0
OpenPuck/usb_mount.cpp

@@ -0,0 +1,127 @@
+#include "usb_mount.h"
+#include "bonds.h"
+#include <Arduino.h>
+
+uint8_t g_usbMountCount = 0;
+int8_t g_usbToBond[NSLOT];
+int8_t g_bondToUsb[NSLOT];
+
+static bool g_dynOn = false;
+static uint8_t g_maxSlots = NSLOT;
+
+// A controller counts as "connected" (mount-worthy) while it has replied recently. More lenient than the
+// 300ms link-up check so a brief RF gap doesn't unmount it; the debounce below adds further hysteresis.
+#define CONN_UP_MS 1200u
+// The connected set must hold steady this long before we re-enumerate -- absorbs RF blips and the staggered
+// way multiple controllers connect at boot (one re-enumeration once the set settles, not one per controller).
+#define MOUNT_DEBOUNCE_MS 2000u
+
+static uint8_t connectedMask()
+{
+	unsigned long now = millis();
+	uint8_t m = 0;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] != 0 &&
+		    (now - g_connReplyMs[s]) < CONN_UP_MS)
+			m |= (uint8_t)(1u << s);
+	return m;
+}
+
+static uint8_t mountedMask()
+{
+	uint8_t m = 0;
+	for (uint8_t u = 0; u < g_usbMountCount; u++)
+		if (g_usbToBond[u] >= 0)
+			m |= (uint8_t)(1u << g_usbToBond[u]);
+	return m;
+}
+
+// Compute the order-preserving, budget-capped mount list for `mask` into out[], returning the count. Ordering
+// follows WHEN each controller was mounted: controllers already mounted keep their current USB-slot order (a
+// departure compacts the rest down but never reorders survivors), and newly-connected controllers are appended
+// at the end (ascending bond order among simultaneous joiners). Does NOT mutate the live map.
+static uint8_t computeOrder(uint8_t mask, int8_t out[NSLOT])
+{
+	uint8_t n = 0;
+	// 1. keep currently-mounted controllers that are still connected, in their existing order
+	for (uint8_t u = 0; u < g_usbMountCount && n < g_maxSlots; u++) {
+		int8_t b = g_usbToBond[u];
+		if (b >= 0 && (mask & (1u << b)))
+			out[n++] = b;
+	}
+	// 2. append newly-connected controllers (not already mounted)
+	for (int s = 0; s < NSLOT && n < g_maxSlots; s++) {
+		if (!(mask & (1u << s)))
+			continue;
+		bool have = false;
+		for (uint8_t i = 0; i < n; i++)
+			if (out[i] == (int8_t)s) {
+				have = true;
+				break;
+			}
+		if (!have)
+			out[n++] = (int8_t)s;
+	}
+	return n;
+}
+static uint8_t maskOfList(const int8_t *list, uint8_t n)
+{
+	uint8_t m = 0;
+	for (uint8_t i = 0; i < n; i++)
+		if (list[i] >= 0)
+			m |= (uint8_t)(1u << list[i]);
+	return m;
+}
+static void commitOrder(const int8_t *list, uint8_t n)
+{
+	for (int i = 0; i < NSLOT; i++) {
+		g_usbToBond[i] = -1;
+		g_bondToUsb[i] = -1;
+	}
+	for (uint8_t u = 0; u < n; u++) {
+		g_usbToBond[u] = list[u];
+		g_bondToUsb[list[u]] = (int8_t)u;
+	}
+	g_usbMountCount = n;
+}
+
+void usbMountRebuildMap()
+{
+	int8_t tmp[NSLOT];
+	uint8_t n = computeOrder(connectedMask(), tmp);
+	commitOrder(tmp, n);
+}
+
+void usbMountEnable(bool on, uint8_t maxSlots)
+{
+	g_dynOn = on;
+	g_maxSlots = maxSlots ? (maxSlots <= NSLOT ? maxSlots : NSLOT) : 1;
+	for (int i = 0; i < NSLOT; i++)
+		g_usbToBond[i] = g_bondToUsb[i] = -1;
+	g_usbMountCount = 0;
+}
+
+void usbMountTask()
+{
+	if (!g_dynOn)
+		return;
+	static uint8_t lastMask = 0xFF; // force first compare
+	static unsigned long stableSince = 0;
+	unsigned long now = millis();
+	uint8_t want = connectedMask();
+	if (want != lastMask) { // set is moving -> restart the stability timer
+		lastMask = want;
+		stableSince = now;
+		return;
+	}
+	// `want` has been steady since stableSince. Re-enumerate once it has held long enough AND the resulting
+	// (order-preserving, capped) mount SET differs from what is currently mounted.
+	if ((now - stableSince) < MOUNT_DEBOUNCE_MS)
+		return;
+	int8_t tmp[NSLOT];
+	uint8_t n = computeOrder(want, tmp);
+	if (maskOfList(tmp, n) == mountedMask())
+		return; // same set already mounted (order is preserved across joins/leaves)
+	commitOrder(tmp, n);
+	usbReenumerate(g_usbMountCount);
+}

+ 38 - 0
OpenPuck/usb_mount.h

@@ -0,0 +1,38 @@
+// usb_mount.h -- dynamic USB mounting of ACTIVELY-CONNECTED controllers (emulated modes only).
+//
+// Problem: a puck can be bonded to several controllers but only some are powered on / linked at a time. The
+// emulated modes used to mount one USB HID gamepad per BONDED slot at boot, so the host saw phantom pads for
+// controllers that were off. This module makes the USB device present only the CONNECTED controllers, and
+// re-enumerate (NO MCU reboot -- RF links stay up) whenever that set changes.
+//
+// Model: a fixed pool of per-mode USB-slot objects (HID/XInput interfaces) is created ONCE at boot (locking
+// their TinyUSB instance indices). At any moment the k connected controllers are presented as a DENSE PREFIX
+// usbSlot 0..k-1 of that pool; usbSlot u is fed from bond slot g_usbToBond[u]. USB can't hold an interface
+// "gap", so the prefix is compacted -> player identity follows CONNECTION ORDER (a mid-set disconnect shifts
+// the rest down; a reconnect appends). Re-enumeration is debounced so an RF blip doesn't reshuffle.
+#pragma once
+#include <stdint.h>
+#include "bonds.h" // NSLOT
+
+// Active mount map. usbSlot (0..g_usbMountCount-1) -> bond slot; and the inverse for output-report routing.
+extern uint8_t g_usbMountCount; // # of currently-mounted (connected) controllers
+extern int8_t g_usbToBond[NSLOT]; // usbSlot -> bondSlot (-1 = unused)
+extern int8_t g_bondToUsb[NSLOT]; // bondSlot -> usbSlot (-1 = not mounted)
+
+// Enable the dynamic watcher for this boot (emulated modes call this; puck/lizard leave it off so their begin()
+// keeps the legacy behavior). maxSlots caps the mounted count to the mode's HID-instance budget (CFG_TUD_HID
+// minus the wake mouse, etc.). When disabled, usbMountTask() is a no-op.
+void usbMountEnable(bool on, uint8_t maxSlots);
+
+// Build g_usbToBond/g_bondToUsb + g_usbMountCount from the CURRENT connected set (bond-slot order). Called by
+// the boot enumerate and on every accepted change.
+void usbMountRebuildMap();
+
+// Per-loop watcher: detect a debounced change in the connected set and trigger usbReenumerate(k). No-op unless
+// dynamic mounting is enabled.
+void usbMountTask();
+
+// Provided by OpenPuck.ino: tear down + rebuild the USB config descriptor presenting `k` slot interfaces
+// (fixed interfaces -- WebUSB / wake mouse -- replayed too), then re-attach. NO MCU reboot. Call with the map
+// already rebuilt for k connected controllers.
+void usbReenumerate(uint8_t k);

+ 5 - 0
OpenPuck/wake_hid.cpp

@@ -37,3 +37,8 @@ bool wakeHidMove(int8_t dx, int8_t dy)
 	// boot mouse descriptor has no report ID -> report_id 0; buttons=0 so we move but never click
 	return g_wakeHid.mouseReport(0, 0, dx, dy, 0, 0);
 }
+
+void wakeHidAddInterface()
+{
+	TinyUSBDevice.addInterface(g_wakeHid);
+}

+ 6 - 1
OpenPuck/wake_hid.h

@@ -16,9 +16,14 @@
 
 #include <stdint.h>
 
-// register the boot-mouse wake interface (call from setup() for clean modes + normal puck boot)
+// register the boot-mouse wake interface (call from setup() for clean modes + normal puck boot). Locks its
+// TinyUSB HID instance index -- for dynamic-mount modes call this BEFORE the slot pool so it is HID instance 0.
 void wakeHidBegin();
 
+// Re-add the (already-begun) wake-mouse interface to a freshly-cleared config descriptor, for a dynamic
+// re-enumeration that doesn't reboot. begin() is once-only; this re-emits the interface descriptor.
+void wakeHidAddInterface();
+
 // true once wakeHidBegin() has registered the boot mouse this boot (false on the debug-CDC boot, which omits it)
 bool wakeHidPresent();
 

+ 99 - 16
OpenPuck/webusb_config.cpp

@@ -11,25 +11,32 @@
 
 Adafruit_USBD_WebUSB usb_web;
 
-// blob payload = [ver=2][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
+// blob payload = [ver=9][mode][mDiv][mFric][qamMap(active)][abSwap(active)][back0..3(active)][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
 //                [gitDirty][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
-// keep the blob under 64 bytes total (readBlob reads one 64-byte packet)
-#define WB_PAYLEN 60
+//                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
+//                [v9: per-type cfg, 4x7B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics}]
+// p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v9 extends to 101 bytes (103 total incl header);
+// browser reads with transferIn(128) to span the two USB-FS packets.
+#define WB_PAYLEN 101
 static void webusbSendBlob()
 {
 	if (!usb_web.connected())
 		return;
-	bool up = (g_connSlot >= 0 && (millis() - g_connReplyMs) < 300);
+	// "connected" = the most recent poll cycle's slot had a fresh F-reply (matches what the firmware
+	// presents to Steam on 0x79). The blob is sent on the panel's poll, so the slot + up flag change
+	// every ~250ms in normal use.
+	int cs = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
+	bool up = (g_curSlot >= 0 && (millis() - g_connReplyMs[cs]) < 300);
 	uint8_t p[2 + WB_PAYLEN];
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (7 = +raw accel; 6 = +swPro120/gyroScale; 5 = +rumbleScale)
-	p[2] = 7;
+	// protocol version (9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 9;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -39,7 +46,7 @@ static void webusbSendBlob()
 	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[12] = (g_curSlot >= 0) ? (uint8_t)g_curSlot : 0xFF;
 	p[13] = up ? 1 : 0;
 	p[14] = (uint8_t)g_f1ps;
 	p[15] = (uint8_t)(g_f1ps >> 8);
@@ -68,8 +75,13 @@ static void webusbSendBlob()
 	p[36] = (uint8_t)(g_pollPeriodUs >>
 			  8); // measured poll period (intended 4000)
 	p[37] = OPK_LOG; // logging build? panel shows/hides its log UI
-	p[38] = g_battery; // controller battery % (report 0x43); 0=unknown
-	p[39] = g_linkRssi; // RAW signal strength |dBm| (0=no sample)
+	p[38] = g_battery
+		[g_curSlot >= 0 && g_curSlot < NSLOT ?
+			 g_curSlot :
+			 0]; // controller battery % (report 0x43); 0=unknown
+	p[39] = g_linkRssi[g_curSlot >= 0 && g_curSlot < NSLOT ?
+				   g_curSlot :
+				   0]; // RAW signal strength |dBm| (0=no sample)
 	// git commit this firmware was built from + dirty flag; injected at build time
 	// (build_info.h / gen_version.sh); "unknown" if the version header wasn't generated.
 	p[40] = OPK_GIT_DIRTY ? 1 : 0;
@@ -87,9 +99,33 @@ static void webusbSendBlob()
 	// Switch Pro gyro sensitivity x10 (protocol v6)
 	p[55] = g_swGyroScale10;
 	{
-		int16_t a[3] = { g_in.ax, g_in.ay, g_in.az };
+		int16_t a[3] = { g_in[0].ax, g_in[0].ay, g_in[0].az };
 		memcpy(&p[56], a, 6);
 	} // raw accelerometer for scale diagnostics (protocol v7)
+
+	// per-slot link status for all bond slots (protocol v8)
+	p[62] = (uint8_t)bondedSlotCount();
+	{
+		unsigned long nowMs = millis();
+		for (int s = 0; s < NSLOT; s++) {
+			bool sup = g_slot[s].used && g_connReplyMs[s] != 0 &&
+				   (nowMs - g_connReplyMs[s]) < 300u;
+			p[63 + s * 3] = sup ? 1 : 0;
+			p[64 + s * 3] = g_battery[s];
+			p[65 + s * 3] = g_linkRssi[s];
+		}
+	}
+	// per-emulated-type button config (protocol v9): 4 types x 7 bytes from p[75]
+	for (int et = 0; et < ET_COUNT; et++) {
+		uint8_t *q = &p[75 + et * 7];
+		q[0] = g_type[et].back[0];
+		q[1] = g_type[et].back[1];
+		q[2] = g_type[et].back[2];
+		q[3] = g_type[et].back[3];
+		q[4] = g_type[et].qamMap;
+		q[5] = g_type[et].abSwap;
+		q[6] = g_type[et].padHaptics;
+	}
 	usb_web.write(p, sizeof p);
 	usb_web.flush();
 }
@@ -160,7 +196,7 @@ void webusbPoll()
 				       (op == 0x03 || op == 0x05) ? 2 :
 				       (op == 0x0A)		  ? 4 :
 								    1;
-			if (op < 0x01 || op > 0x0A) { // resync: drop one byte
+			if (op < 0x01 || op > 0x0C) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
@@ -200,11 +236,48 @@ void webusbPoll()
 					delay(40);
 					NVIC_SystemReset();
 				}
+
+				// reboot into serial DFU (adafruit-nrfutil)
+			} else if (op == 0x0B) {
+				usb_web.flush();
+				delay(40);
+				enterSerialDfu();
+
+				// reboot into UF2 bootloader (USB mass storage)
+			} else if (op == 0x0C) {
+				usb_web.flush();
+				delay(40);
+				enterUf2Dfu();
+
 			} else if (op == 0x02) {
 				uint8_t f = buf[1], v = buf[2];
 
 				// every settable field persists (poll rate is no longer settable)
 				bool persist = true;
+				// per-type cfg writes (protocol v9): field = 40 + et*8 + k, k: 0..3 back, 4 qam, 5 abSwap,
+				// 6 padHaptics. Edits g_type[et]; refresh the live mirrors if it's the active type.
+				if (f >= 40 && f < 40 + ET_COUNT * 8) {
+					uint8_t et = (uint8_t)((f - 40) / 8),
+						k = (uint8_t)((f - 40) % 8);
+					if (et < ET_COUNT) {
+						if (k < 4)
+							g_type[et].back[k] = v;
+						else if (k == 4)
+							g_type[et].qamMap = v;
+						else if (k == 5)
+							g_type[et].abSwap = v ? 1 : 0;
+						else if (k == 6)
+							g_type[et].padHaptics =
+								v ? 1 : 0;
+						if (et == g_etype)
+							applyActiveType();
+					}
+					saveCfg();
+					webusbSendBlob();
+					memmove(buf, buf + need, n - need);
+					n -= need;
+					continue;
+				}
 				switch (f) {
 				case 1:
 					g_mDiv = v < 4 ? 4 : v;
@@ -213,14 +286,21 @@ void webusbPoll()
 					g_mFric = v > 99 ? 99 : v;
 					break;
 				// case 3 (padSmooth) removed -- Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
+				// Legacy single-value fields (4 abSwap, 5-8 back, 21 qam) edit the ACTIVE emulated type.
 				case 4:
-					g_abSwap = v ? 1 : 0;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].abSwap = v ? 1 : 0;
+						applyActiveType();
+					}
 					break;
 				case 5:
 				case 6:
 				case 7:
 				case 8:
-					g_back[f - 5] = v;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].back[f - 5] = v;
+						applyActiveType();
+					}
 					break;
 				// case 9 (pollU100) removed -- poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
 
@@ -274,9 +354,12 @@ void webusbPoll()
 					NVIC_SystemReset();
 					break;
 
-				// QAM physical button remap code (0=default/unmapped)
+				// QAM physical button remap code (0=default/unmapped) -- active emulated type
 				case 21:
-					g_qamMap = v;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].qamMap = v;
+						applyActiveType();
+					}
 					break;
 
 				// rumble strength % (0=off, 100=1x, 200=double)
@@ -286,7 +369,7 @@ void webusbPoll()
 
 				// Switch Pro report rate (0=66Hz,1=120Hz,2=full)
 				case 23:
-					g_swProRate = (v <= 2) ? v : 1;
+					g_swProRate = (v <= 2) ? v : 2;
 					swProSaveCfg();
 					persist = false;
 					break;

+ 2 - 0
OpenPuck/webusb_config.h

@@ -3,6 +3,8 @@
 //   host->dev:  0x01                 GET  -> reply status blob
 //               0x02 <field> <value> SET one byte field (see the switch in webusb_config.cpp)
 //               0x03 <mode>          switch USB mode (0..6): persist + reboot
+//               0x0B                 reboot into serial DFU (adafruit-nrfutil)
+//               0x0C                 reboot into UF2 bootloader (USB mass storage)
 //   dev->host:  0xA5 <len> <payload>  status blob (mode/tunables/link state/rates)
 // No setLandingPage() on purpose -- it would pop a Chrome "open <url>?" notification on every plug-in.
 #pragma once

+ 18 - 5
README.md

@@ -39,6 +39,8 @@ Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to
 
 I'm also adding various QOL items as I go as well. For example having to hold the Steam button for like 6 seconds feels like an eternity. If Steam is open you can do Steam +Y for a shutdown. I'm adding Steam + Y for 2 seconds as a shutdown chort in ALL modes now.
 
+Note: to use the Switch mode on a real Switch you'll need to [enable the pro controller wired communication option](https://www.nintendo.com/en-gb/Support/Troubleshooting/How-to-Enable-Disable-Pro-Controller-Wired-Communication-1516284.html).
+
 ### A note on the Lizard mode:
 The Lizard mode behaves similarly to how the controller behaves when Steam is closed, but this will work even when Steam is open. This has a few advantages
 the biggest one being that you can use inputs when a high privilege application is in the foreground (like the Task Manager, when using Steam if you wanna be able to do that Steam must be run as admin).
@@ -46,22 +48,33 @@ the biggest one being that you can use inputs when a high privilege application
 Additionally it has some shortcuts that might be useful: Steam + L5/R5 will do volume control, RB is Alt so you can RB + Select to move through windows, LB is Ctrl 
 and Steam + L4 ls Ctrl + Alt + Delete.
 
-# How to use it
+# How to install/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.
 
+# Pairing
+Both OpenPuck and the controller need to be hooked up to the same machine with a data capable USB C cable at the same time and Steam must be running. Steam should in most cases automatically pop up a menu to pair the controller. If not, you can go to Settings -> Controllers and press "Add Controller".
+
+If you want to use the second slot for OpenPuck, you'll need to first turn the controller off and then hold LB + A + Steam to turn the controller back on (the chime when the controller comes on in this mode sounds different). Then connect the USB C cable and continue to pair.
+
+Switching slots requires turning the controller off (Steam + Y if steam is running, Steam + Y held for 2 seconds if Steam isn't running or if you're in a different mode, or just hold the Steam button for an eternity until the controller shuts off) and then you hold RB for slot 1 and LB for slot 2 while holding A and Steam to turn the controller back on.
+
 # Configuration
-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 other modes 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 other modes 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. Note that it might not work in all modes on all machines but should always work in the Steam Controller mode (which you can revert to with back-4 + A).
+
+# 3D Printed Cases
+- [jaki-gh](https://github.com/jaki-gh) has contributed a 3D printable housing with OpenPuck written on it alongside a Steam logo. You can find that [here](https://www.thingiverse.com/thing:7371668).
+- [StonnedModder](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case) built a case meant to accomodate a USB C to USB A adapter which you can find [here](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case).
+- Another plain case for these pro micros can be found [here](https://www.printables.com/model/1285346-pro-micro-nicenano-nrf52840-dongle-case/collections).
 
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
-- Ensure multiple controllers can function correctly (if I can ever buy one)
+- Multi-controller support is now working in Steam/Lizard mode (up to 4 controllers, see the `U` CDC command for a per-slot status dump). Non-puck modes (Xbox, Switch, PS5, DS4) remain single-controller by design — the user can pair one controller and use the appropriate mode.
 - Design the charging portion (and make it short proof)
-- Design a 3D printable housing
 
 # Contributions
 The firmware is split into small, single-responsibility modules under `OpenPuck/` (one file per emulated controller, plus the RF, config, and host-interface layers). Start with [ARCHITECTURE.md](./ARCHITECTURE.md) for the map of how it all fits together and how to add a new USB personality.
 
-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.
+I have tested this software fairly extensively but I have limited resources. I only have one controller so I've been unable to do full end-to-end multi-controller hardware testing — the per-slot session address, per-slot USB fanout, and per-slot haptic demux are in place and the design is sound, but the `U` CDC command exists specifically so anyone with a second controller can verify the per-slot state at a glance. Please submit issues with any issues you find. PRs also welcome of course.
 
 # Acknowledgements
 - Valve for putting out the amazing controller

+ 3 - 0
docs/PROTOCOL.md

@@ -189,6 +189,9 @@ 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.
+- The session base/prefix are PER-BOND (one address per controller, derived from the bond UUID + the
+  puck's FICR DEVICEID so two pucks that bond the same controller still isolate). The real puck
+  supports up to four simultaneous controllers on the same channel; OpenPuck mirrors that.
 
 ## 7. Connected session
 

+ 275 - 79
docs/index.html

@@ -33,6 +33,20 @@
   .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}
+
+  /* ---- controller slot tabs ---- */
+  .slot-tabs{display:flex;gap:6px;margin:0 0 14px;flex-wrap:wrap}
+  .slot-tab{display:flex;align-items:center;gap:7px;padding:5px 14px;border-radius:8px;font-size:12px;font-weight:600;background:#0e1017;border:1px solid #232a3a;color:var(--mut);cursor:pointer;transition:border-color .15s,color .15s,background .15s}
+  .slot-tab:hover{border-color:var(--acc2);color:var(--fg)}
+  .slot-tab.active{background:#111c2e;border-color:var(--acc2);color:var(--fg)}
+  .slot-tab .dot{width:7px;height:7px;border-radius:50%;background:var(--bad);flex-shrink:0;transition:background .3s}
+  .slot-tab .dot.up{background:var(--acc);animation:pulse-link 2.2s ease-in-out infinite}
+  @keyframes pulse-link{
+    0%,100%{box-shadow:0 0 0 0 rgba(91,214,160,.5)}
+    55%{box-shadow:0 0 0 5px rgba(91,214,160,0)}
+  }
+  @media(prefers-reduced-motion:reduce){.slot-tab .dot.up{animation:none}}
+  .per-slot-grid{margin-bottom:12px}
 </style>
 </head>
 <body>
@@ -59,9 +73,6 @@
         <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
         <button data-mode="5" class="modebtn">PS5 DualSense</button>
         <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
-        <!-- modes 7/8 (PS5/DS4 "game/clean") hidden from the panel for now; still reachable via serial console -->
-        <!-- <button data-mode="7" class="modebtn">PS5 (game/clean)</button> -->
-        <!-- <button data-mode="8" class="modebtn">DS4 (game/clean)</button> -->
       </div>
       <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only.</p>
       <div class="row"><label>Persist last mode</label>
@@ -80,11 +91,19 @@
 
     <div class="card">
       <h2>Link status</h2>
-      <div class="statgrid">
+
+      <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
+      <div class="slot-tabs" id="slotTabs" style="display:none"></div>
+
+      <!-- Per-slot: RF link / Battery / Signal -->
+      <div class="statgrid per-slot-grid">
         <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
         <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
         <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
-        <div class="stat"><div class="k">Slot</div><div class="v" id="stSlot">—</div></div>
+      </div>
+
+      <!-- Global stats -->
+      <div class="statgrid">
         <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 class="stat logonly"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
@@ -92,7 +111,10 @@
         <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
         <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
         <div class="stat"><div class="k">Build (git)</div><div class="v" id="stBuild">—</div></div>
+        <div class="stat"><div class="k">Last reset</div><div class="v" id="stReset">—</div></div>
       </div>
+      <p class="note" style="margin-top:6px"><strong>Reset history</strong> (newest first, persists across power-off): <span id="resetHist" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
+        <em>watchdog</em>/<em>LOCKUP</em> = a fault (loop hang / crash); <em>soft reboot</em> = an intentional reset (mode change); <em>power-on</em> = normal plug-in.</p>
       <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
         Raw SC2 accel (before scaling). This controller is a ±2 g sensor (~16384 = 1 g); the Switch report divides it by 4 to present the genuine ±8 g (4096 = 1 g) scale so the console's gravity-correction engages and the gyro stops drifting.</p>
       <p class="note logonly">Polls/s ≈ 250 expected; it's capped by Loop period (≈4000 µs needed). "Slowest stage" is the loop section eating the most time per iteration.</p>
@@ -102,6 +124,14 @@
       <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
         <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path Steam's "turn off controller" and host-suspend use. Watch the link status to see it disconnect.</span>
       </div>
+      <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px">
+        <button id="dfuSerial">Reboot to Serial DFU</button>
+        <span class="note" style="flex:1">Reboots the puck into serial DFU mode (adafruit-nrfutil). The device drops off USB — flash with <code>adafruit-nrfutil dfu serial …</code> then replug.</span>
+      </div>
+      <div class="row" style="margin:10px 0 0">
+        <button id="dfuUf2">Reboot to UF2 Bootloader</button>
+        <span class="note" style="flex:1">Reboots the puck into the UF2 mass-storage bootloader. It mounts as a USB drive — drag the <code>.uf2</code> file onto it to flash, then it reboots automatically.</span>
+      </div>
       <div class="row" style="margin:18px 0 0;border-top:1px solid #4a2030;padding-top:14px">
         <button id="factoryErase" style="background:#3a1420;border-color:#7a2233;color:#ff9bb0;font-weight:600">⚠ Factory erase</button>
         <span class="note" style="flex:1"><strong style="color:#ff9bb0">Danger:</strong> wipes <em>all</em> persistent storage on the copycat — the paired controller bond <em>and</em> every saved setting (mode, chords, back-paddle map, sensitivity). The copycat reboots to factory defaults and you must <strong>re-pair the controller</strong>. This cannot be undone.</span>
@@ -124,24 +154,15 @@
     <div class="card">
       <h2>Switch Pro motion</h2>
       <div class="row"><label>Report rate</label><select id="swRate"></select></div>
-      <p class="note" style="margin:0 0 6px">120 Hz (default) is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback. Full rate (~250 Hz) is lowest-latency but non-genuine — use only if your console tolerates it.</p>
+      <p class="note" style="margin:0 0 6px">Full rate (~250 Hz, default) is lowest-latency. 120 Hz is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback — drop to it only if your console rejects the faster rates.</p>
       <div class="row"><label>Gyro scale</label><select id="swGyro"></select></div>
       <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</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>
-      <div class="row"><label>QAM (3 dots)</label><select id="qamMap"></select></div>
-      <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes. QAM remap applies to all modes (0 = default per-mode behavior).</p>
+      <p class="note" style="margin-top:0">Separate mappings per emulated controller type — each only offers targets that exist on that controller. The type matching the current mode is highlighted. Steam &amp; Lizard are native puck modes (Steam does its own remapping) and aren't configured here.</p>
+      <div id="typeCfgs"></div>
     </div>
 
     <div class="card logonly">
@@ -163,45 +184,135 @@
 <script>
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)"];
 const CHORD_FIELD = [17, 18, 19];
-const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Quick Access Menu"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"],[16,"PS Touch Click"],[17,"PS5 Mute"]];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
-// populate back-paddle + chord 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); }
+// Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
+// only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*8 + k
+// (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics). Blob v9 reads them back at p[75 + et*7 + ...].
+const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
+const TYPE_DEFS = [
+  {key:"XBOX", name:"Xbox", labels:{1:"A",2:"B",3:"X",4:"Y",5:"LB",6:"RB",7:"L3",8:"R3",9:"Back",10:"Start",11:"Guide",19:"LT",20:"RT",...DPAD}},
+  {key:"SWITCH", name:"Switch", labels:{1:"A",2:"B",3:"X",4:"Y",5:"L",6:"R",7:"L-Stick",8:"R-Stick",9:"Minus",10:"Plus",11:"Home",19:"ZL",20:"ZR",...DPAD,18:"Capture / Screenshot"}},
+  {key:"DS4", name:"DS4", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click"}},
+  {key:"DS5", name:"DS5", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click",17:"Mute"}},
+];
+// Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
+function etypeForMode(m){ switch(m){case 1:return 0; case 2:case 4:return 1; case 6:case 8:return 2; case 5:case 7:return 3; default:return -1;} }
+const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
+const typeEls = []; // per-type {sec, abSwap, pad, back[4], qam}
+function mkSelect(def, includeNone){
+  const sel=document.createElement("select");
+  if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
+  else { const o=document.createElement("option"); o.value=0; o.textContent="Default (per-mode)"; sel.appendChild(o); }
+  for(const c of Object.keys(def.labels).map(Number).sort((a,b)=>a-b)){
+    const o=document.createElement("option"); o.value=c; o.textContent=def.labels[c]; sel.appendChild(o); }
+  return sel;
 }
-{ const sel = document.getElementById("qamMap");
-  const d = document.createElement("option"); d.value=0; d.textContent="Default (varies by mode)"; sel.appendChild(d);
-  for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
+let curTab=0, tabInited=false;
+function setTab(et){
+  curTab=et;
+  typeEls.forEach((rec,i)=>{
+    rec.sec.style.display = (i===et) ? "" : "none";
+    rec.tab.classList.toggle("active", i===et);
+  });
 }
+(function buildTypeCfgs(){
+  const host=document.getElementById("typeCfgs");
+  const bar=document.createElement("div"); bar.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px";
+  host.appendChild(bar);
+  TYPE_DEFS.forEach((def,et)=>{
+    // tab button
+    const tab=document.createElement("button"); tab.className="slot-tab"; tab.style.cursor="pointer";
+    tab.innerHTML='<span>'+def.name+'</span><span class="active-dot" style="display:none;font-size:11px;color:var(--acc)"> ●</span>';
+    tab.onclick=()=>setTab(et); bar.appendChild(tab);
+    // panel
+    const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
+    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null};
+    // A/B swap + trackpad haptics toggles
+    const tog=document.createElement("div"); tog.className="row";
+    tog.innerHTML='<label>A/B + X/Y swap</label>';
+    const ab=document.createElement("button"); ab.textContent="off"; tog.appendChild(ab);
+    const padLbl=document.createElement("label"); padLbl.textContent="Trackpad haptics"; padLbl.style.marginLeft="14px"; tog.appendChild(padLbl);
+    const pad=document.createElement("button"); pad.textContent="on"; tog.appendChild(pad);
+    sec.appendChild(tog);
+    rec.abSwap=ab; rec.pad=pad;
+    ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*8+5, on?0:1); };
+    pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*8+6, on?0:1); };
+    // back paddles
+    for(let i=0;i<4;i++){
+      const row=document.createElement("div"); row.className="row";
+      const lab=document.createElement("label"); lab.textContent=BACK_LABELS[i]; row.appendChild(lab);
+      const sel=mkSelect(def,true); row.appendChild(sel); sec.appendChild(row);
+      sel.addEventListener("change",()=>setField(40+et*8+i, +sel.value));
+      rec.back.push(sel);
+    }
+    // QAM
+    { const row=document.createElement("div"); row.className="row";
+      const lab=document.createElement("label"); lab.textContent="QAM (3 dots)"; row.appendChild(lab);
+      const sel=mkSelect(def,false); row.appendChild(sel); sec.appendChild(row);
+      sel.addEventListener("change",()=>setField(40+et*8+4, +sel.value));
+      rec.qam=sel; }
+    host.appendChild(sec); typeEls.push(rec);
+  });
+  setTab(0);
+})();
 for(const sel of document.querySelectorAll("select.chord")){
-  MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;   // hide PS5/DS4 "game/clean" modes from the panel for now
+  MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
     const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
 }
-// Switch Pro report-rate options (0=66Hz, 1=120Hz default, 2=full ~250Hz)
 { const sel=document.getElementById("swRate");
-  for(const [lbl,v] of [["120 Hz (default)",1],["66 Hz (compatibility)",0],["Full rate (~250 Hz)",2]]){
+  for(const [lbl,v] of [["Full rate (~250 Hz, default)",2],["120 Hz",1],["66 Hz (compatibility)",0]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
-// Switch Pro gyro-scale options (value = scale x10, so it fits in one byte)
 { const sel=document.getElementById("swGyro");
   for(const [lbl,v] of [["0.5×",5],["1.0×",10],["1.5×",15],["2.0×",20],["2.5×",25],["3.0×",30]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
 
+// ---- per-slot tab state ----
+let g_activeSlot = 0;
+let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for bonded slots
+
+function updateSlotDisplay(slotIdx) {
+  const d = g_slotData[slotIdx];
+  if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—"; return; }
+  $("#stLink").innerHTML = d.up ? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
+  $("#stBatt").textContent = (d.up && d.battery) ? d.battery+"%" : "—";
+  $("#stRssi").textContent = (d.up && d.rssi) ? ("-"+d.rssi+" dBm") : "—";
+}
+
+function renderSlotTabs(bondedCount) {
+  const bar = $("#slotTabs");
+  if (bondedCount <= 1) { bar.style.display="none"; updateSlotDisplay(g_activeSlot); return; }
+  bar.style.display="flex";
+  bar.innerHTML="";
+  for (let s=0; s<4; s++) {
+    const d = g_slotData[s];
+    if (!d) continue;
+    const btn = document.createElement("button");
+    btn.className = "slot-tab" + (s===g_activeSlot ? " active" : "");
+    const dot = document.createElement("span");
+    dot.className = "dot" + (d.up ? " up" : "");
+    btn.appendChild(dot);
+    btn.appendChild(document.createTextNode("Controller "+(s+1)));
+    btn.onclick = () => { g_activeSlot=s; renderSlotTabs(bondedCount); };
+    bar.appendChild(btn);
+  }
+  updateSlotDisplay(g_activeSlot);
+}
+
 async function connect(){
   try{
     dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
     await dev.open();
     if(dev.configuration===null) await dev.selectConfiguration(1);
-    // WebUSB = vendor class 0xFF with bulk IN + bulk OUT (skip XInput 0xFF/5D/01 which uses interrupt only)
     let found=null;
     for(const itf of dev.configuration.interfaces){
       const a=itf.alternate;
       if(a.interfaceClass!==0xFF) continue;
-      if(a.interfaceSubClass===0x5D) continue;   // Xbox XInput vendor
+      if(a.interfaceSubClass===0x5D) 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; }
@@ -209,20 +320,17 @@ async function connect(){
     if(!found){ log("no WebUSB bulk vendor interface — reflash firmware with WebUSB enabled, or another app may be holding the 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;
+  polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
   $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
   $("#panel").classList.add("hide");
   log("device disconnected (likely a mode-switch reboot — reconnect)");
@@ -234,10 +342,10 @@ async function send(bytes){
 }
 async function readBlob(){
   try{
-    const r = await dev.transferIn(epIn, 64);
+    // v8 blob is 75 bytes (two USB-FS packets); read 128 to capture both in one call.
+    const r = await dev.transferIn(epIn, 128);
     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);
@@ -245,52 +353,137 @@ async function readBlob(){
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 function applyBlob(p){
-  // [ver][mode][mDiv][mFric][rsvd][abSwap][b0..b3][slot][up][f1lo][f1hi][pollU100(unused)][newlo][newhi]
-  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];   // p[4] = QAM remap code (0 = default)
-  const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
+  const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
+  const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
-  const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];   // v2 blob: chordBtn after qos/persist (Y defaults to Switch Pro=4)
-  const polls=(p.length>27?(p[26]|(p[27]<<8)):0);          // poll TX rate
-  const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);         // loop diag
+  const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
+  const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
+  const loopUs=(p.length>29?(p[28]|(p[29]<<8)):0);
   const worstIdx=(p.length>30?p[30]:0), worstUs=(p.length>32?(p[31]|(p[32]<<8)):0);
   const WORST=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led"];
-  const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);         // MEASURED poll period
-  const pollIntended=(p.length>14?p[14]*100:0);            // intended poll interval (g_pollUs)
-  const logEnabled=(p.length>35?p[35]:0);                  // is this a logging build (-DOPK_LOG=1)?
+  const pollUs=(p.length>34?(p[33]|(p[34]<<8)):0);
+  const pollIntended=(p.length>14?p[14]*100:0);
+  const logEnabled=(p.length>35?p[35]:0);
   for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
-  // mode buttons
   for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
   $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
-  // status
-  const battery=(p.length>36?p[36]:0);                    // controller battery % (report 0x43); 0 = unknown
-  const rumbleScale=(p.length>51?p[51]:200);              // rumble strength % (protocol v5+; older blob -> default)
-  const swProRate=(p.length>52?p[52]:1);                  // Switch Pro report rate 0/1/2 (protocol v6+; default 120Hz)
-  const swGyro10=(p.length>53?p[53]:10);                  // Switch Pro gyro scale x10 (protocol v6+)
-  const rssi=(p.length>37?p[37]:0);                       // RAW signal strength |dBm| (0 = no sample)
-  $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
-  $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
-  $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
-  $("#stSlot").textContent = slot===0xFF? "—" : slot;
+  const battery=(p.length>36?p[36]:0);
+  const rumbleScale=(p.length>51?p[51]:200);
+  const swProRate=(p.length>52?p[52]:2);
+  const swGyro10=(p.length>53?p[53]:10);
+  const rssi=(p.length>37?p[37]:0);
+
+  // per-slot link status (protocol v8, p.length >= 73)
+  const hasSlotsData = p.length >= 73;
+  if (hasSlotsData) {
+    const bondedN = p[60];
+    g_slotData = [];
+    for (let s=0; s<4; s++) {
+      const base = 61 + s*3;
+      if (p.length >= base+3) {
+        // only include bonded slots (a slot with up=0 could be bonded but offline)
+        // check if the slot appears at all in the bonded map by checking if bondedN covers it;
+        // the firmware only sets non-zero battery/rssi for actually-bonded slots
+        const slotUp = !!p[base];
+        const slotBatt = p[base+1];
+        const slotRssi = p[base+2];
+        // slot is "bonded" if firmware included it in bondedN coverage
+        // we populate slotData for all 4 and renderSlotTabs skips gaps
+        g_slotData[s] = {up: slotUp, battery: slotBatt, rssi: slotRssi};
+      }
+    }
+    // keep only bonded slots by cross-referencing bondedN
+    // firmware sends bondedSlotCount but not a slot bitmask; use battery>0 || rssi>0 || up as presence hint
+    // simpler: just count how many of the 4 slots have any non-zero data OR are marked up
+    // Actually the firmware always writes all 4 slots; we filter by the bond system's "used" flag via presence.
+    // For clean display: we'll show tabs only for slots that have ever had a reply OR are bonded (battery/rssi set).
+    // The firmware sets g_battery[s]=0 and g_linkRssi[s]=0 for unused slots, so 0/0/0 = not bonded.
+    // But a bonded offline slot also has 0/0/0 initially. We rely on bondedN for count and show the first bondedN slots
+    // that have g_slot[s].used — but we can't easily know which slots are used from the blob.
+    // Best heuristic: show all 4 slots if bondedN==4, otherwise use slot indices where battery or rssi != 0 OR up==1,
+    // OR fall back to showing slots 0..bondedN-1 if none have data yet (device just booted).
+    let bondedSlots = [];
+    for (let s=0; s<4; s++) {
+      if (g_slotData[s] && (g_slotData[s].up || g_slotData[s].battery || g_slotData[s].rssi)) bondedSlots.push(s);
+    }
+    // fallback: if no data yet (all offline, just booted), show slots 0..bondedN-1
+    if (bondedSlots.length === 0 && bondedN > 0) {
+      for (let s=0; s<bondedN; s++) bondedSlots.push(s);
+    }
+    // remove slotData for non-bonded slots so tabs don't appear for them
+    const bondedSet = new Set(bondedSlots);
+    for (let s=0; s<4; s++) { if (!bondedSet.has(s)) g_slotData[s] = null; }
+    // clamp active slot to a valid bonded slot
+    if (!g_slotData[g_activeSlot]) g_activeSlot = bondedSlots[0] || 0;
+    renderSlotTabs(bondedN);
+  } else {
+    // old firmware: single-slot display
+    $("#slotTabs").style.display="none";
+    $("#stLink").innerHTML = up? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
+    $("#stBatt").textContent = (up && battery)? battery+"%" : "—";
+    $("#stRssi").textContent = (up && rssi)? ("-"+rssi+" dBm") : "—";
+  }
+
+  // global stats
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
   $("#stPolls").textContent = up? polls+" /s" : "—";
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
-  // build provenance (protocol v4+): dirty flag at p[38], 12-byte NUL-padded git hash at p[39..50]. Independent
-  // of link state. Older firmware sends a shorter blob -> stays "—".
+  // reset-cause history (protocol v9): JS p[73]=count, p[74..83]=last 10 causes (newest first).
+  // Persisted in puck flash, so a reboot that happened on a Switch is still readable here after replugging.
+  function resetReasonText(rr){
+    if (rr === 0) return "power-on";
+    const r = [];
+    if (rr & 0x02) r.push("watchdog");      // DOG: loop wedge / hang
+    if (rr & 0x08) r.push("LOCKUP");        // CPU lockup = HardFault escalation (real crash)
+    if (rr & 0x04) r.push("soft reboot");   // SREQ: NVIC_SystemReset (mode change etc.)
+    if (rr & 0x01) r.push("reset pin");
+    return r.length ? r.join("+") : ("0x"+rr.toString(16));
+  }
+  if (p.length > 73) {
+    const n = Math.min(p[73], 10);
+    const hist = [];
+    for (let i=0; i<n; i++) hist.push(p[74+i]);
+    const fault = (rr)=>!!(rr & 0x0A); // watchdog or lockup = a real fault
+    if (n === 0) { $("#stReset").textContent = "—"; }
+    else {
+      const newest = hist[0];
+      const head = fault(newest) ? ('<span class="pill dn">'+resetReasonText(newest)+'</span>') : resetReasonText(newest);
+      $("#stReset").innerHTML = head;
+      // full history list under the tile (newest -> oldest)
+      const items = hist.map((rr,i)=>{
+        const t = resetReasonText(rr);
+        return (i===0?'<strong>':'')+ '#'+(n-i)+' '+(fault(rr)?('<span style="color:#ff6b6b">'+t+'</span>'):t) +(i===0?' (latest)</strong>':'');
+      });
+      const el = $("#resetHist"); if (el) el.innerHTML = items.join(' &nbsp;·&nbsp; ');
+    }
+  }
   const gitDirty=(p.length>38?p[38]:0); let gitHash="";
   for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
   $("#stBuild").innerHTML = gitHash
     ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
     : "—";
-  // sliders (only if not being dragged)
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
-  $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
-  for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
-  { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
-  // raw accelerometer diagnostic (protocol v7): 3x s16 LE at p[54..59]
+  // per-type button config (blob v9): 4 types x 7 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
+  // the firmware's payload byte 75 lands at JS index 73 here.
+  if(p.length>=73+TYPE_DEFS.length*7){
+    const activeEt=etypeForMode(mode);
+    typeEls.forEach((rec,et)=>{
+      const q=73+et*7;
+      const back=[p[q],p[q+1],p[q+2],p[q+3]], qam=p[q+4], ab=p[q+5], pad=p[q+6];
+      rec.back.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=back[i]; });
+      if(document.activeElement!==rec.qam) rec.qam.value=qam;
+      rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
+      rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
+      // dot marks the type matching the current puck mode
+      rec.activeDot.style.display = (et===activeEt) ? "" : "none";
+    });
+    // open the tab for the current mode's type on first load (then leave the user's choice alone)
+    if(!tabInited){ tabInited=true; setTab(activeEt>=0?activeEt:0); }
+  }
   if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
     const ax=s16(54),ay=s16(56),az=s16(58);
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
@@ -305,7 +498,7 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight||capturing) return; inflight=true;   // don't let the status poll steal capture frames
+  if(!dev||inflight||capturing) return; inflight=true;
   await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
   inflight=false;
 }
@@ -314,9 +507,9 @@ async function setField(field,value){
 }
 function fmtCapEntry(e,maxMs){
   const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
-  const t = `-${String(maxMs-e.ms).padStart(6," ")}ms  `;   // age vs the newest entry (consistent across drain batches)
+  const t = `-${String(maxMs-e.ms).padStart(6," ")}ms  `;
   if(e.slot===0xFD){ const k=e.bytes[0]; return `${t}════════ ${k===2?"RECONNECT (block+reinit armed)":k===1?"RF LINK UP":"RF LINK DOWN"} ════════`; }
-  const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot}     `;                        // 0xFE = frame WE sent the controller
+  const who = (e.slot===0xFE) ? "TX→ctlr " : `if${e.slot}     `;
   return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")}  n=${e.nb}:  ${hex}`;
 }
 function downloadCap(){
@@ -325,11 +518,10 @@ function downloadCap(){
   a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
   a.download="puck-capture.txt"; a.click();
 }
-// ---- live Start/Stop capture: poll the firmware's drain (op 0x06) on a timer and accumulate in the browser ----
-let liveTimer=null, draining=false, capLines=[];   // capLines = array of {ms,slot,rid,nb,bytes}
+let liveTimer=null, draining=false, capLines=[];
 function renderCap(){
   if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
-  const maxMs=capLines[capLines.length-1].ms;       // entries are chronological -> newest is last
+  const maxMs=capLines[capLines.length-1].ms;
   const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
   $("#capOut").textContent=t; window._lastCap=t;
 }
@@ -364,15 +556,15 @@ async function drainTick(){
 }
 async function startCapture(){
   if(!dev||capturing) return;
-  capturing=true; capLines=[];                       // capturing=true pauses the status poll so it can't steal frames
+  capturing=true; capLines=[];
   $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
   $("#capStart").disabled=true; $("#capStop").disabled=false;
-  await send([0x05,1]);                               // rewind drain to OLDEST -> dumps the whole ring from boot
+  await send([0x05,1]);
   liveTimer=setInterval(drainTick,100);
 }
 async function stopCapture(){
   if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
-  await drainTick();                                  // final flush
+  await drainTick();
   try{ await send([0x05,0]); }catch(e){}
   capturing=false;
   $("#capStart").disabled=false; $("#capStop").disabled=true;
@@ -390,28 +582,32 @@ $("#capStart").onclick=startCapture;
 $("#capStop").onclick=stopCapture;
 $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
 $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
+$("#dfuSerial").onclick=async()=>{
+  if(!dev){ log("not connected"); return; }
+  if(!confirm("Reboot into serial DFU?\n\nThe puck will disconnect immediately. Flash with adafruit-nrfutil, then replug.")) return;
+  await send([0x0B]); log("serial DFU reboot sent — device disconnecting");
+};
+$("#dfuUf2").onclick=async()=>{
+  if(!dev){ log("not connected"); return; }
+  if(!confirm("Reboot into UF2 bootloader?\n\nThe puck will disconnect and mount as a USB drive. Drag the .uf2 file onto it to flash.")) return;
+  await send([0x0C]); log("UF2 bootloader reboot sent — device disconnecting");
+};
 $("#factoryErase").onclick=async()=>{
   if(!dev){ log("not connected"); return; }
-  // Triple gate so this is impossible to fire accidentally: warn, warn harder, then require typing ERASE.
   if(!confirm("Factory erase?\n\nThis wipes ALL persistent storage on the copycat:\n  • the paired-controller bond (you'll have to re-pair)\n  • every saved setting (mode, chords, back paddles, sensitivity)\n\nThe copycat reboots to factory defaults. This CANNOT be undone.")) return;
   if(!confirm("Are you absolutely sure?\n\nThere is no recovery. The controller bond and all settings will be gone.")) return;
   const typed=prompt('Final confirmation — type  ERASE  (all caps) to wipe everything:');
   if(typed!=="ERASE"){ log("factory erase cancelled (confirmation text did not match)"); return; }
-  // op 0x0A + magic "ERS" (0x45,0x52,0x53) -- firmware reformats the FS and reboots; the device drops off USB.
   await send([0x0A,0x45,0x52,0x53]);
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };
-$("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 for(const id of ["mDiv","mFric","rumble"]){
   const field={mDiv:1,mFric:2,rumble:22}[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));
-}
-$("#qamMap").addEventListener("change", ()=>setField(21, +$("#qamMap").value));
+// per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
 $("#swRate").addEventListener("change", ()=>setField(23, +$("#swRate").value));
 $("#swGyro").addEventListener("change", ()=>setField(24, +$("#swGyro").value));
 for(const sel of document.querySelectorAll("select.chord")){

+ 48 - 14
docs/sniffer.html

@@ -44,6 +44,7 @@
   <span style="flex:1"></span>
   <button id="reacq" disabled>Re-acquire</button>
   <button id="survey" disabled>Survey ch2</button>
+  <button id="forget" disabled>Forget bonds</button>
   <label class="note">lock ibex <input type="text" id="tgtIbex" placeholder="EF7171B4" style="width:84px;background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:4px;font:inherit"></label>
   <button id="setTgt" disabled>Lock</button>
   <label class="note">pin ch <input type="number" id="pinCh" min="1" max="80" placeholder="auto"></label>
@@ -60,6 +61,8 @@
   <span>P→C: <b id="npc">0</b></span>
   <span>C→P: <b id="ncp">0</b></span>
   <span>crc-bad: <b id="nbad">0</b></span>
+  <span>drops: <b id="ndrop">0</b></span>
+  <span>bond: <b id="nbond">none yet</b></span>
   <span>shown: <b id="nshown">0</b></span>
 </div>
 <div class="stats" style="border-top:none;padding-top:0">
@@ -101,7 +104,7 @@ async function connect(){
     await dev.claimInterface(ifNum);
     await dev.controlTransferOut({requestType:"class",recipient:"interface",request:0x22,value:0x01,index:ifNum});
     $("#state").textContent="connected"; $("#state").className="pill up"; $("#connect").textContent="Reconnect";
-    for(const id of ["cap","clear","dl","dltxt","reacq","survey","setTgt","setch","setsess"]) $("#"+id).disabled=false;
+    for(const id of ["cap","clear","dl","dltxt","reacq","survey","forget","setTgt","setch","setsess"]) $("#"+id).disabled=false;
     navigator.usb.addEventListener("disconnect",e=>{ if(e.device===dev) onGone(); });
     log(`connected — iface ${ifNum} epIn ${epIn} epOut ${epOut} — waiting for stream…`);
     rxBytes=0; gotData=false;
@@ -117,17 +120,21 @@ function append(buf){ const m=new Uint8Array(acc.length+buf.length); m.set(acc);
 function parse(){
   let i=0;
   while(i+2<=acc.length){
-    if(acc[i]===0xC1 && acc[i+1]===0xDE){            // status (19 bytes)
-      if(i+19>acc.length) break;
+    if(acc[i]===0xC1 && acc[i+1]===0xDE){            // status (22 bytes)
+      if(i+22>acc.length) break;
       acqState=acc[i+2];
       const ch=acc[i+3], b=acc.slice(i+4,i+8), pfx=acc[i+8];
       const advCh=acc[i+12], ab=acc.slice(i+13,i+17), apfx=acc[i+17], lastMatch=acc[i+18];
+      const drops=acc[i+19]|(acc[i+20]<<8);          // frames lost to a full ring on the device (0 = lossless)
+      const bondInfo=acc[i+21], camped=!!(bondInfo&0x80), nbonds=(bondInfo>>4)&0x7, bchans=bondInfo&0xF;
       sessTxt=`ch ${ch}  addr ${hex(b)}/${pfx.toString(16).padStart(2,"0")}  (last pipe ${lastMatch})`;
-      $("#acq").textContent=acqState===1?"CAPTURE (session locked)":"ACQUIRE (scanning ibex/ch2)";
+      $("#acq").textContent=acqState===1?(camped?"CAPTURE (auto-camped on learned bond)":"CAPTURE (session locked)"):"ACQUIRE (scanning ibex/ch2)";
       $("#acq").style.color=acqState===1?"#3fb950":"#d29922";
       $("#sess").textContent=sessTxt;
       $("#adv").textContent = advCh ? `ch ${advCh}  addr ${hex(ab)}/${apfx.toString(16).padStart(2,"0")}` : "— (no E1 yet)";
-      i+=19; continue;
+      const de=$("#ndrop"); if(de){ de.textContent=drops; de.style.color = drops? "#f85149" : "#3fb950"; }
+      const be=$("#nbond"); if(be){ be.textContent = nbonds? `${nbonds} learned${camped?` · camped (${bchans} ch)`:""}` : "none yet"; be.style.color = camped? "#3fb950" : (nbonds?"#d29922":"#8b949e"); }
+      i+=22; continue;
     }
     if(acc[i]===0xC0 && acc[i+1]===0xDE){            // packet
       if(i+3>acc.length) break;
@@ -144,19 +151,39 @@ function parse(){
   acc=acc.slice(i);
 }
 let allFrames=[], seqCtr=0, nShown=0;          // ring of recent frames so filters can re-render; seq = stable # column
+let lastShownSeq=0, dirty=false;               // throttled-render bookkeeping (see renderTick)
 const FRAMEBUF=8000;
+// Hot path (runs per frame at the full capture rate, inside the USB read loop): keep it CHEAP so transferIn is
+// never blocked -- a blocked read backs up the device FIFO, stalls its drain, and makes the on-device ring drop
+// (the `drops` counter). So we store the raw bytes and defer the expensive hex-string formatting (ensureHex) and
+// all DOM work to a throttled renderTick that only touches frames actually displayed/exported.
 function onPacket(t_us,ch,crc,rssi,match,raw){
   // raw = [LENGTH, S1, payload...]; opcode = raw[2]; dir from opcode high nibble
   const op=raw.length>2?raw[2]:0;
   const dir=(op&0xF0)===0xE0?"P→C":(op&0xF0)===0xF0?"C→P":"?";
   if(dir==="P→C")nPC++; else if(dir==="C→P")nCP++; if(!crc)nBad++;
-  const payload=raw.slice(2);                        // from opcode onward (what we care about)
-  const ent={seq:++seqCtr,t_us,ms:(t_us/1000),ch,pipe:match,dir,op:op.toString(16).padStart(2,"0"),crc,rssi:-rssi,len:raw.length>0?raw[0]:0,raw:hex(raw),payload:hex(payload)};
+  const ent={seq:++seqCtr,t_us,ms:(t_us/1000),ch,pipe:match,dir,op:op.toString(16).padStart(2,"0"),crc,rssi:-rssi,len:raw.length>0?raw[0]:0,bytes:raw};
   if(capturing) rec.push(ent);
-  allFrames.push(ent); if(allFrames.length>FRAMEBUF) allFrames.shift();
-  if(passesFilter(ent)) addRow(ent);
-  $("#npc").textContent=nPC; $("#ncp").textContent=nCP; $("#nbad").textContent=nBad; $("#nrec").textContent=rec.length;
+  allFrames.push(ent);
+  if(allFrames.length>FRAMEBUF*2) allFrames.splice(0,allFrames.length-FRAMEBUF); // bulk trim, amortized O(1) (was per-frame shift = O(n))
+  dirty=true;
 }
+// Lazily fill in the hex strings only when a frame is rendered, filtered-by-hex, or exported -- never in the hot path.
+function ensureHex(e){ if(e.raw===undefined){ e.raw=hex(e.bytes); e.payload=hex(e.bytes.subarray(2)); } return e; }
+// Render the table on a display-rate tick instead of per frame: append only the frames newer than the last shown,
+// so DOM work is bounded by the screen refresh, not the 500+/s capture rate.
+function renderTick(){
+  if(dirty){
+    dirty=false;
+    let i=allFrames.length-1; const fresh=[];
+    while(i>=0 && allFrames[i].seq>lastShownSeq){ fresh.push(allFrames[i]); i--; }
+    for(let k=fresh.length-1;k>=0;k--){ const e=fresh[k]; if(passesFilter(e)) addRow(e); }
+    if(allFrames.length) lastShownSeq=allFrames[allFrames.length-1].seq;
+    $("#npc").textContent=nPC; $("#ncp").textContent=nCP; $("#nbad").textContent=nBad; $("#nrec").textContent=rec.length;
+  }
+  requestAnimationFrame(renderTick);
+}
+requestAnimationFrame(renderTick);
 const rows=$("#rows");
 function passesFilter(e){
   if($("#fHideRoutine").checked){                    // drop the 99% spam: input replies + bare polls
@@ -169,10 +196,11 @@ function passesFilter(e){
   if(lf==="ne49" && e.len===49) return false;
   if(lf==="gt49" && e.len<=49) return false;
   if(lf==="gt1"  && e.len<=1)  return false;
-  const hx=$("#filter").value.trim().toLowerCase().replace(/ /g,""); if(hx && !e.payload.replace(/ /g,"").includes(hx)) return false;
+  const hx=$("#filter").value.trim().toLowerCase().replace(/ /g,""); if(hx && !ensureHex(e).payload.replace(/ /g,"").includes(hx)) return false;
   return true;
 }
 function addRow(e){
+  ensureHex(e);
   const tr=document.createElement("tr");
   tr.className=(e.dir==="P→C"?"pc":e.dir==="C→P"?"cp":"")+(e.crc?"":" bad");
   tr.innerHTML=`<td>${e.seq}</td><td>${e.ms.toFixed(1)}</td><td class="dir">${e.dir}</td><td>${e.ch}</td><td>${e.pipe}</td><td>${e.op}</td><td>${e.crc?"ok":"BAD"}</td><td>${e.rssi}</td><td>${e.len}</td><td class="hex">${e.payload}</td>`;
@@ -185,6 +213,7 @@ function addRow(e){
 function applyFilters(){                              // re-render visible table from the buffer with current filters
   rows.innerHTML=""; nShown=0;
   for(const e of allFrames.slice(-MAXROWS)) if(passesFilter(e)) addRow(e);
+  lastShownSeq=allFrames.length?allFrames[allFrames.length-1].seq:0; // keep renderTick appending after a manual re-render
 }
 for(const id of ["fHideRoutine","fDir","fOp","fLen","filter"]) $("#"+id).addEventListener("input",applyFilters);
 async function readLoop(){
@@ -211,8 +240,9 @@ $("#cap").onclick=async()=>{
   else{ await cmd(0x02); log(`CAP ENDED — ${rec.length} packets recorded`); }
   $("#nrec").textContent=rec.length;
 };
-$("#clear").onclick=()=>{ rec=[]; allFrames=[]; seqCtr=0; rows.innerHTML=""; nPC=nCP=nBad=nShown=0; for(const id of ["nrec","npc","ncp","nbad","nshown"]) $("#"+id).textContent=0; };
+$("#clear").onclick=()=>{ rec=[]; allFrames=[]; seqCtr=0; lastShownSeq=0; dirty=false; rows.innerHTML=""; nPC=nCP=nBad=nShown=0; for(const id of ["nrec","npc","ncp","nbad","nshown"]) $("#"+id).textContent=0; };
 $("#reacq").onclick=()=>{ $("#pinCh").value=""; cmd(0x03); log("re-acquire requested"); };
+$("#forget").onclick=()=>{ cmd(0x08); log("forgot all learned bonds — will relearn from the next E1"); };
 let surveying=false;
 $("#survey").onclick=()=>{ surveying=!surveying; $("#survey").classList.toggle("active",surveying); cmd(0x06); log(surveying?"SURVEY on — listing every E1 beacon on ch2 (no lock). Each distinct 'op e1' payload = one puck/slot session.":"survey off"); };
 $("#setTgt").onclick=()=>{ const v=$("#tgtIbex").value.trim().replace(/^0x/i,""); if(/^[0-9a-fA-F]{8}$/.test(v)){ const u=parseInt(v,16); cmd(0x07,u&0xff,(u>>8)&0xff,(u>>16)&0xff,(u>>24)&0xff); log("locking ibex_uuid 0x"+v.toUpperCase()+" — only that slot's E1 (then hunting its channels for F1)"); } else log("enter ibex_uuid as 8 hex digits from pairtui, e.g. EF7171B4"); };
@@ -225,9 +255,13 @@ $("#setsess").onclick=()=>{   // "b0 b1 b2 b3 pfx ch" -- base/prefix as hex, cha
   cmd(0x05,b[0],b[1],b[2],b[3],b[4],ch); log(`pinned session addr ${v.slice(0,4).join(" ")}/${v[4]} ch ${ch}`);
 };
 function save(name,text,type){ const a=document.createElement("a"); a.href=URL.createObjectURL(new Blob([text],{type})); a.download=name; a.click(); }
-$("#dl").onclick=()=>save("sniff-"+Date.now()+".json", JSON.stringify(rec,null,1), "application/json");
+$("#dl").onclick=()=>{
+  // materialize hex + drop the raw byte array so the JSON matches the old shape (seq,ms,ch,dir,op,crc,rssi,len,raw,payload)
+  const out=rec.map(e=>{ ensureHex(e); const{bytes,...r}=e; return r; });
+  save("sniff-"+Date.now()+".json", JSON.stringify(out,null,1), "application/json");
+};
 $("#dltxt").onclick=()=>{
-  const lines=rec.map(e=>`${e.ms.toFixed(1)}ms ch${e.ch} ${e.dir} ${e.crc?"":"BAD "}rssi${e.rssi} op=${e.op} | ${e.payload}`);
+  const lines=rec.map(e=>`${e.ms.toFixed(1)}ms ch${e.ch} ${e.dir} ${e.crc?"":"BAD "}rssi${e.rssi} op=${e.op} | ${ensureHex(e).payload}`);
   save("sniff-"+Date.now()+".txt", lines.join("\n"), "text/plain");
 };
 if(!navigator.usb) log("This browser has no WebUSB. Use Chrome/Edge over https or localhost.");

+ 28 - 27
puck_sniffer/README.md

@@ -28,39 +28,38 @@ WebUSB needs a secure context, so **not** a `file://` path. Either:
 
 Click **Connect sniffer**, pick *OpenPuck Sniffer*.
 
-## 3. Capture — the exact sequence (do it in this order)
+## 3. Capture — the exact sequence
 
-Why order matters: the link is Nordic **Gazell** — the on-air address is random per session, and **a *connected*
-slot never beacons on ch2; only a *reconnecting* one does.** So you can't lock onto an already-connected
-controller — you must catch its **reconnect**. The bond's **ibex_uuid is stable** across reconnects (only the
-address changes), so we anchor the lock on that uuid and force a reconnect.
+The link is Nordic **Gazell**. A controller's **bonded on-air address is fixed at pairing and REUSED on every
+reconnect** (reconnect is a silent ESB resume — no fresh address, no guaranteed ch2 re-beacon), and the session
+hops a small **stable** channel map. So the sniffer **learns each bond once** — it persists the session
+(base/prefix/channel) to flash keyed by the bond's **ibex_uuid** and remembers which channels carry its replies —
+and from then on **auto-camps** straight onto it, catching the next clean reconnect with **no ch2 catch and no
+manual pinning**.
 
 ### Setup (once)
 1. **Unplug the copycat** (the OpenPuck dev board) if it's plugged in — it beacons its own `E1`s and pollutes
    ch2. Leave only: the **real Valve puck**, the **controller**, and the **sniffer board**.
-2. Controller connected to the **real puck** (Steam sees it). With `pairtui` (see `../pairtui/`), note the
-   connected slot's **ibex_uuid** (e.g. `EF7171B4`) — it's the bond id, stable across sessions.
+2. With `pairtui` (see `../pairtui/`), note the connected slot's **ibex_uuid** (e.g. `EF7171B4`).
 
-### Arm the sniffer
+### First run — teach it the bond (one time per controller)
 3. Flash the sniffer, open the app (`docs/sniffer.html` over localhost/https — not `file://`), **Connect** →
    *OpenPuck Sniffer*. Confirm **rx** is climbing.
-4. In **lock ibex**, type the uuid (`EF7171B4`), click **Lock**. Status → **ACQUIRE** (waiting on ch2 for *only*
-   that slot's `E1`; ignores other slots and the copycat).
-5. Click **● Start Cap**.
-
-### Trigger (the step that's easy to miss)
-6. **Power-cycle the controller** — hold its power button until it turns **off**, wait ~2 s, turn it back **on**.
-   On reconnect it re-runs ch2 discovery and broadcasts slot 3's `E1` (matching the uuid) with a **fresh** session
-   address → the sniffer matches the uuid, locks that session, and hunts its channels.
-7. Watch the app: link → **CAPTURE (session locked)** and **`C→P` climbs**. Wiggle sticks / trackpad to keep
-   traffic flowing.
-
-### Capture the action
-8. With `C→P` climbing, do the thing in Steam (change brightness / LED, or turn off).
-9. **■ End Cap → Download.**
-
-**Always: Lock the uuid → Start Cap → *then* power-cycle.** The most common failure is the controller silently
-auto-reconnecting *before* the lock is armed, so its `E1` is never caught.
+4. In **lock ibex**, type the uuid (`EF7171B4`), click **Lock**.
+   - If **bond** shows `… camped`, it already knows this controller → skip to step 6.
+   - Otherwise status is **ACQUIRE** (waiting on ch2 for that slot's `E1`).
+5. Click **● Start Cap**, then **power-cycle the controller** (hold power until off, ~2 s, back on). When its `E1`
+   is caught the sniffer locks the session, **persists the bond**, and the **bond** stat shows `1 learned · camped`.
+
+### Every run after — just capture the reconnect
+6. With the bond known, the link goes **CAPTURE (auto-camped on learned bond)** on its own (on plug-in if it's the
+   only bond, or the moment you **Lock** its uuid). **Power-cycle the controller** and its reconnect is captured
+   from the first packets — `C→P` climbs with no further steps.
+7. Wiggle sticks / trackpad to keep traffic flowing; do the thing in Steam (change brightness / LED, or turn off).
+8. **■ End Cap → Download.**
+
+> **Forget bonds** clears the learned store (relearn from the next `E1`). Use it if a controller was re-paired
+> (its bonded address changed). The learned channel set self-corrects as new reply channels are observed.
 
 Direction: `P→C` (opcode `0xE_`, puck→controller — LED/shutoff/brightness commands ride here) vs `C→P` (`0xF_`,
 controller→puck — input + battery/telemetry).
@@ -112,5 +111,7 @@ bypasses auto-acquire entirely, which is the reliable path if the real puck's `E
 
 ## Stream protocol (WebUSB bulk)
 - packet: `C0 DE [N] [t_us:4 LE] [ch] [flags] [rssi] [match] [N raw bytes]`  (flags bit0 = CRC ok; match = RXMATCH pipe; raw = `[LEN][S1][payload]`)
-- status: `C1 DE [state] [curCh] [base:4] [prefix] [cap] [hb:2] [advCh] [advBase:4] [advPfx] [lastMatch]`  (19 B; adv* = session parsed from the last `E1`)
-- commands (bulk OUT): `01` start · `02` stop · `03` re-acquire · `04 <ch>` pin channel · `05 <b0 b1 b2 b3 pfx ch>` pin full session
+- status: `C1 DE [state] [curCh] [base:4] [prefix] [cap] [hb:2] [advCh] [advBase:4] [advPfx] [lastMatch] [drops:2 LE] [bond]`  (22 B; adv* = session parsed from the last `E1`; `drops` = frames lost to a full on-device ring, `0` = lossless capture; `bond` bit7 = camped on a learned bond, bits4-6 = #bonds stored, bits0-3 = #channels learned for the camped bond)
+- commands (bulk OUT): `01` start · `02` stop · `03` re-acquire (un-camp) · `04 <ch>` pin channel · `05 <b0 b1 b2 b3 pfx ch>` pin full session · `06` survey · `07 <u0 u1 u2 u3>` lock ibex_uuid (auto-camps if the bond is already learned) · `08` forget all learned bonds
+
+Learned bonds persist in flash (`/sniffbonds.bin`, keyed by ibex_uuid: session base/prefix/channel + observed reply channels), so after one successful learn the sniffer auto-camps on the controller's stable bonded address and catches its reconnects with no manual steps.

+ 351 - 97
puck_sniffer/puck_sniffer.ino

@@ -16,29 +16,144 @@
 //
 // STREAM (over WebUSB bulk IN), little-endian:
 //   packet:  C0 DE [N] [t_us:4] [ch] [flags] [rssi] [match] [N raw bytes]   flags bit0=CRC ok; match=RXMATCH pipe
-//   status:  C1 DE [state][curCh][b0 b1 b2 b3][prefix][cap][rxLo rxHi][advCh][ab0 ab1 ab2 ab3][apfx][lastMatch]
-//            (state 0=acquire 1=capture; adv* = session params last parsed out of an E1 host frame; 19 bytes)
+//   status:  C1 DE [state][curCh][b0 b1 b2 b3][prefix][cap][rxLo rxHi][advCh][ab0 ab1 ab2 ab3][apfx][lastMatch][dropLo dropHi][bond]
+//            (state 0=acquire 1=capture; adv* = session params last parsed out of an E1 host frame; drop* =
+//             frames lost to a full ring -- 0 = lossless; bond bit7=camped on a learned bond, bits4-6=#bonds
+//             stored, bits0-3=#channels learned for the camped bond; 22 bytes)
 // COMMANDS (WebUSB bulk OUT):
-//   01 start  | 02 stop  | 03 re-acquire (back to ibex/ch2)  | 04 <ch> pin channel (keep current address)
-//   05 <b0 b1 b2 b3 pfx ch> pin a full session: force this exact base/prefix/channel (use after reading a real
-//      E1 by eye -- the surest way to camp on the session when auto-acquire's fixed E1 offsets are wrong)
+//   01 start | 02 stop | 03 re-acquire (back to ibex/ch2, un-camp) | 04 <ch> pin channel (keep current address)
+//   05 <b0 b1 b2 b3 pfx ch> pin a full session: force this exact base/prefix/channel (manual override)
+//   06 survey | 07 <u0 u1 u2 u3> lock ibex_uuid (auto-camps if its bond is already learned) | 08 forget all bonds
+//
+// AUTO-CAMP (no manual pinning): the bonded address is stable across reconnects (RE ground truth), so once we've
+// seen a controller's E1 once we persist its session (base/prefix/channel) to flash keyed by ibex_uuid and learn
+// which channels carry its replies. On a later boot / uuid-lock we camp straight back on it and sweep its learned
+// channels, catching the controller's next clean reconnect with no ch2 catch and no manual pin.
 //
 // Build (Adafruit nRF52 core, same flags as OpenPuck):
 //   arduino-cli compile -b adafruit:nrf52:feather52840 \
 //     --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb}" puck_sniffer
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
 #include "radio.h"
+using namespace Adafruit_LittleFS_Namespace;
 
 Adafruit_USBD_WebUSB usb_web;
 
+// Forward-declared so Arduino's auto-generated prototypes (inserted before the first function below) can name it;
+// the full definition follows under "capture ring".
+struct Cap;
+
+// ---- learned-bond store (the "no manual pinning" mechanism) -------------------------------------------------
+// RE ground truth (~/nrf-sniffer/protocol): a controller's bonded on-air address is DERIVED AT PAIRING and REUSED
+// on every reconnect (reconnect = silent ESB resumption, no fresh address, no guaranteed ch2 re-beacon), and the
+// session hops a small STABLE channel map. So once we have observed a bond's address + a working channel ONCE, we
+// can camp straight back on it for all future reconnects -- no ch2 catch, no manual `pin session`. We persist what
+// we learn to flash keyed by the bond's ibex_uuid, so it even survives unplugging the sniffer.
+#define BONDFILE "/sniffbonds.bin"
+#define BOND_MAGIC 0x53424E32u // "SBN2"
+#define NBONDS 4
+#define BOND_NCH 6 // learned channels per bond (the map is ~4; a couple of spares)
+struct Bond {
+	uint8_t uuid[4]; // ibex_uuid key; all-zero = empty slot
+	uint8_t base[4]; // session ESB base
+	uint8_t prefix; // session ESB prefix
+	uint8_t primaryCh; // channel last advertised in the bond's E1
+	uint8_t chans[BOND_NCH]; // channels we've actually seen carry 0xF* replies
+	uint8_t nchans;
+};
+static Bond g_bonds[NBONDS];
+static int g_curBond = -1; // index of the bond we're camped on (-1 = none)
+static bool g_bondDirty = false; // a learn changed the table -> flush to flash in loop()
+static unsigned long g_lastBondSave = 0;
+
+static bool uuidSet(const uint8_t u[4])
+{
+	return (u[0] | u[1] | u[2] | u[3]) != 0;
+}
+static int bondFind(const uint8_t uuid[4])
+{
+	for (int i = 0; i < NBONDS; i++)
+		if (uuidSet(g_bonds[i].uuid) &&
+		    memcmp(g_bonds[i].uuid, uuid, 4) == 0)
+			return i;
+	return -1;
+}
+static int bondFirst()
+{
+	for (int i = 0; i < NBONDS; i++)
+		if (uuidSet(g_bonds[i].uuid))
+			return i;
+	return -1;
+}
+// Find or create the slot for a uuid (evicts slot 0 if the small table is full -- fine for a debug tool).
+static int bondAlloc(const uint8_t uuid[4])
+{
+	int i = bondFind(uuid);
+	if (i >= 0)
+		return i;
+	for (int k = 0; k < NBONDS; k++)
+		if (!uuidSet(g_bonds[k].uuid)) {
+			memset(&g_bonds[k], 0, sizeof(Bond));
+			memcpy(g_bonds[k].uuid, uuid, 4);
+			return k;
+		}
+	memset(&g_bonds[0], 0, sizeof(Bond));
+	memcpy(g_bonds[0].uuid, uuid, 4);
+	return 0;
+}
+// Record that channel `ch` carried a real (CRC-ok) controller reply for this bond. One known-good channel is
+// already enough to re-catch a reconnect (the controller hops the whole map, so it WILL transmit on it); learning
+// the few that appear just makes the re-acquire sweep tighter.
+static void bondLearnChan(int idx, uint8_t ch)
+{
+	if (idx < 0 || ch < 1 || ch > 80)
+		return;
+	Bond &b = g_bonds[idx];
+	for (int i = 0; i < b.nchans; i++)
+		if (b.chans[i] == ch)
+			return;
+	if (b.nchans < BOND_NCH) {
+		b.chans[b.nchans++] = ch;
+		g_bondDirty = true;
+	}
+}
+static void bondsLoad()
+{
+	memset(g_bonds, 0, sizeof g_bonds);
+	File f(InternalFS);
+	if (f.open(BONDFILE, FILE_O_READ)) {
+		uint32_t magic = 0;
+		if (f.read((uint8_t *)&magic, 4) == 4 && magic == BOND_MAGIC)
+			f.read((uint8_t *)g_bonds, sizeof g_bonds);
+		f.close();
+	}
+}
+// Flash write blocks for tens of ms -> ONLY ever call from loop() (never the RADIO ISR). The ring absorbs the
+// frames captured during the write.
+static void bondsSave()
+{
+	InternalFS.remove(BONDFILE);
+	File f(InternalFS);
+	if (f.open(BONDFILE, FILE_O_WRITE)) {
+		uint32_t magic = BOND_MAGIC;
+		f.write((uint8_t *)&magic, 4);
+		f.write((uint8_t *)g_bonds, sizeof g_bonds);
+		f.close();
+	}
+	g_bondDirty = false;
+	g_lastBondSave = millis();
+}
+
 // ---- capture ring (single producer + consumer, both loop context -> no locking) ----
 struct Cap {
 	uint32_t t;
 	uint8_t ch, flags, rssi, match, n;
 	uint8_t b[96];
 };
-#define RINGN 512
+#define RINGN 1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
 static Cap g_ring[RINGN];
 static volatile uint16_t g_head = 0, g_tail = 0;
 static inline uint16_t rnext(uint16_t i)
@@ -51,8 +166,10 @@ static void tuneDiscovery();
 static void tuneSession();
 
 static bool g_streaming = false; // app sent START -> emit packet frames
-static uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
-static uint8_t g_curCh = 2;
+// g_state/g_curCh are written by loop() (retune) and READ by the RADIO END ISR (which stamps each captured frame
+// with the current channel/state) -> volatile so the ISR never sees a cached value.
+static volatile uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
+static volatile uint8_t g_curCh = 2;
 static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
 static uint8_t g_sPrefix = 0x10;
 static uint8_t g_forceCh =
@@ -67,9 +184,12 @@ static bool g_survey = false;
 // E1 per bonded slot and we must pick the CONNECTED controller's. Read its uuid from pairtui (e.g. 0xEF7171B4).
 static bool g_haveTarget = false;
 static uint8_t g_targetIbex[4] = { 0, 0, 0, 0 };
-static unsigned long g_lastRx = 0; // for stall -> sweep/re-acquire
-static uint8_t g_lastMatch =
+static volatile unsigned long g_lastRx = 0; // for stall -> sweep/re-acquire
+static volatile uint8_t g_lastMatch =
 	0; // RXMATCH pipe of the most recent frame (diagnostic)
+// Frames lost to a full ring (producer = RADIO ISR outran the loop's USB drain). Incremented in the ISR, surfaced
+// in the status frame so capture completeness is MEASURED rather than silently assumed. Lossless = this stays 0.
+static volatile uint32_t g_drops = 0;
 
 // What the last E1 host frame ADVERTISED (parsed by the assumed offsets). Reported in status EVEN IF we don't
 // lock, so the panel can show whether those offsets are sane (channel in range, address non-degenerate) -- the
@@ -80,7 +200,8 @@ static uint8_t g_advCh = 0, g_advBase[4] = { 0, 0, 0, 0 }, g_advPfx = 0;
 // strands us on ch2 seeing only E1/E2), step through clean candidate channels KEEPING the learned address --
 // this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
 static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
-static uint8_t g_sweepIdx = 0, g_sweepDry = 0;
+static uint8_t g_sweepIdx = 0;
+static volatile uint8_t g_sweepDry = 0; // reset in the RADIO ISR on any session frame
 
 // Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
 // controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
@@ -89,7 +210,9 @@ static uint8_t g_sweepIdx = 0, g_sweepDry = 0;
 // replies appear, then PARK there. g_lastFrx = last controller (0xF*) frame; g_primaryCh = E1-advertised channel.
 static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52 };
 static uint8_t g_primaryCh = 78, g_huntIdx = 0;
-static unsigned long g_lastFrx = 0, g_huntMs = 0;
+// g_lastFrx is set in the RADIO ISR (on a 0xF* reply) and read by the hunt logic in loop() -> volatile.
+static volatile unsigned long g_lastFrx = 0;
+static unsigned long g_huntMs = 0;
 #define HUNT_DWELL_MS 350u
 #define HUNT_NOFRX_MS 1000u
 
@@ -110,6 +233,27 @@ static void tuneSession()
 	rfSetAddrMulti(g_sBase, g_sPrefix);
 	rxArmInit();
 }
+// Camp directly on a learned bond (no ch2 discovery): adopt its stored address and start listening on its
+// best-known channel. Because the bonded address is stable, this is all that's needed to catch the controller's
+// next clean reconnect -- the hunt sweep below walks the bond's learned channels until its 0xF* replies appear.
+static void campBond(int idx)
+{
+	if (idx < 0)
+		return;
+	Bond &b = g_bonds[idx];
+	g_curBond = idx;
+	memcpy(g_sBase, b.base, 4);
+	g_sPrefix = b.prefix;
+	if (b.primaryCh >= 1 && b.primaryCh <= 80)
+		g_primaryCh = b.primaryCh;
+	// prefer a channel we've actually seen replies on, else the advertised primary
+	g_curCh = (b.nchans && b.chans[0] >= 1 && b.chans[0] <= 80) ?
+			  b.chans[0] :
+			  g_primaryCh;
+	g_huntIdx = 0;
+	g_lastFrx = millis();
+	tuneSession();
+}
 
 // Continuous (gapless) RX: READY->START and END->START keep the radio receiving; ADDRESS->RSSISTART samples
 // signal strength for each packet. We poll EVENTS_END and copy rfrx before the next packet can overwrite it.
@@ -122,15 +266,24 @@ static void rxArmInit()
 			    RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
 	NRF_RADIO->EVENTS_END = 0;
 	NRF_RADIO->EVENTS_ADDRESS = 0;
+	// Capture the completed frame from the RADIO END interrupt (see RADIO_IRQHandler). The NVIC enable/priority is
+	// set once in setup(); here we (re)arm the END interrupt source after every retune.
+	NRF_RADIO->INTENSET = RADIO_INTENSET_END_Msk;
 	NRF_RADIO->TASKS_RXEN = 1;
 }
 
+// Producer = the RADIO END ISR (owns g_head); consumer = loop() (owns g_tail). Lock-free single-producer/
+// single-consumer ring: on full we drop the NEWEST frame and COUNT it in g_drops -- the ISR must never touch
+// g_tail. (The old code dropped the oldest by advancing g_tail from the producer, which raced the consumer and
+// also hid the loss.) g_drops is surfaced in the status frame so a dropped frame is measured, not assumed away.
 static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
 		     uint8_t match, const uint8_t *b, uint8_t n)
 {
 	uint16_t h = g_head, nx = rnext(h);
-	if (nx == g_tail)
-		g_tail = rnext(g_tail); // full -> drop oldest
+	if (nx == g_tail) {
+		g_drops++; // ring full -> drop newest, count it
+		return;
+	}
 	Cap &c = g_ring[h];
 	c.t = t;
 	c.ch = ch;
@@ -142,16 +295,23 @@ static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
 	g_head = nx;
 }
 
-// Drain the radio: copy every completed RX frame into the ring. Called very often from loop() so the tight
-// poll->reply exchange is caught. RSSISAMPLE/CRCSTATUS belong to the frame whose END we just saw.
-static void rxPump()
+// RADIO END interrupt -- the ONE latency-critical step, and the reason capture is now lossless.
+//
+// The radio re-arms into the SAME rfrx buffer (END_START short), so the NEXT packet's payload starts overwriting
+// rfrx only ~60us after this END (ramp-up + preamble + address). Snapshotting the just-completed frame HERE -- in
+// a high-priority ISR that PREEMPTS the USB writes/flushes running in loop() -- guarantees the copy lands well
+// inside that 60us window. The previous design polled EVENTS_END from loop(), where a blocking USB flush could
+// delay the copy past 60us and let the next frame overwrite rfrx (a tight poll->reply pair, <100us apart, was the
+// danger) -- a silent loss. RSSISAMPLE/CRCSTATUS/RXMATCH belong to the frame whose END we just saw.
+//
+// The ISR ONLY captures. Interpretation (E1 parse / acquire-lock / QoS hop) is left to processFrame() in loop(),
+// so this handler stays a few microseconds long and can't itself become the stall it's meant to prevent.
+extern "C" void RADIO_IRQHandler(void)
 {
-	for (int guard = 0; guard < 8 && NRF_RADIO->EVENTS_END; guard++) {
+	while (NRF_RADIO->EVENTS_END) {
 		NRF_RADIO->EVENTS_END = 0;
 		bool crcok = NRF_RADIO->CRCSTATUS & 1;
 		uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
-
-		// which logical address (pipe) the frame hit
 		uint8_t match = (uint8_t)(NRF_RADIO->RXMATCH & 0x7);
 		g_lastMatch = match;
 		uint8_t len = rfrx[0];
@@ -167,44 +327,68 @@ static void rxPump()
 		if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0)
 			// controller reply = ACTIVE session here -> stop hunting, park
 			g_lastFrx = millis();
-		// Parse the session params advertised in an E1 host-frame (payload[0]=0xE1) by the ASSUMED offsets, and
-		// always record them for the status report -- so the panel shows what auto-acquire would lock onto even
-		// when we choose not to (lets us eyeball whether the real puck's offsets match ours).
-		if (crcok && len >= 18 && rfrx[2] == 0xE1) {
-			g_advCh = rfrx[11];
-			g_advBase[0] = rfrx[15];
-			g_advBase[1] = rfrx[16];
-			g_advBase[2] = rfrx[17];
-			g_advBase[3] = rfrx[18];
-			g_advPfx = rfrx[19];
-			if (g_pinSession || g_survey)
-				// pinned / survey -> never auto-retune (just record adv)
-				continue;
-			// Target a specific bonded slot by its ibex_uuid (E1 bytes rfrx[7..10]): the puck beacons one E1 per slot,
-			// so skip any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
-			if (g_haveTarget &&
-			    memcmp(rfrx + 7, g_targetIbex, 4) != 0)
-				continue;
-			// ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
-			if (g_advCh >= 1 && g_advCh <= 80) {
-				if (g_state == 0) {
-					memcpy(g_sBase, g_advBase, 4);
-					g_sPrefix = g_advPfx;
-					g_primaryCh = g_advCh;
-					g_curCh = g_forceCh ? g_forceCh :
-							      g_advCh;
-					g_huntIdx = 0;
-					g_lastFrx = millis();
-					tuneSession();
-					return;
-				} else if (!g_forceCh && g_advCh != g_curCh) {
-					g_curCh = g_advCh;
-					tuneSession();
-					return;
-				}
-			}
+	}
+}
+
+// Interpret ONE captured frame (loop context, fed from the ring). Parses the session params advertised in an E1
+// host-frame (payload[0]=0xE1) by the ASSUMED offsets and always records them for the status report -- so the
+// panel shows what auto-acquire would lock onto even when we choose not to (lets us eyeball whether the real
+// puck's offsets match ours). Returns true if it retuned the radio. This is the exact logic that used to run
+// inline in rxPump; it just no longer shares a thread with the latency-sensitive copy.
+static bool processFrame(const Cap &c)
+{
+	bool crcok = c.flags & 1;
+	uint8_t len = c.n >= 1 ? c.b[0] : 0;
+	uint8_t op = c.n >= 3 ? c.b[2] : 0;
+	// LEARN: a CRC-ok controller reply (0xF*) on the session we're camped on proves THIS channel carries the
+	// bond's traffic -> remember it, so the next reconnect can re-acquire straight onto it (no ch2 catch).
+	if (crcok && g_state == 1 && g_curBond >= 0 && (op & 0xF0) == 0xF0)
+		bondLearnChan(g_curBond, c.ch);
+	if (!(crcok && len >= 18 && op == 0xE1))
+		return false;
+	g_advCh = c.b[11];
+	g_advBase[0] = c.b[15];
+	g_advBase[1] = c.b[16];
+	g_advBase[2] = c.b[17];
+	g_advBase[3] = c.b[18];
+	g_advPfx = c.b[19];
+	if (g_pinSession || g_survey)
+		// pinned / survey -> never auto-retune (just record adv)
+		return false;
+	// Target a specific bonded slot by its ibex_uuid (E1 bytes [7..10]): the puck beacons one E1 per slot, so skip
+	// any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
+	if (g_haveTarget && memcmp(c.b + 7, g_targetIbex, 4) != 0)
+		return false;
+	// ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
+	if (g_advCh >= 1 && g_advCh <= 80) {
+		// LEARN the bonded address from this E1 (keyed by ibex_uuid) and persist it -- this is what lets future
+		// reconnects be caught with no manual pinning.
+		int bi = bondAlloc(c.b + 7);
+		Bond &b = g_bonds[bi];
+		if (memcmp(b.base, g_advBase, 4) != 0 || b.prefix != g_advPfx ||
+		    b.primaryCh != g_advCh) {
+			memcpy(b.base, g_advBase, 4);
+			b.prefix = g_advPfx;
+			b.primaryCh = g_advCh;
+			g_bondDirty = true;
+		}
+		if (g_state == 0) {
+			g_curBond = bi;
+			memcpy(g_sBase, g_advBase, 4);
+			g_sPrefix = g_advPfx;
+			g_primaryCh = g_advCh;
+			g_curCh = g_forceCh ? g_forceCh : g_advCh;
+			g_huntIdx = 0;
+			g_lastFrx = millis();
+			tuneSession();
+			return true;
+		} else if (!g_forceCh && g_advCh != g_curCh) {
+			g_curCh = g_advCh;
+			tuneSession();
+			return true;
 		}
 	}
+	return false;
 }
 
 // ---- WebUSB stream out ----
@@ -233,33 +417,55 @@ static void emitStatus()
 	last = millis();
 	static uint16_t rx = 0;
 	rx++;
-	uint8_t f[19] = { 0xC1,		0xDE,
-			  g_state,	g_curCh,
-			  g_sBase[0],	g_sBase[1],
-			  g_sBase[2],	g_sBase[3],
-			  g_sPrefix,	(uint8_t)(g_streaming ? 1 : 0),
-			  (uint8_t)rx,	(uint8_t)(rx >> 8),
-			  g_advCh,	g_advBase[0],
-			  g_advBase[1], g_advBase[2],
-			  g_advBase[3], g_advPfx,
-			  g_lastMatch };
+	uint32_t drops = g_drops; // snapshot the volatile once
+	// bond byte: bit7 = camped on a learned bond; bits0-3 = #channels learned for it; bits4-6 = #bonds stored
+	int nbonds = 0;
+	for (int i = 0; i < NBONDS; i++)
+		if (uuidSet(g_bonds[i].uuid))
+			nbonds++;
+	uint8_t curN = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
+	uint8_t bondInfo = (uint8_t)((g_curBond >= 0 ? 0x80 : 0) |
+				     ((nbonds & 0x7) << 4) | (curN & 0xF));
+	uint8_t f[22] = { 0xC1,		  0xDE,
+			  g_state,	  g_curCh,
+			  g_sBase[0],	  g_sBase[1],
+			  g_sBase[2],	  g_sBase[3],
+			  g_sPrefix,	  (uint8_t)(g_streaming ? 1 : 0),
+			  (uint8_t)rx,	  (uint8_t)(rx >> 8),
+			  g_advCh,	  g_advBase[0],
+			  g_advBase[1],	  g_advBase[2],
+			  g_advBase[3],	  g_advPfx,
+			  g_lastMatch,	  (uint8_t)drops,
+			  (uint8_t)(drops >> 8), bondInfo };
 	usb_web.write(f, sizeof f);
 	usb_web.flush();
 }
-static void drainToHost()
+// Consume the ring (loop context): interpret each captured frame (E1 parse / acquire-lock / QoS hop) and, when
+// streaming, emit it to the host. processFrame() runs even when NOT streaming, because acquisition has to happen
+// before Start Cap. Capture itself is the RADIO ISR's job now -- this side just drains, so a slow USB flush here
+// can no longer cost us a frame (worst case the ring backs up and g_drops ticks, which the panel shows).
+static void consumeRing()
 {
-	if (!usb_web.connected())
-		return;
-
-	// bound per-call so RX stays responsive; the ring covers bursts
-	uint16_t budget = 64;
+	bool connected = usb_web.connected();
+	bool emit = connected && g_streaming;
+	// bound per-call so the loop stays responsive; the 512-deep ring absorbs anything beyond this
+	uint16_t budget = 128;
 	while (budget-- && g_tail != g_head) {
-		if (g_streaming)
-			emitPacket(g_ring[g_tail]);
+		Cap c = g_ring[g_tail]; // copy out before releasing the slot to the producer
 		g_tail = rnext(g_tail);
+		bool retuned = processFrame(c); // may retune the radio (acquire / channel hop)
+		if (emit)
+			emitPacket(c);
+		// On a retune, stop draining this round: the remaining queued frames were captured on the OLD channel, and
+		// a flooded ch2 often queues several E1s -- re-applying each would thrash the radio. (The old rxPump did the
+		// same via an immediate `return`.) The rest drain next loop.
+		if (retuned)
+			break;
+	}
+	if (connected) {
+		usb_web.flush();
+		emitStatus();
 	}
-	usb_web.flush();
-	emitStatus();
 }
 static void handleCmd()
 {
@@ -278,6 +484,7 @@ static void handleCmd()
 			g_forceCh = 0;
 			g_pinSession = false;
 			g_survey = false;
+			g_curBond = -1; // un-camp; relearn from a fresh E1
 			tuneDiscovery();
 		} // re-acquire from ibex/ch2
 		else if (c == 0x06) {
@@ -299,8 +506,21 @@ static void handleCmd()
 				g_survey = false;
 				g_forceCh = 0;
 				g_pinSession = false;
-				tuneDiscovery();
+				// If we've already learned this controller's bond, camp straight on it (no ch2 catch needed -- the
+				// bonded address is stable, so its next reconnect lands right here). Otherwise discover it on ch2,
+				// and processFrame() will persist the bond the first time its E1 is seen.
+				int bi = bondFind(g_targetIbex);
+				if (bi >= 0)
+					campBond(bi);
+				else
+					tuneDiscovery();
 			}
+		} else if (c == 0x08) {
+			// forget all learned bonds (start clean)
+			memset(g_bonds, 0, sizeof g_bonds);
+			g_curBond = -1;
+			bondsSave();
+			tuneDiscovery();
 		} else if (c == 0x04) {
 			int ch = usb_web.read();
 			if (ch >= 1 && ch <= 80) {
@@ -344,41 +564,75 @@ void setup()
 	for (int i = 0; i < 200 && !USBDevice.mounted(); i++)
 		delay(10);
 
+	// Capture is interrupt-driven: the RADIO END ISR snapshots each frame the instant it completes, preempting the
+	// USB work in loop() so the copy always beats the ~60us buffer-overwrite window. Give it a high priority and
+	// enable it once here; rxArmInit() arms the END source (INTENSET) after every (re)tune. (No SoftDevice runs, so
+	// RADIO_IRQn is ours.)
+	NVIC_SetPriority(RADIO_IRQn, 2);
+	NVIC_ClearPendingIRQ(RADIO_IRQn);
+	NVIC_EnableIRQ(RADIO_IRQn);
+
 	// harmless (we never TX); leaves the radio tunables at their validated defaults
 	rfGenSessionAddr();
-	tuneDiscovery();
+
+	// Load any bonds learned on a previous run. If we already know a controller's bonded session, camp straight on
+	// it -- so a clean reconnect is captured the moment the controller powers back on, with NO ch2 catch and NO
+	// manual pin. (With multiple bonds we wait for the host to `lock ibex` the one you want; a single bond is
+	// unambiguous, so we adopt it immediately.) Otherwise fall back to ch2 discovery to learn one.
+	InternalFS.begin();
+	bondsLoad();
+	int count = 0, b0 = bondFirst();
+	for (int i = 0; i < NBONDS; i++)
+		if (uuidSet(g_bonds[i].uuid))
+			count++;
+	if (count == 1)
+		campBond(b0); // sole known bond -> adopt immediately (unambiguous)
+	else
+		tuneDiscovery(); // 0 bonds, or several -> discover / wait for a uuid lock
 	g_lastRx = millis();
 }
 
 void loop()
 {
-	rxPump();
+	// Capture now happens in RADIO_IRQHandler (interrupt-driven, gapless), so loop() no longer races to poll the
+	// radio. It just services host commands and drains the capture ring to USB.
 	handleCmd();
-
-	// pump again around the (fast, buffered) USB work to catch tight poll->reply pairs
-	rxPump();
-	drainToHost();
-	// Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise: while we're NOT seeing the
-	// controller's 0xF* replies (the ACTIVE session), the keepalive trickles in on the primary but the real E3/F1
-	// exchange is on another channel of the per-session set -> hunt {primary,2,80,+candidates}, dwelling briefly on
-	// each, KEEPING the learned address. The instant a 0xF* arrives (g_lastFrx) we stop and park there. After two
-	// full dry passes (no replies anywhere -> session address likely changed on a reconnect) fall back to discovery
+	consumeRing();
+	// Persist newly-learned bond data, debounced (flash write blocks tens of ms; the ring covers it).
+	if (g_bondDirty && millis() - g_lastBondSave > 3000)
+		bondsSave();
+	// Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise, while we're NOT seeing the
+	// controller's 0xF* replies, sweep the session's channels, KEEPING the (stable, bonded) address, dwelling briefly
+	// on each. The instant a 0xF* arrives (g_lastFrx) we stop and park. The sweep walks the bond's LEARNED channels
+	// first (fast re-acquire onto a known-good one), then the advertised primary, then a broad candidate list to
+	// discover the rest of the map. When a bond is known the address is stable, so we never abandon to ch2 -- we just
+	// keep sweeping until the controller's reconnect appears; only with NO bond at all do we fall back to discovery
 	// to re-read an E1. Hunting only runs when there are no replies, so an active capture is never disturbed.
 	if (g_state == 1 && !g_forceCh && !g_pinSession &&
 	    millis() - g_lastFrx > HUNT_NOFRX_MS &&
 	    millis() - g_huntMs > HUNT_DWELL_MS) {
 		g_huntMs = millis();
-		uint8_t ch =
-			(g_huntIdx == 0) ?
-				g_primaryCh :
-				HUNT_CH[(g_huntIdx - 1) % (sizeof HUNT_CH)];
+		uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
+		uint8_t total = learned + 1 + (uint8_t)sizeof HUNT_CH;
+		uint8_t slot = g_huntIdx;
+		uint8_t ch;
+		if (slot < learned)
+			ch = g_bonds[g_curBond].chans[slot];
+		else if (slot == learned)
+			ch = g_primaryCh;
+		else
+			ch = HUNT_CH[(slot - learned - 1) % (sizeof HUNT_CH)];
 		g_huntIdx++;
-		if (g_huntIdx > 1 + 2 * (uint8_t)sizeof HUNT_CH) {
+		if (g_huntIdx >= total) {
 			g_huntIdx = 0;
-			tuneDiscovery();
-			g_lastFrx = millis();
-			g_huntMs = millis();
-			return;
+			if (g_curBond < 0) {
+				// nothing learned -> re-read an E1 on the ch2 rendezvous
+				tuneDiscovery();
+				g_lastFrx = millis();
+				g_huntMs = millis();
+				return;
+			}
+			// bonded address is stable -> keep sweeping its set, never abandon to ch2
 		}
 		g_curCh = ch;
 		rfConfig(g_curCh);