Explorar el Código

Multiple controllers (#66)

* feat: support connecting multiple controllers to the puck

* fix: emulate multiple controllers in non-steam modes

* fix: 4 back chords weren't working on all controllers

* limited slots for limited controllers + polling rate fix

* Hopefully more stable connection

* more fixes

* Align discovery-beacon cadence to main (fix multi-ctrl link churn)

The merge left the branch's RF behavior matching main EXCEPT one
air-budget deviation: the ch2 discovery beacon ran every 40ms whenever a
controller was connected but another bonded slot's controller was off
(allUp=false), vs main's 200ms. ch2 discovery shares g_sessCh's air time
while a controller is linked, so the extra ~25 beacons/s stole reply
windows from the connected controller -> reply-rate sag -> >1500ms reply
gaps -> hapticOnReconnect re-fired its re-init burst (the spurious
"onesey twosey" clicks) + link churn.

Restore main's exact cadence: every-loop when nothing is connected,
200ms once any slot is linked. A late joiner still enumerates in ~1s.

With this, every remaining difference from main reduces to identical
single-controller behavior (poll first slot; F2 cooldown; unique
per-puck session address) -- the only true divergences left are the
per-bond addresses and per-cycle multi-slot polling that simultaneous
controllers fundamentally require.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jariullah Safi <jari@simberobotics.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Josh LeBlanc hace 4 meses
padre
commit
bffa2410ef

+ 27 - 8
OpenPuck/OpenPuck.ino

@@ -47,8 +47,9 @@ void setup()
 	genSerial();
 	genSerial();
 	ledInit();
 	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();
 	InternalFS.begin();
 #if OPK_FACTORY_RESET
 #if OPK_FACTORY_RESET
 	// Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
 	// Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
@@ -56,6 +57,11 @@ void setup()
 	factoryResetOnce(OPK_GIT_HASH);
 	factoryResetOnce(OPK_GIT_HASH);
 #endif
 #endif
 	loadCfg();
 	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
 	// decide USB presentation BEFORE registering interfaces
 	g_xbox = !modeIsPuck(g_usbMode);
 	g_xbox = !modeIsPuck(g_usbMode);
@@ -97,8 +103,10 @@ void setup()
 	if (puckMode) {
 	if (puckMode) {
 		USBDevice.setSerialDescriptor(g_unit);
 		USBDevice.setSerialDescriptor(g_unit);
 	} else {
 	} else {
-		snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit,
-			 MODE_SUFFIX[g_usbMode - 1]);
+		// 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);
 		USBDevice.setSerialDescriptor(g_usbSerial);
 	}
 	}
 
 
@@ -133,7 +141,6 @@ void setup()
 		USBDevice.remoteWakeup();
 		USBDevice.remoteWakeup();
 		ledWakePulse();
 		ledWakePulse();
 	} // wake host if bus was sleeping when we (re-)attached
 	} // wake host if bus was sleeping when we (re-)attached
-	loadBonds();
 	hapticInit();
 	hapticInit();
 	static const char *MODE_NAME[] = {
 	static const char *MODE_NAME[] = {
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
 		"STEAM(puck)",	       "XBOX(xinput+mouse)",
@@ -152,9 +159,21 @@ void setup()
 				"DEBUG boot (CDC console on, wake mouse off; reverts next boot)" :
 				"DEBUG boot (CDC console on, wake mouse off; reverts next boot)" :
 				"normal (CDC off, wake mouse on)");
 				"normal (CDC off, wake mouse on)");
 	Serial.printf(
 	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
 	// 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
 	// 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.
 	// needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.

+ 10 - 2
OpenPuck/bonds.cpp

@@ -4,8 +4,7 @@
 using namespace Adafruit_LittleFS_Namespace;
 using namespace Adafruit_LittleFS_Namespace;
 
 
 Slot g_slot[NSLOT];
 Slot g_slot[NSLOT];
-int g_connSlot = -1;
-unsigned long g_connReplyMs = 0;
+unsigned long g_connReplyMs[NSLOT] = { 0 };
 volatile bool g_dirty = false;
 volatile bool g_dirty = false;
 bool g_pairing = false;
 bool g_pairing = false;
 
 
@@ -46,3 +45,12 @@ void loadBonds()
 		}
 		}
 	f.close();
 	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
 // 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
 // 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
 // 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
 #pragma once
 #include <stdint.h>
 #include <stdint.h>
 
 
@@ -19,13 +19,15 @@ struct Slot {
 };
 };
 extern Slot g_slot[NSLOT];
 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 volatile bool g_dirty; // bonds changed -> flush to flash from loop()
 extern bool g_pairing;
 extern bool g_pairing;
 
 
 bool recEmpty(const uint8_t *r);
 bool recEmpty(const uint8_t *r);
 void loadBonds();
 void loadBonds();
 void saveBonds();
 void saveBonds();
+int bondedSlotCount();

+ 4 - 0
OpenPuck/config.h

@@ -101,6 +101,10 @@ void swProSaveCfg();
 #define POLL_US_DEFAULT 4000u
 #define POLL_US_DEFAULT 4000u
 // host-side HID stream cadence for translated modes (~250 Hz)
 // host-side HID stream cadence for translated modes (~250 Hz)
 #define USB_STREAM_MS 4u
 #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).
 // RF poll cadence (us). FIXED -- not configurable (see loadCfg).
 extern const uint32_t g_pollUs;
 extern const uint32_t g_pollUs;
 
 

+ 10 - 5
OpenPuck/controllers.h

@@ -5,9 +5,11 @@
 //   begin()       -- called once from setup(): set the USB VID/PID/strings and register this mode's
 //   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,
 //                    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.
 //                    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.
 //   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()).
 //   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 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)
 				uint8_t bodyTlen)
 	{
 	{
+		(void)slot;
 		(void)rep;
 		(void)rep;
 		(void)fresh;
 		(void)fresh;
 		(void)bodyTlen;
 		(void)bodyTlen;
@@ -32,8 +35,10 @@ class IController {
 	// Other controller->host input reports decoded from the F1 reply (NOT 0x45): power/battery status report
 	// 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.
 	// 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).
 	// 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)rid;
 		(void)data;
 		(void)data;
 		(void)n;
 		(void)n;

+ 5 - 4
OpenPuck/gamepad_util.cpp

@@ -181,12 +181,13 @@ uint32_t psButtonsFromSteam(uint32_t raw)
 		psOrBackCode(&b, g_back[3]);
 		psOrBackCode(&b, g_back[3]);
 	return b;
 	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) |
 	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_MENU) ? 0x10 : 0) |
 	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
 	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
 	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
 	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);

+ 3 - 1
OpenPuck/gamepad_util.h

@@ -86,6 +86,8 @@ static inline uint32_t tritonFromCode(uint8_t c)
 // PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
 // PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
 uint32_t psButtonsFromSteam(
 uint32_t psButtonsFromSteam(
 	uint32_t raw); // apply back-paddle + chord-guard + QAM remapping
 	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 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)
 uint8_t psFaceNibble(uint32_t b); // face buttons (with A/B + X/Y swap)

+ 189 - 165
OpenPuck/haptics.cpp

@@ -14,8 +14,8 @@ uint8_t g_relayOp = 0xE3; // E3 poll
 uint8_t g_relaySub = 0x05;
 uint8_t g_relaySub = 0x05;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 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 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
 // 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()
 void hapticSendShutdown()
 {
 {
 	static const uint8_t OFF[4] = { 0x6f, 0x66, 0x66, 0x21 }; // "off!"
 	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++)
 	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)
 // 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)
 // a non-zero 0x82 haptic is currently active (awaiting host stop)
 static bool g_haptic82On = false;
 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)
 // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
 static unsigned long g_reinitAt = 0;
 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)
 // latch that engaged during/after use, even seconds after connect)
 static bool g_hapClearArmed = false;
 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 {
 struct RelayMsg {
 	uint8_t rid, len;
 	uint8_t rid, len;
 	uint8_t data[RELAY_MAXP];
 	uint8_t data[RELAY_MAXP];
 };
 };
 // deep enough to hold a full Steam settings/LED transaction burst without loss
 // deep enough to hold a full Steam settings/LED transaction burst without loss
 #define RELAY_QLEN 32
 #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
 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)
 static inline uint8_t rqNext(uint8_t i)
 {
 {
 	return (uint8_t)((i + 1) % RELAY_QLEN);
 	return (uint8_t)((i + 1) % RELAY_QLEN);
@@ -71,28 +74,35 @@ static inline uint8_t rqNext(uint8_t i)
 
 
 bool relayPending()
 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)
 	if (plen > RELAY_MAXP)
 		plen = RELAY_MAXP;
 		plen = RELAY_MAXP;
+	if (slot != 0xFF && slot >= NSLOT)
+		return false;
 	uint32_t pm = __get_PRIMASK();
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
 	__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) {
 	if (rid == 0x82 || rid == 0x80) {
 		g_haptic82Ms = millis();
 		g_haptic82Ms = millis();
 		g_hapClearArmed = true;
 		g_hapClearArmed = true;
@@ -138,8 +148,8 @@ void hapticDumpLog()
 {
 {
 	const uint16_t N = HAPLOG_N;
 	const uint16_t N = HAPLOG_N;
 	uint32_t now = millis();
 	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++) {
 	for (uint16_t i = 0; i < N; i++) {
 		HapLog &e = g_hapLog[(uint16_t)((g_hapHead + i) % N)];
 		HapLog &e = g_hapLog[(uint16_t)((g_hapHead + i) % N)];
 		if (!e.ms && !e.rid)
 		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
 #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)
 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)
 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()
 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();
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
 	__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);
 	__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.
 	// 0x82 is a discrete pad click -- the spurious end-of-movement "click"/buzz the real puck never produces.
 	g_haptic82On = haptic82PayloadOn(p, n);
 	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.
 	// user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
 	if (g_rumbleScale != 100) {
 	if (g_rumbleScale != 100) {
 		uint32_t l = (uint32_t)lowFreq * 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;
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 	}
 	}
 	bool on = lowFreq || highFreq;
 	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;
 		return false;
 
 
 	// SDL's current Steam/Triton structs define output report 0x80 as:
 	// 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[6] = (uint8_t)(highFreq & 0xFF);
 	p[7] = (uint8_t)(highFreq >> 8);
 	p[7] = (uint8_t)(highFreq >> 8);
 	p[8] = 0;
 	p[8] = 0;
-	if (!relayEnqueue(0x80, p, sizeof p))
+	if (!relayEnqueue(0x80, p, sizeof p, slot))
 		return false;
 		return false;
-	g_rumble80Ms = millis();
-	g_rumble80On = on;
+	g_rumble80Ms[slot] = millis();
+	g_rumble80On[slot] = on;
 	return true;
 	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()
 void rfConnQueueHapticRelay()
 {
 {
 	if (relayPending())
 	if (relayPending())
@@ -293,34 +322,34 @@ void rfConnQueueHapticRelay()
 	static const uint8_t HAP_ON[3] = { 0x01, 0x01, 0xF7 };
 	static const uint8_t HAP_ON[3] = { 0x01, 0x01, 0xF7 };
 	static const uint8_t HAP_OFF[3] = { 0x01, 0x01, 0x00 };
 	static const uint8_t HAP_OFF[3] = { 0x01, 0x01, 0x00 };
 	if (g_testHaptic) {
 	if (g_testHaptic) {
-		if (relayEnqueue(0x82, HAP_ON, 3))
+		if (relayEnqueue(0x82, HAP_ON, 3, 0xFF))
 			g_testHaptic--;
 			g_testHaptic--;
 	} else if (g_hapticStop && !g_xbox) {
 	} else if (g_hapticStop && !g_xbox) {
-		if (relayEnqueue(0x82, HAP_OFF, 3))
+		if (relayEnqueue(0x82, HAP_OFF, 3, 0xFF))
 			g_hapticStop--;
 			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)
 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) {
 		if (m.rid) {
 			uint8_t rl = m.len;
 			uint8_t rl = m.len;
 			if (rl > RELAY_MAXP)
 			if (rl > RELAY_MAXP)
 				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 =
 			bool land01 =
 				(m.rid == 0x9F) ||
 				(m.rid == 0x9F) ||
 				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
 				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
@@ -341,32 +370,23 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				memcpy(p + 4, m.data, rl);
 				memcpy(p + 4, m.data, rl);
 				plen = (uint8_t)(4 + 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);
 			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);
 			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).
+void hapticReinit(uint8_t slot)
 {
 {
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
 				       0x00, 0x08, 0x07, 0x00, 0x31,
 				       0x00, 0x08, 0x07, 0x00, 0x31,
@@ -382,100 +402,104 @@ void hapticReinit()
 		0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
 		0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
 	};
 	};
 	// reset action (FUN_0001f554) -- Steam sends this first
 	// 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
 	// 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);
 }
 }
 void hapticInit()
 void hapticInit()
 {
 {
-	g_rqHead = g_rqTail = 0;
 	g_haptic82On = false;
 	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;
 	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_haptic82On = false;
-	g_rumble80On = false;
-
-	// drop any haptic ON queued before the link came up
+	g_rumble80On[slot] = false;
+	g_rumble80Ms[slot] = 0;
 	hapticCancelPendingOn();
 	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;
 	g_reinitLeft = HAPTIC_REINIT_SHOTS;
 	uint8_t mk = 2;
 	uint8_t mk = 2;
-
-	// capture marker: RECONNECT detected (block+reinit armed)
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 }
 }
 void hapticTask()
 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();
 		hapticReinit();
 		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
 		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
 				     (millis() + HAPTIC_REINIT_GAP_MS) :
 				     (millis() + HAPTIC_REINIT_GAP_MS) :
 				     0;
 				     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 bool wasSusp = true;
+	static unsigned long suspSinceMs = 0;
+	static bool suspArmed = false;
 	bool susp = USBDevice.suspended();
 	bool susp = USBDevice.suspended();
 	bool vbus = (NRF_POWER->USBREGSTATUS &
 	bool vbus = (NRF_POWER->USBREGSTATUS &
 		     POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0;
 		     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();
 		hapticSendShutdown();
+		suspArmed = false; // fire once per suspend
+	}
 	wasSusp = susp;
 	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 &&
 	if (!g_xbox && g_haptic82On &&
 	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
 	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
 		g_haptic82On = false;
 		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 &&
 	if (g_hapClearArmed &&
 	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
 	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
 		g_hapClearArmed = false;
 		g_hapClearArmed = false;

+ 22 - 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
 // 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.
 // 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
 // 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
 // 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
 #pragma once
 #include <stdint.h>
 #include <stdint.h>
 #include "config.h" // OPK_LOG
 #include "config.h" // OPK_LOG
+#include "bonds.h" // NSLOT
 
 
 // after this much host silence, consider the current 0x82 haptic stream inactive
 // after this much host silence, consider the current 0x82 haptic stream inactive
 #define HAPTIC_QUIET_MS 300u
 #define HAPTIC_QUIET_MS 300u
@@ -42,9 +41,10 @@
 #define HAPTIC_SHUTDOWN_SHOTS 3u
 #define HAPTIC_SHUTDOWN_SHOTS 3u
 
 
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
-// Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
-// goes on the air). ISR-safe (brief PRIMASK critical section). Returns false (dropped) when the ring is full.
-bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen);
+// 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)
 // anything still queued (xinput uses it to pace rumble re-queues)
 bool relayPending();
 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
 extern volatile uint8_t g_testHaptic; // 't<n>' injects n test haptics
 // pending haptic-STOP frames to relay (kill a latched whine)
 // pending haptic-STOP frames to relay (kill a latched whine)
 extern volatile uint8_t g_hapticStop;
 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)
 // relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
 void hapticSendShutdown();
 void hapticSendShutdown();
@@ -85,13 +86,14 @@ static inline bool hapLogPull(uint32_t *, uint8_t *, uint8_t *, uint8_t *,
 }
 }
 #endif
 #endif
 
 
-bool hapticLinkUp();
-bool haptic82Blocked();
+bool hapticLinkUp(int slot = -1);
+bool haptic82Blocked(int slot = -1);
 bool hapticRelaySlotOk(int slot);
 bool hapticRelaySlotOk(int slot);
 void haptic82HostReport(const uint8_t *p, uint16_t n);
 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).
 // 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
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
@@ -105,9 +107,12 @@ void hapticInit();
 // per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
 // per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
 void hapticTask();
 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
 // 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
 // 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.
 // 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);

+ 125 - 74
OpenPuck/mode_hidgyro.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "gamepad_util.h"
 #include "config.h"
 #include "config.h"
 #include "haptics.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <Arduino.h>
 #include <string.h>
 #include <string.h>
@@ -10,35 +11,47 @@
 HidGyroController g_hidGyroCtl;
 HidGyroController g_hidGyroCtl;
 
 
 static const uint8_t GYRO_HID_DESC[] = {
 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_TOUCH_H 942
 #define DS4_STATUS_USB 0x1B // cable + level 11 (full)
 #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 const uint8_t DS4_MAC[6] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55, 0x53 };
+// 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. SteamOS drives the DS4 (054C:05C4) with hid-playstation (older kernels: hid-sony); both
-// issue GET_FEATURE requests during probe (calibration + MAC + firmware) and ABORT the bind if any STALLs or
-// returns the wrong length. ps_get_report enforces, on every bus: the response is the exact report size with the
-// report id as its first byte (data fields then start at buf[1]); CRC is only checked over Bluetooth. macOS/SDL
-// are lenient (re-derive), which is why the DS4 shows up there but not on a Deck. TinyUSB writes the report id
-// itself and hands us the buffer PAST it, so we fill only the PAYLOAD and return size-1. We satisfy both drivers'
-// report ids. (Sizes per drivers/hid/hid-playstation.c: 0x02=37, 0x12=16, 0xA3=49; legacy hid-sony MAC 0x81=7.)
-static uint16_t hidGyroGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
-			   uint16_t reqlen)
+// 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)
 	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
 		return 0;
 		return 0;
 	memset(buf, 0, reqlen);
 	memset(buf, 0, reqlen);
@@ -51,12 +64,12 @@ static uint16_t hidGyroGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 	case 0x12: // pairing info / MAC, hid-playstation (16 incl id)
 	case 0x12: // pairing info / MAC, hid-playstation (16 incl id)
 		if (reqlen < 15)
 		if (reqlen < 15)
 			return 0;
 			return 0;
-		memcpy(buf, DS4_MAC, 6);
+		memcpy(buf, g_ds4Mac[slot], 6);
 		return 15;
 		return 15;
 	case 0x81: // MAC, legacy hid-sony USB (7 incl id)
 	case 0x81: // MAC, legacy hid-sony USB (7 incl id)
 		if (reqlen < 6)
 		if (reqlen < 6)
 			return 0;
 			return 0;
-		memcpy(buf, DS4_MAC, 6);
+		memcpy(buf, g_ds4Mac[slot], 6);
 		return 6;
 		return 6;
 	case 0xA3: // firmware / hardware info (49 incl id)
 	case 0xA3: // firmware / hardware info (49 incl id)
 		if (reqlen < 48)
 		if (reqlen < 48)
@@ -68,10 +81,10 @@ static uint16_t hidGyroGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		return 0;
 		return 0;
 	}
 	}
 }
 }
-
-static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		       uint16_t n)
+static void hidGyroSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			     uint8_t const *b, uint16_t n)
 {
 {
+	(void)slot;
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
 		return;
 	uint8_t id;
 	uint8_t id;
@@ -89,48 +102,77 @@ 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.
 	// DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
 	if (id != 0x05 || pn < 5)
 	if (id != 0x05 || pn < 5)
 		return;
 		return;
-	hapticSteamRumble((uint16_t)p[4] * 257u,
-			  (uint16_t)p[3] * 257u); // DS4: left=low, right=high
+	hapticSteamRumble((uint16_t)p[4] * 257u, (uint16_t)p[3] * 257u,
+			  slot); // 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])
+static void hidGyroBuild(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),
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	memset(out, 0, 63);
 	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[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[slot]++ & 0x0F) << 4) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_STEAM) ? 0x01 : 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.gz & 0xFF;
-	out[15] = g_in.gz >> 8;
-	out[16] = (-g_in.gy) & 0xFF;
-	out[17] = (-g_in.gy) >> 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;
 	out[29] = DS4_STATUS_USB;
 	if (lTouch || rTouch) {
 	if (lTouch || rTouch) {
 		uint16_t lx, ly, rx, ry;
 		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[32] = 1;
-		out[33] = tstamp++;
+		out[33] = tstamp[slot]++;
 		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
 		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
 	} else {
 	} else {
 		out[32] = 0;
 		out[32] = 0;
@@ -144,26 +186,35 @@ void HidGyroController::begin()
 {
 {
 	USBDevice.setID(0x054C, 0x05C4);
 	USBDevice.setID(0x054C, 0x05C4);
 
 
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor
-	USBDevice.setDeviceVersion(0x0104);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
 	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
 	USBDevice.setProductDescriptor("Wireless Controller");
 	USBDevice.setProductDescriptor("Wireless Controller");
-	g_hidGyro.enableOutEndpoint(true);
-	g_hidGyro.setReportCallback(hidGyroGet, 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();
+	initDs4Macs();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		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::task()
 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 (int s = 0; s < NSLOT; s++) {
+		if (!g_hidGyro[s].ready())
+			continue;
+		if (millis() - g_gyroLastMs[s] < USB_STREAM_MS)
+			continue;
+		g_gyroLastMs[s] = millis();
+		uint8_t p[63];
+		hidGyroBuild((uint8_t)s, p);
+		g_hidGyro[s].sendReport(0x01, p, sizeof p);
+	}
 }
 }

+ 106 - 58
OpenPuck/mode_ps5.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "gamepad_util.h"
 #include "config.h"
 #include "config.h"
 #include "haptics.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <Arduino.h>
 #include <string.h>
 #include <string.h>
@@ -36,18 +37,33 @@ static const uint8_t PS5_HID_DESC[] = {
 };
 };
 #define PS5_TOUCH_H 1080
 #define PS5_TOUCH_H 1080
 #define PS5_STATUS_USB 0x1A // charging + level 10 (~100%)
 #define PS5_STATUS_USB 0x1A // charging + level 10 (~100%)
-static unsigned long g_ps5LastMs = 0;
-static Adafruit_USBD_HID g_ps5;
-static const uint8_t PS5_MAC[6] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55, 0x54 };
+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];
 
 
-// GET_FEATURE handler. SteamOS's hid-playstation reads calibration (0x05), pairing/MAC (0x09) and firmware
-// (0x20) during probe and ABORTS the bind unless each returns its EXACT size with the report id as the first
-// byte; CRC is checked only over Bluetooth. macOS/SDL are lenient, so the DualSense shows up there but not on a
-// Deck. TinyUSB writes the report id itself and hands us the buffer PAST it, so we fill only the PAYLOAD and
-// return size-1. Sizes per drivers/hid/hid-playstation.c: 0x05=41, 0x09=20, 0x20=64.
-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)
 	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
 		return 0;
 		return 0;
 	memset(buf, 0, reqlen);
 	memset(buf, 0, reqlen);
@@ -72,7 +88,7 @@ static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		if (reqlen < 19)
 		if (reqlen < 19)
 			return 0;
 			return 0;
 		// MAC at kernel buf[1..6] = payload[0..5]
 		// MAC at kernel buf[1..6] = payload[0..5]
-		memcpy(buf, PS5_MAC, 6);
+		memcpy(buf, g_ps5Mac[slot], 6);
 		return 19;
 		return 19;
 	case 0x20: // firmware info (64 incl id)
 	case 0x20: // firmware info (64 incl id)
 		if (reqlen < 63)
 		if (reqlen < 63)
@@ -84,8 +100,8 @@ static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		return 0;
 		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)
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
 		return;
@@ -108,45 +124,69 @@ static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	}
 	}
 	if (id != 0x02 || pn < 4)
 	if (id != 0x02 || pn < 4)
 		return;
 		return;
-	hapticSteamRumble((uint16_t)p[3] * 257u,
-			  (uint16_t)p[2] *
-				  257u); // DualSense: left=low, right=high
+	hapticSteamRumble((uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u, slot);
+	// 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])
+static void ps5Build(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),
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	memset(out, 0, 63);
 	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[slot]++;
 	out[7] = psHatNibble(b) | psFaceNibble(b);
 	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) |
 	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_MUTE) ? 0x04 : 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;
 	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);
 	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
 	out[52] = PS5_STATUS_USB;
 	out[52] = PS5_STATUS_USB;
 }
 }
@@ -155,26 +195,34 @@ void Ps5Controller::begin()
 {
 {
 	USBDevice.setID(0x054C, 0x0CE6);
 	USBDevice.setID(0x054C, 0x0CE6);
 
 
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor
-	USBDevice.setDeviceVersion(0x0104);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0110 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
 	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
 	USBDevice.setProductDescriptor("DualSense Wireless Controller");
 	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();
+	initPs5Macs();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		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::task()
 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 (int s = 0; s < NSLOT; s++) {
+		if (!g_ps5[s].ready())
+			continue;
+		if (millis() - g_ps5LastMs[s] < USB_STREAM_MS)
+			continue;
+		g_ps5LastMs[s] = millis();
+		uint8_t p[63];
+		ps5Build((uint8_t)s, p);
+		g_ps5[s].sendReport(0x01, p, sizeof p);
+	}
 }
 }

+ 43 - 27
OpenPuck/mode_switch_hori.cpp

@@ -2,6 +2,7 @@
 #include "triton.h"
 #include "triton.h"
 #include "gamepad_util.h"
 #include "gamepad_util.h"
 #include "config.h"
 #include "config.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <Arduino.h>
 
 
@@ -26,8 +27,9 @@ static const uint8_t SWITCH_HID_DESC[] = {
 	// vendor (output, 8 bytes; unused)
 	// vendor (output, 8 bytes; unused)
 	0x0A, 0x21, 0x26, 0x95, 0x08, 0x91, 0x02, 0xC0
 	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 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)
 static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
 static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
@@ -74,9 +76,10 @@ static inline void backCodeToHatDirs(uint8_t c, bool &u, bool &d, bool &l,
 		r = true;
 		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
 // 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;
 	uint16_t btn = 0;
 	if (g_qamMap && (b & TB_QAM)) {
 	if (g_qamMap && (b & TB_QAM)) {
 		b &= ~(uint32_t)TB_QAM;
 		b &= ~(uint32_t)TB_QAM;
@@ -101,9 +104,9 @@ static void switchBuildHoripad(uint8_t out[8])
 	if (b & TB_RB)
 	if (b & TB_RB)
 		btn |= 0x20; // L, R
 		btn |= 0x20; // L, R
 	// ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
 	// 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;
 		btn |= 0x40;
-	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		btn |= 0x80;
 		btn |= 0x80;
 	if (b & TB_MENU)
 	if (b & TB_MENU)
 		btn |= 0x100;
 		btn |= 0x100;
@@ -154,10 +157,11 @@ static void switchBuildHoripad(uint8_t out[8])
 	out[0] = btn & 0xFF;
 	out[0] = btn & 0xFF;
 	out[1] = btn >> 8;
 	out[1] = btn >> 8;
 	out[2] = hat;
 	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;
 	out[7] = 0;
 }
 }
 
 
@@ -165,25 +169,37 @@ void SwitchHoriController::begin()
 {
 {
 	USBDevice.setID(0x0F0D, 0x0092);
 	USBDevice.setID(0x0F0D, 0x0092);
 
 
-	// Windows caches config by VID:PID:bcdDevice, so any interface change MUST bump this
-	USBDevice.setDeviceVersion(0x0202);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0210 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
 	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();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		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::task()
 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 controller. Per-slot last-ms so each HID is paced
+	// independently (same as the single-controller case); sending all 4 back-to-back in one loop keeps the
+	// 4ms target per controller while running at the full loop rate.
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_switch[s].ready())
+			continue;
+		if (millis() - g_swLastMs[s] < USB_STREAM_MS)
+			continue;
+		g_swLastMs[s] = millis();
+		uint8_t p[8];
+		switchBuildHoripad((uint8_t)s, p);
+		g_switch[s].sendReport(0, p,
+				       sizeof p); // report-id-less descriptor
+	}
 }
 }

+ 231 - 141
OpenPuck/mode_switch_pro.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "gamepad_util.h"
 #include "config.h"
 #include "config.h"
 #include "haptics.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
 #include <InternalFileSystem.h>
@@ -140,13 +141,29 @@ static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
 		return 0;
 		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
 // 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;
+}
 static uint16_t jcRumbleAmp(const uint8_t r[4])
 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
 	// 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 +175,21 @@ static uint16_t jcRumbleAmp(const uint8_t r[4])
 	uint8_t a = hf > lf ? hf : lf; // 0..0x7F
 	uint8_t a = hf > lf ? hf : lf; // 0..0x7F
 	return (uint16_t)a << 9; // scale to ~16-bit motor speed
 	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)
 	if (pn < 9)
 		return; // [timer][left rumble x4][right rumble x4]
 		return; // [timer][left rumble x4][right rumble x4]
 	uint16_t lo = jcRumbleAmp(p + 1), hi = jcRumbleAmp(p + 5);
 	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
 	// only relay on change: the Switch streams rumble every frame; re-sending
 	// unchanged values would flood the RF relay and loop the motor
 	// 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;
 		return;
-	lastLo = lo;
-	lastHi = hi;
-	hapticSteamRumble(lo, hi);
+	g_jcLastLo[slot] = lo;
+	g_jcLastHi[slot] = hi;
+	hapticSteamRumble(lo, hi, slot);
 }
 }
 static int jcStick12(int16_t v, bool inv)
 static int jcStick12(int16_t v, bool inv)
 { // steam int16 (center 0) -> 12-bit (center 0x800), clamped
 { // steam int16 (center 0) -> 12-bit (center 0x800), clamped
@@ -187,10 +206,9 @@ 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),
 // 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.
 // 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;
+	uint32_t b = g_in[slot].buttons;
 	if (g_qamMap && (b & TB_QAM)) {
 	if (g_qamMap && (b & TB_QAM)) {
 		b &= ~(uint32_t)TB_QAM;
 		b &= ~(uint32_t)TB_QAM;
 		b |= tritonFromCode(g_qamMap);
 		b |= tritonFromCode(g_qamMap);
@@ -214,9 +232,9 @@ static void jcInputPrefix(uint8_t *out)
 		jc |= JC_BTN_L;
 		jc |= JC_BTN_L;
 	if (b & TB_RB)
 	if (b & TB_RB)
 		jc |= JC_BTN_R;
 		jc |= JC_BTN_R;
-	if ((g_in.lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		jc |= JC_BTN_ZL;
 		jc |= JC_BTN_ZL;
-	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		jc |= JC_BTN_ZR;
 		jc |= JC_BTN_ZR;
 	if (b & TB_VIEW)
 	if (b & TB_VIEW)
 		jc |= JC_BTN_PLUS;
 		jc |= JC_BTN_PLUS;
@@ -244,7 +262,7 @@ static void jcInputPrefix(uint8_t *out)
 		jc |= codeToJc(g_back[2], fA, fB, fX, fY);
 		jc |= codeToJc(g_back[2], fA, fB, fX, fY);
 	if (b & TB_R5)
 	if (b & TB_R5)
 		jc |= codeToJc(g_back[3], fA, fB, fX, fY);
 		jc |= codeToJc(g_back[3], fA, fB, fX, fY);
-	out[0] = g_jcTimer++;
+	out[0] = g_jcTimer[slot]++;
 
 
 	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
 	// 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.
 	// A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too.
@@ -256,15 +274,15 @@ static void jcInputPrefix(uint8_t *out)
 	// charging_grip bit (button "common" byte, bit7): genuine Pro Controller always sets it on USB; real
 	// 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.
 	// Switch uses it to recognise a wired controller. Not a button, so hid-nintendo ignores it.
 	out[3] |= 0x80;
 	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[slot].lx, g_in[slot].ly);
+	jcPackStick(out + 8, g_in[slot].rx, g_in[slot].ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	out[11] = 0x09;
 	out[11] = 0x09;
 }
 }
-static void switchProBuild(uint8_t out[63])
+static void switchProBuild(uint8_t slot, uint8_t out[63])
 {
 {
 	memset(out, 0, 63);
 	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
 	// 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.
 	// rotation, det +1): SwitchX<-+gy, SwitchY<--gx, SwitchZ<-+gz.
 	//
 	//
@@ -278,14 +296,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
 	// ~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, 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)
 	// 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[slot].ay / SW_ACCEL_DIV);
+	int16_t aY = (int16_t)((-(int16_t)g_in[slot].ax) / SW_ACCEL_DIV);
+	int16_t aZ = (int16_t)(g_in[slot].az / SW_ACCEL_DIV);
 
 
 	// gyro outputs scaled by the user sensitivity factor
 	// 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[slot].gy);
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[slot].gx));
+	int16_t gyaw = gscale((int16_t)g_in[slot].gz);
 	for (int k = 0; k < 3; k++) {
 	for (int k = 0; k < 3; k++) {
 		int o = 12 + k * 12;
 		int o = 12 + k * 12;
 		out[o + 0] = aX & 0xFF;
 		out[o + 0] = aX & 0xFF;
@@ -317,7 +335,8 @@ static const uint8_t SPI_PARAMS2[18] = { 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
 					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
 					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
 static const uint8_t SPI_COLOR[13] = { 0x32, 0x32, 0x32, 0xE6, 0xE6, 0xE6, 0x32,
 static const uint8_t SPI_COLOR[13] = { 0x32, 0x32, 0x32, 0xE6, 0xE6, 0xE6, 0x32,
 				       0x32, 0x32, 0x32, 0x32, 0x32, 0xFF };
 				       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 uint8_t g_spiStickCal[18];
 static void jcPack12(uint8_t *o9, const uint16_t v[6])
 static void jcPack12(uint8_t *o9, const uint16_t v[6])
 { // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
 { // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
@@ -346,14 +365,6 @@ static void jcBuildStickCal()
 // Manual-pairing (subcommand 0x01) reply payloads. A real Switch runs this 3-stage BT key exchange over USB so it
 // 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
 // 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).
 // 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
 static const uint8_t BT_PAIR_2[31] = { // type 2: LTK exchange
 	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
 	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
 	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
 	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -364,53 +375,77 @@ static const uint8_t BT_PAIR_3[31] = {
 }; // type 3: save pairing (all zero body)
 }; // type 3: save pairing (all zero body)
 // User-calibration SPI mirror (0x8000-0x80FF). A real Pro Controller stores the gyro/accel (and stick) calibration
 // 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
 // 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()
 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);
 	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();
 		f.close();
 	}
 	}
 }
 }
 // jcSpiWrite runs in the USB ISR (via jcSet). NEVER do flash I/O here -- a blocking LittleFS erase/write in the
 // 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
 // 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
 // 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;
 	bool changed = false;
 	for (uint8_t i = 0; i < len && i < avail; i++) {
 	for (uint8_t i = 0; i < len && i < avail; i++) {
 		uint32_t a = addr + i;
 		uint32_t a = addr + i;
 		if (a >= 0x8000 && a < 0x8100) {
 		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;
 				changed = true;
 			}
 			}
 		}
 		}
 	}
 	}
 	if (changed) {
 	if (changed) {
-		g_userCalDirty = true;
-		g_userCalDirtyMs = millis();
+		g_userCalDirty[slot] = true;
+		g_userCalDirtyMs[slot] = millis();
 	} // defer the flash write to task()
 	} // 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++) {
 	for (uint8_t i = 0; i < len; i++) {
 		uint32_t a = addr + i;
 		uint32_t a = addr + i;
@@ -427,52 +462,74 @@ static void spiRead(uint32_t addr, uint8_t len, uint8_t *dst)
 			v = SPI_PARAMS2[a - 0x6098];
 			v = SPI_PARAMS2[a - 0x6098];
 		else if (a >= 0x8000 && a < 0x8100)
 		else if (a >= 0x8000 && a < 0x8100)
 			// persisted user cal (0xFF blank -> factory fallback)
 			// persisted user cal (0xFF blank -> factory fallback)
-			v = g_userCal[a - 0x8000];
+			v = g_userCal[slot][a - 0x8000];
 		dst[i] = v;
 		dst[i] = v;
 	}
 	}
 }
 }
 // Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
 // 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
 #define JC_REPLEN 63
 struct JcRep {
 struct JcRep {
 	uint8_t rid;
 	uint8_t rid;
 	uint8_t data[JC_REPLEN];
 	uint8_t data[JC_REPLEN];
 };
 };
 #define JCQ_N 8
 #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
 		return; // full -> drop; the host re-requests on timeout
 	if (len > JC_REPLEN)
 	if (len > JC_REPLEN)
 		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.
 // 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];
 	uint8_t p[JC_REPLEN];
 	memset(p, 0, sizeof p);
 	memset(p, 0, sizeof p);
-	jcInputPrefix(p);
+	jcInputPrefix(slot, p);
 	p[13] = sub;
 	p[13] = sub;
 	switch (sub) {
 	switch (sub) {
 	// manual BT pairing: 3-stage key exchange a real Switch runs over USB
 	// manual BT pairing: 3-stage key exchange a real Switch runs over USB
 	case 0x01: {
 	case 0x01: {
-		const uint8_t *d = BT_PAIR_3;
 		uint8_t t = (alen >= 1) ? args[0] : 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
 		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;
 		break;
 	}
 	}
 	case 0x02: // request device info
 	case 0x02: // request device info
@@ -483,7 +540,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		p[15] = 0x48;
 		p[15] = 0x48;
 		p[16] = 0x03; // controller type: Pro Controller
 		p[16] = 0x03; // controller type: Pro Controller
 		p[17] = 0x02; // fixed
 		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[24] = 0x01; // colors stored in SPI (0x6050)
 		p[25] = 0x01; // fixed
 		p[25] = 0x01; // fixed
 		break;
 		break;
@@ -504,7 +561,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		p[16] = args[2];
 		p[16] = args[2];
 		p[17] = args[3];
 		p[17] = args[3];
 		p[18] = rl;
 		p[18] = rl;
-		spiRead(a, rl, &p[19]);
+		spiRead(slot, a, rl, &p[19]);
 		break;
 		break;
 	}
 	}
 	// SPI flash write -> persist user calibration so Switch motion-cal sticks
 	// SPI flash write -> persist user calibration so Switch motion-cal sticks
@@ -514,7 +571,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 				     ((uint32_t)args[1] << 8) |
 				     ((uint32_t)args[1] << 8) |
 				     ((uint32_t)args[2] << 16) |
 				     ((uint32_t)args[2] << 16) |
 				     ((uint32_t)args[3] << 24);
 				     ((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);
 				   (alen > 5) ? (uint16_t)(alen - 5) : 0);
 		}
 		}
 		p[12] = 0x80; // write ACK
 		p[12] = 0x80; // write ACK
@@ -523,7 +580,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 	// set input report mode (0x30 = standard full) -> begin streaming
 	// set input report mode (0x30 = standard full) -> begin streaming
 	case 0x03:
 	case 0x03:
 		if (alen >= 1 && args[0] == 0x30)
 		if (alen >= 1 && args[0] == 0x30)
-			g_swProReportMode = 0x30;
+			g_swProReportMode[slot] = 0x30;
 		p[12] = 0x80;
 		p[12] = 0x80;
 		break;
 		break;
 	// trigger buttons elapsed time -> canned reply (genuine pad returns data)
 	// trigger buttons elapsed time -> canned reply (genuine pad returns data)
@@ -545,13 +602,12 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		// 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
 		// 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
 		break;
 		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)
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
 		return;
@@ -572,18 +628,24 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 		if (pn < 1)
 		if (pn < 1)
 			return;
 			return;
 		if (p[0] == 0x01) {
 		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
 		} // device type + MAC
 		else if (p[0] == 0x02) {
 		else if (p[0] == 0x02) {
 			uint8_t d[1] = { 0x02 };
 			uint8_t d[1] = { 0x02 };
-			jcEnq(0x81, d, 1);
+			jcEnq(slot, 0x81, d, 1);
 		} // handshake
 		} // handshake
 		else if (p[0] == 0x03) {
 		else if (p[0] == 0x03) {
 			uint8_t d[1] = { 0x03 };
 			uint8_t d[1] = { 0x03 };
-			jcEnq(0x81, d, 1);
+			jcEnq(slot, 0x81, d, 1);
 		} // set baudrate
 		} // set baudrate
 		// 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
 		// 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
 		return;
 		return;
@@ -591,68 +653,96 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	if (id == 0x01) { // [timer][rumble x8][subcmd][args...]
 	if (id == 0x01) { // [timer][rumble x8][subcmd][args...]
 		if (pn < 10)
 		if (pn < 10)
 			return;
 			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;
 		return;
 	}
 	}
 	if (id == 0x10) {
 	if (id == 0x10) {
-		jcRumble(p, pn);
+		jcRumble(slot, p, pn);
 		return;
 		return;
 	}
 	}
 	// report 0x82: ignored
 	// 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 };
 
 
 void SwitchProController::begin()
 void SwitchProController::begin()
 {
 {
 	USBDevice.setID(0x057E, 0x2009);
 	USBDevice.setID(0x057E, 0x2009);
-	// bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
-	USBDevice.setDeviceVersion(0x0212);
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0220 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setProductDescriptor("Pro Controller");
 	USBDevice.setProductDescriptor("Pro Controller");
 	jcBuildStickCal();
 	jcBuildStickCal();
 
 
-	// restore any persisted Switch motion calibration (per-unit IMU bias correction)
 	loadUserCal();
 	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();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		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::task()
 {
 {
-	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();
-	}
-	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
+	for (int s = 0; s < NSLOT; 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);
 }
 }

+ 122 - 52
OpenPuck/mode_xinput.cpp

@@ -1,3 +1,13 @@
+// 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 bonded controller slot. The single class driver routes xfer/open
+// callbacks by endpoint address into the per-slot state. The right-pad mouse is a single shared interface
+// (only slot 0's right-pad drives it). Number of registered interfaces matches bondedSlotCount() at boot.
 #include "mode_xinput.h"
 #include "mode_xinput.h"
 #include "triton.h"
 #include "triton.h"
 #include "gamepad_util.h"
 #include "gamepad_util.h"
@@ -35,21 +45,30 @@ enum {
 
 
 // ===================== XInput custom TinyUSB class driver =====================
 // ===================== XInput custom TinyUSB class driver =====================
 // Custom class driver + an Adafruit_USBD_Interface subclass emitting the interface + 0x21-blob + 2 endpoints.
 // 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 \
 #define XINPUT_DESC_LEN \
 	(9 + 17 + 7 + 7) // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
 	(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];
 // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
 // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
 #define RUMBLE_STUCK_MS 2500u
 #define RUMBLE_STUCK_MS 2500u
 
 
 static void xi_init(void)
 static void xi_init(void)
 {
 {
+	for (int s = 0; s < NSLOT; s++)
+		g_xiSlot[s].inUse = false;
 }
 }
 static bool xi_deinit(void)
 static bool xi_deinit(void)
 {
 {
@@ -58,15 +77,33 @@ static bool xi_deinit(void)
 static void xi_reset(uint8_t rhport)
 static void xi_reset(uint8_t rhport)
 {
 {
 	(void)rhport;
 	(void)rhport;
-	g_xiEpIn = g_xiEpOut = 0;
+	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,
 static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 			uint16_t max_len)
 			uint16_t max_len)
 {
 {
 	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
 	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
 	      itf->bInterfaceProtocol == 0x01))
 	      itf->bInterfaceProtocol == 0x01))
 		return 0;
 		return 0;
-	g_xiItf = itf->bInterfaceNumber;
+	int nBonds = bondedSlotCount();
+	int slot = -1;
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_xiSlot[s].inUse && (nBonds == 0 || g_slot[s].used)) {
+			slot = s;
+			break;
+		}
+	}
+	if (slot < 0)
+		return 0;
+	XiSlot &S = g_xiSlot[slot];
+	S.itf = itf->bInterfaceNumber;
 	uint8_t const *p = (uint8_t const *)itf;
 	uint8_t const *p = (uint8_t const *)itf;
 	uint8_t const *end = p + max_len;
 	uint8_t const *end = p + max_len;
 	uint16_t used = itf->bLength;
 	uint16_t used = itf->bLength;
@@ -79,17 +116,18 @@ static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 				(tusb_desc_endpoint_t const *)p;
 				(tusb_desc_endpoint_t const *)p;
 			usbd_edpt_open(rhport, ep);
 			usbd_edpt_open(rhport, ep);
 			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
 			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
-				g_xiEpIn = ep->bEndpointAddress;
+				S.epIn = ep->bEndpointAddress;
 			else
 			else
-				g_xiEpOut = ep->bEndpointAddress;
+				S.epOut = ep->bEndpointAddress;
 			opened++;
 			opened++;
 		}
 		}
 		used += blen;
 		used += blen;
 		p += 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;
 	return used;
 }
 }
 static bool xi_ctrl(uint8_t rhport, uint8_t stage,
 static bool xi_ctrl(uint8_t rhport, uint8_t stage,
@@ -99,21 +137,30 @@ static bool xi_ctrl(uint8_t rhport, uint8_t stage,
 	(void)req;
 	(void)req;
 	return stage != CONTROL_STAGE_SETUP;
 	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)
 static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t res, uint32_t n)
 {
 {
 	(void)res;
 	(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;
 	return true;
 }
 }
@@ -166,13 +213,18 @@ class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
 		return TinyUSBDevice.addInterface(*this);
 		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;
 		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[0] = 0x00;
 	r[1] = 0x14;
 	r[1] = 0x14;
 	r[2] = buttons & 0xFF;
 	r[2] = buttons & 0xFF;
@@ -188,15 +240,16 @@ static void xinputSend(uint16_t buttons, uint8_t lt, uint8_t rt, int16_t lx,
 	r[12] = ry & 0xFF;
 	r[12] = ry & 0xFF;
 	r[13] = ry >> 8;
 	r[13] = ry >> 8;
 	memset(r + 14, 0, 6);
 	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 =====================
 // ===================== right-pad mouse interface =====================
 static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
 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 =====================
 // ===================== 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
 // 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 +290,7 @@ static uint16_t codeToXB(uint8_t c)
 		return 0;
 		return 0;
 	}
 	}
 }
 }
-static void rfXboxGamepad(const uint8_t *r)
+static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 {
 {
 	uint32_t b = btnsOf(r);
 	uint32_t b = btnsOf(r);
 	if (g_qamMap && (b & TB_QAM)) {
 	if (g_qamMap && (b & TB_QAM)) {
@@ -290,13 +343,14 @@ static void rfXboxGamepad(const uint8_t *r)
 	uint8_t lt = trigU8(u16off(r, 4)),
 	uint8_t lt = trigU8(u16off(r, 4)),
 		rt = trigU8(u16off(
 		rt = trigU8(u16off(
 			r, 6)); // triggers u16 (half-scale) -> full-range u8
 			r, 6)); // triggers u16 (half-scale) -> full-range u8
-	xinputSend(btn, lt, rt, (int16_t)s16off(r, 8),
+	xinputSend(slot, btn, lt, rt, (int16_t)s16off(r, 8),
 		   (int16_t)s16off(r, 10), // L stick X/Y
 		   (int16_t)s16off(r, 10), // L stick X/Y
 		   (int16_t)s16off(r, 12),
 		   (int16_t)s16off(r, 12),
 		   (int16_t)s16off(r, 14)); // R stick X/Y
 		   (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 -> 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)
 static void rfXboxMouse(const uint8_t *r)
 {
 {
 	uint32_t b = btnsOf(r);
 	uint32_t b = btnsOf(r);
@@ -356,37 +410,53 @@ static void rfXboxMouse(const uint8_t *r)
 // ===================== IController =====================
 // ===================== IController =====================
 void XboxController::begin()
 void XboxController::begin()
 {
 {
-	g_rumbleLow = g_rumbleHigh = 0;
-
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
 	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);
+	// bcdDevice encodes the bonded count so Windows re-reads the config descriptor when the count changes.
+	int n = bondedSlotCount();
+	USBDevice.setDeviceVersion(
+		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");
 	USBDevice.setProductDescriptor("Controller");
-	g_xinput.setStringDescriptor("Controller");
-	g_xinput.begin();
+	for (int s = 0; s < NSLOT; s++) {
+		if (n > 0 && !g_slot[s].used)
+			continue;
+		if (n == 0 && s > 0)
+			break;
+		g_xinput[s].setStringDescriptor("Controller");
+		g_xinput[s].begin();
+	}
 	g_mouse.setStringDescriptor("OpenPuck Mouse");
 	g_mouse.setStringDescriptor("OpenPuck Mouse");
 	g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
 	g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
 	g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC);
 	g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC);
 	g_mouse.setPollInterval(1);
 	g_mouse.setPollInterval(1);
 	g_mouse.begin();
 	g_mouse.begin();
 }
 }
-void XboxController::onReport45(const uint8_t *rep, bool fresh,
+void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)
 				uint8_t bodyTlen)
 {
 {
 	(void)fresh;
 	(void)fresh;
 	(void)bodyTlen;
 	(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()
 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);
+		}
 	}
 	}
 }
 }

+ 1 - 1
OpenPuck/mode_xinput.h

@@ -11,7 +11,7 @@
 class XboxController : public IController {
 class XboxController : public IController {
     public:
     public:
 	void begin() override;
 	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;
 			uint8_t bodyTlen) override;
 
 
 	// legacy XInput rumble -> haptic relay + stuck-rumble watchdog
 	// legacy XInput rumble -> haptic relay + stuck-rumble watchdog

+ 100 - 64
OpenPuck/puck_hid.cpp

@@ -153,11 +153,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 		if (rid >= 0x80 && rid <= 0x86 && n >= 1 &&
 		if (rid >= 0x80 && rid <= 0x86 && n >= 1 &&
 		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
 		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
-			if (!haptic82Blocked()) {
+			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
 				relayEnqueue(rid, b,
 					     (uint8_t)(n > RELAY_MAXP ?
 					     (uint8_t)(n > RELAY_MAXP ?
 							       RELAY_MAXP :
 							       RELAY_MAXP :
-							       n));
+							       n),
+					     (uint8_t)slot);
 				// Track on/off from what was actually RELAYED (= the controller's believed state): a
 				// 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),
 				// 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).
 				// a blocked ON must not set it (nothing reached the controller -> no spurious clicks).
@@ -204,7 +205,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)
 		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
 		bool relayOk = hapticRelaySlotOk(slot) &&
 		bool relayOk = hapticRelaySlotOk(slot) &&
 			       !(haptic82 && (lizardActive() || muted));
 			       !(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
 			// 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
 			// 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.
 			// 18B cap, and the chopped frames were why those settings never landed on the controller.
@@ -213,7 +214,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				Serial.printf(
 				Serial.printf(
 					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
 					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
 					cmd, len, (unsigned)RELAY_MAXP);
 					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)
 			// track from RELAYED frames only (see the OUTPUT path)
 			if (haptic82)
 			if (haptic82)
@@ -256,8 +257,9 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		hostStampAlive();
 		hostStampAlive();
 		S.resp[0] = 0xB4;
 		S.resp[0] = 0xB4;
 		S.resp[1] = 0x01;
 		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 :
 				    0x02 :
 				    0x01;
 				    0x01;
 		S.resp_len = 63;
 		S.resp_len = 63;
@@ -376,10 +378,15 @@ void SteamPuckController::begin()
 }
 }
 
 
 // Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. PURELY a
 // 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)
 				     uint8_t bodyTlen)
 {
 {
+	(void)fresh;
+	(void)bodyTlen;
 	// Host asleep -> forward NOTHING. While suspended, every sendReport attempt can translate into a host wake,
 	// 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
 	// 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.
 	// press / controller connect), handled in rf_link.cpp via the device-level USB resume signal.
@@ -389,10 +396,15 @@ void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
 	// the freshly-woken desktop.
 	// the freshly-woken desktop.
 	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
 	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
 		return;
 		return;
-	if (g_connSlot < 0 || g_connSlot >= NSLOT)
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	if (!g_slot[slot].used)
 		return;
 		return;
 	if (lizardActive()) {
 	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 {
 	} else {
 		uint8_t blen = bodyTlen - 1;
 		uint8_t blen = bodyTlen - 1;
 		if (blen > 45)
 		if (blen > 45)
@@ -400,9 +412,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
 		// 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
 		// only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
 		// stair-step. g_fwdNewOnly toggles for A/B.
 		// 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,
 				0x45, rep + 1,
 				blen); // Steam/SDL Triton: input report 0x45
 				blen); // Steam/SDL Triton: input report 0x45
 	}
 	}
@@ -410,58 +421,73 @@ 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
 // 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
 // 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())
 	if (USBDevice.suspended())
 		return;
 		return;
 	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
 	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
 		return;
 		return;
-	if (g_connSlot < 0 || g_connSlot >= NSLOT)
+	if (slot < 0 || slot >= NSLOT)
 		return;
 		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 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
 // 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
 // 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
 // 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
 #define NUDGE_JIGGLE_PX 10
 void SteamPuckController::wakeEvent()
 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()
 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())
 	if (USBDevice.suspended())
 		return; // wait for resume; reports can't cross a suspended bus
 		return; // wait for resume; reports can't cross a suspended bus
-	if (g_connSlot < 0 || g_connSlot >= NSLOT || !hid[g_connSlot].ready())
-		return;
-	static unsigned long stepMs = 0;
-	if (millis() - stepMs < 15)
-		return; // pace the edges
-	stepMs = millis();
-	hid_mouse_report_t m;
-	m.buttons = 0;
-	m.x = (g_nudgeStep == 1) ? NUDGE_JIGGLE_PX : -NUDGE_JIGGLE_PX;
-	m.y = 0;
-	m.wheel = 0;
-	m.pan = 0;
-	hid[g_connSlot].sendReport(0x40, &m,
-				   sizeof m); // jiggle right, then back
-	g_nudgeStep = (g_nudgeStep >= 2) ? 0 : (uint8_t)(g_nudgeStep + 1);
+	static unsigned long stepMs[NSLOT] = { 0 };
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_nudgeStep[s])
+			continue;
+		if (millis() - g_nudgeMs[s] > 5000) {
+			g_nudgeStep[s] = 0;
+			continue;
+		} // bus never resumed -> drop the nudge
+		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 = (g_nudgeStep[s] == 1) ? NUDGE_JIGGLE_PX :
+					      -NUDGE_JIGGLE_PX;
+		m.y = 0;
+		m.wheel = 0;
+		m.pan = 0;
+		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);
+	}
 }
 }
 
 
 // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
 // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
@@ -483,26 +509,33 @@ void SteamPuckController::task()
 	// no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
 	// no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
 	if (USBDevice.suspended())
 	if (USBDevice.suspended())
 		return;
 		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/
 		// 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
 		// 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
 		// 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 consumes 0x45 -> a loop of connect-time haptic buzzes. Resending until acked still covers
 		// "Steam missed the edge".
 		// "Steam missed the edge".
 		bool steamAcked = g_steamAliveMs &&
 		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;
 			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
 			// 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
 			// 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.
 			// controller reply (rf_link). 0 = no sample yet -> keep the capture value rather than garbage.
@@ -514,17 +547,20 @@ void SteamPuckController::task()
 			// so the bar tracks usable range, not antenna gain. Clamp keeps it in a sane window.
 			// 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,
 			uint8_t s7b[12] = { 0xF7, 0x01, 0x89, 0x00, 0x00, 0x00,
 					    0x03, 0x00, 0xDD, 0x00, 0x3A, 0x02 };
 					    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)
 				if (mag < 25)
 					mag = 25;
 					mag = 25;
 				else if (mag > 95)
 				else if (mag > 95)
 					mag = 95;
 					mag = 95;
 				s7b[8] = (uint8_t)(0u - (uint8_t)mag);
 				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 {
 class SteamPuckController : public IController {
     public:
     public:
 	void begin() override;
 	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;
 			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
 		override; // forward controller 0x43/0x44 status to Steam
 	void task() override;
 	void task() override;
 
 

+ 51 - 16
OpenPuck/radio.cpp

@@ -1,4 +1,5 @@
 #include "radio.h"
 #include "radio.h"
+#include "bonds.h" // g_slot[]
 
 
 // Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key preceding "ibex" is a DIFFERENT key,
 // Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key preceding "ibex" is a DIFFERENT key,
 // NOT the discovery address.
 // 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'.
 // data bursts -> reply-rate crash. Tunable 'C'.
 uint8_t g_sessCh = 18;
 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];
 		     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++) {
 	for (int i = 0; i < 4; i++) {
 		uint8_t b = (uint8_t)(h >> (i * 8));
 		uint8_t b = (uint8_t)(h >> (i * 8));
 		if (b == 0x00 || b == 0xFF)
 		if (b == 0x00 || b == 0xFF)
 			b ^= 0x5A;
 			b ^= 0x5A;
-		g_sessBase[i] = b;
+		g_sessBase[slot][i] = b;
 	}
 	}
 	uint8_t p = (uint8_t)(h2 >> 16);
 	uint8_t p = (uint8_t)(h2 >> 16);
 	if (p == 0x00 || p == 0xFF)
 	if (p == 0x00 || p == 0xFF)
 		p = 0x5C;
 		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];
 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_rfPrefix; // discovery prefix (rodata byte @0x56f98)
 extern uint8_t g_rfCh; // current TX/RX channel (hopped during beacon/poll)
 extern uint8_t g_rfCh; // current TX/RX channel (hopped during beacon/poll)
 extern uint8_t g_rfBase[4]; // "ibex"
 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;
 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)
 // RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
 extern uint8_t rfrx[100], rftx[100];
 extern uint8_t rfrx[100], rftx[100];

+ 298 - 168
OpenPuck/rf_link.cpp

@@ -63,19 +63,33 @@ uint16_t g_pollsps = 0;
 uint16_t g_pollPeriodUs = 0;
 uint16_t g_pollPeriodUs = 0;
 static uint32_t g_pollDtSum = 0;
 static uint32_t g_pollDtSum = 0;
 static uint16_t g_pollDtCnt = 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 ----
 // ---- 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 uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
 static unsigned long g_stMs = 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_stNew = 0;
 static uint32_t g_stCrc = 0, g_stNoRx = 0;
 static uint32_t g_stCrc = 0, g_stNoRx = 0;
 static uint32_t g_chF1[3] = { 0, 0, 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_lastPollUs = 0;
 static uint32_t g_connRx = 0;
 static uint32_t g_connRx = 0;
 static unsigned long g_lastSessBeacon = 0, g_lastDisc = 0;
 static unsigned long g_lastSessBeacon = 0, g_lastDisc = 0;
@@ -85,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;
 // 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.
 // 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
 // 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)
 static void rfHostFrameOnce(int slot, bool discovery)
 {
 {
 	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
 	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
@@ -110,13 +135,14 @@ static void rfHostFrameOnce(int slot, bool discovery)
 	// payload[9] session channel: controller runs the session on this clean
 	// payload[9] session channel: controller runs the session on this clean
 	// channel (adopts buf[0xe]); discovery beacon still TXes on ch2
 	// channel (adopts buf[0xe]); discovery beacon still TXes on ch2
 	rftx[11] = g_sessCh;
 	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);
 	rfConfig(g_rfCh);
 	rfSetAddr(txBase, txPfx);
 	rfSetAddr(txBase, txPfx);
 	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
 	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
@@ -162,19 +188,27 @@ static void rfHostFrameOnce(int slot, bool discovery)
 
 
 void rfHopTo(uint8_t newCh)
 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;
 		return;
 	uint8_t cur = g_sessCh, savedRfCh = g_rfCh;
 	uint8_t cur = g_sessCh, savedRfCh = g_rfCh;
 	g_sessCh = newCh;
 	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;
 	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
 	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,
 uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 		 uint16_t rxWinUs)
 		 uint16_t rxWinUs)
 {
 {
@@ -185,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)
 	rftx[1] = s1; // S1 (type-specific)
 	memcpy(rftx + 2, payload, plen); // payload[0]=type byte, then data/TLVs
 	memcpy(rftx + 2, payload, plen); // payload[0]=type byte, then data/TLVs
 	rfConfig(ch);
 	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->PACKETPTR = (uint32_t)rftx;
 	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
 	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
 			    RADIO_SHORTS_END_DISABLE_Msk;
 			    RADIO_SHORTS_END_DISABLE_Msk;
@@ -227,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
 			// 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.
 			// connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
 			if (rtype >= 0xF0) {
 			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,
 				// 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
 				// 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).
 				// (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)
 					    1500u)
-					hapticOnReconnect();
+					hapticOnReconnect(s);
 				g_connRx++;
 				g_connRx++;
 				// link alive -> loop() suppresses the redundant E1 beacon
 				// link alive -> loop() suppresses the redundant E1 beacon
-				g_connReplyMs = millis();
+				g_connReplyMs[s] = millis();
 				// |dBm| of this reply (started by the ADDRESS short)
 				// |dBm| of this reply (started by the ADDRESS short)
 				uint8_t rs =
 				uint8_t rs =
 					(uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
 					(uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
-				// EWMA, ~8-sample horizon
+				// EWMA, ~8-sample horizon, per-slot
 				if (rs)
 				if (rs)
-					g_linkRssi =
-						g_linkRssi ?
-							(uint8_t)((g_linkRssi *
+					g_linkRssi[s] =
+						g_linkRssi[s] ?
+							(uint8_t)((g_linkRssi[s] *
 									   7u +
 									   7u +
 								   rs + 4u) /
 								   rs + 4u) /
 								  8u) :
 								  8u) :
@@ -252,9 +291,20 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			}
 			}
 			if (rtype == 0xF1)
 			if (rtype == 0xF1)
 				g_stF1++;
 				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)
 			// F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
 			if (rtype == 0xF3) {
 			if (rtype == 0xF3) {
 				g_stF3++;
 				g_stF3++;
@@ -293,80 +343,96 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						const uint8_t *rep =
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 							&rfrx[idx + 2];
 						bool fresh =
 						bool fresh =
-							(rep[1] != g_lastSeq);
+							(rep[1] !=
+							 g_lastSeq[g_curSlot]);
 						// genuine new report vs stale poll-repeat
 						// genuine new report vs stale poll-repeat
 						if (fresh) {
 						if (fresh) {
 							g_stNew++;
 							g_stNew++;
-							g_lastSeq = rep[1];
+							g_lastSeq[g_curSlot] =
+								rep[1];
 						}
 						}
 						uint32_t bb = btnsOf(rep);
 						uint32_t bb = btnsOf(rep);
 						// USB remote wakeup on Steam button short press (down + up within 1 s). A long press likely means
 						// 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.
 						// 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) {
 							if (fresh) {
 								bool steamNow =
 								bool steamNow =
 									(bb &
 									(bb &
 									 TB_STEAM) !=
 									 TB_STEAM) !=
 									0;
 									0;
-								// rising edge: record press time
 								if (steamNow &&
 								if (steamNow &&
-								    !steamWasDown)
-									steamDownMs =
+								    !steamWasDown
+									    [g_curSlot])
+									steamDownMs[g_curSlot] =
 										millis();
 										millis();
-								// falling edge within 1 s -> short press -> wake
 								if (!steamNow &&
 								if (!steamNow &&
-								    steamWasDown &&
-								    millis() - steamDownMs <
+								    steamWasDown
+									    [g_curSlot] &&
+								    millis() - steamDownMs[g_curSlot] <
 									    1000u &&
 									    1000u &&
 								    USBDevice
 								    USBDevice
 									    .suspended()) {
 									    .suspended()) {
 									USBDevice
 									USBDevice
 										.remoteWakeup();
 										.remoteWakeup();
 									ledWakePulse();
 									ledWakePulse();
-									// post-resume nudge (host needs real input to actually wake)
 									if (g_active)
 									if (g_active)
 										g_active->wakeEvent();
 										g_active->wakeEvent();
 								}
 								}
-								steamWasDown =
+								steamWasDown[g_curSlot] =
 									steamNow;
 									steamNow;
 							}
 							}
 						}
 						}
 						// Decode the report into the shared g_in (one source, read by every IController).
 						// 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
 						// 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
 						// `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
 						// 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).
 						// 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
 							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 |
 							if ((bb & (TB_STEAM |
 								   TB_Y)) ==
 								   TB_Y)) ==
 							    (TB_STEAM | TB_Y)) {
 							    (TB_STEAM | TB_Y)) {
-								if (offHoldMs ==
+								if (offHoldMs[g_curSlot] ==
 								    0)
 								    0)
-									offHoldMs =
+									offHoldMs[g_curSlot] =
 										millis();
 										millis();
 								else if (
 								else if (
-									!offFired &&
+									!offFired[g_curSlot] &&
 									(unsigned long)(millis() -
 									(unsigned long)(millis() -
-											offHoldMs) >=
+											offHoldMs[g_curSlot]) >=
 										2000u) {
 										2000u) {
-									offFired =
+									offFired[g_curSlot] =
 										true;
 										true;
 									hapticSendShutdown();
 									hapticSendShutdown();
 								}
 								}
 								if (g_usbMode !=
 								if (g_usbMode !=
 								    MODE_STEAM) {
 								    MODE_STEAM) {
 									// stream modes read g_in
 									// stream modes read g_in
-									g_in.buttons &=
+									g_in[g_curSlot]
+										.buttons &=
 										~(uint32_t)(TB_STEAM |
 										~(uint32_t)(TB_STEAM |
 											    TB_Y);
 											    TB_Y);
 									// push modes read btnsOf(rep): TB_Y in rep[2],
 									// push modes read btnsOf(rep): TB_Y in rep[2],
@@ -383,32 +449,40 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 											   16);
 											   16);
 								}
 								}
 							} else {
 							} else {
-								offHoldMs = 0;
-								offFired =
+								offHoldMs[g_curSlot] =
+									0;
+								offFired[g_curSlot] =
 									false;
 									false;
 							}
 							}
 						}
 						}
-						g_in.lx =
+						g_in[g_curSlot].lx =
 							(int16_t)s16off(rep, 8);
 							(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));
 							trigU8(u16off(rep, 4));
 						// for the Switch digital-trigger threshold
 						// for the Switch digital-trigger threshold
-						g_in.rt =
+						g_in[g_curSlot].rt =
 							trigU8(u16off(rep, 6));
 							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
 						// 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
 						// 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
 						// tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a short 0x45 (button-only, or
@@ -417,13 +491,21 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						if (tlen >= 46 &&
 						if (tlen >= 46 &&
 						    (size_t)(idx + 2) + 46 <=
 						    (size_t)(idx + 2) + 46 <=
 							    (size_t)end)
 							    (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.
 						// intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
 						if ((bb & CHORD_BACK4) ==
 						if ((bb & CHORD_BACK4) ==
 						    CHORD_BACK4)
 						    CHORD_BACK4)
@@ -436,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.
 						// g_in); PUSH modes (Xbox, puck/lizard) build + send their host report here.
 						if (g_active)
 						if (g_active)
 							g_active->onReport45(
 							g_active->onReport45(
-								rep, fresh,
-								tlen);
+								g_curSlot, rep,
+								fresh, tlen);
 						lastRep = rep;
 						lastRep = rep;
 						lastTlen = tlen;
 						lastTlen = tlen;
 					} else if (ttype == 6 &&
 					} else if (ttype == 6 &&
@@ -453,10 +535,15 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						const uint8_t *rep =
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 							&rfrx[idx + 2];
 						// 0x43 body[1] (~0x5e=94) reads as battery % (sniff-derived)
 						// 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)
 						if (g_active)
 							g_active->onAuxReport(
 							g_active->onAuxReport(
+								g_curSlot,
 								rep[0], rep + 1,
 								rep[0], rep + 1,
 								(uint8_t)(tlen -
 								(uint8_t)(tlen -
 									  1));
 									  1));
@@ -465,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.
 				// 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;
 					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;
 							want = MODE_STEAM;
-						else if (g_in.buttons & TB_B)
+						else if (g_in[g_curSlot].buttons &
+							 TB_B)
 							want = g_chordBtn[0];
 							want = g_chordBtn[0];
-						else if (g_in.buttons & TB_X)
+						else if (g_in[g_curSlot].buttons &
+							 TB_X)
 							want = g_chordBtn[1];
 							want = g_chordBtn[1];
-						else if (g_in.buttons & TB_Y)
+						else if (g_in[g_curSlot].buttons &
+							 TB_Y)
 							want = g_chordBtn[2];
 							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 &&
 						    want != g_usbMode &&
 						    modeValid(want) &&
 						    modeValid(want) &&
 						    !USBDevice.suspended()) {
 						    !USBDevice.suspended()) {
@@ -488,8 +589,9 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							NVIC_SystemReset();
 							NVIC_SystemReset();
 						}
 						}
 					} else {
 					} 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
 				// compact stream for rf_controller_ui.py -- NON-BLOCKING: skip if CDC TX is backed up (a blocking
@@ -532,23 +634,35 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 	return rxlen;
 	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()
 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;
 			break;
 		}
 		}
-	// need a bonded slot (session established by host frame)
-	if (g_connSlot < 0)
+	if (firstUsed < 0) {
+		g_curSlot = -1;
 		return;
 		return;
+	}
+
 	uint8_t ch = g_sessCh;
 	uint8_t ch = g_sessCh;
 	// announce HOST AWAKE: E7 00 00, a few times. The real puck does NOT do this (the controller streams F1 to a
 	// 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.
 	// bare E3 with no E7) -- skipped unless g_e7announce ('n') re-enables the legacy handshake.
 	if (g_connSt == 0) {
 	if (g_connSt == 0) {
+		g_curSlot = firstUsed;
 		if (g_e7announce) {
 		if (g_e7announce) {
 			uint8_t p[3] = { 0xE7, 0x00, g_e7b };
 			uint8_t p[3] = { 0xE7, 0x00, g_e7b };
 			rfConnTx(ch, 0x01, p, 3);
 			rfConnTx(ch, 0x01, p, 3);
@@ -563,66 +677,79 @@ static void rfConnStep()
 			g_connSt = 1;
 			g_connSt = 1;
 			g_connStep = 0;
 			g_connStep = 0;
 		}
 		}
-		// poll loop: E3 (bare, or +GET report 0x45) every poll; re-assert awake periodically if g_e7announce
-	} 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++;
 		}
 		}
+		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) {
 		if (g_e7announce && (g_connPoll & 0x1F) == 0) {
 			uint8_t pa[3] = { 0xE7, 0x00, g_e7b };
 			uint8_t pa[3] = { 0xE7, 0x00, g_e7b };
-			rfConnTx(ch, 0x01, pa, 3);
-		} // re-assert awake/version (legacy; real puck never sends E7)
+			rfConnTx(ch, 0x01, pa, 3, 600);
+		}
 		rfConnQueueHapticRelay();
 		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);
 			rfConnFlushRelay(ch, rs1);
 		}
 		}
-		{
-			// 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;
-			if (g_pollGet) {
-				// legacy: E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
-				uint8_t p[5] = { 0xE3, 0x02, 0x01, 0x45,
-						 g_getParam };
-				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]++;
+		// 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 };
+			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++;
 		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);
 	}
 	}
 }
 }
 
 
@@ -633,8 +760,7 @@ void rfLinkTask()
 	// drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect cooldown so a controller
 	// 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.
 	// that's powering off isn't immediately re-woken/reconnected.
 	if (g_rfHost && millis() - g_connCooldown > 2500) {
 	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
 		// 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). The real puck
 		// (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 --
 		// sends NO E1 on its session channel; gated by g_e1keepalive ('m') so this can be A/B'd on hardware --
@@ -646,7 +772,12 @@ void rfLinkTask()
 			for (int s = 0; s < NSLOT; s++)
 			for (int s = 0; s < NSLOT; s++)
 				rfHostFrameOnce(s, false);
 				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)) {
 		if (millis() - g_lastDisc >= (connNow ? 200u : 0u)) {
 			g_lastDisc = millis();
 			g_lastDisc = millis();
 			g_rfCh = 2;
 			g_rfCh = 2;
@@ -658,10 +789,9 @@ void rfLinkTask()
 		rfConnStep();
 		rfConnStep();
 	} // connected-mode: poll controller, read input
 	} // 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;
 		static bool wasRfConn = false;
-		bool nowRfConn =
-			(g_connSlot >= 0 && millis() - g_connReplyMs < 300);
+		bool nowRfConn = anySlotLinkUp();
 		if (nowRfConn && !wasRfConn && USBDevice.suspended()) {
 		if (nowRfConn && !wasRfConn && USBDevice.suspended()) {
 			USBDevice.remoteWakeup();
 			USBDevice.remoteWakeup();
 			ledWakePulse();
 			ledWakePulse();
@@ -672,7 +802,7 @@ void rfLinkTask()
 		wasRfConn = nowRfConn;
 		wasRfConn = nowRfConn;
 	}
 	}
 	// QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
 	// QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
-	if (g_qos && g_connSlot >= 0 && millis() - g_qosCheckMs >= 600) {
+	if (g_qos && g_curSlot >= 0 && millis() - g_qosCheckMs >= 600) {
 		uint16_t bad = g_qosBad;
 		uint16_t bad = g_qosBad;
 		g_qosBad = 0;
 		g_qosBad = 0;
 		g_qosCheckMs = millis();
 		g_qosCheckMs = millis();
@@ -705,7 +835,7 @@ void rfLinkTask()
 				(unsigned long)g_stNew, (unsigned long)g_stF3,
 				(unsigned long)g_stNew, (unsigned long)g_stF3,
 				(int8_t)g_connF3v, g_e7b,
 				(int8_t)g_connF3v, g_e7b,
 				(unsigned long)g_stCrc, (unsigned long)g_stNoRx,
 				(unsigned long)g_stCrc, (unsigned long)g_stNoRx,
-				g_connSlot);
+				g_curSlot);
 		g_stPoll = 0;
 		g_stPoll = 0;
 		g_stF1 = 0;
 		g_stF1 = 0;
 		g_stNew = 0;
 		g_stNew = 0;

+ 20 - 8
OpenPuck/rf_link.h

@@ -17,6 +17,7 @@
 // F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
 // F1 reply = type 0xF1 then TLV type4(analog/buttons)+type6(per-module gyro/accel/sticks).
 #pragma once
 #pragma once
 #include <stdint.h>
 #include <stdint.h>
+#include "bonds.h" // NSLOT
 
 
 // ---- operational toggles + tunables (set from the CDC console / WebUSB; read here + by webusb status) ----
 // ---- 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)
 // auto-start host beacon on boot (resumes puck role after a USB replug)
@@ -47,11 +48,19 @@ extern unsigned long g_connCooldown;
 // connected-mode state (reset by the 'k' console toggle)
 // connected-mode state (reset by the 'k' console toggle)
 extern uint8_t g_connSt, g_connStep;
 extern uint8_t g_connSt, g_connStep;
 extern uint16_t g_connPoll;
 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;
 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
 // QoS adaptive channel hopping
 extern uint8_t g_qos; // 0=off (static g_sessCh), 1=auto-hop on degradation
 extern uint8_t g_qos; // 0=off (static g_sessCh), 1=auto-hop on degradation
 extern uint8_t g_hopIdx;
 extern uint8_t g_hopIdx;
@@ -70,15 +79,18 @@ extern uint16_t g_pollsps;
 extern uint16_t g_pollPeriodUs;
 extern uint16_t g_pollPeriodUs;
 
 
 // Smoothed controller->puck signal strength, sampled by the radio (RSSISAMPLE) on each CRC-good controller
 // 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
 // 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.
 // (compensates the Pro Micro antenna vs Valve's front-end). Tune against one known-distance reading.
 #define RSSI_DBM_OFFSET 20
 #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.
 // 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
 // rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect

+ 50 - 1
OpenPuck/serial_console.cpp

@@ -250,7 +250,8 @@ void serialConsolePoll()
 				uint16_t val = sp ? strtoul(sp, 0, 0) : 0;
 				uint16_t val = sp ? strtoul(sp, 0, 0) : 0;
 				uint8_t pl[3] = { id, (uint8_t)(val & 0xFF),
 				uint8_t pl[3] = { id, (uint8_t)(val & 0xFF),
 						  (uint8_t)(val >> 8) };
 						  (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(
 				Serial.printf(
 					"# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",
 					"# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",
 					id, val);
 					id, val);
@@ -299,6 +300,54 @@ void serialConsolePoll()
 				Serial.printf(
 				Serial.printf(
 					"# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",
 					"# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",
 					g_qos ? "ON" : "off");
 					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') {
 			} else if (line[0] == 'r') {
 				g_rxWin = strtoul(line + 1, 0, 10);
 				g_rxWin = strtoul(line + 1, 0, 10);
 				if (g_rxWin < 150)
 				if (g_rxWin < 150)

+ 1 - 1
OpenPuck/triton.cpp

@@ -1,6 +1,6 @@
 #include "triton.h"
 #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,
 void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 	       int16_t *gx, int16_t *gy, int16_t *gz)
 	       int16_t *gx, int16_t *gy, int16_t *gz)

+ 5 - 1
OpenPuck/triton.h

@@ -73,6 +73,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);
 	       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) ----
 // ---- 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 {
 struct PuckInput {
 	// raw Triton buttons (TB_*); per-mode builders apply their own chord masking
 	// raw Triton buttons (TB_*); per-mode builders apply their own chord masking
 	uint32_t buttons;
 	uint32_t buttons;
@@ -82,4 +85,5 @@ struct PuckInput {
 	int16_t ax, ay, az; // accelerometer
 	int16_t ax, ay, az; // accelerometer
 	int16_t gx, gy, gz; // gyroscope
 	int16_t gx, gy, gz; // gyroscope
 };
 };
-extern PuckInput g_in;
+#include "bonds.h" // NSLOT
+extern PuckInput g_in[NSLOT];

+ 33 - 10
OpenPuck/webusb_config.cpp

@@ -11,25 +11,30 @@
 
 
 Adafruit_USBD_WebUSB usb_web;
 Adafruit_USBD_WebUSB usb_web;
 
 
-// blob payload = [ver=2][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
+// blob payload = [ver=8][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
 //                [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]
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
 //                [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]
 //                [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]
+// v8 extends to 73 bytes (75 total incl header); browser reads with transferIn(128) to span the two USB-FS packets.
+#define WB_PAYLEN 73
 static void webusbSendBlob()
 static void webusbSendBlob()
 {
 {
 	if (!usb_web.connected())
 	if (!usb_web.connected())
 		return;
 		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];
 	uint8_t p[2 + WB_PAYLEN];
 	p[0] = 0xA5;
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 	p[1] = WB_PAYLEN;
 
 
-	// protocol version (7 = +raw accel; 6 = +swPro120/gyroScale; 5 = +rumbleScale)
-	p[2] = 7;
+	// protocol version (8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 8;
 	p[3] = g_usbMode;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
 	p[5] = (uint8_t)g_mFric;
@@ -39,7 +44,7 @@ static void webusbSendBlob()
 	p[9] = g_back[1];
 	p[9] = g_back[1];
 	p[10] = g_back[2];
 	p[10] = g_back[2];
 	p[11] = g_back[3];
 	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[13] = up ? 1 : 0;
 	p[14] = (uint8_t)g_f1ps;
 	p[14] = (uint8_t)g_f1ps;
 	p[15] = (uint8_t)(g_f1ps >> 8);
 	p[15] = (uint8_t)(g_f1ps >> 8);
@@ -68,8 +73,13 @@ static void webusbSendBlob()
 	p[36] = (uint8_t)(g_pollPeriodUs >>
 	p[36] = (uint8_t)(g_pollPeriodUs >>
 			  8); // measured poll period (intended 4000)
 			  8); // measured poll period (intended 4000)
 	p[37] = OPK_LOG; // logging build? panel shows/hides its log UI
 	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
 	// 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.
 	// (build_info.h / gen_version.sh); "unknown" if the version header wasn't generated.
 	p[40] = OPK_GIT_DIRTY ? 1 : 0;
 	p[40] = OPK_GIT_DIRTY ? 1 : 0;
@@ -87,9 +97,22 @@ static void webusbSendBlob()
 	// Switch Pro gyro sensitivity x10 (protocol v6)
 	// Switch Pro gyro sensitivity x10 (protocol v6)
 	p[55] = g_swGyroScale10;
 	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);
 		memcpy(&p[56], a, 6);
 	} // raw accelerometer for scale diagnostics (protocol v7)
 	} // 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];
+		}
+	}
 	usb_web.write(p, sizeof p);
 	usb_web.write(p, sizeof p);
 	usb_web.flush();
 	usb_web.flush();
 }
 }

+ 2 - 2
README.md

@@ -68,13 +68,13 @@ A webusb based configuration UI is available [here](https://safijari.github.io/o
 
 
 # Future work
 # Future work
 - Find a way to make Xinput mode and mouse work together on all platforms
 - 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 the charging portion (and make it short proof)
 
 
 # Contributions
 # 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.
 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
 # Acknowledgements
 - Valve for putting out the amazing controller
 - 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.
 - `proteus_uuid` is the first 4 bytes of the bond record.
 - `ibex_uuid` is the next 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 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
 ## 7. Connected session
 
 

+ 165 - 50
docs/index.html

@@ -33,6 +33,20 @@
   .note{color:var(--mut);font-size:12px;margin-top:10px}
   .note{color:var(--mut);font-size:12px;margin-top:10px}
   #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:120px;overflow:auto}
   #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}
   .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>
 </style>
 </head>
 </head>
 <body>
 <body>
@@ -59,9 +73,6 @@
         <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
         <button data-mode="4" class="modebtn">Switch Pro + gyro</button>
         <button data-mode="5" class="modebtn">PS5 DualSense</button>
         <button data-mode="5" class="modebtn">PS5 DualSense</button>
         <button data-mode="6" class="modebtn">HID gyro (DS4)</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>
       </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>
       <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>
       <div class="row"><label>Persist last mode</label>
@@ -80,11 +91,19 @@
 
 
     <div class="card">
     <div class="card">
       <h2>Link status</h2>
       <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">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">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">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">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"><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>
         <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">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 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">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>
       </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>
       <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>
         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>
       <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>
@@ -137,7 +159,6 @@
       <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
       <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
     </div>
     </div>
 
 
-
     <div class="card">
     <div class="card">
       <h2>Button mapping</h2>
       <h2>Button mapping</h2>
       <div class="row"><label>A/B + X/Y swap</label>
       <div class="row"><label>A/B + X/Y swap</label>
@@ -185,31 +206,60 @@ for(const sel of document.querySelectorAll("select.back")){
   for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
   for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
 }
 }
 for(const sel of document.querySelectorAll("select.chord")){
 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); });
     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");
 { 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 [["120 Hz (default)",1],["66 Hz (compatibility)",0],["Full rate (~250 Hz)",2]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
     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");
 { 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]]){
   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); }
     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(){
 async function connect(){
   try{
   try{
     dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
     dev = await navigator.usb.requestDevice({filters:[{vendorId:0x28DE},{vendorId:0x045E},{vendorId:0x057E},{vendorId:0x0F0D},{vendorId:0x054C}]});
     await dev.open();
     await dev.open();
     if(dev.configuration===null) await dev.selectConfiguration(1);
     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;
     let found=null;
     for(const itf of dev.configuration.interfaces){
     for(const itf of dev.configuration.interfaces){
       const a=itf.alternate;
       const a=itf.alternate;
       if(a.interfaceClass!==0xFF) continue;
       if(a.interfaceClass!==0xFF) continue;
-      if(a.interfaceSubClass===0x5D) continue;   // Xbox XInput vendor
+      if(a.interfaceSubClass===0x5D) continue;
       let bin=0,bout=0;
       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; } }
       for(const e of a.endpoints){ if(e.type==="bulk"){ if(e.direction==="in")bin=e.endpointNumber; else bout=e.endpointNumber; } }
       if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
       if(bin&&bout){ found={ifNum:itf.interfaceNumber, epIn:bin, epOut:bout}; break; }
@@ -217,14 +267,11 @@ 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; }
     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;
     ifNum=found.ifNum; epIn=found.epIn; epOut=found.epOut;
     await dev.claimInterface(ifNum);
     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});
     await dev.controlTransferOut({requestType:"class", recipient:"interface", request:0x22, value:0x01, index:ifNum});
     $("#connState").textContent="connected"; $("#connState").className="pill up";
     $("#connState").textContent="connected"; $("#connState").className="pill up";
     $("#connectBtn").textContent="Reconnect";
     $("#connectBtn").textContent="Reconnect";
     $("#panel").classList.remove("hide");
     $("#panel").classList.remove("hide");
     log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
     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(); }});
     navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
     startPolling();
     startPolling();
   }catch(e){ log("connect failed: "+e.message); }
   }catch(e){ log("connect failed: "+e.message); }
@@ -242,10 +289,10 @@ async function send(bytes){
 }
 }
 async function readBlob(){
 async function readBlob(){
   try{
   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;
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
     const d=new Uint8Array(r.data.buffer);
-    // find 0xA5 frame
     let i=0; while(i<d.length && d[i]!==0xA5) i++;
     let i=0; while(i<d.length && d[i]!==0xA5) i++;
     if(i+2>d.length) return null;
     if(i+2>d.length) return null;
     const len=d[i+1]; const p=d.slice(i+2, i+2+len);
     const len=d[i+1]; const p=d.slice(i+2, i+2+len);
@@ -253,52 +300,123 @@ async function readBlob(){
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 }
 function applyBlob(p){
 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 mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];
   const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
   const 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 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 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";
   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);
   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;
   $("#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]:1);
+  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" : "—";
   $("#stRate").textContent = up? f1+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
   $("#stNew").textContent = up? newps+" /s" : "—";
   $("#stPolls").textContent = up? polls+" /s" : "—";
   $("#stPolls").textContent = up? polls+" /s" : "—";
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µ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="";
   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]);
   for(let i=39;i<51 && i<p.length && p[i];i++) gitHash+=String.fromCharCode(p[i]);
   $("#stBuild").innerHTML = gitHash
   $("#stBuild").innerHTML = gitHash
     ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
     ? (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);
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   $("#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]; }
   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; }
   { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
-  // raw accelerometer diagnostic (protocol v7): 3x s16 LE at p[54..59]
   if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
   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 ax=s16(54),ay=s16(56),az=s16(58);
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
@@ -313,7 +431,7 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 
 let inflight=false;
 let inflight=false;
 async function refresh(){
 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);
   await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
   inflight=false;
   inflight=false;
 }
 }
@@ -322,9 +440,9 @@ async function setField(field,value){
 }
 }
 function fmtCapEntry(e,maxMs){
 function fmtCapEntry(e,maxMs){
   const hex=e.bytes.map(x=>x.toString(16).padStart(2,"0")).join(" ");
   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"} ════════`; }
   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}`;
   return `${t}${who} cmd=${e.rid.toString(16).padStart(2,"0")}  n=${e.nb}:  ${hex}`;
 }
 }
 function downloadCap(){
 function downloadCap(){
@@ -333,11 +451,10 @@ function downloadCap(){
   a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
   a.href=URL.createObjectURL(new Blob([t],{type:"text/plain"}));
   a.download="puck-capture.txt"; a.click();
   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(){
 function renderCap(){
   if(!capLines.length){ $("#capOut").textContent="(nothing captured yet)"; window._lastCap=""; return; }
   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");
   const t=capLines.map(e=>fmtCapEntry(e,maxMs)).join("\n");
   $("#capOut").textContent=t; window._lastCap=t;
   $("#capOut").textContent=t; window._lastCap=t;
 }
 }
@@ -372,15 +489,15 @@ async function drainTick(){
 }
 }
 async function startCapture(){
 async function startCapture(){
   if(!dev||capturing) return;
   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)";
   $("#capOut").textContent="(dumping ring from boot, then live — press Stop when done)";
   $("#capStart").disabled=true; $("#capStop").disabled=false;
   $("#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);
   liveTimer=setInterval(drainTick,100);
 }
 }
 async function stopCapture(){
 async function stopCapture(){
   if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
   if(liveTimer){ clearInterval(liveTimer); liveTimer=null; }
-  await drainTick();                                  // final flush
+  await drainTick();
   try{ await send([0x05,0]); }catch(e){}
   try{ await send([0x05,0]); }catch(e){}
   capturing=false;
   capturing=false;
   $("#capStart").disabled=false; $("#capStop").disabled=true;
   $("#capStart").disabled=false; $("#capStop").disabled=true;
@@ -410,12 +527,10 @@ $("#dfuUf2").onclick=async()=>{
 };
 };
 $("#factoryErase").onclick=async()=>{
 $("#factoryErase").onclick=async()=>{
   if(!dev){ log("not connected"); return; }
   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("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;
   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:');
   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; }
   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]);
   await send([0x0A,0x45,0x52,0x53]);
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };
 };