Parcourir la source

more code reuse

Jari il y a 2 mois
Parent
commit
2ddf64aa93

+ 1 - 0
OpenPuck/OpenPuck.ino

@@ -77,6 +77,7 @@ static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
 // g_usbToBond must already be built for k connected controllers. Called at boot and by usb_mount's watcher.
 void usbReenumerate(uint8_t k)
 {
+	faultDiagTrace(FR_MOUNT, k); // flight recorder: mount-set change
 	USBDevice.detach();
 	delay(20);
 	USBDevice.clearConfiguration();

+ 4 - 21
OpenPuck/config.h

@@ -21,27 +21,10 @@
 #define OPK_FACTORY_RESET 0
 #endif
 
-// ---- USB presentation modes (g_usbMode). RF poll/relay is identical across all; only USB enumeration +
-//      report mapping differ. ----
-#define MODE_STEAM 0 // Valve puck; auto-lizard when Steam closed
-#define MODE_XBOX 1 // XInput + right-pad mouse
-#define MODE_SW_HORI 2 // HORIPAD (Switch console whitelist)
-#define MODE_LIZARD \
-	3 // Puck HID; always keyboard+mouse (ignores Steam heartbeat)
-#define MODE_SW_PRO 4 // Nintendo Switch Pro Controller (057E:2009) + gyro
-// Sony DualSense (054C:0CE6) + gyro + split trackpad (+ wake mouse + WebUSB panel)
-#define MODE_PS5 5
-// DS4-layout generic HID gamepad + gyro (+ wake mouse + WebUSB panel)
-#define MODE_HIDGYRO 6
-// DualSense, CLEAN single-HID (no wake/WebUSB) so PC games classify it as PlayStation (Fortnite)
-#define MODE_PS5_GAME 7
-// DS4, CLEAN single-HID (no wake/WebUSB) for game PlayStation classification
-#define MODE_DS4_GAME 8
-// Sony DualShock 3 / Sixaxis (054C:0268) -- CLEAN single-HID so it enumerates on a REAL PS3 console (the
-// console wants a bare Sixaxis HID, not a composite). Answers the PS3's GET_REPORT(0xF2/0xF5/0xEF/0x01)
-// enable handshake. + gyro/accel + rumble.
-#define MODE_PS3 9
-#define MODE_MAX 9
+// ---- USB presentation modes (g_usbMode). RF poll/relay is identical across all;
+// only USB enumeration + report mapping differ. The MODE_* numbers are the shared
+// WebUSB protocol contract (see src/common/usb_modes.h) — identical to PicoPuck.
+#include "src/common/usb_modes.h"
 
 // The two "game" personalities drop the wake-mouse + WebUSB interfaces so the device is a genuine single-HID PS
 // controller (some PC games -- e.g. Fortnite/UE GameInput -- refuse PS classification when extra interfaces are

+ 0 - 6
OpenPuck/identity.cpp

@@ -7,12 +7,6 @@ char g_unit[16];
 char g_board[16];
 char g_usbSerial[18];
 
-const uint8_t ATTR83[25] = { // 0x83 attributes (product 0x1304 = Proteus puck)
-	0x01, 0x04, 0x13, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
-	0x00, 0x0A, 0xF2, 0xF9, 0xD2, 0x68, 0x04, 0x53, 0xD0,
-	0x18, 0x6A, 0x09, 0x47, 0x00, 0x00, 0x00
-};
-
 void genSerial()
 {
 	uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];

+ 1 - 1
OpenPuck/identity.h

@@ -11,6 +11,6 @@ extern char g_usbSerial
 	[18]; // per-mode USB serial (Steam uses g_unit; others get a suffix)
 
 // 0x83 attributes, returned by the puck feature command channel.
-extern const uint8_t ATTR83[25];
+#include "src/common/hid_reports.h" // ATTR83 (shared)
 
 void genSerial();

+ 8 - 0
OpenPuck/src/common/hid_reports.c

@@ -207,3 +207,11 @@ const uint8_t PUCK_HID_DESC[372] = {
 	0x08, 0x95, 0x3f, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3f, 0x09,
 	0x01, 0xb1, 0x02, 0x85, 0x02, 0x95, 0x3f, 0x09, 0x01, 0xb1, 0x02, 0xc0,
 };
+
+// 0x83 device-attribute blob (product 0x1304 = Proteus puck); Steam reads it
+// via the puck feature channel. Byte-identical across both firmwares.
+const uint8_t ATTR83[25] = {
+	0x01, 0x04, 0x13, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
+	0x00, 0x0a, 0xf2, 0xf9, 0xd2, 0x68, 0x04, 0x53, 0xd0,
+	0x18, 0x6a, 0x09, 0x47, 0x00, 0x00, 0x00,
+};

+ 2 - 0
OpenPuck/src/common/hid_reports.h

@@ -30,6 +30,8 @@ extern const uint8_t BT_PAIR_2[31];
 
 extern const uint8_t PUCK_HID_DESC[372];
 
+extern const uint8_t ATTR83[25];
+
 #ifdef __cplusplus
 }
 #endif

+ 23 - 0
OpenPuck/src/common/usb_modes.h

@@ -0,0 +1,23 @@
+// usb_modes.h — USB presentation mode numbers, shared by OpenPuck and PicoPuck.
+// These ARE the WebUSB protocol's mode ids (the panel sends mode-switch opcodes
+// and reads the active mode as these values), so they MUST stay identical between
+// the two firmwares — a mismatch would mis-switch modes. RF/BT transport is the
+// same across all; only USB enumeration + report mapping differ per mode.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef OPK_COMMON_USB_MODES_H
+#define OPK_COMMON_USB_MODES_H
+
+#define MODE_STEAM 0 // Valve puck (auto-lizard when Steam closed)
+#define MODE_XBOX 1 // Xbox 360 XInput
+#define MODE_SW_HORI 2 // Switch HORIPAD
+#define MODE_LIZARD 3 // Puck HID, always keyboard+mouse
+#define MODE_SW_PRO 4 // Nintendo Switch Pro Controller + gyro
+#define MODE_PS5 5 // Sony DualSense + gyro + split trackpad
+#define MODE_HIDGYRO 6 // DS4-layout generic HID gamepad + gyro
+#define MODE_PS5_GAME 7 // DualSense, clean single-HID
+#define MODE_DS4_GAME 8 // DS4, clean single-HID
+#define MODE_PS3 9 // DualShock 3 / Sixaxis
+#define MODE_MAX 9
+
+#endif // OPK_COMMON_USB_MODES_H

+ 133 - 0
OpenPuck/src/common/usb_mount.c

@@ -0,0 +1,133 @@
+// usb_mount.c — see usb_mount.h. Ported verbatim from OpenPuck's original policy;
+// the platform-specific connected signal + re-enumerate are behind hooks so both
+// firmwares get IDENTICAL mount/unmount behaviour.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "usb_mount.h"
+
+#define NSLOT USB_MOUNT_NSLOT
+
+uint8_t g_usb_mount_count = 0;
+int8_t g_usb_to_bond[NSLOT];
+int8_t g_bond_to_usb[NSLOT];
+
+static bool s_dyn_on = false;
+static uint8_t s_max_slots = NSLOT;
+
+// The connected set must hold steady this long before we re-enumerate — absorbs
+// RF blips and the staggered way several controllers connect at boot (one
+// re-enumeration once the set settles, not one per controller).
+#define MOUNT_DEBOUNCE_MS 2000u
+// Lazy removal: a disconnected slot's interface is only dropped (and the device
+// re-enumerated down) once NO controller has sent input for this long — i.e.
+// during genuine downtime. Re-enumeration is risky mid-use, so we never shrink
+// while anything is being played; dead slots linger as idle interfaces and are
+// cleaned up when the rig goes quiet. Additions are NOT gated by this.
+#define IDLE_CLEANUP_MS 60000u
+
+static uint8_t mounted_mask(void)
+{
+	uint8_t m = 0;
+	for (uint8_t u = 0; u < g_usb_mount_count; u++)
+		if (g_usb_to_bond[u] >= 0)
+			m |= (uint8_t)(1u << g_usb_to_bond[u]);
+	return m;
+}
+
+// Order-preserving, budget-capped mount list for `mask`. Controllers already
+// mounted keep their USB-slot order (a departure compacts survivors down but
+// never reorders them); newly-connected controllers are appended (ascending bond
+// order among simultaneous joiners). Does NOT mutate the live map.
+static uint8_t compute_order(uint8_t mask, int8_t out[NSLOT])
+{
+	uint8_t n = 0;
+	for (uint8_t u = 0; u < g_usb_mount_count && n < s_max_slots; u++) {
+		int8_t b = g_usb_to_bond[u];
+		if (b >= 0 && (mask & (1u << b)))
+			out[n++] = b;
+	}
+	for (int s = 0; s < NSLOT && n < s_max_slots; 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 mask_of_list(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 commit_order(const int8_t *list, uint8_t n)
+{
+	for (int i = 0; i < NSLOT; i++) {
+		g_usb_to_bond[i] = -1;
+		g_bond_to_usb[i] = -1;
+	}
+	for (uint8_t u = 0; u < n; u++) {
+		g_usb_to_bond[u] = list[u];
+		g_bond_to_usb[list[u]] = (int8_t)u;
+	}
+	g_usb_mount_count = n;
+}
+
+void usb_mount_rebuild_map(void)
+{
+	int8_t tmp[NSLOT];
+	uint8_t n = compute_order(usb_mount_connected_mask(), tmp);
+	commit_order(tmp, n);
+}
+
+void usb_mount_enable(bool on, uint8_t max_slots)
+{
+	s_dyn_on = on;
+	s_max_slots = max_slots ? (max_slots <= NSLOT ? max_slots : NSLOT) : 1;
+	for (int i = 0; i < NSLOT; i++)
+		g_usb_to_bond[i] = g_bond_to_usb[i] = -1;
+	g_usb_mount_count = 0;
+}
+
+void usb_mount_task(void)
+{
+	if (!s_dyn_on)
+		return;
+	static uint8_t last_mask = 0xFF; // force first compare
+	static uint32_t stable_since = 0;
+	uint32_t now = usb_mount_now_ms();
+
+	// ADD IMMEDIATELY, REMOVE LAZILY. We only ever SHRINK the interface set
+	// during genuine downtime:
+	//   - active (input within IDLE_CLEANUP_MS): target = connected UNION
+	//     already-mounted → the set only grows; a dropped controller keeps its
+	//     now-idle interface and a reconnect reuses it with NO re-enumeration.
+	//   - idle (no input for a full minute): target = exactly the connected set →
+	//     any lingering dead slot is dropped and we re-enumerate down, safely.
+	uint32_t last_any = usb_mount_last_input_ms();
+	bool idle = (last_any == 0) || (now - last_any) >= IDLE_CLEANUP_MS;
+	uint8_t want =
+		idle ? usb_mount_connected_mask() :
+		       (uint8_t)(usb_mount_connected_mask() | mounted_mask());
+	if (want != last_mask) { // set is moving → restart the stability timer
+		last_mask = want;
+		stable_since = now;
+		return;
+	}
+	if ((now - stable_since) < MOUNT_DEBOUNCE_MS)
+		return;
+	int8_t tmp[NSLOT];
+	uint8_t n = compute_order(want, tmp);
+	if (mask_of_list(tmp, n) == mounted_mask())
+		return; // same set already mounted (order preserved across joins/leaves)
+	commit_order(tmp, n);
+	usb_reenumerate(g_usb_mount_count);
+}

+ 65 - 0
OpenPuck/src/common/usb_mount.h

@@ -0,0 +1,65 @@
+// usb_mount.h — dynamic USB mounting of ACTIVELY-CONNECTED controllers in the
+// emulated modes, shared by OpenPuck and PicoPuck so the mount/unmount BEHAVIOUR
+// is identical: present one HID gamepad per CONNECTED controller (not per bonded
+// slot), re-enumerating with NO reboot as that set changes.
+//
+// The k connected controllers are shown as a DENSE PREFIX usbSlot 0..k-1; usbSlot
+// u is fed from bond slot g_usb_to_bond[u]. USB can't hold an interface "gap", so
+// the prefix is compacted → player identity follows CONNECTION ORDER. Re-enum is
+// debounced (RF blips / staggered boot joins) and shrinks only during idle
+// (add-now, remove-lazy) so we never tear down USB mid-play.
+//
+// Transport/USB-framework specifics live behind four platform hooks each firmware
+// implements (connected mask, last-input time, now, and the actual re-enumerate).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef OPK_COMMON_USB_MOUNT_H
+#define OPK_COMMON_USB_MOUNT_H
+
+#include <stdint.h>
+#include <stdbool.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#ifndef USB_MOUNT_NSLOT
+#define USB_MOUNT_NSLOT 4
+#endif
+
+// Active mount map. usbSlot (0..g_usb_mount_count-1) → bond slot, and the inverse
+// for output-report routing.
+extern uint8_t g_usb_mount_count;
+extern int8_t g_usb_to_bond[USB_MOUNT_NSLOT]; // usbSlot → bondSlot (-1 unused)
+extern int8_t g_bond_to_usb[USB_MOUNT_NSLOT]; // bondSlot → usbSlot (-1 unmounted)
+
+// Enable the dynamic watcher for this boot (emulated modes call this; puck/lizard
+// leave it off). max_slots caps the mounted count to the mode's interface budget.
+void usb_mount_enable(bool on, uint8_t max_slots);
+
+// Rebuild the map from the CURRENT connected set (bond-slot order). Called by the
+// boot enumerate and on every accepted change.
+void usb_mount_rebuild_map(void);
+
+// Per-loop watcher: on a debounced change of the connected set, commit the new
+// map and call usb_reenumerate(). No-op unless enabled.
+void usb_mount_task(void);
+
+// ---- platform hooks (provided by each firmware) ----------------------------
+// Bitmask (bit s) of slots that are currently mount-worthy (connected).
+uint8_t usb_mount_connected_mask(void);
+// millis() of the most-recent input across all slots (0 = never). Drives the
+// idle-cleanup (remove-lazy) branch.
+uint32_t usb_mount_last_input_ms(void);
+// Monotonic milliseconds.
+uint32_t usb_mount_now_ms(void);
+// Tear down + rebuild the USB config presenting `k` gamepad interfaces (fixed
+// WebUSB / wake interfaces replayed too), then re-attach. NO MCU reboot. The map
+// is already committed for k connected controllers when this is called.
+void usb_reenumerate(uint8_t k);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // OPK_COMMON_USB_MOUNT_H

+ 17 - 133
OpenPuck/usb_mount.cpp

@@ -1,29 +1,17 @@
+// usb_mount.cpp -- OpenPuck's platform hooks for the SHARED dynamic-mount policy
+// (src/common/usb_mount.c). The policy (map/debounce/add-now-remove-lazy) is
+// shared; here we supply the RF-specific connected signal (a slot counts as
+// connected while it has replied within CONN_UP_MS) and the clock. The actual
+// re-enumerate (usbReenumerate) lives in OpenPuck.ino.
 #include "usb_mount.h"
-#include "bonds.h"
-#include "fault_diag.h" // faultDiagTrace() -- flight recorder
+#include "bonds.h" // g_slot / g_connReplyMs
 #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.
+// More lenient than the 300 ms link-up check so a brief RF gap doesn't unmount a
+// slot; the shared debounce 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
-// Lazy removal: a disconnected slot's interface is only dropped (and the device re-enumerated down) once NO
-// controller has sent input for this long -- i.e. during genuine downtime. Re-enumeration tears down/rebuilds
-// USB from the loop task and is risky to do mid-use, so we never shrink while anything is being played; we
-// just let dead slots linger as idle interfaces and clean them up when the rig goes quiet. Additions are NOT
-// gated by this -- a newly-connected controller grows the set right away.
-#define IDLE_CLEANUP_MS 60000u
 
-static uint8_t connectedMask()
+extern "C" uint8_t usb_mount_connected_mask(void)
 {
 	unsigned long now = millis();
 	uint8_t m = 0;
@@ -34,120 +22,16 @@ static uint8_t connectedMask()
 	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()
+extern "C" uint32_t usb_mount_last_input_ms(void)
 {
-	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;
+	unsigned long last = 0;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used && g_connReplyMs[s] > last)
+			last = g_connReplyMs[s];
+	return (uint32_t)last;
 }
 
-void usbMountTask()
+extern "C" uint32_t usb_mount_now_ms(void)
 {
-	if (!g_dynOn)
-		return;
-	static uint8_t lastMask = 0xFF; // force first compare
-	static unsigned long stableSince = 0;
-	unsigned long now = millis();
-	// ADD IMMEDIATELY, REMOVE LAZILY. Re-enumeration (usbReenumerate) tears down + rebuilds the whole USB
-	// config from the loop task; doing that while the high-priority usbd task is mid-tud_task races its
-	// internal state -> HardFault/reboot. So we only ever SHRINK the interface set during genuine downtime:
-	//   - active (some controller sent input within IDLE_CLEANUP_MS): target = connected UNION already-mounted
-	//     -> the set only grows. A controller that drops (e.g. an errant ReversePuck disconnect) keeps its
-	//     now-idle interface and reconnecting reuses it with NO re-enumeration. task() gates input per slot,
-	//     so an idle interface just streams nothing.
-	//   - idle (no controller input for a full minute): target = exactly the connected set -> any lingering
-	//     dead slot is dropped and we re-enumerate down, safely, because nobody is playing.
-	// New controllers grow the set right away (after the usual debounce) regardless of idle state.
-	unsigned long lastAny = 0;
-	for (int s = 0; s < NSLOT; s++)
-		if (g_slot[s].used && g_connReplyMs[s] > lastAny)
-			lastAny = g_connReplyMs[s];
-	bool idle = (lastAny == 0) || (now - lastAny) >= IDLE_CLEANUP_MS;
-	uint8_t want = idle ? connectedMask() :
-			      (uint8_t)(connectedMask() | mountedMask());
-	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. With the union above,
-	// while active that can only happen when a NEW controller needs an interface (grow); a shrink only ever
-	// comes from the idle branch dropping a long-dead slot.
-	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);
-	faultDiagTrace(FR_MOUNT, g_usbMountCount);
-	usbReenumerate(g_usbMountCount);
+	return (uint32_t)millis();
 }

+ 19 - 38
OpenPuck/usb_mount.h

@@ -1,44 +1,25 @@
-// 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.
+// usb_mount.h -- OpenPuck adapter over the SHARED dynamic-mount policy
+// (src/common/usb_mount.{h,c}, linked by both OpenPuck and PicoPuck so the
+// mount/unmount behaviour is identical). The map + debounce + add-now/remove-lazy
+// logic lives in the shared file; this header keeps OpenPuck's camelCase call
+// sites unchanged, and OpenPuck provides the platform hooks (connected mask /
+// last-input / now in usb_mount.cpp; usbReenumerate in OpenPuck.ino).
 #pragma once
 #include <stdint.h>
 #include "bonds.h" // NSLOT
+#include "src/common/usb_mount.h"
 
-// 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)
+// camelCase compatibility aliases -> shared snake_case symbols.
+#define g_usbMountCount g_usb_mount_count
+#define g_usbToBond g_usb_to_bond
+#define g_bondToUsb g_bond_to_usb
+#define usbMountEnable usb_mount_enable
+#define usbMountRebuildMap usb_mount_rebuild_map
+#define usbMountTask usb_mount_task
+// OpenPuck.ino defines the re-enumerate hook under its historical name.
+#define usbReenumerate usb_reenumerate
 
-// 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);
-
-// Persist `mode` and reboot into it, cleanly detaching USB first so the host tears down the outgoing
-// personality (releasing any held input) before the new one enumerates -- otherwise the ~ms reset-disconnect
-// can be too brief for the host to notice, leaving a ghost device with stuck input until a physical replug.
-// Does not return. Call for every user-facing mode switch (chord / panel / console).
+// Persist `mode` and reboot into it, cleanly detaching USB first so the host
+// tears down the outgoing personality (releasing any held input). Does not
+// return. OpenPuck-specific (defined in OpenPuck.ino); PicoPuck has its own.
 void modeSwitchReboot(uint8_t mode);

+ 1 - 0
PicoPuck/CMakeLists.txt

@@ -68,6 +68,7 @@ target_sources(picopuck PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/../OpenPuck/src/common/hid_reports.c
     ${CMAKE_CURRENT_SOURCE_DIR}/../OpenPuck/src/common/hd_rumble.c
     ${CMAKE_CURRENT_SOURCE_DIR}/../OpenPuck/src/common/gamepad_util.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/../OpenPuck/src/common/usb_mount.c
 )
 
 target_include_directories(picopuck PRIVATE

+ 1 - 12
PicoPuck/src/config/modes.h

@@ -6,18 +6,7 @@
 
 #include <stdint.h>
 #include <stdbool.h>
-
-#define MODE_STEAM 0     // Valve puck (transparent SC2 / synth for generic pads)
-#define MODE_XBOX 1      // Xbox 360 XInput
-#define MODE_SW_HORI 2   // Switch HORIPAD
-#define MODE_LIZARD 3    // Puck HID, always keyboard+mouse
-#define MODE_SW_PRO 4    // Nintendo Switch Pro Controller + gyro
-#define MODE_PS5 5       // Sony DualSense + gyro + split trackpad
-#define MODE_HIDGYRO 6   // DS4-layout generic HID gamepad + gyro
-#define MODE_PS5_GAME 7  // DualSense, clean single-HID
-#define MODE_DS4_GAME 8  // DS4, clean single-HID
-#define MODE_PS3 9       // DualShock 3 / Sixaxis
-#define MODE_MAX 9
+#include "usb_modes.h" // shared MODE_* numbers (WebUSB protocol contract)
 
 // Modes that present the Valve puck HID (Steam personality + its 4 slots).
 static inline bool mode_is_puck(uint8_t m)

+ 21 - 11
PicoPuck/src/main.c

@@ -13,6 +13,7 @@
 #include "puck/personality.h"
 #include "puck/emu_present.h"
 #include "puck/xinput.h"
+#include "usb_mount.h"
 #include "usb/usb_tx.h"
 #include "usb/usb_descriptors.h"
 #include "usb/webusb.h"
@@ -28,7 +29,7 @@ int main(void)
 {
 	stdio_init_all();
 
-	settings_load();  // active USB mode (before descriptors)
+	settings_load(); // active USB mode (before descriptors)
 	identity_init();
 	slots_init();
 	puck_personality_init();
@@ -41,13 +42,20 @@ int main(void)
 	bool puck = mode_is_puck(settings_mode());
 	bool xinput = mode_is_xinput(settings_mode());
 
+	// HID emulated modes present one gamepad per CONNECTED controller, mounted /
+	// unmounted dynamically (no reboot) — same behaviour as OpenPuck. Puck and
+	// XInput modes keep their fixed interface layout.
+	if (!puck && !xinput)
+		usb_mount_enable(true, PP_NSLOT);
+
 	// The user LED hangs off the CYW43 chip; this init also hands the radio to
 	// BTstack (btstack_cyw43_init on the async_context).
 	bool have_radio = (cyw43_arch_init() == 0);
 	bool have_bt = have_radio && bt_host_init();
 
 	printf("\n[picopuck] boot commit=%s board=%d mode=%d radio=%d bt=%d\n",
-	       PICOPUCK_GIT_COMMIT, PP_BOARD, settings_mode(), have_radio, have_bt);
+	       PICOPUCK_GIT_COMMIT, PP_BOARD, settings_mode(), have_radio,
+	       have_bt);
 
 	watchdog_enable(PP_WATCHDOG_MS, /*pause_on_debug=*/true);
 
@@ -55,21 +63,22 @@ int main(void)
 	bool led_on = false;
 
 	while (1) {
-		tud_task();       // USB device events
-		usb_tx_pump();    // drain queued HID reports
+		tud_task(); // USB device events
+		usb_tx_pump(); // drain queued HID reports
 
 		if (puck)
-			puck_personality_task();  // puck 0x79 / 0x7B / 0x43 + synth stream
+			puck_personality_task(); // puck 0x79 / 0x7B / 0x43 + synth stream
 		else if (xinput)
-			xinput_task();            // Xbox 360 XInput report stream
+			xinput_task(); // Xbox 360 XInput report stream
 		else
-			emu_present_task();       // emulated controller report stream
+			emu_present_task(); // emulated controller report stream
 
-		webusb_task();    // panel commands (works in every mode)
+		usb_mount_task(); // dynamic mount/unmount on connect/disconnect (emu modes)
+		webusb_task(); // panel commands (works in every mode)
 
 		if (have_bt) {
-			cyw43_arch_poll();  // services BTstack on its async_context
-			bt_host_task();     // scan / battery / rssi / rumble
+			cyw43_arch_poll(); // services BTstack on its async_context
+			bt_host_task(); // scan / battery / rssi / rumble
 		} else if (have_radio) {
 			cyw43_arch_poll();
 		}
@@ -77,7 +86,8 @@ int main(void)
 		if (absolute_time_diff_us(get_absolute_time(), next_led) <= 0) {
 			led_on = !led_on;
 			if (have_radio)
-				cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, led_on);
+				cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN,
+						    led_on);
 			next_led = make_timeout_time_ms(PP_STATUS_LED_MS);
 		}
 

+ 55 - 13
PicoPuck/src/puck/emu_present.c

@@ -1,4 +1,10 @@
-// emu_present.c — drive the active emulated controller from g_in (see .h).
+// emu_present.c — drive the active emulated controller(s) from g_in (see .h).
+//
+// Dynamic mounting (shared common/usb_mount.c): the host is presented one HID
+// gamepad per CONNECTED controller. usbSlot u (interface u) is fed from bond slot
+// g_usb_to_bond[u]; the report callbacks route the same way. This file also
+// supplies the mounter's platform hooks (connected mask / last-input / now) — the
+// re-enumerate hook lives in usb_descriptors.c.
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include "puck/emu_present.h"
@@ -7,17 +13,25 @@
 #include "usb/usb_tx.h"
 #include "usb/usb_descriptors.h"
 #include "config/modes.h"
+#include "config/picopuck_config.h"
 #include "sys/settings.h"
+#include "usb_mount.h"
 
 #include "pico/time.h"
 
-#define EMU_STREAM_MS 4u  // ~250 Hz, matching OpenPuck's USB_STREAM_MS
+_Static_assert(PP_NSLOT == USB_MOUNT_NSLOT,
+	       "usb_mount slot count must match PP_NSLOT");
+
+#define EMU_STREAM_MS 4u // ~250 Hz, matching OpenPuck's USB_STREAM_MS
 
 static uint32_t s_last_ms;
 
-// Interface i maps 1:1 to controller slot i. Each emulated HID interface streams
-// its slot's g_in (neutral when that slot is disconnected, keeping the interface
-// alive as an idle pad). Report callbacks route the same way.
+// usbSlot u → bond slot (the u-th connected controller), or -1 if not mounted.
+static int mounted_slot(uint8_t u)
+{
+	return (u < USB_MOUNT_NSLOT) ? g_usb_to_bond[u] : -1;
+}
+
 void emu_present_task(void)
 {
 	const emu_mode_t *e = emu_mode_for(settings_mode());
@@ -28,13 +42,15 @@ void emu_present_task(void)
 		return;
 	s_last_ms = t;
 
-	uint8_t nif = usb_emu_iface_count();
-	for (uint8_t i = 0; i < nif; i++) {
+	for (uint8_t u = 0; u < g_usb_mount_count; u++) {
+		int slot = mounted_slot(u);
+		if (slot < 0)
+			continue;
 		uint8_t body[64];
 		uint8_t rid = 0;
-		uint16_t n = e->build(i, body, &rid);
+		uint16_t n = e->build(slot, body, &rid);
 		if (n)
-			usb_tx_hid(i, rid, body, n);
+			usb_tx_hid(u, rid, body, n);
 	}
 }
 
@@ -42,8 +58,9 @@ uint16_t emu_get_report(uint8_t instance, uint8_t report_id, uint8_t type,
 			uint8_t *buf, uint16_t reqlen)
 {
 	const emu_mode_t *e = emu_mode_for(settings_mode());
-	if (e && e->get_report)
-		return e->get_report(instance, report_id, type, buf, reqlen);
+	int slot = mounted_slot(instance);
+	if (e && e->get_report && slot >= 0)
+		return e->get_report(slot, report_id, type, buf, reqlen);
 	return 0;
 }
 
@@ -51,6 +68,31 @@ void emu_set_report(uint8_t instance, uint8_t report_id, uint8_t type,
 		    const uint8_t *buf, uint16_t len)
 {
 	const emu_mode_t *e = emu_mode_for(settings_mode());
-	if (e && e->set_report)
-		e->set_report(instance, report_id, type, buf, len);
+	int slot = mounted_slot(instance);
+	if (e && e->set_report && slot >= 0)
+		e->set_report(slot, report_id, type, buf, len);
+}
+
+// ---- shared dynamic-mount platform hooks -----------------------------------
+uint8_t usb_mount_connected_mask(void)
+{
+	uint8_t m = 0;
+	for (int s = 0; s < PP_NSLOT; s++)
+		if (g_slot[s].connected)
+			m |= (uint8_t)(1u << s);
+	return m;
+}
+
+uint32_t usb_mount_last_input_ms(void)
+{
+	uint32_t last = 0;
+	for (int s = 0; s < PP_NSLOT; s++)
+		if (g_slot[s].conn_reply_ms > last)
+			last = g_slot[s].conn_reply_ms;
+	return last;
+}
+
+uint32_t usb_mount_now_ms(void)
+{
+	return to_ms_since_boot(get_absolute_time());
 }

+ 0 - 5
PicoPuck/src/puck/identity.c

@@ -9,11 +9,6 @@
 
 // 0x83 attributes (product 0x1304, stored as u32 LE at offset 1). Verbatim from
 // OpenPuck/identity.cpp — Steam matches the puck by this blob.
-const uint8_t ATTR83[25] = {
-	0x01, 0x04, 0x13, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
-	0x00, 0x0A, 0xF2, 0xF9, 0xD2, 0x68, 0x04, 0x53, 0xD0,
-	0x18, 0x6A, 0x09, 0x47, 0x00, 0x00, 0x00
-};
 
 char g_board[16];
 char g_unit[16];

+ 1 - 1
PicoPuck/src/puck/identity.h

@@ -8,7 +8,7 @@
 
 // The 25-byte 0x83 GET_ATTRIBUTES payload (product 0x1304 = Valve puck), copied
 // from OpenPuck/identity.cpp. Steam reads this to identify the dongle.
-extern const uint8_t ATTR83[25];
+#include "hid_reports.h" // ATTR83 (shared)
 
 // Board / unit serial strings (Valve "MXB..." / "FXB..." shape), derived from
 // the RP2040/RP2350 unique flash id at boot. NUL-terminated, <=15 chars.

+ 388 - 92
PicoPuck/src/usb/usb_descriptors.c

@@ -8,7 +8,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0-or-later
 #include <string.h>
+#include <stdio.h>
 #include "tusb.h"
+#include "pico/time.h"
 #include "usb/usb_descriptors.h"
 #include "usb/puck_desc.h"
 #include "puck/identity.h"
@@ -26,14 +28,20 @@ void webusb_set_connected(bool connected);
 
 // ---- cached per-boot presentation ------------------------------------------
 static uint8_t s_mode;
-static const emu_mode_t *s_emu;  // NULL for puck/xinput modes
-static bool s_xinput;            // MODE_XBOX: custom vendor class, not HID
-static uint8_t s_emu_ifaces = 1; // # of emulated HID interfaces (one per controller)
+static const emu_mode_t *s_emu; // NULL for puck/xinput modes
+static bool s_xinput; // MODE_XBOX: custom vendor class, not HID
+static uint8_t s_emu_ifaces =
+	1; // # of emulated HID interfaces (one per controller)
 static tusb_desc_device_t s_dev;
-static uint8_t s_emu_cfg[220];   // PP_NSLOT HID (32 B each) + config + vendor
+static uint8_t s_emu_cfg[220]; // PP_NSLOT HID (32 B each) + config + vendor
 static uint8_t s_vendor_itf;
+static char s_emu_serial[24]; // emulated serial; encodes the mounted count
+static void rebuild_emu_serial(void);
 
-uint8_t usb_emu_iface_count(void) { return s_emu_ifaces; }
+uint8_t usb_emu_iface_count(void)
+{
+	return s_emu_ifaces;
+}
 
 #define MS_OS_20_DESC_LEN 0xB2
 static uint8_t s_msos[MS_OS_20_DESC_LEN];
@@ -47,34 +55,82 @@ static uint16_t build_emu_config(uint8_t *b, const emu_mode_t *emu, uint8_t nif)
 {
 	uint16_t rlen = emu->report_desc_len;
 	uint8_t poll = emu->poll_ms ? emu->poll_ms : 1;
-	if (nif < 1) nif = 1;
-	if (nif > PP_NSLOT) nif = PP_NSLOT;
+	// nif may be 0 (no controllers connected yet → present only the WebUSB vendor
+	// interface); the dynamic mounter grows/shrinks it as controllers come/go.
+	if (nif > PP_NSLOT)
+		nif = PP_NSLOT;
 	const uint16_t total = 9 + nif * (9 + 9 + 7 + 7) + (9 + 7 + 7);
 	uint8_t *p = b;
 	// configuration
-	*p++ = 9; *p++ = TUSB_DESC_CONFIGURATION;
-	*p++ = (uint8_t)(total & 0xFF); *p++ = (uint8_t)(total >> 8);
-	*p++ = (uint8_t)(nif + 1); *p++ = 1; *p++ = 0; *p++ = 0x80; *p++ = 250;
+	*p++ = 9;
+	*p++ = TUSB_DESC_CONFIGURATION;
+	*p++ = (uint8_t)(total & 0xFF);
+	*p++ = (uint8_t)(total >> 8);
+	*p++ = (uint8_t)(nif + 1);
+	*p++ = 1;
+	*p++ = 0;
+	*p++ = 0x80;
+	*p++ = 250;
 	for (uint8_t i = 0; i < nif; i++) {
 		// HID interface i — IN + OUT (rumble / Switch-Pro subcommands ride the
 		// interrupt OUT pipe; output reports also work via EP0 SET_REPORT).
-		*p++ = 9; *p++ = TUSB_DESC_INTERFACE; *p++ = i; *p++ = 0; *p++ = 2;
-		*p++ = TUSB_CLASS_HID; *p++ = 0; *p++ = 0; *p++ = 0;
-		*p++ = 9; *p++ = HID_DESC_TYPE_HID; *p++ = 0x11; *p++ = 0x01; *p++ = 0;
-		*p++ = 1; *p++ = HID_DESC_TYPE_REPORT;
-		*p++ = (uint8_t)(rlen & 0xFF); *p++ = (uint8_t)(rlen >> 8);
-		*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = (uint8_t)(0x81 + i); *p++ = 0x03;
-		*p++ = 64; *p++ = 0; *p++ = poll;
-		*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = (uint8_t)(0x01 + i); *p++ = 0x03;
-		*p++ = 64; *p++ = 0; *p++ = poll;
+		*p++ = 9;
+		*p++ = TUSB_DESC_INTERFACE;
+		*p++ = i;
+		*p++ = 0;
+		*p++ = 2;
+		*p++ = TUSB_CLASS_HID;
+		*p++ = 0;
+		*p++ = 0;
+		*p++ = 0;
+		*p++ = 9;
+		*p++ = HID_DESC_TYPE_HID;
+		*p++ = 0x11;
+		*p++ = 0x01;
+		*p++ = 0;
+		*p++ = 1;
+		*p++ = HID_DESC_TYPE_REPORT;
+		*p++ = (uint8_t)(rlen & 0xFF);
+		*p++ = (uint8_t)(rlen >> 8);
+		*p++ = 7;
+		*p++ = TUSB_DESC_ENDPOINT;
+		*p++ = (uint8_t)(0x81 + i);
+		*p++ = 0x03;
+		*p++ = 64;
+		*p++ = 0;
+		*p++ = poll;
+		*p++ = 7;
+		*p++ = TUSB_DESC_ENDPOINT;
+		*p++ = (uint8_t)(0x01 + i);
+		*p++ = 0x03;
+		*p++ = 64;
+		*p++ = 0;
+		*p++ = poll;
 	}
 	// vendor interface (last) — WebUSB
-	*p++ = 9; *p++ = TUSB_DESC_INTERFACE; *p++ = nif; *p++ = 0; *p++ = 2;
-	*p++ = TUSB_CLASS_VENDOR_SPECIFIC; *p++ = 0; *p++ = 0; *p++ = 0;
-	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x05; *p++ = 0x02;
-	*p++ = 64; *p++ = 0; *p++ = 0;
-	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x85; *p++ = 0x02;
-	*p++ = 64; *p++ = 0; *p++ = 0;
+	*p++ = 9;
+	*p++ = TUSB_DESC_INTERFACE;
+	*p++ = nif;
+	*p++ = 0;
+	*p++ = 2;
+	*p++ = TUSB_CLASS_VENDOR_SPECIFIC;
+	*p++ = 0;
+	*p++ = 0;
+	*p++ = 0;
+	*p++ = 7;
+	*p++ = TUSB_DESC_ENDPOINT;
+	*p++ = 0x05;
+	*p++ = 0x02;
+	*p++ = 64;
+	*p++ = 0;
+	*p++ = 0;
+	*p++ = 7;
+	*p++ = TUSB_DESC_ENDPOINT;
+	*p++ = 0x85;
+	*p++ = 0x02;
+	*p++ = 64;
+	*p++ = 0;
+	*p++ = 0;
 	return total;
 }
 
@@ -85,70 +141,262 @@ static uint16_t build_emu_config(uint8_t *b, const emu_mode_t *emu, uint8_t nif)
 // 0x83 / OUT 0x03. Returns total length.
 static uint16_t build_xinput_config(uint8_t *b)
 {
-	const uint16_t total = 9 + (9 + 7 + 7) + (9 + 17 + 7 + 7);  // 72
+	const uint16_t total = 9 + (9 + 7 + 7) + (9 + 17 + 7 + 7); // 72
 	uint8_t *p = b;
 	// configuration (2 interfaces)
-	*p++ = 9; *p++ = TUSB_DESC_CONFIGURATION;
-	*p++ = (uint8_t)(total & 0xFF); *p++ = (uint8_t)(total >> 8);
-	*p++ = 2; *p++ = 1; *p++ = 0; *p++ = 0x80; *p++ = 250;
+	*p++ = 9;
+	*p++ = TUSB_DESC_CONFIGURATION;
+	*p++ = (uint8_t)(total & 0xFF);
+	*p++ = (uint8_t)(total >> 8);
+	*p++ = 2;
+	*p++ = 1;
+	*p++ = 0;
+	*p++ = 0x80;
+	*p++ = 250;
 	// vendor interface (itf 0) — WebUSB
-	*p++ = 9; *p++ = TUSB_DESC_INTERFACE; *p++ = 0; *p++ = 0; *p++ = 2;
-	*p++ = TUSB_CLASS_VENDOR_SPECIFIC; *p++ = 0; *p++ = 0; *p++ = 4;
-	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x02; *p++ = 0x02;
-	*p++ = 64; *p++ = 0; *p++ = 0;
-	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x82; *p++ = 0x02;
-	*p++ = 64; *p++ = 0; *p++ = 0;
+	*p++ = 9;
+	*p++ = TUSB_DESC_INTERFACE;
+	*p++ = 0;
+	*p++ = 0;
+	*p++ = 2;
+	*p++ = TUSB_CLASS_VENDOR_SPECIFIC;
+	*p++ = 0;
+	*p++ = 0;
+	*p++ = 4;
+	*p++ = 7;
+	*p++ = TUSB_DESC_ENDPOINT;
+	*p++ = 0x02;
+	*p++ = 0x02;
+	*p++ = 64;
+	*p++ = 0;
+	*p++ = 0;
+	*p++ = 7;
+	*p++ = TUSB_DESC_ENDPOINT;
+	*p++ = 0x82;
+	*p++ = 0x02;
+	*p++ = 64;
+	*p++ = 0;
+	*p++ = 0;
 	// XInput interface (itf 1) — vendor class 0xFF/0x5D/0x01, 2 interrupt eps
-	*p++ = 9; *p++ = TUSB_DESC_INTERFACE; *p++ = 1; *p++ = 0; *p++ = 2;
-	*p++ = 0xFF; *p++ = 0x5D; *p++ = 0x01; *p++ = 0;
+	*p++ = 9;
+	*p++ = TUSB_DESC_INTERFACE;
+	*p++ = 1;
+	*p++ = 0;
+	*p++ = 2;
+	*p++ = 0xFF;
+	*p++ = 0x5D;
+	*p++ = 0x01;
+	*p++ = 0;
 	// XInput "unknown" 0x21 descriptor (verbatim; bytes 6/13 = IN/OUT ep addr)
-	*p++ = 0x11; *p++ = 0x21; *p++ = 0x00; *p++ = 0x01; *p++ = 0x01; *p++ = 0x25;
-	*p++ = 0x83; *p++ = 0x14; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00; *p++ = 0x00;
-	*p++ = 0x13; *p++ = 0x03; *p++ = 0x08; *p++ = 0x00; *p++ = 0x00;
+	*p++ = 0x11;
+	*p++ = 0x21;
+	*p++ = 0x00;
+	*p++ = 0x01;
+	*p++ = 0x01;
+	*p++ = 0x25;
+	*p++ = 0x83;
+	*p++ = 0x14;
+	*p++ = 0x00;
+	*p++ = 0x00;
+	*p++ = 0x00;
+	*p++ = 0x00;
+	*p++ = 0x13;
+	*p++ = 0x03;
+	*p++ = 0x08;
+	*p++ = 0x00;
+	*p++ = 0x00;
 	// XInput IN endpoint (interrupt, 0x83, 1 ms)
-	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x83; *p++ = 0x03;
-	*p++ = 0x20; *p++ = 0; *p++ = 1;
+	*p++ = 7;
+	*p++ = TUSB_DESC_ENDPOINT;
+	*p++ = 0x83;
+	*p++ = 0x03;
+	*p++ = 0x20;
+	*p++ = 0;
+	*p++ = 1;
 	// XInput OUT endpoint (interrupt, 0x03, 8 ms)
-	*p++ = 7; *p++ = TUSB_DESC_ENDPOINT; *p++ = 0x03; *p++ = 0x03;
-	*p++ = 0x20; *p++ = 0; *p++ = 8;
+	*p++ = 7;
+	*p++ = TUSB_DESC_ENDPOINT;
+	*p++ = 0x03;
+	*p++ = 0x03;
+	*p++ = 0x20;
+	*p++ = 0;
+	*p++ = 8;
 	return total;
 }
 
 // MS OS 2.0 template (WINUSB compatible id + DeviceInterfaceGUIDs). The
 // function-subset "first interface" byte is patched per mode at init.
 static const uint8_t k_msos_template[MS_OS_20_DESC_LEN] = {
-	U16_TO_U8S_LE(0x000A), U16_TO_U8S_LE(MS_OS_20_SET_HEADER_DESCRIPTOR),
-	U32_TO_U8S_LE(0x06030000), U16_TO_U8S_LE(MS_OS_20_DESC_LEN),
-	U16_TO_U8S_LE(0x0008), U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_CONFIGURATION),
-	0, 0, U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A),
-	U16_TO_U8S_LE(0x0008), U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_FUNCTION),
-	0 /* first interface, patched */, 0,
+	U16_TO_U8S_LE(0x000A),
+	U16_TO_U8S_LE(MS_OS_20_SET_HEADER_DESCRIPTOR),
+	U32_TO_U8S_LE(0x06030000),
+	U16_TO_U8S_LE(MS_OS_20_DESC_LEN),
+	U16_TO_U8S_LE(0x0008),
+	U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_CONFIGURATION),
+	0,
+	0,
+	U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A),
+	U16_TO_U8S_LE(0x0008),
+	U16_TO_U8S_LE(MS_OS_20_SUBSET_HEADER_FUNCTION),
+	0 /* first interface, patched */,
+	0,
 	U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A - 0x08),
-	U16_TO_U8S_LE(0x0014), U16_TO_U8S_LE(MS_OS_20_FEATURE_COMPATBLE_ID),
-	'W', 'I', 'N', 'U', 'S', 'B', 0x00, 0x00,
-	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	U16_TO_U8S_LE(0x0014),
+	U16_TO_U8S_LE(MS_OS_20_FEATURE_COMPATBLE_ID),
+	'W',
+	'I',
+	'N',
+	'U',
+	'S',
+	'B',
+	0x00,
+	0x00,
+	0x00,
+	0x00,
+	0x00,
+	0x00,
+	0x00,
+	0x00,
+	0x00,
+	0x00,
 	U16_TO_U8S_LE(MS_OS_20_DESC_LEN - 0x0A - 0x08 - 0x08 - 0x14),
 	U16_TO_U8S_LE(MS_OS_20_FEATURE_REG_PROPERTY),
-	U16_TO_U8S_LE(0x0007), U16_TO_U8S_LE(0x002A),
-	'D', 0x00, 'e', 0x00, 'v', 0x00, 'i', 0x00, 'c', 0x00, 'e', 0x00, 'I', 0x00,
-	'n', 0x00, 't', 0x00, 'e', 0x00, 'r', 0x00, 'f', 0x00, 'a', 0x00, 'c', 0x00,
-	'e', 0x00, 'G', 0x00, 'U', 0x00, 'I', 0x00, 'D', 0x00, 's', 0x00, 0x00, 0x00,
+	U16_TO_U8S_LE(0x0007),
+	U16_TO_U8S_LE(0x002A),
+	'D',
+	0x00,
+	'e',
+	0x00,
+	'v',
+	0x00,
+	'i',
+	0x00,
+	'c',
+	0x00,
+	'e',
+	0x00,
+	'I',
+	0x00,
+	'n',
+	0x00,
+	't',
+	0x00,
+	'e',
+	0x00,
+	'r',
+	0x00,
+	'f',
+	0x00,
+	'a',
+	0x00,
+	'c',
+	0x00,
+	'e',
+	0x00,
+	'G',
+	0x00,
+	'U',
+	0x00,
+	'I',
+	0x00,
+	'D',
+	0x00,
+	's',
+	0x00,
+	0x00,
+	0x00,
 	U16_TO_U8S_LE(0x0050),
-	'{', 0x00, '9', 0x00, '7', 0x00, '5', 0x00, 'F', 0x00, '4', 0x00, '4', 0x00,
-	'D', 0x00, '9', 0x00, '-', 0x00, '0', 0x00, 'D', 0x00, '0', 0x00, '8', 0x00,
-	'-', 0x00, '4', 0x00, '3', 0x00, 'F', 0x00, 'D', 0x00, '-', 0x00, '8', 0x00,
-	'B', 0x00, '3', 0x00, 'E', 0x00, '-', 0x00, '1', 0x00, '2', 0x00, '7', 0x00,
-	'C', 0x00, 'A', 0x00, '8', 0x00, 'A', 0x00, 'F', 0x00, 'F', 0x00, 'F', 0x00,
-	'9', 0x00, 'D', 0x00, '}', 0x00, 0x00, 0x00, 0x00, 0x00,
+	'{',
+	0x00,
+	'9',
+	0x00,
+	'7',
+	0x00,
+	'5',
+	0x00,
+	'F',
+	0x00,
+	'4',
+	0x00,
+	'4',
+	0x00,
+	'D',
+	0x00,
+	'9',
+	0x00,
+	'-',
+	0x00,
+	'0',
+	0x00,
+	'D',
+	0x00,
+	'0',
+	0x00,
+	'8',
+	0x00,
+	'-',
+	0x00,
+	'4',
+	0x00,
+	'3',
+	0x00,
+	'F',
+	0x00,
+	'D',
+	0x00,
+	'-',
+	0x00,
+	'8',
+	0x00,
+	'B',
+	0x00,
+	'3',
+	0x00,
+	'E',
+	0x00,
+	'-',
+	0x00,
+	'1',
+	0x00,
+	'2',
+	0x00,
+	'7',
+	0x00,
+	'C',
+	0x00,
+	'A',
+	0x00,
+	'8',
+	0x00,
+	'A',
+	0x00,
+	'F',
+	0x00,
+	'F',
+	0x00,
+	'F',
+	0x00,
+	'9',
+	0x00,
+	'D',
+	0x00,
+	'}',
+	0x00,
+	0x00,
+	0x00,
+	0x00,
+	0x00,
 };
 
 void usb_descriptors_init(void)
 {
 	s_mode = settings_mode();
 	s_xinput = mode_is_xinput(s_mode);
-	s_emu = (mode_is_puck(s_mode) || s_xinput) ? NULL : emu_mode_for(s_mode);
+	s_emu = (mode_is_puck(s_mode) || s_xinput) ? NULL :
+						     emu_mode_for(s_mode);
 	if (!mode_is_puck(s_mode) && !s_xinput && !s_emu)
-		s_mode = MODE_STEAM;  // unknown/unimplemented emu mode → puck fallback
+		s_mode =
+			MODE_STEAM; // unknown/unimplemented emu mode → puck fallback
 
 	memset(&s_dev, 0, sizeof(s_dev));
 	s_dev.bLength = sizeof(tusb_desc_device_t);
@@ -165,23 +413,20 @@ void usb_descriptors_init(void)
 		s_dev.idProduct = 0x028E;
 		s_dev.bcdDevice = 0x0120;
 		build_xinput_config(s_emu_cfg);
-		s_vendor_itf = 0;  // WebUSB vendor is itf 0 in the XInput config
+		s_vendor_itf = 0; // WebUSB vendor is itf 0 in the XInput config
 	} else if (s_emu) {
 		s_dev.idVendor = s_emu->vid;
 		s_dev.idProduct = s_emu->pid;
 		s_dev.bcdDevice = s_emu->bcd;
-		// One HID interface per paired controller (min 1) so several connected
-		// controllers each appear as their own pad. Bond count is read from the
-		// persisted label table (BT isn't up yet at descriptor-build time). New
-		// pairings made while already in an emulated mode need a replug/reboot to
-		// gain an interface.
-		uint8_t nb = 0;
-		const pp_cfg_t *c = settings();
-		for (int i = 0; i < 4; i++)
-			if (c->bond[i].used) nb++;
-		s_emu_ifaces = nb < 1 ? 1 : (nb > PP_NSLOT ? PP_NSLOT : nb);
+		// Start with NO gamepad interfaces (nothing is connected at boot); the
+		// dynamic mounter (usb_mount / usb_reenumerate) grows the count to match
+		// the CONNECTED controllers and shrinks it lazily — same behaviour as
+		// OpenPuck (present only connected pads, not bonded ones).
+		s_emu_ifaces = 0;
 		build_emu_config(s_emu_cfg, s_emu, s_emu_ifaces);
-		s_vendor_itf = s_emu_ifaces;  // vendor is the interface after the HIDs
+		s_vendor_itf =
+			s_emu_ifaces; // vendor is the interface after the HIDs
+		rebuild_emu_serial();
 	} else {
 		s_dev.idVendor = PP_USB_VID;
 		s_dev.idProduct = PP_USB_PID;
@@ -190,7 +435,7 @@ void usb_descriptors_init(void)
 	}
 
 	memcpy(s_msos, k_msos_template, MS_OS_20_DESC_LEN);
-	s_msos[22] = s_vendor_itf;  // function-subset "first interface"
+	s_msos[22] = s_vendor_itf; // function-subset "first interface"
 }
 
 const uint8_t *tud_descriptor_device_cb(void)
@@ -204,22 +449,65 @@ const uint8_t *tud_hid_descriptor_report_cb(uint8_t instance)
 	return s_emu ? s_emu->report_desc : PUCK_HID_DESC;
 }
 
+// The emulated-mode serial encodes the mounted-interface count, so the host
+// invalidates its cached config descriptor whenever we re-enumerate up/down
+// (mirrors OpenPuck's usbReenumerate serial bump).
+static void rebuild_emu_serial(void)
+{
+	snprintf(s_emu_serial, sizeof(s_emu_serial), "%.16s.%u", g_usb_serial,
+		 (unsigned)s_emu_ifaces);
+}
+
+// Rebuild the emulated config for `k` HID gamepad interfaces (0..PP_NSLOT) and
+// refresh the vendor-interface index + MS-OS patch + serial. Called by
+// usb_reenumerate() while detached.
+void usb_descriptors_set_emu_ifaces(uint8_t k)
+{
+	if (!s_emu)
+		return;
+	if (k > PP_NSLOT)
+		k = PP_NSLOT;
+	s_emu_ifaces = k;
+	build_emu_config(s_emu_cfg, s_emu, k);
+	s_vendor_itf = k; // vendor is the interface after the k HIDs
+	s_msos[22] = s_vendor_itf;
+	rebuild_emu_serial();
+}
+
+// Platform hook for the shared dynamic mounter: present `k` gamepad interfaces
+// with NO reboot. Rebuild the descriptor, drop off the bus briefly so the host
+// sees the change, then re-attach and re-enumerate. Cooperative single-core loop
+// context, so this can't race tud_task (unlike OpenPuck's RTOS caveat).
+void usb_reenumerate(uint8_t k)
+{
+	usb_descriptors_set_emu_ifaces(k);
+	tud_disconnect();
+	busy_wait_ms(50);
+	tud_connect();
+}
+
 // ---- puck configuration descriptor -----------------------------------------
-#define PUCK_CONFIG_TOTAL_LEN                                                  \
-	(TUD_CONFIG_DESC_LEN + 4 * TUD_HID_DESC_LEN + TUD_VENDOR_DESC_LEN +     \
+#define PUCK_CONFIG_TOTAL_LEN                                               \
+	(TUD_CONFIG_DESC_LEN + 4 * TUD_HID_DESC_LEN + TUD_VENDOR_DESC_LEN + \
 	 CFG_TUD_CDC * TUD_CDC_DESC_LEN)
 
 static const uint8_t desc_configuration_puck[] = {
-	TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, PUCK_CONFIG_TOTAL_LEN, 0x00, 250),
+	TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, PUCK_CONFIG_TOTAL_LEN, 0x00,
+			      250),
 	TUD_HID_DESCRIPTOR(ITF_NUM_HID0, 0, HID_ITF_PROTOCOL_NONE,
-			   PUCK_HID_DESC_SIZE, EPNUM_HID0, CFG_TUD_HID_EP_BUFSIZE, 1),
+			   PUCK_HID_DESC_SIZE, EPNUM_HID0,
+			   CFG_TUD_HID_EP_BUFSIZE, 1),
 	TUD_HID_DESCRIPTOR(ITF_NUM_HID1, 0, HID_ITF_PROTOCOL_NONE,
-			   PUCK_HID_DESC_SIZE, EPNUM_HID1, CFG_TUD_HID_EP_BUFSIZE, 1),
+			   PUCK_HID_DESC_SIZE, EPNUM_HID1,
+			   CFG_TUD_HID_EP_BUFSIZE, 1),
 	TUD_HID_DESCRIPTOR(ITF_NUM_HID2, 0, HID_ITF_PROTOCOL_NONE,
-			   PUCK_HID_DESC_SIZE, EPNUM_HID2, CFG_TUD_HID_EP_BUFSIZE, 1),
+			   PUCK_HID_DESC_SIZE, EPNUM_HID2,
+			   CFG_TUD_HID_EP_BUFSIZE, 1),
 	TUD_HID_DESCRIPTOR(ITF_NUM_HID3, 0, HID_ITF_PROTOCOL_NONE,
-			   PUCK_HID_DESC_SIZE, EPNUM_HID3, CFG_TUD_HID_EP_BUFSIZE, 1),
-	TUD_VENDOR_DESCRIPTOR(ITF_NUM_VENDOR, 4, EPNUM_VENDOR_OUT, EPNUM_VENDOR_IN, 64),
+			   PUCK_HID_DESC_SIZE, EPNUM_HID3,
+			   CFG_TUD_HID_EP_BUFSIZE, 1),
+	TUD_VENDOR_DESCRIPTOR(ITF_NUM_VENDOR, 4, EPNUM_VENDOR_OUT,
+			      EPNUM_VENDOR_IN, 64),
 #if CFG_TUD_CDC
 	TUD_CDC_DESCRIPTOR(ITF_NUM_CDC, 5, EPNUM_CDC_NOTIF, 8, EPNUM_CDC_OUT,
 			   EPNUM_CDC_IN, 64),
@@ -233,14 +521,15 @@ const uint8_t *tud_descriptor_configuration_cb(uint8_t index)
 }
 
 // ---- BOS + MS OS 2.0 --------------------------------------------------------
-#define BOS_TOTAL_LEN                                                          \
-	(TUD_BOS_DESC_LEN + TUD_BOS_WEBUSB_DESC_LEN +                          \
+#define BOS_TOTAL_LEN                                 \
+	(TUD_BOS_DESC_LEN + TUD_BOS_WEBUSB_DESC_LEN + \
 	 TUD_BOS_MICROSOFT_OS_DESC_LEN)
 
 static const uint8_t desc_bos[] = {
 	TUD_BOS_DESCRIPTOR(BOS_TOTAL_LEN, 2),
 	TUD_BOS_WEBUSB_DESCRIPTOR(VENDOR_REQUEST_WEBUSB, 0),
-	TUD_BOS_MS_OS_20_DESCRIPTOR(MS_OS_20_DESC_LEN, VENDOR_REQUEST_MICROSOFT),
+	TUD_BOS_MS_OS_20_DESCRIPTOR(MS_OS_20_DESC_LEN,
+				    VENDOR_REQUEST_MICROSOFT),
 };
 
 const uint8_t *tud_descriptor_bos_cb(void)
@@ -261,7 +550,8 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
 			uint16_t total_len;
 			memcpy(&total_len, s_msos + 8, 2);
 			return tud_control_xfer(rhport, request,
-						(void *)(uintptr_t)s_msos, total_len);
+						(void *)(uintptr_t)s_msos,
+						total_len);
 		}
 		return false;
 	case TUSB_REQ_TYPE_CLASS:
@@ -277,7 +567,13 @@ bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
 }
 
 // ---- string descriptors ----------------------------------------------------
-enum { STRID_LANGID = 0, STRID_MANUFACTURER, STRID_PRODUCT, STRID_SERIAL, STRID_VENDOR };
+enum {
+	STRID_LANGID = 0,
+	STRID_MANUFACTURER,
+	STRID_PRODUCT,
+	STRID_SERIAL,
+	STRID_VENDOR
+};
 static uint16_t desc_str[32];
 
 const uint16_t *tud_descriptor_string_cb(uint8_t index, uint16_t langid)
@@ -298,7 +594,7 @@ const uint16_t *tud_descriptor_string_cb(uint8_t index, uint16_t langid)
 			str = s_emu ? s_emu->product : "Steam Controller Puck";
 			break;
 		case STRID_SERIAL:
-			str = g_usb_serial;
+			str = s_emu ? s_emu_serial : g_usb_serial;
 			break;
 		case STRID_VENDOR:
 			str = "PicoPuck WebUSB";

+ 6 - 3
PicoPuck/src/usb/usb_descriptors.h

@@ -43,9 +43,12 @@ enum {
 // Cache the active mode's descriptors. Call once at boot, before tud_init().
 void usb_descriptors_init(void);
 
-// Number of emulated HID interfaces presented in the active emulated mode (one
-// per paired controller; 1 otherwise). Used to fan the report stream + report
-// callbacks across interface==slot.
+// Number of emulated HID gamepad interfaces currently presented (== the dynamic
+// mount count). Used to fan the report stream across the mounted interfaces.
 uint8_t usb_emu_iface_count(void);
 
+// Rebuild the emulated config for `k` gamepad interfaces (0..PP_NSLOT). Called by
+// usb_reenumerate() (the shared dynamic mounter's platform hook) while detached.
+void usb_descriptors_set_emu_ifaces(uint8_t k);
+
 #endif // PICOPUCK_USB_DESCRIPTORS_H