Przeglądaj źródła

fix: emulate multiple controllers in non-steam modes

Joshua LeBlanc 4 miesięcy temu
rodzic
commit
deade53535

+ 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[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_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)

+ 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)
 // 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;
@@ -252,7 +253,8 @@ static void hapticCancelPendingOn()
 }
 }
 void haptic82HostReport(const uint8_t *p, uint16_t n, int slot)
 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)
 	if (n < 3)
 		return;
 		return;
 	g_haptic82Ms = millis();
 	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.
 	// 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,
@@ -273,9 +277,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:
@@ -294,10 +301,10 @@ 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, 0))
+	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). Test
 // 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_rqHead = g_rqTail = 0;
 	g_haptic82On = false;
 	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)
 	// boot: block stale Steam 0x82 until link stable (every slot)
 	for (int s = 0; s < NSLOT; s++)
 	for (int s = 0; s < NSLOT; s++)
 		g_hapticBlockUntil[s] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 		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)
 	// no haptics relayed for the next 3s (per-slot)
 	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_hapticBlockUntil[slot] = millis() + HAPTIC_RECONNECT_BLOCK_MS;
 	g_haptic82On = false;
 	g_haptic82On = false;
-	g_rumble80On = false;
+	g_rumble80On[slot] = false;
+	g_rumble80Ms[slot] = 0;
 
 
 	// drop any haptic ON queued before the link came up
 	// drop any haptic ON queued before the link came up
 	hapticCancelPendingOn();
 	hapticCancelPendingOn();
@@ -517,8 +528,14 @@ void hapticTask()
 	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);
+	// 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
 	// 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
 	// 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
 	// 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 haptic82Blocked(int slot = -1);
 bool hapticRelaySlotOk(int slot);
 bool hapticRelaySlotOk(int slot);
 void haptic82HostReport(const uint8_t *p, uint16_t n, int slot = -1);
 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).
 // 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

+ 122 - 73
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[0].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[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[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[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;
 	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[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[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,33 @@ 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);
+	// 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.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);
+	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()
 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 "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[0].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[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[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[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;
 	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);
 	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
 	out[52] = PS5_STATUS_USB;
 	out[52] = PS5_STATUS_USB;
 }
 }
@@ -155,26 +195,32 @@ 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);
+	// 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.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);
+	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()
 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 "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[0].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[0].lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		btn |= 0x40;
 		btn |= 0x40;
-	if ((g_in[0].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[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;
 	out[7] = 0;
 }
 }
 
 
@@ -165,25 +169,35 @@ 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);
+	// 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.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);
+	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()
 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 "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[0].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[0].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[0].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[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.
 	// 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[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
 	// 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++) {
 	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)
@@ -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
 // 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
 // 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
 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 +379,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 +466,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] = 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;
 		break;
 	}
 	}
 	case 0x02: // request device info
 	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[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 +565,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 +575,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 +584,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 +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
 		// 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 +632,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 +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 (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);
+	// 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.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)
+	// restore any persisted Switch motion calibration (per-unit IMU bias correction). Per-slot files.
 	loadUserCal();
 	loadUserCal();
 	swProLoadCfg(); // restore report-rate + gyro-scale settings
 	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()
 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 "mode_xinput.h"
 #include "triton.h"
 #include "triton.h"
 #include "gamepad_util.h"
 #include "gamepad_util.h"
@@ -35,21 +48,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 +80,32 @@ 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. 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,
 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 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 *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 +118,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 +139,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 +215,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 +242,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 +292,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 +345,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,17 +412,18 @@ 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
 	// 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.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");
 	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.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);
@@ -376,18 +433,28 @@ void XboxController::begin()
 void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)
 				uint8_t bodyTlen)
 {
 {
-	(void)slot; // xinput mode is single-controller by design
 	(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);
+		}
 	}
 	}
 }
 }