Explorar o código

Separate configs, haptics toggle etc (#82)

* separate controller configs and add a haptics tgl

* dynamic mounting for multiple controllers

* ensure predictable mounting order

* restore back button mounting
Jari hai 4 meses
pai
achega
3fa93fe327

+ 99 - 43
OpenPuck/OpenPuck.ino

@@ -34,6 +34,9 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "serial_console.h"
 #include "wake_hid.h"
 #include "status_led.h"
+#include "usb_mount.h"
+#include "identity.h"
+#include <stdio.h>
 
 #if CFG_TUD_HID < 4
 #error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
@@ -42,6 +45,37 @@ using namespace Adafruit_LittleFS_Namespace;
 // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
 static uint8_t g_usbCfgDesc[512];
 
+// Per-mode USB serial suffix (modes 1..8: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game D=ds4game).
+static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D' };
+// Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
+static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
+
+// Dynamic re-enumeration (NO MCU reboot): tear down + rebuild the config descriptor presenting `k` connected-
+// controller slots, replaying the fixed interfaces in locked HID-instance order (wake mouse = instance 0,
+// before the slot pool; WebUSB is vendor-class so order-free), then re-attach. RF/firmware state survives.
+// g_usbToBond must already be built for k connected controllers. Called at boot and by usb_mount's watcher.
+void usbReenumerate(uint8_t k)
+{
+	USBDevice.detach();
+	delay(20);
+	USBDevice.clearConfiguration();
+	USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);
+	g_active->usbIdentity(); // clearConfiguration reset VID/PID/strings -- restore them
+	// serial carries the mounted count so the host invalidates its cached config descriptor on a change
+	snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%u", g_unit,
+		 MODE_SUFFIX[(g_usbMode >= 1 && g_usbMode <= 8) ? g_usbMode - 1 :
+							         0],
+		 (unsigned)k);
+	USBDevice.setSerialDescriptor(g_usbSerial);
+	if (s_dynWantWakeMouse)
+		wakeHidAddInterface(); // HID instance 0
+	g_active->mountSlots(k); // mode's fixed HIDs (if any) + k slot interfaces
+	if (s_dynWantWebusb)
+		USBDevice.addInterface(usb_web);
+	USBDevice.setConfigurationAttribute(0x80 | 0x20);
+	USBDevice.attach();
+}
+
 void setup()
 {
 	genSerial();
@@ -81,59 +115,79 @@ void setup()
 	// clean-PS modes skip BOTH the wake mouse and WebUSB -- no config panel / host-wake; chord back to Steam
 	// (back-paddle 4 + A) to reach the panel. Normal MODE_PS5 / MODE_HIDGYRO keep wake + panel.
 	const bool psClean = modeIsCleanPS(g_usbMode);
-	USBDevice.detach();
-	delay(30);
-	if (keepCdc) {
+	const bool dynamic = g_active->dynamicMount();
+
+	if (dynamic) {
+		// Dynamic mount: present only ACTIVELY-CONNECTED controllers; usbReenumerate re-attaches (no reboot)
+		// as the set changes. Emulated modes are never puck; clean-PS drops the wake mouse + WebUSB.
+		s_dynWantWakeMouse = !psClean;
+		s_dynWantWebusb = !psClean;
+		USBDevice.detach();
+		delay(30);
+		USBDevice.clearConfiguration();
 		USBDevice.setConfigurationBuffer(g_usbCfgDesc,
 						 sizeof g_usbCfgDesc);
+		// Lock TinyUSB HID instance indices ONCE, canonical order: wake mouse (HID 0) then the mode's pool.
+		if (s_dynWantWakeMouse)
+			wakeHidBegin();
+		g_active->beginPool();
+		if (s_dynWantWebusb)
+			usb_web.begin();
+		usbMountEnable(true, g_active->maxSlots());
+		usbMountRebuildMap(); // initial connected set (usually empty at cold boot)
+		usbReenumerate(g_usbMountCount); // build the live descriptor + attach
 	} else {
-		USBDevice.clearConfiguration();
+		USBDevice.detach();
+		delay(30);
+		if (keepCdc) {
+			USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+							 sizeof g_usbCfgDesc);
+		} else {
+			USBDevice.clearConfiguration();
 
-		// headroom over the default 256 B cap
-		USBDevice.setConfigurationBuffer(g_usbCfgDesc,
-						 sizeof g_usbCfgDesc);
-	}
+			// headroom over the default 256 B cap
+			USBDevice.setConfigurationBuffer(g_usbCfgDesc,
+							 sizeof g_usbCfgDesc);
+		}
 
-	// Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
-	// identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
-	// identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
-	static const char MODE_SUFFIX[] = {
-		'X', 'N', 'L', 'P', 'S', 'G', 'Q', 'D'
-	}; // modes 1..8 (Q=PS5 game, D=DS4 game)
-	if (puckMode) {
-		USBDevice.setSerialDescriptor(g_unit);
-	} else {
-		// Bond count in serial so Windows invalidates its cached config descriptor if the count changes.
-		snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%d", g_unit,
-			 MODE_SUFFIX[g_usbMode - 1],
-			 bondedSlotCount() > 0 ? bondedSlotCount() : 1);
-		USBDevice.setSerialDescriptor(g_usbSerial);
-	}
+		// Distinct USB serial PER MODE (must be set AFTER clearConfiguration, which nulls it). Hosts cache USB
+		// identity by VID:PID:serial; reusing one serial under a changing VID:PID can make a host refuse the new
+		// identity. Steam keeps the exact unit serial (its pairing identity); the others get a 1-char suffix.
+		if (puckMode) {
+			USBDevice.setSerialDescriptor(g_unit);
+		} else {
+			// Bond count in serial so Windows invalidates its cached config descriptor if the count changes.
+			snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c%d",
+				 g_unit, MODE_SUFFIX[g_usbMode - 1],
+				 bondedSlotCount() > 0 ? bondedSlotCount() : 1);
+			USBDevice.setSerialDescriptor(g_usbSerial);
+		}
 
-	// SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
-	// and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
-	if (puckMode)
-		usb_web.begin();
-	if (puckMode && !keepCdc)
-		wakeHidBegin();
+		// SDL3's Proteus/Triton HIDAPI driver only binds slot HIDs on USB interfaces 2..5. Register WebUSB (IF 0)
+		// and the wake mouse (IF 1) before the four puck slots so hid[0..3] land on IF 2..5 like the real puck.
+		if (puckMode)
+			usb_web.begin();
+		if (puckMode && !keepCdc)
+			wakeHidBegin();
 
-	g_active->begin();
+		g_active->begin();
 
-	// Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
-	// no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
-	// modes so the device stays a single clean HID gamepad (see psClean above).
-	if (!puckMode && !keepCdc && !psClean)
-		wakeHidBegin();
+		// Boot-mouse wake interface for clean (non-puck) modes, and for puck on the one-shot debug boot (CDC on,
+		// no endpoint room for wake mouse on a normal puck boot -- wake is registered above instead). Skipped for PS
+		// modes so the device stays a single clean HID gamepad (see psClean above).
+		if (!puckMode && !keepCdc && !psClean)
+			wakeHidBegin();
 
-	// WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
-	// slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
-	if (!puckMode && !psClean)
-		usb_web.begin();
-	// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
-	USBDevice.setConfigurationAttribute(0x80 | 0x20);
+		// WebUSB config panel -- every mode EXCEPT the PlayStation modes. Puck: registered above (IF 0) before wake +
+		// slots; other clean modes after controller. PS modes omit it to present a genuine single-HID PS controller.
+		if (!puckMode && !psClean)
+			usb_web.begin();
+		// bmAttributes: required(0x80) | remote_wakeup(0x20). Remote Wakeup lets us signal wake-from-sleep.
+		USBDevice.setConfigurationAttribute(0x80 | 0x20);
 
-	// re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
-	USBDevice.attach();
+		// re-attach with the final descriptor (host re-reads it fresh -> deterministic enumeration)
+		USBDevice.attach();
+	}
 	Serial.begin(115200);
 	for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
 		delay(10); // wait up to 3s for USB mount, but NEVER hang
@@ -230,6 +284,7 @@ void loop()
 	t = micros();
 	ledTask();
 	acc[6] += (uint32_t)(micros() - t);
+	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	loops++;
 	if (millis() - secMs >= 1000) {
 		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
@@ -256,5 +311,6 @@ void loop()
 	rfLinkTask();
 	hapticTask();
 	ledTask();
+	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 #endif
 }

+ 49 - 16
OpenPuck/config.cpp

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

+ 49 - 3
OpenPuck/config.h

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

+ 30 - 0
OpenPuck/controllers.h

@@ -55,6 +55,36 @@ class IController {
 	{
 		return false;
 	}
+
+	// ---- Dynamic USB mounting (see usb_mount.h). Emulated modes that present only CONNECTED controllers
+	//      override these; the defaults keep the legacy "begin() mounts everything" behavior for puck/lizard
+	//      and any not-yet-converted mode. ----
+	// true => this mode uses the dynamic mount/unmount path (usbReenumerate driven by the connectivity watcher)
+	// rather than registering its slot interfaces in begin(). maxSlots() then caps the mounted count.
+	virtual bool dynamicMount() const
+	{
+		return false;
+	}
+	// HID-instance budget for slot interfaces (CFG_TUD_HID minus any fixed HID like the wake mouse).
+	virtual uint8_t maxSlots() const
+	{
+		return 0;
+	}
+	// Re-apply this mode's USB identity (VID/PID, manufacturer/product strings, bcdDevice). Called by
+	// usbReenumerate after clearConfiguration (which resets them). begin() also calls it.
+	virtual void usbIdentity()
+	{
+	}
+	// One-time at boot: create the slot interface/HID object pool and lock their TinyUSB instance indices
+	// (begin() each). Adds them to the throwaway descriptor; usbReenumerate then presents the live prefix.
+	virtual void beginPool()
+	{
+	}
+	// (Re)add the first k slot interfaces (a dense prefix of the pool) to the freshly-cleared config descriptor.
+	virtual void mountSlots(uint8_t k)
+	{
+		(void)k;
+	}
 };
 
 // The singleton for a given mode (nullptr if the mode is unknown). Defined in controllers.cpp.

+ 6 - 0
OpenPuck/gamepad_util.cpp

@@ -158,6 +158,12 @@ static void psOrBackCode(uint32_t *b, uint8_t c)
 	case 17:
 		*b |= TB_MUTE;
 		break;
+	case 19:
+		*b |= TB_L2; // left trigger (L2)
+		break;
+	case 20:
+		*b |= TB_R2; // right trigger (R2)
+		break;
 	default:
 		break;
 	}

+ 4 - 0
OpenPuck/gamepad_util.h

@@ -78,6 +78,10 @@ static inline uint32_t tritonFromCode(uint8_t c)
 		return TB_TOUCH;
 	case 17:
 		return TB_MUTE;
+	case 19:
+		return TB_L2; // left trigger (LT / L2 / ZL)
+	case 20:
+		return TB_R2; // right trigger (RT / R2 / ZR)
 	default:
 		return 0;
 	}

+ 22 - 0
OpenPuck/haptics.cpp

@@ -386,6 +386,24 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 // register writes). Brightness (reg 0x2D) is omitted to avoid stomping the LED. The 0x87 frames go out on
 // legacy framing so the controller discards them; the three 0x81 frames are the effective reset. slot=0xFF
 // broadcasts to all connected controllers (the re-init is settings-only and harmless on healthy controllers).
+// One-shot SET_SETTINGS (0x87) write of the controller's GLOBAL trackpad-haptics enable. The controller plays
+// touchpad haptic sequences autonomously (haptics-sequencer-touchpad) gated by settings/haptics/enabled; in the
+// emulated (non-Steam) modes the host never sends SC haptic reports, so the only way to silence the pad buzz is
+// to write this setting. Wire format [id][val u16 LE] -- confirmed from the controller's SET_SETTINGS handler
+// (decomp FUN_0001f61c; id 0x30 == settings/sensors/imu/mode is the cross-check anchor in the same id space).
+// The exact id for settings/haptics/enabled needs a one-time `scmd labels` HW read (candidates from the captured
+// reinit: 0x18 / 0x2e / 0x34 / 0x35). Until it is set, SETTING_HAPTICS_ENABLED stays 0xFF and this is inert.
+#define SETTING_HAPTICS_ENABLED 0xFF
+void hapticSetPadEnabled(uint8_t slot, bool on)
+{
+#if SETTING_HAPTICS_ENABLED != 0xFF
+	uint8_t pl[3] = { SETTING_HAPTICS_ENABLED, (uint8_t)(on ? 1 : 0), 0 };
+	relayEnqueue(0x87, pl, 3, slot);
+#else
+	(void)slot;
+	(void)on;
+#endif
+}
 void hapticReinit(uint8_t slot)
 {
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
@@ -410,6 +428,10 @@ void hapticReinit(uint8_t slot)
 	relayEnqueue(0x87, H35, sizeof H35, slot);
 	relayEnqueue(0x81, T81A, sizeof T81A, slot);
 	relayEnqueue(0x81, T81B, sizeof T81B, slot);
+	// Re-apply the active emulated type's trackpad-haptics preference last (after the haptic-config writes
+	// above, which would otherwise re-enable it). Default-on types send "enable"; Switch (padHaptics=0)
+	// disables. Inert until the setting id is captured.
+	hapticSetPadEnabled(slot, g_padHaptics != 0);
 }
 void hapticInit()
 {

+ 2 - 0
OpenPuck/haptics.h

@@ -116,3 +116,5 @@ void hapticReinit(uint8_t slot = 0xFF);
 // controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
 // Per-slot: only the slot that just reconnected is blocked, the others keep relaying.
 void hapticOnReconnect(int slot);
+// Write the controller's global trackpad-haptics enable setting (per active emulated type). slot 0xFF = broadcast.
+void hapticSetPadEnabled(uint8_t slot, bool on);

+ 41 - 20
OpenPuck/mode_hidgyro.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -84,7 +85,6 @@ static uint16_t hidGyroGetCommon(uint8_t slot, uint8_t rid,
 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;
@@ -102,8 +102,12 @@ static void hidGyroSetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
 	// DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
 	if (id != 0x05 || pn < 5)
 		return;
+	// `slot` is the USB slot the report arrived on -> route rumble to its mapped bond slot.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
 	hapticSteamRumble((uint16_t)p[4] * 257u, (uint16_t)p[3] * 257u,
-			  slot); // DS4: left=low, right=high
+			  (uint8_t)bond); // DS4: left=low, right=high
 }
 // Per-slot callback trampolines -- the Adafruit HID class has ONE _get/_set pair shared across instances,
 // and the lib's tud_hid_*_cb doesn't pass the interface index to the user callback. Generate a
@@ -134,7 +138,8 @@ static ds4_getcb_t const DS4_GETCB[NSLOT] = { hidGyroGet0, hidGyroGet1,
 static ds4_setcb_t const DS4_SETCB[NSLOT] = { hidGyroSet0, hidGyroSet1,
 					      hidGyroSet2, hidGyroSet3 };
 
-static void hidGyroBuild(uint8_t slot, uint8_t out[63])
+// usbSlot drives the per-HID counters; slot (bond) is the controller whose decoded input feeds the report.
+static void hidGyroBuild(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 {
 	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
@@ -147,7 +152,7 @@ static void hidGyroBuild(uint8_t slot, uint8_t out[63])
 	out[4] = psHatNibble(b) | psFaceNibble(b);
 	out[5] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
 	static uint8_t ctr[NSLOT] = { 0 };
-	out[6] = ((ctr[slot]++ & 0x0F) << 4) |
+	out[6] = ((ctr[usbSlot]++ & 0x0F) << 4) |
 		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
 		 ((b & TB_STEAM) ? 0x01 : 0);
 	out[7] = g_in[slot].lt;
@@ -172,7 +177,7 @@ static void hidGyroBuild(uint8_t slot, uint8_t out[63])
 				 &ry);
 		static uint8_t tstamp[NSLOT] = { 0 };
 		out[32] = 1;
-		out[33] = tstamp[slot]++;
+		out[33] = tstamp[usbSlot]++;
 		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
 	} else {
 		out[32] = 0;
@@ -182,21 +187,29 @@ static void hidGyroBuild(uint8_t slot, uint8_t out[63])
 	}
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void HidGyroController::begin()
+{
+}
+// HID budget: clean DS4 (MODE_DS4_GAME) has no wake mouse; normal HIDGYRO keeps it (1 HID).
+uint8_t HidGyroController::maxSlots() const
+{
+	uint8_t cap = modeIsCleanPS(g_usbMode) ? (uint8_t)CFG_TUD_HID :
+						 (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void HidGyroController::usbIdentity()
 {
 	USBDevice.setID(0x054C, 0x05C4);
-
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0120);
 	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
 	USBDevice.setProductDescriptor("Wireless Controller");
+}
+void HidGyroController::beginPool()
+{
 	initDs4Macs();
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
 		g_hidGyro[s].enableOutEndpoint(true);
 		g_hidGyro[s].setReportCallback(DS4_GETCB[s], DS4_SETCB[s]);
 		g_hidGyro[s].setReportDescriptor(GYRO_HID_DESC,
@@ -205,16 +218,24 @@ void HidGyroController::begin()
 		g_hidGyro[s].begin();
 	}
 }
+void HidGyroController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_hidGyro[u]);
+}
 void HidGyroController::task()
 {
-	for (int s = 0; s < NSLOT; s++) {
-		if (!g_hidGyro[s].ready())
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_hidGyro[u].ready())
+			continue;
+		if (millis() - g_gyroLastMs[u] < USB_STREAM_MS)
 			continue;
-		if (millis() - g_gyroLastMs[s] < USB_STREAM_MS)
+		int bond = g_usbToBond[u];
+		if (bond < 0)
 			continue;
-		g_gyroLastMs[s] = millis();
+		g_gyroLastMs[u] = millis();
 		uint8_t p[63];
-		hidGyroBuild((uint8_t)s, p);
-		g_hidGyro[s].sendReport(0x01, p, sizeof p);
+		hidGyroBuild(u, (uint8_t)bond, p);
+		g_hidGyro[u].sendReport(0x01, p, sizeof p);
 	}
 }

+ 8 - 0
OpenPuck/mode_hidgyro.h

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

+ 42 - 18
OpenPuck/mode_ps5.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -124,7 +125,12 @@ static void ps5SetCommon(uint8_t slot, uint8_t rid, hid_report_type_t type,
 	}
 	if (id != 0x02 || pn < 4)
 		return;
-	hapticSteamRumble((uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u, slot);
+	// `slot` here is the USB slot the report arrived on -> route rumble to the bond slot it's mapped to.
+	int bond = (slot < NSLOT) ? g_usbToBond[slot] : -1;
+	if (bond < 0)
+		return;
+	hapticSteamRumble((uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u,
+			  (uint8_t)bond);
 	// DualSense: left=low, right=high
 }
 #define PS5CB(N)                                                               \
@@ -153,7 +159,8 @@ static ps5_getcb_t const PS5_GETCB[NSLOT] = { ps5Get0, ps5Get1, ps5Get2,
 static ps5_setcb_t const PS5_SETCB[NSLOT] = { ps5Set0, ps5Set1, ps5Set2,
 					      ps5Set3 };
 
-static void ps5Build(uint8_t slot, uint8_t out[63])
+// usbSlot drives the per-HID sequence counter; bond is the controller whose decoded input feeds the report.
+static void ps5Build(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 {
 	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
 	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
@@ -166,7 +173,7 @@ static void ps5Build(uint8_t slot, uint8_t out[63])
 	out[4] = g_in[slot].lt;
 	out[5] = g_in[slot].rt;
 	static uint8_t seq[NSLOT] = { 0 };
-	out[6] = seq[slot]++;
+	out[6] = seq[usbSlot]++;
 	out[7] = psHatNibble(b) | psFaceNibble(b);
 	out[8] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
 	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
@@ -191,21 +198,30 @@ static void ps5Build(uint8_t slot, uint8_t out[63])
 	out[52] = PS5_STATUS_USB;
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void Ps5Controller::begin()
+{
+}
+// HID budget: clean PS modes have no wake mouse (CFG_TUD_HID slots); normal PS5 keeps the wake mouse (1 HID).
+uint8_t Ps5Controller::maxSlots() const
+{
+	uint8_t cap = modeIsCleanPS(g_usbMode) ? (uint8_t)CFG_TUD_HID :
+						 (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void Ps5Controller::usbIdentity()
 {
 	USBDevice.setID(0x054C, 0x0CE6);
-
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0110 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0110);
 	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
 	USBDevice.setProductDescriptor("DualSense Wireless Controller");
+}
+// One-time: create the DualSense HID pool and lock instance indices (wake mouse, if any, was begun first).
+void Ps5Controller::beginPool()
+{
 	initPs5Macs();
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
 		g_ps5[s].enableOutEndpoint(true);
 		g_ps5[s].setReportCallback(PS5_GETCB[s], PS5_SETCB[s]);
 		g_ps5[s].setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
@@ -213,16 +229,24 @@ void Ps5Controller::begin()
 		g_ps5[s].begin();
 	}
 }
+void Ps5Controller::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_ps5[u]);
+}
 void Ps5Controller::task()
 {
-	for (int s = 0; s < NSLOT; s++) {
-		if (!g_ps5[s].ready())
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_ps5[u].ready())
+			continue;
+		if (millis() - g_ps5LastMs[u] < USB_STREAM_MS)
 			continue;
-		if (millis() - g_ps5LastMs[s] < USB_STREAM_MS)
+		int bond = g_usbToBond[u];
+		if (bond < 0)
 			continue;
-		g_ps5LastMs[s] = millis();
+		g_ps5LastMs[u] = millis();
 		uint8_t p[63];
-		ps5Build((uint8_t)s, p);
-		g_ps5[s].sendReport(0x01, p, sizeof p);
+		ps5Build(u, (uint8_t)bond, p);
+		g_ps5[u].sendReport(0x01, p, sizeof p);
 	}
 }

+ 8 - 0
OpenPuck/mode_ps5.h

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

+ 47 - 23
OpenPuck/mode_switch_hori.cpp

@@ -3,6 +3,7 @@
 #include "gamepad_util.h"
 #include "config.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 
@@ -31,7 +32,7 @@ static const uint8_t SWITCH_HID_DESC[] = {
 static Adafruit_USBD_HID g_switch[NSLOT];
 static unsigned long g_swLastMs[NSLOT] = { 0 };
 
-// back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
+// back-paddle / QAM code (g_back[], g_qamMap) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home) 18=Capture
 static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
 			     uint16_t fY)
 {
@@ -58,6 +59,12 @@ static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
 		return 0x200;
 	case 11:
 		return 0x1000;
+	case 18:
+		return 0x2000; // Capture / Screenshot (Switch-only target)
+	case 19:
+		return 0x40; // ZL (left trigger)
+	case 20:
+		return 0x80; // ZR (right trigger)
 	default:
 		return 0;
 	}
@@ -81,10 +88,9 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 {
 	uint32_t b = g_in[slot].buttons;
 	uint16_t btn = 0;
-	if (g_qamMap && (b & TB_QAM)) {
-		b &= ~(uint32_t)TB_QAM;
-		b |= tritonFromCode(g_qamMap);
-	}
+	// QAM (3 dots) remap: the configured code is applied to the Switch output just like a back paddle below
+	// (so Capture(18) and every Switch target work). 0 = unmapped -> QAM does nothing on Switch.
+	bool qam = g_qamMap && (b & TB_QAM);
 	// Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
 	if ((b & CHORD_BACK4) == CHORD_BACK4)
 		b &= ~(uint32_t)(TB_A | TB_X | TB_Y);
@@ -127,6 +133,8 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 		btn |= codeToSwitch(g_back[2], fA, fB, fX, fY);
 	if (b & TB_R5)
 		btn |= codeToSwitch(g_back[3], fA, fB, fX, fY);
+	if (qam)
+		btn |= codeToSwitch(g_qamMap, fA, fB, fX, fY);
 	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF,
 	     r = b & TB_DRT; // hat: 0=N..7=NW, 8=neutral
 	if (b & TB_L4)
@@ -137,6 +145,8 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 		backCodeToHatDirs(g_back[2], u, d, l, r);
 	if (b & TB_R5)
 		backCodeToHatDirs(g_back[3], u, d, l, r);
+	if (qam)
+		backCodeToHatDirs(g_qamMap, u, d, l, r);
 	uint8_t hat = 8;
 	if (u && r)
 		hat = 1;
@@ -165,20 +175,27 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 	out[7] = 0;
 }
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void SwitchHoriController::begin()
+{
+}
+// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the HORIPAD pool.
+uint8_t SwitchHoriController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SwitchHoriController::usbIdentity()
 {
 	USBDevice.setID(0x0F0D, 0x0092);
-
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0210 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0210);
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
+}
+void SwitchHoriController::beginPool()
+{
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
 		g_switch[s].enableOutEndpoint(true);
 		g_switch[s].setReportDescriptor(SWITCH_HID_DESC,
 						sizeof SWITCH_HID_DESC);
@@ -186,20 +203,27 @@ void SwitchHoriController::begin()
 		g_switch[s].begin();
 	}
 }
+void SwitchHoriController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_switch[u]);
+}
 void SwitchHoriController::task()
 {
-	// stream the 8-byte HORIPAD report at ~250Hz 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())
+	// stream the 8-byte HORIPAD report at ~250Hz per CONNECTED controller. usbSlot u -> bond slot for input;
+	// switchBuildHoripad reads g_in[bond] (no per-USB-slot state in the HORIPAD report).
+	for (uint8_t u = 0; u < g_usbMountCount; u++) {
+		if (!g_switch[u].ready())
+			continue;
+		if (millis() - g_swLastMs[u] < USB_STREAM_MS)
 			continue;
-		if (millis() - g_swLastMs[s] < USB_STREAM_MS)
+		int bond = g_usbToBond[u];
+		if (bond < 0)
 			continue;
-		g_swLastMs[s] = millis();
+		g_swLastMs[u] = millis();
 		uint8_t p[8];
-		switchBuildHoripad((uint8_t)s, p);
-		g_switch[s].sendReport(0, p,
+		switchBuildHoripad((uint8_t)bond, p);
+		g_switch[u].sendReport(0, p,
 				       sizeof p); // report-id-less descriptor
 	}
 }

+ 8 - 0
OpenPuck/mode_switch_hori.h

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

+ 64 - 29
OpenPuck/mode_switch_pro.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Adafruit_LittleFS.h>
 #include <InternalFileSystem.h>
@@ -22,7 +23,7 @@ SwitchProController g_switchPro;
 #define SW_ACCEL_DIV 4
 
 uint8_t g_swProRate =
-	1; // 0 = 66Hz, 1 = 120Hz (default), 2 = full (~250Hz / USB_STREAM_MS)
+	2; // 0 = 66Hz, 1 = 120Hz, 2 = full (~250Hz / USB_STREAM_MS, default)
 uint8_t g_swGyroScale10 = 10; // gyro sensitivity x10 (10 = 1.0x)
 
 // Persist the two Switch Pro motion settings in their own tiny file so they never trigger a global-Cfg reset.
@@ -44,8 +45,8 @@ static void swProLoadCfg()
 	uint8_t b[3];
 	if (f.open(SWPRO_CFG_FILE, FILE_O_READ)) {
 		if (f.read(b, 3) == 3 && b[0] == 0x01) {
-			// 0=66Hz, 1=120Hz, 2=full; bad value -> default 120Hz
-			g_swProRate = (b[1] <= 2) ? b[1] : 1;
+			// 0=66Hz, 1=120Hz, 2=full; bad value -> default full
+			g_swProRate = (b[1] <= 2) ? b[1] : 2;
 			g_swGyroScale10 =
 				(b[2] >= 5 && b[2] <= 30) ?
 					b[2] :
@@ -97,6 +98,7 @@ static const uint8_t SWPRO_HID_DESC[] = {
 #define JC_BTN_RSTICK (1u << 10)
 #define JC_BTN_LSTICK (1u << 11)
 #define JC_BTN_HOME (1u << 12)
+#define JC_BTN_CAPTURE (1u << 13)
 #define JC_BTN_DOWN (1u << 16)
 #define JC_BTN_UP (1u << 17)
 #define JC_BTN_RIGHT (1u << 18)
@@ -129,6 +131,12 @@ static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
 		return JC_BTN_PLUS;
 	case 11:
 		return JC_BTN_HOME;
+	case 18:
+		return JC_BTN_CAPTURE; // Capture / Screenshot (Switch-only target)
+	case 19:
+		return JC_BTN_ZL; // left trigger
+	case 20:
+		return JC_BTN_ZR; // right trigger
 	case 12:
 		return JC_BTN_UP;
 	case 13:
@@ -164,6 +172,14 @@ static void initJcMacs()
 	}
 	g_jcMacInit = true;
 }
+// Dynamic mount: the handshake/state arrays are keyed by USB slot, but the controller INPUT (and rumble target)
+// belongs to the bond slot the USB slot is mapped to. This resolves usbSlot -> bondSlot (identity fallback if
+// somehow unmapped, e.g. legacy paths).
+static inline uint8_t jcBondOf(uint8_t usbSlot)
+{
+	int b = (usbSlot < NSLOT) ? g_usbToBond[usbSlot] : -1;
+	return (b >= 0) ? (uint8_t)b : usbSlot;
+}
 static uint16_t jcRumbleAmp(const uint8_t r[4])
 {
 	// Nintendo packs a high band (r[0],r[1]) and low band (r[2],r[3]) of frequency+amplitude. We only need a
@@ -189,7 +205,7 @@ static void jcRumble(uint8_t slot, const uint8_t *p, uint16_t pn)
 		return;
 	g_jcLastLo[slot] = lo;
 	g_jcLastHi[slot] = hi;
-	hapticSteamRumble(lo, hi, slot);
+	hapticSteamRumble(lo, hi, jcBondOf(slot)); // route to the mapped controller
 }
 static int jcStick12(int16_t v, bool inv)
 { // steam int16 (center 0) -> 12-bit (center 0x800), clamped
@@ -208,11 +224,10 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
 // shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
 static void jcInputPrefix(uint8_t slot, uint8_t *out)
 {
-	uint32_t b = g_in[slot].buttons;
-	if (g_qamMap && (b & TB_QAM)) {
-		b &= ~(uint32_t)TB_QAM;
-		b |= tritonFromCode(g_qamMap);
-	}
+	uint8_t bond = jcBondOf(slot); // input data comes from the mapped controller; timer/state stay per USB slot
+	uint32_t b = g_in[bond].buttons;
+	// QAM (3 dots) remap -> applied via codeToJc below like a back paddle (so Capture(18)/any target work).
+	bool qam = g_qamMap && (b & TB_QAM);
 	if ((b & CHORD_BACK4) == CHORD_BACK4)
 		b &= ~(uint32_t)(TB_A | TB_B | TB_X | TB_Y);
 	uint32_t fA = g_abSwap ? JC_BTN_B : JC_BTN_A,
@@ -232,9 +247,9 @@ static void jcInputPrefix(uint8_t slot, uint8_t *out)
 		jc |= JC_BTN_L;
 	if (b & TB_RB)
 		jc |= JC_BTN_R;
-	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & 0x8000000u))
+	if ((g_in[bond].lt >= SW_TRIG_ON) || (b & 0x8000000u))
 		jc |= JC_BTN_ZL;
-	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & 0x800000u))
+	if ((g_in[bond].rt >= SW_TRIG_ON) || (b & 0x800000u))
 		jc |= JC_BTN_ZR;
 	if (b & TB_VIEW)
 		jc |= JC_BTN_PLUS;
@@ -262,6 +277,8 @@ static void jcInputPrefix(uint8_t slot, 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);
+	if (qam)
+		jc |= codeToJc(g_qamMap, fA, fB, fX, fY);
 	out[0] = g_jcTimer[slot]++;
 
 	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
@@ -274,13 +291,14 @@ static void jcInputPrefix(uint8_t slot, 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[slot].lx, g_in[slot].ly);
-	jcPackStick(out + 8, g_in[slot].rx, g_in[slot].ry);
+	jcPackStick(out + 5, g_in[bond].lx, g_in[bond].ly);
+	jcPackStick(out + 8, g_in[bond].rx, g_in[bond].ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	out[11] = 0x09;
 }
 static void switchProBuild(uint8_t slot, uint8_t out[63])
 {
+	uint8_t bond = jcBondOf(slot); // IMU data comes from the mapped controller
 	memset(out, 0, 63);
 	jcInputPrefix(slot, out);
 	// Gyro slot order follows hid-nintendo: raw+6 = ROLL, raw+8 = PITCH, raw+10 = YAW. Source routing (a proper
@@ -296,14 +314,14 @@ static void switchProBuild(uint8_t slot, 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[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);
+	int16_t aX = (int16_t)(g_in[bond].ay / SW_ACCEL_DIV);
+	int16_t aY = (int16_t)((-(int16_t)g_in[bond].ax) / SW_ACCEL_DIV);
+	int16_t aZ = (int16_t)(g_in[bond].az / SW_ACCEL_DIV);
 
 	// gyro outputs scaled by the user sensitivity factor
-	int16_t groll = gscale((int16_t)g_in[slot].gy);
-	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[slot].gx));
-	int16_t gyaw = gscale((int16_t)g_in[slot].gz);
+	int16_t groll = gscale((int16_t)g_in[bond].gy);
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[bond].gx));
+	int16_t gyaw = gscale((int16_t)g_in[bond].gz);
 	for (int k = 0; k < 3; k++) {
 		int o = 12 + k * 12;
 		out[o + 0] = aX & 0xFF;
@@ -680,24 +698,31 @@ typedef void (*jc_setcb_t)(uint8_t, hid_report_type_t, uint8_t const *,
 			   uint16_t);
 static jc_setcb_t const JC_SETCB[NSLOT] = { jcSet0, jcSet1, jcSet2, jcSet3 };
 
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void SwitchProController::begin()
+{
+}
+// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the Pro Controller pool.
+uint8_t SwitchProController::maxSlots() const
+{
+	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	return cap < NSLOT ? cap : (uint8_t)NSLOT;
+}
+void SwitchProController::usbIdentity()
 {
 	USBDevice.setID(0x057E, 0x2009);
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0220 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0220);
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setProductDescriptor("Pro Controller");
+}
+void SwitchProController::beginPool()
+{
 	jcBuildStickCal();
-
 	loadUserCal();
 	swProLoadCfg();
 	initJcMacs();
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
+	uint8_t pool = maxSlots();
+	for (uint8_t s = 0; s < pool; s++) {
 		g_swPro[s].enableOutEndpoint(true);
 		g_swPro[s].setReportCallback(NULL, JC_SETCB[s]);
 		g_swPro[s].setReportDescriptor(SWPRO_HID_DESC,
@@ -706,9 +731,19 @@ void SwitchProController::begin()
 		g_swPro[s].begin();
 	}
 }
+void SwitchProController::mountSlots(uint8_t k)
+{
+	for (uint8_t u = 0; u < k; u++) {
+		// Each re-enumeration restarts the host handshake -- clear this USB slot's gate + reply FIFO so we
+		// don't stream 0x30 before the (new) host has re-selected report mode.
+		g_swProReportMode[u] = 0;
+		g_jcQh[u] = g_jcQt[u] = 0;
+		USBDevice.addInterface(g_swPro[u]);
+	}
+}
 void SwitchProController::task()
 {
-	for (int s = 0; s < NSLOT; s++) {
+	for (uint8_t s = 0; s < g_usbMountCount; s++) {
 		if (!g_swPro[s].ready())
 			continue;
 		// Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst

+ 8 - 0
OpenPuck/mode_switch_pro.h

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

+ 62 - 23
OpenPuck/mode_xinput.cpp

@@ -5,15 +5,17 @@
 // for the right trackpad. Host binds by VID/PID (045E:028E) + the FF/5D/01 interface. The OUT endpoint carries
 // rumble, relayed to the controller as a haptic by task().
 //
-// Multi-controller: one XInput interface per bonded controller slot. The single class driver routes xfer/open
-// callbacks by endpoint address into the per-slot state. The right-pad mouse is a single shared interface
-// (only slot 0's right-pad drives it). Number of registered interfaces matches bondedSlotCount() at boot.
+// Multi-controller: one XInput interface per CONNECTED controller (dynamic mount -- see usb_mount.h; the device
+// re-enumerates without rebooting as controllers connect/disconnect). The single class driver routes xfer/open
+// callbacks by endpoint address into the per-bond-slot state; xi_open claims slots in connection order. The
+// right-pad mouse is a single shared interface (only bond slot 0's right-pad drives it).
 #include "mode_xinput.h"
 #include "triton.h"
 #include "gamepad_util.h"
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "usb_mount.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
@@ -62,6 +64,9 @@ struct XiSlot {
 	volatile bool inUse;
 };
 static XiSlot g_xiSlot[NSLOT];
+// Dynamic mount: xi_open is called once per XInput interface in descriptor order; this counts them so the
+// u-th interface claims the u-th connected controller (g_usbToBond[u]). Reset on every (re)enumeration.
+static uint8_t g_xiOpenU = 0;
 // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop)
 #define RUMBLE_STUCK_MS 2500u
 
@@ -77,6 +82,7 @@ static bool xi_deinit(void)
 static void xi_reset(uint8_t rhport)
 {
 	(void)rhport;
+	g_xiOpenU = 0; // restart per-interface claim ordering for this enumeration
 	for (int s = 0; s < NSLOT; s++) {
 		g_xiSlot[s].itf = 0;
 		g_xiSlot[s].epIn = g_xiSlot[s].epOut = 0;
@@ -92,15 +98,18 @@ static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
 	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
 	      itf->bInterfaceProtocol == 0x01))
 		return 0;
-	int nBonds = bondedSlotCount();
-	int slot = -1;
-	for (int s = 0; s < NSLOT; s++) {
-		if (!g_xiSlot[s].inUse && (nBonds == 0 || g_slot[s].used)) {
-			slot = s;
-			break;
-		}
+	// The u-th XInput interface in the descriptor serves the u-th connected controller. g_xiSlot is keyed by
+	// BOND slot so onReport45(bond)/rumble route straight through; only connected bonds get an interface.
+	uint8_t u = g_xiOpenU++;
+	int slot = (u < g_usbMountCount) ? g_usbToBond[u] : -1;
+	if (slot < 0) { // fallback (e.g. map not built): first free slot
+		for (int s = 0; s < NSLOT; s++)
+			if (!g_xiSlot[s].inUse) {
+				slot = s;
+				break;
+			}
 	}
-	if (slot < 0)
+	if (slot < 0 || slot >= NSLOT || g_xiSlot[slot].inUse)
 		return 0;
 	XiSlot &S = g_xiSlot[slot];
 	S.itf = itf->bInterfaceNumber;
@@ -343,6 +352,23 @@ static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 	uint8_t lt = trigU8(u16off(r, 4)),
 		rt = trigU8(u16off(
 			r, 6)); // triggers u16 (half-scale) -> full-range u8
+	// Trigger remaps (codes 19=LT, 20=RT): XInput triggers are analog bytes, not buttons, so a back paddle /
+	// QAM mapped to a trigger pulls it full. QAM arrives as TB_L2/TB_R2 (folded into b via tritonFromCode);
+	// back paddles are matched by their configured code.
+	if (b & TB_L2)
+		lt = 0xFF;
+	if (b & TB_R2)
+		rt = 0xFF;
+	const uint8_t bc[4] = { g_back[0], g_back[1], g_back[2], g_back[3] };
+	const uint32_t bm[4] = { TB_L4, TB_R4, TB_L5, TB_R5 };
+	for (int i = 0; i < 4; i++) {
+		if (!(b & bm[i]))
+			continue;
+		if (bc[i] == 19)
+			lt = 0xFF;
+		else if (bc[i] == 20)
+			rt = 0xFF;
+	}
 	xinputSend(slot, btn, lt, rt, (int16_t)s16off(r, 8),
 		   (int16_t)s16off(r, 10), // L stick X/Y
 		   (int16_t)s16off(r, 12),
@@ -408,29 +434,42 @@ static void rfXboxMouse(const uint8_t *r)
 }
 
 // ===================== IController =====================
+// Dynamic-mount mode: begin() is unused (setup() calls beginPool()+usbReenumerate instead).
 void XboxController::begin()
+{
+}
+// XInput slot interfaces are a custom (non-HID) class, so they don't draw on the CFG_TUD_HID budget (only the
+// wake mouse + right-pad mouse do). Slots are limited by NSLOT / USB endpoints, not HID instances.
+uint8_t XboxController::maxSlots() const
+{
+	return (uint8_t)NSLOT;
+}
+void XboxController::usbIdentity()
 {
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
-	// bcdDevice encodes the bonded count so Windows re-reads the config descriptor when the count changes.
-	int n = bondedSlotCount();
-	USBDevice.setDeviceVersion(
-		(uint16_t)(0x0120 + (uint16_t)(n > 0 ? n - 1 : 0)));
+	USBDevice.setDeviceVersion(0x0120);
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");
-	for (int s = 0; s < NSLOT; s++) {
-		if (n > 0 && !g_slot[s].used)
-			continue;
-		if (n == 0 && s > 0)
-			break;
-		g_xinput[s].setStringDescriptor("Controller");
-		g_xinput[s].begin();
-	}
+}
+void XboxController::beginPool()
+{
+	// Lock the right-pad mouse HID instance (wake mouse was begun first by setup -> this is HID instance 1).
 	g_mouse.setStringDescriptor("OpenPuck Mouse");
 	g_mouse.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);
 	g_mouse.setReportDescriptor(MOUSE_HID_DESC, sizeof MOUSE_HID_DESC);
 	g_mouse.setPollInterval(1);
 	g_mouse.begin();
+	for (int s = 0; s < NSLOT; s++)
+		g_xinput[s].setStringDescriptor("Controller");
+}
+void XboxController::mountSlots(uint8_t k)
+{
+	// Right-pad mouse first (fixed HID), then one XInput interface per connected controller (claimed in
+	// order by xi_open). The wake mouse + WebUSB are added around this by usbReenumerate.
+	USBDevice.addInterface(g_mouse);
+	for (uint8_t u = 0; u < k; u++)
+		USBDevice.addInterface(g_xinput[u]);
 }
 void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				uint8_t bodyTlen)

+ 8 - 0
OpenPuck/mode_xinput.h

@@ -16,5 +16,13 @@ class XboxController : public IController {
 
 	// legacy XInput rumble -> haptic relay + stuck-rumble watchdog
 	void task() override;
+	bool dynamicMount() const override
+	{
+		return true;
+	}
+	uint8_t maxSlots() const override;
+	void usbIdentity() override;
+	void beginPool() override;
+	void mountSlots(uint8_t k) override;
 };
 extern XboxController g_xboxCtl;

+ 32 - 15
OpenPuck/serial_console.cpp

@@ -214,34 +214,51 @@ void serialConsolePoll()
 					"# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",
 					g_mFric);
 			} else if (line[0] == 'W') {
-				g_abSwap = !g_abSwap;
-				saveCfg();
-				Serial.printf(
-					"# A/B + X/Y swap %s (Nintendo layout)\n",
-					g_abSwap ? "ON" : "off");
+				// A/B swap edits the ACTIVE emulated type (Xbox/Switch/DS4/DS5); puck modes have none.
+				if (g_etype < ET_COUNT) {
+					g_type[g_etype].abSwap =
+						!g_type[g_etype].abSwap;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# A/B + X/Y swap %s (Nintendo layout) [type %u]\n",
+						g_abSwap ? "ON" : "off", g_etype);
+				} else
+					Serial.println(
+						"# A/B swap N/A in this mode (Steam/Lizard are not emulated types)");
 			} else if (line[0] == 'K') {
 				int i = line[1] - '0';
 				uint8_t code = strtoul(line + 2, 0, 10);
-				if (i >= 0 && i < 4) {
-					g_back[i] = code;
+				// back paddles edit the ACTIVE emulated type. code 18 = Capture/Screenshot (Switch only).
+				if (i >= 0 && i < 4 && g_etype < ET_COUNT) {
+					g_type[g_etype].back[i] = code;
+					applyActiveType();
 					saveCfg();
 					Serial.printf(
-						"# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",
+						"# back[%d] (%s) -> code %u [type %u]  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute 18=Capture(Switch) 19=LT/L2/ZL 20=RT/R2/ZR]\n",
 						i,
 						(const char *[]){ "L4", "R4",
 								  "L5",
 								  "R5" }[i],
-						code);
-				} else
+						code, g_etype);
+				} else if (g_etype >= ET_COUNT)
+					Serial.println(
+						"# back map N/A in this mode (Steam/Lizard are not emulated types)");
+				else
 					Serial.println(
 						"# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)");
 			} else if (line[0] == 'Q') {
 				uint8_t code = strtoul(line + 1, 0, 10);
-				g_qamMap = code;
-				saveCfg();
-				Serial.printf(
-					"# QAM -> code %u  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",
-					code);
+				if (g_etype < ET_COUNT) {
+					g_type[g_etype].qamMap = code;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# QAM -> code %u [type %u]  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute 18=Capture(Switch) 19=LT/L2/ZL 20=RT/R2/ZR]\n",
+						code, g_etype);
+				} else
+					Serial.println(
+						"# QAM map N/A in this mode (Steam/Lizard are not emulated types)");
 			} else if (line[0] == 'J') {
 				char *sp = 0;
 				uint8_t id = strtoul(line + 1, &sp, 0);

+ 4 - 0
OpenPuck/triton.h

@@ -32,6 +32,10 @@
 #define TB_RPADC 0x400000u
 #define TB_LPADT 0x2000000u
 #define TB_LPADC 0x4000000u
+// Trigger full-pull / click bits (the controller sets these at a full ZL/ZR pull; also the target of a back-
+// paddle / QAM remap to LT/RT). psShouldersByte / the Switch builders already read these as L2/R2 / ZL/ZR.
+#define TB_R2 0x800000u
+#define TB_L2 0x8000000u
 
 // Virtual PS targets, settable ONLY via a back-paddle/QAM remap (psOrBackCode / tritonFromCode), never by the
 // controller itself. They live on bits 30/31 -- the only bits the SC2 0x45 report does NOT use (see PROTOCOL.md

+ 127 - 0
OpenPuck/usb_mount.cpp

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

+ 38 - 0
OpenPuck/usb_mount.h

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

+ 5 - 0
OpenPuck/wake_hid.cpp

@@ -16,3 +16,8 @@ void wakeHidBegin()
 	g_wakeHid.setPollInterval(10);
 	g_wakeHid.begin();
 }
+
+void wakeHidAddInterface()
+{
+	TinyUSBDevice.addInterface(g_wakeHid);
+}

+ 5 - 1
OpenPuck/wake_hid.h

@@ -14,5 +14,9 @@
 // CDC and skips this interface (no endpoint room for both) -- see config.h (g_debugCdcThisBoot).
 #pragma once
 
-// register the boot-keyboard wake interface (call from setup() for clean modes)
+// register the boot-keyboard wake interface (call from setup() for clean modes). Locks its TinyUSB HID
+// instance index -- for dynamic-mount modes call this BEFORE the slot pool so the wake mouse is HID instance 0.
 void wakeHidBegin();
+// Re-add the (already-begun) wake-mouse interface to a freshly-cleared config descriptor, for a dynamic
+// re-enumeration that doesn't reboot. begin() is once-only; this re-emits the interface descriptor.
+void wakeHidAddInterface();

+ 57 - 10
OpenPuck/webusb_config.cpp

@@ -11,15 +11,17 @@
 
 Adafruit_USBD_WebUSB usb_web;
 
-// blob payload = [ver=8][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
+// blob payload = [ver=9][mode][mDiv][mFric][qamMap(active)][abSwap(active)][back0..3(active)][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
 //                [gitDirty][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
 //                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
-// v8 extends to 73 bytes (75 total incl header); browser reads with transferIn(128) to span the two USB-FS packets.
-#define WB_PAYLEN 73
+//                [v9: per-type cfg, 4x7B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics}]
+// p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v9 extends to 101 bytes (103 total incl header);
+// browser reads with transferIn(128) to span the two USB-FS packets.
+#define WB_PAYLEN 101
 static void webusbSendBlob()
 {
 	if (!usb_web.connected())
@@ -33,8 +35,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 8;
+	// protocol version (9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 9;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -113,6 +115,17 @@ static void webusbSendBlob()
 			p[65 + s * 3] = g_linkRssi[s];
 		}
 	}
+	// per-emulated-type button config (protocol v9): 4 types x 7 bytes from p[75]
+	for (int et = 0; et < ET_COUNT; et++) {
+		uint8_t *q = &p[75 + et * 7];
+		q[0] = g_type[et].back[0];
+		q[1] = g_type[et].back[1];
+		q[2] = g_type[et].back[2];
+		q[3] = g_type[et].back[3];
+		q[4] = g_type[et].qamMap;
+		q[5] = g_type[et].abSwap;
+		q[6] = g_type[et].padHaptics;
+	}
 	usb_web.write(p, sizeof p);
 	usb_web.flush();
 }
@@ -241,6 +254,30 @@ void webusbPoll()
 
 				// every settable field persists (poll rate is no longer settable)
 				bool persist = true;
+				// per-type cfg writes (protocol v9): field = 40 + et*8 + k, k: 0..3 back, 4 qam, 5 abSwap,
+				// 6 padHaptics. Edits g_type[et]; refresh the live mirrors if it's the active type.
+				if (f >= 40 && f < 40 + ET_COUNT * 8) {
+					uint8_t et = (uint8_t)((f - 40) / 8),
+						k = (uint8_t)((f - 40) % 8);
+					if (et < ET_COUNT) {
+						if (k < 4)
+							g_type[et].back[k] = v;
+						else if (k == 4)
+							g_type[et].qamMap = v;
+						else if (k == 5)
+							g_type[et].abSwap = v ? 1 : 0;
+						else if (k == 6)
+							g_type[et].padHaptics =
+								v ? 1 : 0;
+						if (et == g_etype)
+							applyActiveType();
+					}
+					saveCfg();
+					webusbSendBlob();
+					memmove(buf, buf + need, n - need);
+					n -= need;
+					continue;
+				}
 				switch (f) {
 				case 1:
 					g_mDiv = v < 4 ? 4 : v;
@@ -249,14 +286,21 @@ void webusbPoll()
 					g_mFric = v > 99 ? 99 : v;
 					break;
 				// case 3 (padSmooth) removed -- Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
+				// Legacy single-value fields (4 abSwap, 5-8 back, 21 qam) edit the ACTIVE emulated type.
 				case 4:
-					g_abSwap = v ? 1 : 0;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].abSwap = v ? 1 : 0;
+						applyActiveType();
+					}
 					break;
 				case 5:
 				case 6:
 				case 7:
 				case 8:
-					g_back[f - 5] = v;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].back[f - 5] = v;
+						applyActiveType();
+					}
 					break;
 				// case 9 (pollU100) removed -- poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
 
@@ -310,9 +354,12 @@ void webusbPoll()
 					NVIC_SystemReset();
 					break;
 
-				// QAM physical button remap code (0=default/unmapped)
+				// QAM physical button remap code (0=default/unmapped) -- active emulated type
 				case 21:
-					g_qamMap = v;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].qamMap = v;
+						applyActiveType();
+					}
 					break;
 
 				// rumble strength % (0=off, 100=1x, 200=double)
@@ -322,7 +369,7 @@ void webusbPoll()
 
 				// Switch Pro report rate (0=66Hz,1=120Hz,2=full)
 				case 23:
-					g_swProRate = (v <= 2) ? v : 1;
+					g_swProRate = (v <= 2) ? v : 2;
 					swProSaveCfg();
 					persist = false;
 					break;

+ 93 - 31
docs/index.html

@@ -154,23 +154,15 @@
     <div class="card">
       <h2>Switch Pro motion</h2>
       <div class="row"><label>Report rate</label><select id="swRate"></select></div>
-      <p class="note" style="margin:0 0 6px">120 Hz (default) is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback. Full rate (~250 Hz) is lowest-latency but non-genuine — use only if your console tolerates it.</p>
+      <p class="note" style="margin:0 0 6px">Full rate (~250 Hz, default) is lowest-latency. 120 Hz is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback — drop to it only if your console rejects the faster rates.</p>
       <div class="row"><label>Gyro scale</label><select id="swGyro"></select></div>
       <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
     </div>
 
     <div class="card">
       <h2>Button mapping</h2>
-      <div class="row"><label>A/B + X/Y swap</label>
-        <button id="abSwap">off</button>
-        <span class="note" style="flex:1">Nintendo layout (affects Xbox &amp; Switch modes)</span>
-      </div>
-      <div class="row"><label>L4 (back upper-left)</label><select class="back" data-i="0"></select></div>
-      <div class="row"><label>R4 (back upper-right)</label><select class="back" data-i="1"></select></div>
-      <div class="row"><label>L5 (back lower-left)</label><select class="back" data-i="2"></select></div>
-      <div class="row"><label>R5 (back lower-right)</label><select class="back" data-i="3"></select></div>
-      <div class="row"><label>QAM (3 dots)</label><select id="qamMap"></select></div>
-      <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes. QAM remap applies to all modes (0 = default per-mode behavior).</p>
+      <p class="note" style="margin-top:0">Separate mappings per emulated controller type — each only offers targets that exist on that controller. The type matching the current mode is highlighted. Steam &amp; Lizard are native puck modes (Steam does its own remapping) and aren't configured here.</p>
+      <div id="typeCfgs"></div>
     </div>
 
     <div class="card logonly">
@@ -192,25 +184,86 @@
 <script>
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)"];
 const CHORD_FIELD = [17, 18, 19];
-const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Quick Access Menu"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"],[16,"PS Touch Click"],[17,"PS5 Mute"]];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
-// populate back-paddle + chord dropdowns
-for(const sel of document.querySelectorAll("select.back")){
-  for(const [c,n] of BTN_CODES){ const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
+// Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
+// only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*8 + k
+// (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics). Blob v9 reads them back at p[75 + et*7 + ...].
+const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
+const TYPE_DEFS = [
+  {key:"XBOX", name:"Xbox", labels:{1:"A",2:"B",3:"X",4:"Y",5:"LB",6:"RB",7:"L3",8:"R3",9:"Back",10:"Start",11:"Guide",19:"LT",20:"RT",...DPAD}},
+  {key:"SWITCH", name:"Switch", labels:{1:"A",2:"B",3:"X",4:"Y",5:"L",6:"R",7:"L-Stick",8:"R-Stick",9:"Minus",10:"Plus",11:"Home",19:"ZL",20:"ZR",...DPAD,18:"Capture / Screenshot"}},
+  {key:"DS4", name:"DS4", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click"}},
+  {key:"DS5", name:"DS5", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click",17:"Mute"}},
+];
+// Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
+function etypeForMode(m){ switch(m){case 1:return 0; case 2:case 4:return 1; case 6:case 8:return 2; case 5:case 7:return 3; default:return -1;} }
+const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
+const typeEls = []; // per-type {sec, abSwap, pad, back[4], qam}
+function mkSelect(def, includeNone){
+  const sel=document.createElement("select");
+  if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
+  else { const o=document.createElement("option"); o.value=0; o.textContent="Default (per-mode)"; sel.appendChild(o); }
+  for(const c of Object.keys(def.labels).map(Number).sort((a,b)=>a-b)){
+    const o=document.createElement("option"); o.value=c; o.textContent=def.labels[c]; sel.appendChild(o); }
+  return sel;
 }
-{ const sel = document.getElementById("qamMap");
-  const d = document.createElement("option"); d.value=0; d.textContent="Default (varies by mode)"; sel.appendChild(d);
-  for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
+let curTab=0, tabInited=false;
+function setTab(et){
+  curTab=et;
+  typeEls.forEach((rec,i)=>{
+    rec.sec.style.display = (i===et) ? "" : "none";
+    rec.tab.classList.toggle("active", i===et);
+  });
 }
+(function buildTypeCfgs(){
+  const host=document.getElementById("typeCfgs");
+  const bar=document.createElement("div"); bar.style.cssText="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px";
+  host.appendChild(bar);
+  TYPE_DEFS.forEach((def,et)=>{
+    // tab button
+    const tab=document.createElement("button"); tab.className="slot-tab"; tab.style.cursor="pointer";
+    tab.innerHTML='<span>'+def.name+'</span><span class="active-dot" style="display:none;font-size:11px;color:var(--acc)"> ●</span>';
+    tab.onclick=()=>setTab(et); bar.appendChild(tab);
+    // panel
+    const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
+    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null};
+    // A/B swap + trackpad haptics toggles
+    const tog=document.createElement("div"); tog.className="row";
+    tog.innerHTML='<label>A/B + X/Y swap</label>';
+    const ab=document.createElement("button"); ab.textContent="off"; tog.appendChild(ab);
+    const padLbl=document.createElement("label"); padLbl.textContent="Trackpad haptics"; padLbl.style.marginLeft="14px"; tog.appendChild(padLbl);
+    const pad=document.createElement("button"); pad.textContent="on"; tog.appendChild(pad);
+    sec.appendChild(tog);
+    rec.abSwap=ab; rec.pad=pad;
+    ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*8+5, on?0:1); };
+    pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*8+6, on?0:1); };
+    // back paddles
+    for(let i=0;i<4;i++){
+      const row=document.createElement("div"); row.className="row";
+      const lab=document.createElement("label"); lab.textContent=BACK_LABELS[i]; row.appendChild(lab);
+      const sel=mkSelect(def,true); row.appendChild(sel); sec.appendChild(row);
+      sel.addEventListener("change",()=>setField(40+et*8+i, +sel.value));
+      rec.back.push(sel);
+    }
+    // QAM
+    { const row=document.createElement("div"); row.className="row";
+      const lab=document.createElement("label"); lab.textContent="QAM (3 dots)"; row.appendChild(lab);
+      const sel=mkSelect(def,false); row.appendChild(sel); sec.appendChild(row);
+      sel.addEventListener("change",()=>setField(40+et*8+4, +sel.value));
+      rec.qam=sel; }
+    host.appendChild(sec); typeEls.push(rec);
+  });
+  setTab(0);
+})();
 for(const sel of document.querySelectorAll("select.chord")){
   MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
     const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
 }
 { const sel=document.getElementById("swRate");
-  for(const [lbl,v] of [["120 Hz (default)",1],["66 Hz (compatibility)",0],["Full rate (~250 Hz)",2]]){
+  for(const [lbl,v] of [["Full rate (~250 Hz, default)",2],["120 Hz",1],["66 Hz (compatibility)",0]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
 { const sel=document.getElementById("swGyro");
@@ -277,7 +330,7 @@ async function connect(){
   }catch(e){ log("connect failed: "+e.message); }
 }
 function onGone(){
-  polling=false; dev=null;
+  polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
   $("#connState").textContent="disconnected"; $("#connState").className="pill dn";
   $("#panel").classList.add("hide");
   log("device disconnected (likely a mode-switch reboot — reconnect)");
@@ -300,8 +353,8 @@ async function readBlob(){
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 function applyBlob(p){
-  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];
-  const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
+  const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
+  const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
   const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
   const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
@@ -316,7 +369,7 @@ function applyBlob(p){
   $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
   const battery=(p.length>36?p[36]:0);
   const rumbleScale=(p.length>51?p[51]:200);
-  const swProRate=(p.length>52?p[52]:1);
+  const swProRate=(p.length>52?p[52]:2);
   const swGyro10=(p.length>53?p[53]:10);
   const rssi=(p.length>37?p[37]:0);
 
@@ -413,10 +466,23 @@ function applyBlob(p){
     ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
     : "—";
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
-  $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
-  for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
-  { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
+  // per-type button config (blob v9): 4 types x 7 bytes from p[75]
+  if(p.length>=75+TYPE_DEFS.length*7){
+    const activeEt=etypeForMode(mode);
+    typeEls.forEach((rec,et)=>{
+      const q=75+et*7;
+      const back=[p[q],p[q+1],p[q+2],p[q+3]], qam=p[q+4], ab=p[q+5], pad=p[q+6];
+      rec.back.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=back[i]; });
+      if(document.activeElement!==rec.qam) rec.qam.value=qam;
+      rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
+      rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
+      // dot marks the type matching the current puck mode
+      rec.activeDot.style.display = (et===activeEt) ? "" : "none";
+    });
+    // open the tab for the current mode's type on first load (then leave the user's choice alone)
+    if(!tabInited){ tabInited=true; setTab(activeEt>=0?activeEt:0); }
+  }
   if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
     const ax=s16(54),ay=s16(56),az=s16(58);
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
@@ -534,17 +600,13 @@ $("#factoryErase").onclick=async()=>{
   await send([0x0A,0x45,0x52,0x53]);
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };
-$("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 for(const id of ["mDiv","mFric","rumble"]){
   const field={mDiv:1,mFric:2,rumble:22}[id];
   $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
 }
-for(const sel of document.querySelectorAll("select.back")){
-  sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
-}
-$("#qamMap").addEventListener("change", ()=>setField(21, +$("#qamMap").value));
+// per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
 $("#swRate").addEventListener("change", ()=>setField(23, +$("#swRate").value));
 $("#swGyro").addEventListener("change", ()=>setField(24, +$("#swGyro").value));
 for(const sel of document.querySelectorAll("select.chord")){