Ver Fonte

fix: emulate multiple controllers in non-steam modes

Joshua LeBlanc há 4 meses atrás
pai
commit
deade53535

+ 5 - 4
OpenPuck/gamepad_util.cpp

@@ -181,12 +181,13 @@ uint32_t psButtonsFromSteam(uint32_t raw)
 		psOrBackCode(&b, g_back[3]);
 	return b;
 }
-// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start)
-uint8_t psShouldersByte(uint32_t b)
+// DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start). The analog trigger values come from
+// the per-slot `g_in[slot]` (pass lt/rt explicitly so a single shoulder byte never leaks across slots).
+uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt)
 {
 	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
-	       ((g_in[0].lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
-	       ((g_in[0].rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
+	       ((lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
+	       ((rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
 	       ((b & TB_MENU) ? 0x10 : 0) |
 	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
 	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);

+ 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).
 uint32_t psButtonsFromSteam(
 	uint32_t raw); // apply back-paddle + chord-guard + QAM remapping
-uint8_t psShouldersByte(uint32_t b); // L1..R3 + Create/Options byte
+// L1..R3 + Create/Options byte. lt/rt are the per-slot analog trigger values so the digital ZL/ZR trip
+// doesn't leak across slots when several slots are active.
+uint8_t psShouldersByte(uint32_t b, uint8_t lt, uint8_t rt);
 uint8_t psHatNibble(uint32_t b); // d-pad -> 8-way hat (8 = neutral)
 uint8_t psFaceNibble(uint32_t b); // face buttons (with A/B + X/Y swap)

+ 33 - 16
OpenPuck/haptics.cpp

@@ -38,11 +38,12 @@ static unsigned long g_haptic82Ms = 0;
 // a non-zero 0x82 haptic is currently active (awaiting host stop)
 static bool g_haptic82On = false;
 
-// millis of last translated host rumble (0x80)
-static unsigned long g_rumble80Ms = 0;
+// millis of last translated host rumble (0x80), per-slot (4 XInput interfaces each have their own stream)
+static unsigned long g_rumble80Ms[NSLOT] = { 0 };
 
-// Steam/Triton rumble is latched on until an explicit zero report
-static bool g_rumble80On = false;
+// Steam/Triton rumble is latched on until an explicit zero report; tracked per-slot so each controller's
+// stuck-rumble watchdog is independent
+static bool g_rumble80On[NSLOT] = { false, false, false, false };
 
 // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
 static unsigned long g_reinitAt = 0;
@@ -252,7 +253,8 @@ static void hapticCancelPendingOn()
 }
 void haptic82HostReport(const uint8_t *p, uint16_t n, int slot)
 {
-	(void)slot; // the on/off flag is global -- one relay stream keeps the watchdog armed across slots
+	// the on/off flag is global -- one relay stream keeps the watchdog armed across slots
+	(void)slot;
 	if (n < 3)
 		return;
 	g_haptic82Ms = millis();
@@ -263,8 +265,10 @@ void haptic82HostReport(const uint8_t *p, uint16_t n, int slot)
 	// 0x82 is a discrete pad click -- the spurious end-of-movement "click"/buzz the real puck never produces.
 	g_haptic82On = haptic82PayloadOn(p, n);
 }
-bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 {
+	if (slot >= NSLOT)
+		return false;
 	// user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
 	if (g_rumbleScale != 100) {
 		uint32_t l = (uint32_t)lowFreq * g_rumbleScale / 100,
@@ -273,9 +277,12 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 	}
 	bool on = lowFreq || highFreq;
-	if (on && haptic82Blocked())
-		return false; // same settle gate as native Steam haptics
-	if (!on && !hapticLinkUp())
+	// Per-slot settle gate (the per-slot reconnect block + link-up check). 0x82 haptics in Steam mode use the
+	// same gate; for XInput, the host only sends a stream while a controller is connected, so this also doubles
+	// as "no controller here, no relay".
+	if (on && haptic82Blocked(slot))
+		return false;
+	if (!on && !hapticLinkUp(slot))
 		return false;
 
 	// SDL's current Steam/Triton structs define output report 0x80 as:
@@ -294,10 +301,10 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
 	p[6] = (uint8_t)(highFreq & 0xFF);
 	p[7] = (uint8_t)(highFreq >> 8);
 	p[8] = 0;
-	if (!relayEnqueue(0x80, p, sizeof p, 0))
+	if (!relayEnqueue(0x80, p, sizeof p, slot))
 		return false;
-	g_rumble80Ms = millis();
-	g_rumble80On = on;
+	g_rumble80Ms[slot] = millis();
+	g_rumble80On[slot] = on;
 	return true;
 }
 // Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate). Test
@@ -428,7 +435,10 @@ void hapticInit()
 {
 	g_rqHead = g_rqTail = 0;
 	g_haptic82On = false;
-	g_rumble80On = false;
+	for (int s = 0; s < NSLOT; s++) {
+		g_rumble80On[s] = false;
+		g_rumble80Ms[s] = 0;
+	}
 	// boot: block stale Steam 0x82 until link stable (every slot)
 	for (int s = 0; s < NSLOT; s++)
 		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
@@ -449,7 +459,8 @@ void hapticOnReconnect(int slot)
 	// no haptics relayed for the next 3s (per-slot)
 	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_haptic82On = false;
-	g_rumble80On = false;
+	g_rumble80On[slot] = false;
+	g_rumble80Ms[slot] = 0;
 
 	// drop any haptic ON queued before the link came up
 	hapticCancelPendingOn();
@@ -517,8 +528,14 @@ void hapticTask()
 	if (!g_xbox && g_haptic82On &&
 	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
 		g_haptic82On = false;
-	if (g_rumble80On && millis() - g_rumble80Ms > 2500u)
-		hapticSteamRumble(0, 0);
+	// Per-slot stuck-rumble watchdog: if a controller's rumble stream has been "on" without a refresh for
+	// 2.5s, force a zero report to clear the controller's latch. The previous single-stream watchdog
+	// couldn't cover a 4-XInput scenario where only one slot is stuck (it would only fire when the
+	// merged-flag went stale).
+	for (int s = 0; s < NSLOT; s++) {
+		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)
+			hapticSteamRumble(0, 0, (uint8_t)s);
+	}
 	// 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

+ 4 - 3
OpenPuck/haptics.h

@@ -95,9 +95,10 @@ bool hapticLinkUp(int slot = -1);
 bool haptic82Blocked(int slot = -1);
 bool hapticRelaySlotOk(int slot);
 void haptic82HostReport(const uint8_t *p, uint16_t n, int slot = -1);
-bool hapticSteamRumble(
-	uint16_t lowFreq,
-	uint16_t highFreq); // queue Steam/Triton output report 0x80 rumble
+// queue a Steam/Triton 0x80 rumble frame. `slot` = bond slot of the originating controller (0..NSLOT-1);
+// defaults to 0 for the legacy single-controller callers. Per-slot so each connected controller can have its
+// own active rumble stream when the host presents multiple gamepads (e.g. 4 XInput devices).
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
 
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared

+ 122 - 73
OpenPuck/mode_hidgyro.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -10,35 +11,47 @@
 HidGyroController g_hidGyroCtl;
 
 static const uint8_t GYRO_HID_DESC[] = {
-	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
-	0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
-	0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46,
-	0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00,
-	0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
-	0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
-	0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
-	0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
-	0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
-	0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
-	0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0xC0
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09,
+	0x31, 0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x25, 0x07, 0x75, 0x04,
+	0x95, 0x01, 0x81, 0x42, 0x65, 0x00, 0x05, 0x09, 0x19, 0x01, 0x29,
+	0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x0E, 0x81, 0x02,
+	0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95, 0x01, 0x15, 0x00,
+	0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09, 0x34, 0x15,
+	0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, 0x06,
+	0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
+	0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24,
+	0xB1, 0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0xC0
 };
 #define DS4_TOUCH_H 942
 #define DS4_STATUS_USB 0x1B // cable + level 11 (full)
-static unsigned long g_gyroLastMs = 0;
-static Adafruit_USBD_HID g_hidGyro;
+static unsigned long g_gyroLastMs[NSLOT] = { 0 };
+// NSLOT HID instances: 4 DS4 gamepads on the wire, one per bond slot. The host enumerates each as a
+// separate input device (hid-playstation, hid-sony, SDL, Steam Input).
+static Adafruit_USBD_HID g_hidGyro[NSLOT];
 
-static 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)
 		return 0;
 	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)
 		if (reqlen < 15)
 			return 0;
-		memcpy(buf, DS4_MAC, 6);
+		memcpy(buf, g_ds4Mac[slot], 6);
 		return 15;
 	case 0x81: // MAC, legacy hid-sony USB (7 incl id)
 		if (reqlen < 6)
 			return 0;
-		memcpy(buf, DS4_MAC, 6);
+		memcpy(buf, g_ds4Mac[slot], 6);
 		return 6;
 	case 0xA3: // firmware / hardware info (49 incl id)
 		if (reqlen < 48)
@@ -68,10 +81,10 @@ static uint16_t hidGyroGet(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		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)
 		return;
 	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.
 	if (id != 0x05 || pn < 5)
 		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[0].buttons);
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	memset(out, 0, 63);
-	out[0] = swStick(g_in[0].lx, false);
-	out[1] = swStick(g_in[0].ly, true);
-	out[2] = swStick(g_in[0].rx, false);
-	out[3] = swStick(g_in[0].ry, true);
+	out[0] = swStick(g_in[slot].lx, false);
+	out[1] = swStick(g_in[slot].ly, true);
+	out[2] = swStick(g_in[slot].rx, false);
+	out[3] = swStick(g_in[slot].ry, true);
 	out[4] = psHatNibble(b) | psFaceNibble(b);
-	out[5] = psShouldersByte(b);
-	static uint8_t ctr = 0;
-	out[6] = ((ctr++ & 0x0F) << 4) |
+	out[5] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
+	static uint8_t ctr[NSLOT] = { 0 };
+	out[6] = ((ctr[slot]++ & 0x0F) << 4) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_STEAM) ? 0x01 : 0);
-	out[7] = g_in[0].lt;
-	out[8] = g_in[0].rt;
-	out[12] = g_in[0].gx & 0xFF;
-	out[13] = g_in[0].gx >> 8;
-	out[14] = g_in[0].gz & 0xFF;
-	out[15] = g_in[0].gz >> 8;
-	out[16] = (-g_in[0].gy) & 0xFF;
-	out[17] = (-g_in[0].gy) >> 8;
-	out[18] = g_in[0].ax & 0xFF;
-	out[19] = g_in[0].ax >> 8;
-	out[20] = g_in[0].ay & 0xFF;
-	out[21] = g_in[0].ay >> 8;
-	out[22] = g_in[0].az & 0xFF;
-	out[23] = g_in[0].az >> 8;
+	out[7] = g_in[slot].lt;
+	out[8] = g_in[slot].rt;
+	out[12] = g_in[slot].gx & 0xFF;
+	out[13] = g_in[slot].gx >> 8;
+	out[14] = g_in[slot].gz & 0xFF;
+	out[15] = g_in[slot].gz >> 8;
+	out[16] = (-g_in[slot].gy) & 0xFF;
+	out[17] = (-g_in[slot].gy) >> 8;
+	out[18] = g_in[slot].ax & 0xFF;
+	out[19] = g_in[slot].ax >> 8;
+	out[20] = g_in[slot].ay & 0xFF;
+	out[21] = g_in[slot].ay >> 8;
+	out[22] = g_in[slot].az & 0xFF;
+	out[23] = g_in[slot].az >> 8;
 	out[29] = DS4_STATUS_USB;
 	if (lTouch || rTouch) {
 		uint16_t lx, ly, rx, ry;
-		steamPadsToTouch(b, DS4_TOUCH_H, g_in[0].lpx, g_in[0].lpy, g_in[0].rpx,
-				 g_in[0].rpy, &lx, &ly, &rx, &ry);
-		static uint8_t tstamp = 0;
+		steamPadsToTouch(b, DS4_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+				 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx,
+				 &ry);
+		static uint8_t tstamp[NSLOT] = { 0 };
 		out[32] = 1;
-		out[33] = tstamp++;
+		out[33] = tstamp[slot]++;
 		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
 	} else {
 		out[32] = 0;
@@ -144,26 +186,33 @@ void HidGyroController::begin()
 {
 	USBDevice.setID(0x054C, 0x05C4);
 
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor
-	USBDevice.setDeviceVersion(0x0104);
+	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor.
+	// Bumped 0x0104 -> 0x0105 for the 4-DS4 enumeration (3 more HID interfaces vs the original).
+	USBDevice.setDeviceVersion(0x0105);
 	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
 	USBDevice.setProductDescriptor("Wireless Controller");
-	g_hidGyro.enableOutEndpoint(true);
-	g_hidGyro.setReportCallback(hidGyroGet, hidGyroSet);
-	g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
+	initDs4Macs();
+	for (int s = 0; s < NSLOT; s++) {
+		g_hidGyro[s].enableOutEndpoint(true);
+		g_hidGyro[s].setReportCallback(DS4_GETCB[s], DS4_SETCB[s]);
+		g_hidGyro[s].setReportDescriptor(GYRO_HID_DESC,
+						 sizeof GYRO_HID_DESC);
 
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_hidGyro.setPollInterval(1);
-	g_hidGyro.begin();
+		// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
+		g_hidGyro[s].setPollInterval(1);
+		g_hidGyro[s].begin();
+	}
 }
 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);
+	}
 }

+ 103 - 57
OpenPuck/mode_ps5.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -36,18 +37,33 @@ static const uint8_t PS5_HID_DESC[] = {
 };
 #define PS5_TOUCH_H 1080
 #define PS5_STATUS_USB 0x1A // charging + level 10 (~100%)
-static unsigned long g_ps5LastMs = 0;
-static Adafruit_USBD_HID g_ps5;
-static 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)
 		return 0;
 	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)
 			return 0;
 		// MAC at kernel buf[1..6] = payload[0..5]
-		memcpy(buf, PS5_MAC, 6);
+		memcpy(buf, g_ps5Mac[slot], 6);
 		return 19;
 	case 0x20: // firmware info (64 incl id)
 		if (reqlen < 63)
@@ -84,8 +100,8 @@ static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
 		return 0;
 	}
 }
-static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		   uint16_t n)
+static void ps5SetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			 uint8_t const *b, uint16_t n)
 {
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
@@ -108,45 +124,69 @@ static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 	}
 	if (id != 0x02 || pn < 4)
 		return;
-	hapticSteamRumble((uint16_t)p[3] * 257u,
-			  (uint16_t)p[2] *
-				  257u); // DualSense: left=low, right=high
+	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[0].buttons);
+	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
 	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
 	memset(out, 0, 63);
-	out[0] = swStick(g_in[0].lx, false);
-	out[1] = swStick(g_in[0].ly, true);
-	out[2] = swStick(g_in[0].rx, false);
-	out[3] = swStick(g_in[0].ry, true);
-	out[4] = g_in[0].lt;
-	out[5] = g_in[0].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[8] = psShouldersByte(b);
+	out[8] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
 	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_MUTE) ? 0x04 : 0);
-	out[15] = g_in[0].gx & 0xFF;
-	out[16] = g_in[0].gx >> 8;
-	out[17] = g_in[0].gz & 0xFF;
-	out[18] = g_in[0].gz >> 8;
-	out[19] = (-g_in[0].gy) & 0xFF;
-	out[20] = (-g_in[0].gy) >> 8;
-	out[21] = g_in[0].ax & 0xFF;
-	out[22] = g_in[0].ax >> 8;
-	out[23] = g_in[0].ay & 0xFF;
-	out[24] = g_in[0].ay >> 8;
-	out[25] = g_in[0].az & 0xFF;
-	out[26] = g_in[0].az >> 8;
+	out[15] = g_in[slot].gx & 0xFF;
+	out[16] = g_in[slot].gx >> 8;
+	out[17] = g_in[slot].gz & 0xFF;
+	out[18] = g_in[slot].gz >> 8;
+	out[19] = (-g_in[slot].gy) & 0xFF;
+	out[20] = (-g_in[slot].gy) >> 8;
+	out[21] = g_in[slot].ax & 0xFF;
+	out[22] = g_in[slot].ax >> 8;
+	out[23] = g_in[slot].ay & 0xFF;
+	out[24] = g_in[slot].ay >> 8;
+	out[25] = g_in[slot].az & 0xFF;
+	out[26] = g_in[slot].az >> 8;
 	uint16_t lx, ly, rx, ry;
-	steamPadsToTouch(b, PS5_TOUCH_H, g_in[0].lpx, g_in[0].lpy, g_in[0].rpx, g_in[0].rpy,
-			 &lx, &ly, &rx, &ry);
+	steamPadsToTouch(b, PS5_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+			 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx, &ry);
 	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
 	out[52] = PS5_STATUS_USB;
 }
@@ -155,26 +195,32 @@ void Ps5Controller::begin()
 {
 	USBDevice.setID(0x054C, 0x0CE6);
 
-	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor
-	USBDevice.setDeviceVersion(0x0104);
+	// Host re-reads config by VID:PID:serial (per-mode suffix); bump invalidates any cached descriptor.
+	// Bumped 0x0104 -> 0x0105 for the 4-DualSense enumeration (3 more HID interfaces vs the original).
+	USBDevice.setDeviceVersion(0x0105);
 	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
 	USBDevice.setProductDescriptor("DualSense Wireless Controller");
-	g_ps5.enableOutEndpoint(true);
-	g_ps5.setReportCallback(ps5Get, ps5Set);
-	g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
+	initPs5Macs();
+	for (int s = 0; s < NSLOT; s++) {
+		g_ps5[s].enableOutEndpoint(true);
+		g_ps5[s].setReportCallback(PS5_GETCB[s], PS5_SETCB[s]);
+		g_ps5[s].setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
 
-	// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-	g_ps5.setPollInterval(1);
-	g_ps5.begin();
+		// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
+		g_ps5[s].setPollInterval(1);
+		g_ps5[s].begin();
+	}
 }
 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);
+	}
 }

+ 40 - 26
OpenPuck/mode_switch_hori.cpp

@@ -2,6 +2,7 @@
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 
@@ -26,8 +27,9 @@ static const uint8_t SWITCH_HID_DESC[] = {
 	// vendor (output, 8 bytes; unused)
 	0x0A, 0x21, 0x26, 0x95, 0x08, 0x91, 0x02, 0xC0
 };
-static Adafruit_USBD_HID g_switch; // HORIPAD gamepad HID interface
-static unsigned long g_swLastMs = 0;
+// NSLOT HORIPAD HID instances -- one per bond slot. The Switch console binds each as a separate gamepad.
+static Adafruit_USBD_HID g_switch[NSLOT];
+static unsigned long g_swLastMs[NSLOT] = { 0 };
 
 // back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
 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;
 }
 // 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[0].buttons;
+	uint32_t b = g_in[slot].buttons;
 	uint16_t btn = 0;
 	if (g_qamMap && (b & TB_QAM)) {
 		b &= ~(uint32_t)TB_QAM;
@@ -101,9 +104,9 @@ static void switchBuildHoripad(uint8_t out[8])
 	if (b & TB_RB)
 		btn |= 0x20; // L, R
 	// ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
-	if ((g_in[0].lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		btn |= 0x40;
-	if ((g_in[0].rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		btn |= 0x80;
 	if (b & TB_MENU)
 		btn |= 0x100;
@@ -154,10 +157,11 @@ static void switchBuildHoripad(uint8_t out[8])
 	out[0] = btn & 0xFF;
 	out[1] = btn >> 8;
 	out[2] = hat;
-	out[3] = swStick(g_in[0].lx, false);
-	out[4] = swStick(g_in[0].ly, true); // HID Y is down-positive -> invert
-	out[5] = swStick(g_in[0].rx, false);
-	out[6] = swStick(g_in[0].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;
 }
 
@@ -165,25 +169,35 @@ void SwitchHoriController::begin()
 {
 	USBDevice.setID(0x0F0D, 0x0092);
 
-	// Windows caches config by VID:PID:bcdDevice, so any interface change MUST bump this
-	USBDevice.setDeviceVersion(0x0202);
+	// Windows caches config by VID:PID:bcdDevice, so any interface change MUST bump this. Bumped 0x0202 ->
+	// 0x0203 for the 4-HORIPAD enumeration (3 more HID interfaces vs the original single).
+	USBDevice.setDeviceVersion(0x0203);
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
-	g_switch.enableOutEndpoint(true);
-	g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
+	for (int s = 0; s < NSLOT; s++) {
+		g_switch[s].enableOutEndpoint(true);
+		g_switch[s].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();
+		// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
+		g_switch[s].setPollInterval(1);
+		g_switch[s].begin();
+	}
 }
 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
+	}
 }

+ 232 - 138
OpenPuck/mode_switch_pro.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
+#include "bonds.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.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;
 	}
 }
-static unsigned long g_swProLastMs = 0;
-static Adafruit_USBD_HID g_swPro;
+// NSLOT Pro-Controller HIDs (one per bond slot) + per-slot handshake state (timer, report-mode gate,
+// subcommand-reply FIFO), per-slot reply queue indices, per-slot last-stream millis, per-slot MAC, and
+// per-slot user-cal SPI mirror.
+static Adafruit_USBD_HID g_swPro[NSLOT];
+static unsigned long g_swProLastMs[NSLOT] = { 0 };
 // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
-static uint8_t g_swProReportMode = 0;
-static const uint8_t JC_MAC[6] = {
-	0x7C, 0xBB, 0x8A, 0x00, 0x00, 0x01
-}; // synthetic but stable controller MAC
+static uint8_t g_swProReportMode[NSLOT] = { 0 };
+static uint8_t g_jcTimer[NSLOT] = { 0 };
+// Per-slot MAC. The console uses the BT addr it reads from subcommand 0x02 to identify the controller;
+// real Pro Controllers each have their own. Last byte varies per slot.
+static const uint8_t JC_MAC_BASE[5] = { 0x7C, 0xBB, 0x8A, 0x00, 0x00 };
+static uint8_t g_jcMac[NSLOT][6];
+static bool g_jcMacInit = false;
+static void initJcMacs()
+{
+	if (g_jcMacInit)
+		return;
+	for (int s = 0; s < NSLOT; s++) {
+		memcpy(g_jcMac[s], JC_MAC_BASE, 5);
+		g_jcMac[s][5] = (uint8_t)(0x10 + s);
+	}
+	g_jcMacInit = true;
+}
 static uint16_t jcRumbleAmp(const uint8_t r[4])
 {
 	// Nintendo packs a high band (r[0],r[1]) and low band (r[2],r[3]) of frequency+amplitude. We only need a
@@ -158,19 +175,21 @@ static uint16_t jcRumbleAmp(const uint8_t r[4])
 	uint8_t a = hf > lf ? hf : lf; // 0..0x7F
 	return (uint16_t)a << 9; // scale to ~16-bit motor speed
 }
-static void jcRumble(const uint8_t *p, uint16_t pn)
+// Per-slot: each Pro Controller has its own rumble stream, so the "last" relay tracking must be per-slot.
+static uint16_t g_jcLastLo[NSLOT] = { 0 };
+static uint16_t g_jcLastHi[NSLOT] = { 0 };
+static void jcRumble(uint8_t slot, const uint8_t *p, uint16_t pn)
 {
 	if (pn < 9)
 		return; // [timer][left rumble x4][right rumble x4]
 	uint16_t lo = jcRumbleAmp(p + 1), hi = jcRumbleAmp(p + 5);
-	static uint16_t lastLo = 0, lastHi = 0;
 	// only relay on change: the Switch streams rumble every frame; re-sending
 	// unchanged values would flood the RF relay and loop the motor
-	if (lo == lastLo && hi == lastHi)
+	if (lo == g_jcLastLo[slot] && hi == g_jcLastHi[slot])
 		return;
-	lastLo = lo;
-	lastHi = hi;
-	hapticSteamRumble(lo, hi);
+	g_jcLastLo[slot] = lo;
+	g_jcLastHi[slot] = hi;
+	hapticSteamRumble(lo, hi, slot);
 }
 static int jcStick12(int16_t v, bool inv)
 { // 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),
 // 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[0].buttons;
+	uint32_t b = g_in[slot].buttons;
 	if (g_qamMap && (b & TB_QAM)) {
 		b &= ~(uint32_t)TB_QAM;
 		b |= tritonFromCode(g_qamMap);
@@ -214,9 +232,9 @@ static void jcInputPrefix(uint8_t *out)
 		jc |= JC_BTN_L;
 	if (b & TB_RB)
 		jc |= JC_BTN_R;
-	if ((g_in[0].lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		jc |= JC_BTN_ZL;
-	if ((g_in[0].rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		jc |= JC_BTN_ZR;
 	if (b & TB_VIEW)
 		jc |= JC_BTN_PLUS;
@@ -244,7 +262,7 @@ static void jcInputPrefix(uint8_t *out)
 		jc |= codeToJc(g_back[2], fA, fB, fX, fY);
 	if (b & TB_R5)
 		jc |= codeToJc(g_back[3], fA, fB, fX, fY);
-	out[0] = g_jcTimer++;
+	out[0] = g_jcTimer[slot]++;
 
 	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
 	// A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too.
@@ -256,15 +274,15 @@ static void jcInputPrefix(uint8_t *out)
 	// charging_grip bit (button "common" byte, bit7): genuine Pro Controller always sets it on USB; real
 	// Switch uses it to recognise a wired controller. Not a button, so hid-nintendo ignores it.
 	out[3] |= 0x80;
-	jcPackStick(out + 5, g_in[0].lx, g_in[0].ly);
-	jcPackStick(out + 8, g_in[0].rx, g_in[0].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.
 	out[11] = 0x09;
 }
-static void switchProBuild(uint8_t out[63])
+static void switchProBuild(uint8_t slot, uint8_t out[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
 	// 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
 	// accel, not gravity), and gyro roll error accumulates into a slow ~45deg lean. Gyro is left at native scale.
 	// accel X <- +ay, accel Y <- -ax, accel Z <- +az (signs match gyro)
-	int16_t aX = (int16_t)(g_in[0].ay / SW_ACCEL_DIV);
-	int16_t aY = (int16_t)((-(int16_t)g_in[0].ax) / SW_ACCEL_DIV);
-	int16_t aZ = (int16_t)(g_in[0].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
-	int16_t groll = gscale((int16_t)g_in[0].gy);
-	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[0].gx));
-	int16_t gyaw = gscale((int16_t)g_in[0].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++) {
 		int o = 12 + k * 12;
 		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 };
 static const uint8_t SPI_COLOR[13] = { 0x32, 0x32, 0x32, 0xE6, 0xE6, 0xE6, 0x32,
 				       0x32, 0x32, 0x32, 0x32, 0x32, 0xFF };
-// built at boot: left[9]+right[9] factory stick calibration (packed 12-bit)
+// built at boot: left[9]+right[9] factory stick calibration (packed 12-bit). Shared across slots -- it's a
+// factory stub the host reads to seed its cal, and we present the same neutral stub for all controllers.
 static uint8_t g_spiStickCal[18];
 static void jcPack12(uint8_t *o9, const uint16_t v[6])
 { // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
@@ -347,13 +366,9 @@ static void jcBuildStickCal()
 // can register the pad (Steam/hid-nintendo do not). The Switch only validates the shape, not the key contents. Each
 // is the 31-byte reply body following the 0x21 input prefix + ack(0x81) + echoed-subcommand(0x01).
 // type 1: echo type + controller BT addr + "Pro Controller" name
-static const uint8_t BT_PAIR_1[31] = {
-	0x01, JC_MAC[0], JC_MAC[1], JC_MAC[2], JC_MAC[3], JC_MAC[4], JC_MAC[5],
-	0x00, 0x25,	 0x08,	    0x50,      0x72,	  0x6F,	     0x20,
-	0x43, 0x6F,	 0x6E,	    0x74,      0x72,	  0x6F,	     0x6C,
-	0x6C, 0x65,	 0x72, // "Pro Controller"
-	0x00, 0x00,	 0x00,	    0x00,      0x00,	  0x68
-};
+// 31-byte payload uses the per-slot MAC; we fill this on demand.
+static const uint8_t BT_PAIR_1_BODY[7] = { 0x01, 0x00, 0x00, 0x00,
+					   0x00, 0x00, 0x00 };
 static const uint8_t BT_PAIR_2[31] = { // type 2: LTK exchange
 	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
 	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -364,53 +379,77 @@ static const uint8_t BT_PAIR_3[31] = {
 }; // type 3: save pairing (all zero body)
 // User-calibration SPI mirror (0x8000-0x80FF). A real Pro Controller stores the gyro/accel (and stick) calibration
 // the Switch writes during "Calibrate Motion Controls" here, then reads it back and applies it -- that's how a
-// resting IMU/neutral offset gets cancelled. We store writes and serve them back, persisted to flash so it survives
-// unplug/replug. 0xFF = blank -> Switch falls back to our factory block at 0x6020 (default until cal'd).
-#define SWCAL_FILE "/swimucal.bin"
-static uint8_t g_userCal[0x100];
+// resting IMU/neutral offset gets cancelled. Per-slot: each Pro Controller has its own SPI mirror, so each
+// gets its own persisted file (NSLOT files; the same 0x100 bytes as before, partitioned by slot index).
+#define SWCAL_FILE_BASE "/swimucal"
+static uint8_t g_userCal[NSLOT][0x100];
+static bool g_userCalLoaded[NSLOT] = { false, false, false, false };
+static void swCalFileName(uint8_t slot, char *out)
+{
+	// "/swimucal0.bin" .. "/swimucal3.bin"
+	const char *base = SWCAL_FILE_BASE;
+	int i = 0;
+	while (base[i]) {
+		out[i] = base[i];
+		i++;
+	}
+	out[i++] = (char)('0' + slot);
+	out[i++] = '.';
+	out[i++] = 'b';
+	out[i++] = 'i';
+	out[i++] = 'n';
+	out[i] = '\0';
+}
 static void loadUserCal()
 {
-	memset(g_userCal, 0xFF, sizeof g_userCal);
-	File f(InternalFS);
-	if (f.open(SWCAL_FILE, FILE_O_READ)) {
-		f.read(g_userCal, sizeof g_userCal);
-		f.close();
+	for (int s = 0; s < NSLOT; s++) {
+		memset(g_userCal[s], 0xFF, sizeof g_userCal[s]);
+		char fn[16];
+		swCalFileName((uint8_t)s, fn);
+		File f(InternalFS);
+		if (f.open(fn, FILE_O_READ)) {
+			f.read(g_userCal[s], sizeof g_userCal[s]);
+			f.close();
+		}
+		g_userCalLoaded[s] = true;
 	}
 }
-static void saveUserCal()
+static void saveUserCal(uint8_t slot)
 {
-	InternalFS.remove(SWCAL_FILE);
+	char fn[16];
+	swCalFileName(slot, fn);
+	InternalFS.remove(fn);
 	File f(InternalFS);
-	if (f.open(SWCAL_FILE, FILE_O_WRITE)) {
-		f.write(g_userCal, sizeof g_userCal);
+	if (f.open(fn, FILE_O_WRITE)) {
+		f.write(g_userCal[slot], sizeof g_userCal[slot]);
 		f.close();
 	}
 }
 // jcSpiWrite runs in the USB ISR (via jcSet). NEVER do flash I/O here -- a blocking LittleFS erase/write in the
 // interrupt wedges USB + the RF poll and corrupts state (device drops into a bad state needing a replug). Only
 // update the RAM mirror and flag it dirty; task() (main loop) does the actual save, debounced so a calibration
-// write-burst coalesces into one flash write.
-static volatile bool g_userCalDirty = false;
-static volatile unsigned long g_userCalDirtyMs = 0;
-static void jcSpiWrite(uint32_t addr, uint8_t len, const uint8_t *data,
-		       uint16_t avail)
+// write-burst coalesces into one flash write. Per-slot: each Pro Controller's ISR write targets its own mirror.
+static volatile bool g_userCalDirty[NSLOT] = { false, false, false, false };
+static volatile unsigned long g_userCalDirtyMs[NSLOT] = { 0 };
+static void jcSpiWrite(uint8_t slot, uint32_t addr, uint8_t len,
+		       const uint8_t *data, uint16_t avail)
 {
 	bool changed = false;
 	for (uint8_t i = 0; i < len && i < avail; i++) {
 		uint32_t a = addr + i;
 		if (a >= 0x8000 && a < 0x8100) {
-			if (g_userCal[a - 0x8000] != data[i]) {
-				g_userCal[a - 0x8000] = data[i];
+			if (g_userCal[slot][a - 0x8000] != data[i]) {
+				g_userCal[slot][a - 0x8000] = data[i];
 				changed = true;
 			}
 		}
 	}
 	if (changed) {
-		g_userCalDirty = true;
-		g_userCalDirtyMs = millis();
+		g_userCalDirty[slot] = true;
+		g_userCalDirtyMs[slot] = millis();
 	} // defer the flash write to task()
 }
-static void spiRead(uint32_t addr, uint8_t len, uint8_t *dst)
+static void spiRead(uint8_t slot, uint32_t addr, uint8_t len, uint8_t *dst)
 {
 	for (uint8_t i = 0; i < len; i++) {
 		uint32_t a = addr + i;
@@ -427,52 +466,74 @@ static void spiRead(uint32_t addr, uint8_t len, uint8_t *dst)
 			v = SPI_PARAMS2[a - 0x6098];
 		else if (a >= 0x8000 && a < 0x8100)
 			// persisted user cal (0xFF blank -> factory fallback)
-			v = g_userCal[a - 0x8000];
+			v = g_userCal[slot][a - 0x8000];
 		dst[i] = v;
 	}
 }
 // Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
-// 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Every reply
-// is sent as a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter
-// than the descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
+// 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Per-slot: each
+// Pro Controller runs its own handshake state machine, so each has its own reply queue. Every reply is sent as
+// a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter than the
+// descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
 #define JC_REPLEN 63
 struct JcRep {
 	uint8_t rid;
 	uint8_t data[JC_REPLEN];
 };
 #define JCQ_N 8
-static volatile uint8_t g_jcQh = 0, g_jcQt = 0;
-static JcRep g_jcQ[JCQ_N];
-static void jcEnq(uint8_t rid, const uint8_t *d, uint8_t len)
+static volatile uint8_t g_jcQh[NSLOT] = { 0, 0, 0, 0 };
+static volatile uint8_t g_jcQt[NSLOT] = { 0, 0, 0, 0 };
+static JcRep g_jcQ[NSLOT][JCQ_N];
+static void jcEnq(uint8_t slot, uint8_t rid, const uint8_t *d, uint8_t len)
 {
-	uint8_t nt = (uint8_t)((g_jcQt + 1) % JCQ_N);
-	if (nt == g_jcQh)
+	uint8_t t = g_jcQt[slot];
+	uint8_t nt = (uint8_t)((t + 1) % JCQ_N);
+	if (nt == g_jcQh[slot])
 		return; // full -> drop; the host re-requests on timeout
 	if (len > JC_REPLEN)
 		len = JC_REPLEN;
-	g_jcQ[g_jcQt].rid = rid;
-	memset(g_jcQ[g_jcQt].data, 0, JC_REPLEN);
-	memcpy(g_jcQ[g_jcQt].data, d, len); // zero-pad to full report length
-	g_jcQt = nt;
+	g_jcQ[slot][t].rid = rid;
+	memset(g_jcQ[slot][t].data, 0, JC_REPLEN);
+	memcpy(g_jcQ[slot][t].data, d, len); // zero-pad to full report length
+	g_jcQt[slot] = nt;
 }
 // Build the 0x21 reply (standard input prefix + ACK + echoed subcommand id + reply data) for a report-0x01 subcommand.
-static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
+static void jcSubcmd(uint8_t slot, uint8_t sub, const uint8_t *args,
+		     uint16_t alen)
 {
 	uint8_t p[JC_REPLEN];
 	memset(p, 0, sizeof p);
-	jcInputPrefix(p);
+	jcInputPrefix(slot, p);
 	p[13] = sub;
 	switch (sub) {
 	// manual BT pairing: 3-stage key exchange a real Switch runs over USB
 	case 0x01: {
-		const uint8_t *d = BT_PAIR_3;
 		uint8_t t = (alen >= 1) ? args[0] : 3;
-		if (t == 1)
-			d = BT_PAIR_1;
-		else if (t == 2)
-			d = BT_PAIR_2;
 		p[12] = 0x81; // pairing ACK
-		memcpy(&p[14], d, 31);
+		if (t == 1) {
+			// echo type + this slot's MAC + "Pro Controller" name
+			p[14] = BT_PAIR_1_BODY[0];
+			memcpy(&p[15], g_jcMac[slot], 6);
+			p[21] = 0x00;
+			p[22] = 0x25;
+			p[23] = 0x08;
+			// "Pro Controller" = 50 72 6F 20 43 6F 6E 74 72 6F 6C 6C 65 72
+			static const uint8_t NAME[14] = {
+				0x50, 0x72, 0x6F, 0x20, 0x43, 0x6F, 0x6E,
+				0x74, 0x72, 0x6F, 0x6C, 0x6C, 0x65, 0x72
+			};
+			memcpy(&p[24], NAME, 14);
+			p[38] = 0x00;
+			p[39] = 0x00;
+			p[40] = 0x00;
+			p[41] = 0x00;
+			p[42] = 0x00;
+			p[43] = 0x68;
+		} else if (t == 2) {
+			memcpy(&p[14], BT_PAIR_2, 31);
+		} else {
+			memcpy(&p[14], BT_PAIR_3, 31);
+		}
 		break;
 	}
 	case 0x02: // request device info
@@ -483,7 +544,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		p[15] = 0x48;
 		p[16] = 0x03; // controller type: Pro Controller
 		p[17] = 0x02; // fixed
-		memcpy(&p[18], JC_MAC, 6);
+		memcpy(&p[18], g_jcMac[slot], 6);
 		p[24] = 0x01; // colors stored in SPI (0x6050)
 		p[25] = 0x01; // fixed
 		break;
@@ -504,7 +565,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		p[16] = args[2];
 		p[17] = args[3];
 		p[18] = rl;
-		spiRead(a, rl, &p[19]);
+		spiRead(slot, a, rl, &p[19]);
 		break;
 	}
 	// SPI flash write -> persist user calibration so Switch motion-cal sticks
@@ -514,7 +575,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 				     ((uint32_t)args[1] << 8) |
 				     ((uint32_t)args[2] << 16) |
 				     ((uint32_t)args[3] << 24);
-			jcSpiWrite(a, args[4], &args[5],
+			jcSpiWrite(slot, a, args[4], &args[5],
 				   (alen > 5) ? (uint16_t)(alen - 5) : 0);
 		}
 		p[12] = 0x80; // write ACK
@@ -523,7 +584,7 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 	// set input report mode (0x30 = standard full) -> begin streaming
 	case 0x03:
 		if (alen >= 1 && args[0] == 0x30)
-			g_swProReportMode = 0x30;
+			g_swProReportMode[slot] = 0x30;
 		p[12] = 0x80;
 		break;
 	// trigger buttons elapsed time -> canned reply (genuine pad returns data)
@@ -545,13 +606,12 @@ static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
 		// 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
 		break;
 	}
-	jcEnq(0x21, p, JC_REPLEN);
+	jcEnq(slot, 0x21, p, JC_REPLEN);
 }
-// USB-ISR callback for the Pro Controller's OUT endpoint. Data arrives via the interrupt OUT endpoint, where
-// this TinyUSB core passes report_id=0 and leaves the real report id in buffer[0] (it does NOT strip it). Also
-// accept the control SET_REPORT shape (id already in `rid`) for safety. report 0x80 = USB handshake; 0x01 = subcommand.
-static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
-		  uint16_t n)
+// USB-ISR callback for the Pro Controller's OUT endpoint. Per-slot dispatch via per-instance callback
+// (jcSet##N closes over the slot index). Report 0x80 = USB handshake; 0x01 = subcommand.
+static void jcSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
+			uint8_t const *b, uint16_t n)
 {
 	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
 		return;
@@ -572,18 +632,24 @@ static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
 		if (pn < 1)
 			return;
 		if (p[0] == 0x01) {
-			uint8_t d[9] = { 0x01,	    0x00,      0x03,
-					 JC_MAC[0], JC_MAC[1], JC_MAC[2],
-					 JC_MAC[3], JC_MAC[4], JC_MAC[5] };
-			jcEnq(0x81, d, 9);
+			uint8_t d[9] = { 0x01,
+					 0x00,
+					 0x03,
+					 g_jcMac[slot][0],
+					 g_jcMac[slot][1],
+					 g_jcMac[slot][2],
+					 g_jcMac[slot][3],
+					 g_jcMac[slot][4],
+					 g_jcMac[slot][5] };
+			jcEnq(slot, 0x81, d, 9);
 		} // device type + MAC
 		else if (p[0] == 0x02) {
 			uint8_t d[1] = { 0x02 };
-			jcEnq(0x81, d, 1);
+			jcEnq(slot, 0x81, d, 1);
 		} // handshake
 		else if (p[0] == 0x03) {
 			uint8_t d[1] = { 0x03 };
-			jcEnq(0x81, d, 1);
+			jcEnq(slot, 0x81, d, 1);
 		} // set baudrate
 		// 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
 		return;
@@ -591,68 +657,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 (pn < 10)
 			return;
-		jcRumble(p, pn);
-		jcSubcmd(p[9], p + 10, (pn > 10) ? (uint16_t)(pn - 10) : 0);
+		jcRumble(slot, p, pn);
+		jcSubcmd(slot, p[9], p + 10,
+			 (pn > 10) ? (uint16_t)(pn - 10) : 0);
 		return;
 	}
 	if (id == 0x10) {
-		jcRumble(p, pn);
+		jcRumble(slot, p, pn);
 		return;
 	}
 	// report 0x82: ignored
 }
+#define JCCB(N)                                                                \
+	static void jcSet##N(uint8_t r, hid_report_type_t t, uint8_t const *b, \
+			     uint16_t n)                                       \
+	{                                                                      \
+		jcSetCommon(N, r, t, b, n);                                    \
+	}
+// clang-format off
+JCCB(0)
+JCCB(1)
+JCCB(2)
+JCCB(3)
+// clang-format on
+typedef void (*jc_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
+			   uint16_t);
+static jc_setcb_t const JC_SETCB[NSLOT] = { jcSet0, jcSet1, jcSet2, jcSet3 };
 
 void SwitchProController::begin()
 {
 	USBDevice.setID(0x057E, 0x2009);
-	// bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
-	USBDevice.setDeviceVersion(0x0212);
+	// bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice). Bumped
+	// 0x0212 -> 0x0213 for the 4-Pro enumeration (3 more HID interfaces vs the original).
+	USBDevice.setDeviceVersion(0x0213);
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setProductDescriptor("Pro Controller");
 	jcBuildStickCal();
 
-	// restore any persisted Switch motion calibration (per-unit IMU bias correction)
+	// restore any persisted Switch motion calibration (per-unit IMU bias correction). Per-slot files.
 	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);
+	initJcMacs();
+	for (int s = 0; s < NSLOT; s++) {
+		g_swPro[s].enableOutEndpoint(true);
+		// answer the Nintendo USB handshake + subcommands (else Steam never binds it)
+		g_swPro[s].setReportCallback(NULL, JC_SETCB[s]);
+		g_swPro[s].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();
+		// 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
+		g_swPro[s].setPollInterval(1);
+		g_swPro[s].begin();
+	}
 }
 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);
 }

+ 118 - 51
OpenPuck/mode_xinput.cpp

@@ -1,3 +1,16 @@
+// 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: 4 XInput interfaces, one per bond slot. The single class driver routes xfer/open callbacks
+// by endpoint address into the per-slot state. The host presents 4 XInput devices, mapped to user slots 1..4.
+// Rumble is per-slot (each XInput has its own OUT endpoint -> the right controller). The right-pad mouse is a
+// single shared interface (the desktop can only consume one mouse): slot 0's right-pad drives it; other slots'
+// right-pad is ignored for the mouse purpose. The right-pad-as-gamepad axis (touch/click) still flows through
+// the per-slot XInput HID so the host sees all four trackpads.
 #include "mode_xinput.h"
 #include "triton.h"
 #include "gamepad_util.h"
@@ -35,21 +48,30 @@ enum {
 
 // ===================== XInput custom TinyUSB class driver =====================
 // Custom class driver + an Adafruit_USBD_Interface subclass emitting the interface + 0x21-blob + 2 endpoints.
+// Per-slot state lives in g_xiSlot[]: each XInput interface gets one. The class driver dispatches open/xfer
+// by endpoint address; xinputSend() targets a specific slot's IN endpoint.
 #define XINPUT_DESC_LEN \
 	(9 + 17 + 7 + 7) // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
-static uint8_t g_xiItf = 0xFF, g_xiEpIn = 0, g_xiEpOut = 0;
-static uint8_t g_xiInBuf[32], g_xiOutBuf[32];
-
-// XInput rumble strengths from the host's OUT packet; translated to Steam/Triton report 0x80.
-static volatile uint16_t g_rumbleLow = 0, g_rumbleHigh = 0;
-static volatile unsigned long g_rumbleMs =
-	0; // millis of last rumble OUT packet (stuck-rumble watchdog)
 
+// per-slot state. `inUse` is set in xi_open from the USB ISR and read in xinputSend from the loop -- mark
+// volatile so the loop doesn't observe a stale 0 across a freshly-attached interface.
+struct XiSlot {
+	uint8_t itf, epIn, epOut;
+	uint8_t inBuf[32], outBuf[32];
+	volatile uint16_t rumbleLow,
+		rumbleHigh; // last host rumble values, x 257
+	volatile unsigned long
+		rumbleMs; // millis of last OUT packet (stuck-rumble watchdog)
+	volatile bool inUse;
+};
+static XiSlot g_xiSlot[NSLOT];
 // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
 #define RUMBLE_STUCK_MS 2500u
 
 static void xi_init(void)
 {
+	for (int s = 0; s < NSLOT; s++)
+		g_xiSlot[s].inUse = false;
 }
 static bool xi_deinit(void)
 {
@@ -58,15 +80,32 @@ static bool xi_deinit(void)
 static void xi_reset(uint8_t 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. We claim the first free slot
+// (round-robin, but begin() fires them in order so the slot index matches the interface index from the
+// host's POV) and remember the endpoint addresses for the xfer_cb dispatch.
 static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 			uint16_t max_len)
 {
 	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
 	      itf->bInterfaceProtocol == 0x01))
 		return 0;
-	g_xiItf = itf->bInterfaceNumber;
+	int slot = -1;
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_xiSlot[s].inUse) {
+			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 *end = p + max_len;
 	uint16_t used = itf->bLength;
@@ -79,17 +118,18 @@ static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 				(tusb_desc_endpoint_t const *)p;
 			usbd_edpt_open(rhport, ep);
 			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
-				g_xiEpIn = ep->bEndpointAddress;
+				S.epIn = ep->bEndpointAddress;
 			else
-				g_xiEpOut = ep->bEndpointAddress;
+				S.epOut = ep->bEndpointAddress;
 			opened++;
 		}
 		used += blen;
 		p += blen;
 	}
-	if (g_xiEpOut)
-		usbd_edpt_xfer(rhport, g_xiEpOut, g_xiOutBuf,
-			       sizeof g_xiOutBuf); // arm OUT (rumble/LED)
+	S.inUse = true;
+	// arm OUT (rumble/LED)
+	if (S.epOut)
+		usbd_edpt_xfer(rhport, S.epOut, S.outBuf, sizeof S.outBuf);
 	return used;
 }
 static bool xi_ctrl(uint8_t rhport, uint8_t stage,
@@ -99,21 +139,30 @@ static bool xi_ctrl(uint8_t rhport, uint8_t stage,
 	(void)req;
 	return stage != CONTROL_STAGE_SETUP;
 }
+// Route an endpoint xfer callback to the slot that owns that endpoint.
 static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t res, uint32_t n)
 {
 	(void)res;
-	if (ep == g_xiEpOut) {
-		// XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
-		if (n >= 5 && g_xiOutBuf[0] == 0x00 && g_xiOutBuf[1] == 0x08) {
-			uint8_t big = g_xiOutBuf[3], sml = g_xiOutBuf[4];
-			g_rumbleLow = (uint16_t)big * 257u;
-			g_rumbleHigh = (uint16_t)sml * 257u;
-			g_rumbleMs =
-				millis(); // stamp for the stuck-rumble watchdog
-			hapticSteamRumble(g_rumbleLow, g_rumbleHigh);
+	for (int s = 0; s < NSLOT; s++) {
+		XiSlot &S = g_xiSlot[s];
+		if (!S.inUse)
+			continue;
+		if (ep == S.epOut) {
+			// XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
+			if (n >= 5 && S.outBuf[0] == 0x00 &&
+			    S.outBuf[1] == 0x08) {
+				uint8_t big = S.outBuf[3], sml = S.outBuf[4];
+				uint16_t lo = (uint16_t)big * 257u;
+				uint16_t hi = (uint16_t)sml * 257u;
+				S.rumbleLow = lo;
+				S.rumbleHigh = hi;
+				S.rumbleMs = millis();
+				hapticSteamRumble(lo, hi, (uint8_t)s);
+			}
+			usbd_edpt_xfer(rhport, S.epOut, S.outBuf,
+				       sizeof S.outBuf);
+			return true;
 		}
-		usbd_edpt_xfer(rhport, g_xiEpOut, g_xiOutBuf,
-			       sizeof g_xiOutBuf); // re-arm OUT
 	}
 	return true;
 }
@@ -166,13 +215,18 @@ class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
 		return TinyUSBDevice.addInterface(*this);
 	}
 };
-static Adafruit_USBD_XInput g_xinput;
-static void xinputSend(uint16_t buttons, uint8_t lt, uint8_t rt, int16_t lx,
-		       int16_t ly, int16_t rx, int16_t ry)
+// NSLOT instances: each registers its own XInput interface (own itfnum + IN/OUT ep pair) during begin().
+// begin() in XboxController::begin() runs in order 0..NSLOT-1, so xi_open's first-free assignment lines up.
+static Adafruit_USBD_XInput g_xinput[NSLOT];
+static void xinputSend(uint8_t slot, uint16_t buttons, uint8_t lt, uint8_t rt,
+		       int16_t lx, int16_t ly, int16_t rx, int16_t ry)
 {
-	if (!tud_mounted() || g_xiEpIn == 0 || usbd_edpt_busy(0, g_xiEpIn))
+	if (slot >= NSLOT || !g_xiSlot[slot].inUse)
 		return;
-	uint8_t *r = g_xiInBuf;
+	XiSlot &S = g_xiSlot[slot];
+	if (!tud_mounted() || S.epIn == 0 || usbd_edpt_busy(0, S.epIn))
+		return;
+	uint8_t *r = S.inBuf;
 	r[0] = 0x00;
 	r[1] = 0x14;
 	r[2] = buttons & 0xFF;
@@ -188,15 +242,16 @@ static void xinputSend(uint16_t buttons, uint8_t lt, uint8_t rt, int16_t lx,
 	r[12] = ry & 0xFF;
 	r[13] = ry >> 8;
 	memset(r + 14, 0, 6);
-	if (usbd_edpt_claim(0, g_xiEpIn)) {
-		if (!usbd_edpt_xfer(0, g_xiEpIn, r, 20))
-			usbd_edpt_release(0, g_xiEpIn);
+	if (usbd_edpt_claim(0, S.epIn)) {
+		if (!usbd_edpt_xfer(0, S.epIn, r, 20))
+			usbd_edpt_release(0, S.epIn);
 	}
 }
 
 // ===================== right-pad mouse interface =====================
 static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
-static Adafruit_USBD_HID g_mouse; // Xbox-mode mouse interface (right trackpad)
+static Adafruit_USBD_HID
+	g_mouse; // Xbox-mode mouse interface (right trackpad, slot 0 only)
 
 // ===================== report 0x45 -> XInput + mouse =====================
 // button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright
@@ -237,7 +292,7 @@ static uint16_t codeToXB(uint8_t c)
 		return 0;
 	}
 }
-static void rfXboxGamepad(const uint8_t *r)
+static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 {
 	uint32_t b = btnsOf(r);
 	if (g_qamMap && (b & TB_QAM)) {
@@ -290,13 +345,14 @@ static void rfXboxGamepad(const uint8_t *r)
 	uint8_t lt = trigU8(u16off(r, 4)),
 		rt = trigU8(u16off(
 			r, 6)); // triggers u16 (half-scale) -> full-range u8
-	xinputSend(btn, lt, rt, (int16_t)s16off(r, 8),
+	xinputSend(slot, btn, lt, rt, (int16_t)s16off(r, 8),
 		   (int16_t)s16off(r, 10), // L stick X/Y
 		   (int16_t)s16off(r, 12),
 		   (int16_t)s16off(r, 14)); // R stick X/Y
 }
 // Right pad -> mouse on a second HID-mouse interface alongside the XInput gamepad. Same glide model as Lizard's
-// right pad; RPad click = left button, LPad click = right.
+// right pad; RPad click = left button, LPad click = right. SINGLE shared mouse: the desktop can only consume
+// one mouse. Slot 0's right-pad drives it; other slots' right-pad input is intentionally ignored here.
 static void rfXboxMouse(const uint8_t *r)
 {
 	uint32_t b = btnsOf(r);
@@ -356,17 +412,18 @@ static void rfXboxMouse(const uint8_t *r)
 // ===================== IController =====================
 void XboxController::begin()
 {
-	g_rumbleLow = g_rumbleHigh = 0;
-
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
 	// Windows caches the config descriptor by VID:PID:bcdDevice, so any interface change here MUST bump bcdDevice
-	// or Windows serves a stale descriptor.
-	USBDevice.setDeviceVersion(0x0115);
+	// or Windows serves a stale descriptor. Bumped 0x0115 -> 0x0116 for the 4-XInput enumeration (4 more IN + 4
+	// more OUT endpoints and 4 more interfaces vs the original single).
+	USBDevice.setDeviceVersion(0x0116);
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");
-	g_xinput.setStringDescriptor("Controller");
-	g_xinput.begin();
+	for (int s = 0; s < NSLOT; s++) {
+		g_xinput[s].setStringDescriptor("Controller");
+		g_xinput[s].begin();
+	}
 	g_mouse.setStringDescriptor("OpenPuck Mouse");
 	g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
 	g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC);
@@ -376,18 +433,28 @@ void XboxController::begin()
 void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)
 {
-	(void)slot; // xinput mode is single-controller by design
 	(void)fresh;
 	(void)bodyTlen;
-	rfXboxGamepad(rep);
-	rfXboxMouse(rep);
+	if (slot < 0 || slot >= NSLOT)
+		return;
+	rfXboxGamepad((uint8_t)slot, rep);
+	// Shared desktop mouse: slot 0's right-pad only. Other slots' right-pad is still part of the
+	// per-slot XInput (TB_RPADT in the gamepad report is unused today but kept for forward-compat).
+	if (slot == 0)
+		rfXboxMouse(rep);
 }
-// Lost-stop watchdog: Steam/Triton rumble is latched, so force a zero report if the host stops refreshing.
+// Lost-stop watchdog per slot: Steam/Triton rumble is latched, so force a zero report if the host stops
+// refreshing on a particular XInput. The haptics.cpp global watchdog fires too, but it skips while the slot
+// is in the post-reconnect block -- this catches a host that kept streaming while we were blocked.
 void XboxController::task()
 {
-	if ((g_rumbleLow || g_rumbleHigh) &&
-	    millis() - g_rumbleMs > RUMBLE_STUCK_MS) {
-		g_rumbleLow = g_rumbleHigh = 0;
-		hapticSteamRumble(0, 0);
+	for (int s = 0; s < NSLOT; s++) {
+		if (!g_xiSlot[s].inUse)
+			continue;
+		if ((g_xiSlot[s].rumbleLow || g_xiSlot[s].rumbleHigh) &&
+		    millis() - g_xiSlot[s].rumbleMs > RUMBLE_STUCK_MS) {
+			g_xiSlot[s].rumbleLow = g_xiSlot[s].rumbleHigh = 0;
+			hapticSteamRumble(0, 0, (uint8_t)s);
+		}
 	}
 }