Ver código fonte

Add per-slot HID mouse to Switch Pro mode for right trackpad

copilot-swe-agent[bot] 4 meses atrás
pai
commit
bab95634de

+ 1 - 1
AGENTS.md

@@ -62,7 +62,7 @@ CI rejects a PR immediately if either fails. The lint lives in
 
 ```sh
 arduino-cli compile -b adafruit:nrf52:feather52840 \
-  --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=4" \
+  --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=5" \
   OpenPuck
 ```
 

+ 11 - 8
OpenPuck/OpenPuck.ino

@@ -38,8 +38,8 @@ using namespace Adafruit_LittleFS_Namespace;
 #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"
+#if CFG_TUD_HID < 5
+#error "build with -DCFG_TUD_HID=5 (extra_flags): Switch Pro needs wake+2*slots HID instances"
 #endif
 
 // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
@@ -62,14 +62,16 @@ void usbReenumerate(uint8_t k)
 	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);
+	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
+	g_active->mountSlots(
+		k); // mode's fixed HIDs (if any) + k slot interfaces
 	if (s_dynWantWebusb)
 		USBDevice.addInterface(usb_web);
 	USBDevice.setConfigurationAttribute(0x80 | 0x20);
@@ -135,7 +137,8 @@ void setup()
 			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
+		usbReenumerate(
+			g_usbMountCount); // build the live descriptor + attach
 	} else {
 		USBDevice.detach();
 		delay(30);

+ 4 - 3
OpenPuck/config.cpp

@@ -39,7 +39,8 @@ 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
+	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;
@@ -67,8 +68,8 @@ const uint32_t g_pollUs = POLL_US_DEFAULT;
 // 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, pollU100, persistMode, bootMode,
-		chordBtn[3], 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
 

+ 4 - 2
OpenPuck/config.h

@@ -120,14 +120,16 @@ struct TypeCfg {
 	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)
+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];
 extern uint8_t g_qamMap;
-extern uint8_t g_padHaptics; // 1 = trackpad haptics on (default), 0 = disabled for the active type
+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();

+ 103 - 8
OpenPuck/mode_switch_pro.cpp

@@ -149,10 +149,26 @@ static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
 		return 0;
 	}
 }
-// NSLOT Pro-Controller HIDs (one per bond slot) + per-slot handshake state (timer, report-mode gate,
-// subcommand-reply FIFO), per-slot reply queue indices, per-slot last-stream millis, per-slot MAC, and
-// per-slot user-cal SPI mirror.
+// HID mouse descriptor for the per-slot right-trackpad mice (relative, report ID 1).
+static const uint8_t SW_MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE(
+	HID_REPORT_ID(1)) };
+
+// NSLOT Pro-Controller HIDs + NSLOT right-trackpad mice (one pair per bond slot).
+// Per-slot handshake state (timer, report-mode gate, subcommand-reply FIFO), per-slot
+// reply queue indices, per-slot last-stream millis, per-slot MAC, and per-slot
+// user-cal SPI mirror.
 static Adafruit_USBD_HID g_swPro[NSLOT];
+// Per-slot mouse HID interface (right trackpad -> relative mouse motion with glide).
+static Adafruit_USBD_HID g_swMouse[NSLOT];
+// Per-slot mouse glide state.
+static float g_swMouseVx[NSLOT];
+static float g_swMouseVy[NSLOT];
+static int g_swMousePrx[NSLOT];
+static int g_swMousePry[NSLOT];
+static bool g_swMouseWasTouching[NSLOT];
+static float g_swMouseRmx[NSLOT];
+static float g_swMouseRmy[NSLOT];
+static uint8_t g_swMousePmbtn[NSLOT];
 static unsigned long g_swProLastMs[NSLOT] = { 0 };
 // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
 static uint8_t g_swProReportMode[NSLOT] = { 0 };
@@ -205,7 +221,8 @@ 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, jcBondOf(slot)); // route to the mapped controller
+	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
@@ -224,7 +241,8 @@ 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)
 {
-	uint8_t bond = jcBondOf(slot); // input data comes from the mapped controller; timer/state stay per USB slot
+	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);
@@ -298,7 +316,8 @@ static void jcInputPrefix(uint8_t slot, uint8_t *out)
 }
 static void switchProBuild(uint8_t slot, uint8_t out[63])
 {
-	uint8_t bond = jcBondOf(slot); // IMU data comes from the mapped controller
+	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
@@ -702,10 +721,10 @@ static jc_setcb_t const JC_SETCB[NSLOT] = { jcSet0, jcSet1, jcSet2, jcSet3 };
 void SwitchProController::begin()
 {
 }
-// Wake mouse (1 HID) is present in Switch mode, leaving CFG_TUD_HID-1 for the Pro Controller pool.
+// Wake mouse (1 HID) + 2 HIDs per slot (Pro Controller + right-trackpad mouse).
 uint8_t SwitchProController::maxSlots() const
 {
-	uint8_t cap = (uint8_t)(CFG_TUD_HID - 1);
+	uint8_t cap = (uint8_t)((CFG_TUD_HID - 1) / 2);
 	return cap < NSLOT ? cap : (uint8_t)NSLOT;
 }
 void SwitchProController::usbIdentity()
@@ -730,6 +749,15 @@ void SwitchProController::beginPool()
 		g_swPro[s].setPollInterval(1);
 		g_swPro[s].begin();
 	}
+	// Per-slot mice registered after the Pro Controller pool so their TinyUSB
+	// instance indices are stable (wake=0, pro[0..N-1], mouse[0..N-1]).
+	for (uint8_t s = 0; s < pool; s++) {
+		g_swMouse[s].setStringDescriptor("Pro Controller Mouse");
+		g_swMouse[s].setReportDescriptor(SW_MOUSE_HID_DESC,
+						 sizeof SW_MOUSE_HID_DESC);
+		g_swMouse[s].setPollInterval(1);
+		g_swMouse[s].begin();
+	}
 }
 void SwitchProController::mountSlots(uint8_t k)
 {
@@ -740,6 +768,17 @@ void SwitchProController::mountSlots(uint8_t k)
 		g_jcQh[u] = g_jcQt[u] = 0;
 		USBDevice.addInterface(g_swPro[u]);
 	}
+	// Per-slot mice mounted after all Pro Controllers; also reset glide state so a
+	// re-enumeration doesn't carry stale velocity or a stuck button.
+	for (uint8_t u = 0; u < k; u++) {
+		g_swMouseVx[u] = 0;
+		g_swMouseVy[u] = 0;
+		g_swMouseWasTouching[u] = false;
+		g_swMouseRmx[u] = 0;
+		g_swMouseRmy[u] = 0;
+		g_swMousePmbtn[u] = 0;
+		USBDevice.addInterface(g_swMouse[u]);
+	}
 }
 void SwitchProController::task()
 {
@@ -780,4 +819,60 @@ void SwitchProController::task()
 		switchProBuild((uint8_t)s, p);
 		g_swPro[s].sendReport(0x30, p, sizeof p);
 	}
+	// Per-slot mice: right trackpad -> relative mouse motion with glide/friction.
+	// Runs independently of the Pro Controller handshake so the mouse is usable on
+	// a PC host even before (or without) a Nintendo Switch connection.
+	for (uint8_t s = 0; s < g_usbMountCount; s++) {
+		if (!g_swMouse[s].ready())
+			continue;
+		uint8_t bond = jcBondOf(s);
+		bool rtouch = (g_in[bond].buttons & TB_RPADT) != 0;
+		int rx = g_in[bond].rpx;
+		int ry = g_in[bond].rpy;
+		if (rtouch) {
+			if (g_swMouseWasTouching[s]) {
+				g_swMouseVx[s] += (float)(rx - g_swMousePrx[s]);
+				g_swMouseVy[s] += (float)(ry - g_swMousePry[s]);
+			}
+			g_swMousePrx[s] = rx;
+			g_swMousePry[s] = ry;
+		}
+		g_swMouseWasTouching[s] = rtouch;
+		// Y-inverted; divisor matches lizard/xbox trackpad-mouse sensitivity.
+		float mxf =
+			g_swMouseVx[s] / (float)(g_mDiv * 10) + g_swMouseRmx[s];
+		float myf = -(g_swMouseVy[s] / (float)(g_mDiv * 10)) +
+			    g_swMouseRmy[s];
+		int dx = (int)mxf;
+		int dy = (int)myf;
+		g_swMouseRmx[s] = mxf - (float)dx;
+		g_swMouseRmy[s] = myf - (float)dy;
+		if (dx > 127)
+			dx = 127;
+		else if (dx < -127)
+			dx = -127;
+		if (dy > 127)
+			dy = 127;
+		else if (dy < -127)
+			dy = -127;
+		float f = g_mFric / 100.0f;
+		g_swMouseVx[s] *= f;
+		g_swMouseVy[s] *= f;
+		if (g_swMouseVx[s] > -1.0f && g_swMouseVx[s] < 1.0f)
+			g_swMouseVx[s] = 0;
+		if (g_swMouseVy[s] > -1.0f && g_swMouseVy[s] < 1.0f)
+			g_swMouseVy[s] = 0;
+		// Right-pad click -> primary mouse button.
+		uint8_t mbtn = (g_in[bond].buttons & TB_RPADC) ? 1u : 0u;
+		if (dx || dy || mbtn != g_swMousePmbtn[s]) {
+			g_swMousePmbtn[s] = mbtn;
+			hid_mouse_report_t m;
+			m.buttons = mbtn;
+			m.x = (int8_t)dx;
+			m.y = (int8_t)dy;
+			m.wheel = 0;
+			m.pan = 0;
+			g_swMouse[s].sendReport(1, &m, sizeof m);
+		}
+	}
 }

+ 2 - 1
OpenPuck/mode_xinput.cpp

@@ -82,7 +82,8 @@ 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
+	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;

+ 7 - 5
OpenPuck/radio.cpp

@@ -43,15 +43,17 @@ void rfGenSessionAddr(int slot)
 		}
 	uint32_t h, h2;
 	if (uuidLive) {
-		h = (uint32_t)uuid[0] * 0x9E3779B1u ^ (uint32_t)uuid[2] * 0x85EBCA6Bu ^
+		h = (uint32_t)uuid[0] * 0x9E3779B1u ^
+		    (uint32_t)uuid[2] * 0x85EBCA6Bu ^
 		    (uint32_t)uuid[4] * 0xC2B2AE35u ^ (uint32_t)uuid[6] ^
 		    NRF_FICR->DEVICEID[0];
-		h2 = (uint32_t)uuid[1] * 0x27D4EB2Fu ^ (uint32_t)uuid[3] * 0x165667B1u ^
-		     (uint32_t)uuid[5] ^ (uint32_t)uuid[7] * 0x9E3779B1u ^
-		     NRF_FICR->DEVICEID[1];
+		h2 = (uint32_t)uuid[1] * 0x27D4EB2Fu ^
+		     (uint32_t)uuid[3] * 0x165667B1u ^ (uint32_t)uuid[5] ^
+		     (uint32_t)uuid[7] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
 	} else {
 		h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
-		h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^ NRF_FICR->DEVICEID[0];
+		h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^
+		     NRF_FICR->DEVICEID[0];
 	}
 	// Scrub degenerate bytes (0x00/0xFF) for clean preamble correlation.
 	for (int i = 0; i < 4; i++) {

+ 4 - 2
OpenPuck/rf_link.cpp

@@ -28,8 +28,10 @@ uint8_t g_e3mode = 1;
 // (a bare E3) was answered by F1 immediately. That contradicts the earlier RE "recipe" (rf_link.h) which
 // assumed E7 + GET-0x45 were required. So these now default to the real-puck behavior; flip them at runtime
 // ('d'/'n' console cmds) to fall back to the legacy GET/E7 path for an A/B comparison on hardware.
-bool g_pollGet = false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
-bool g_e7announce = false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
+bool g_pollGet =
+	false; // false = bare E3 poll (real puck); true = append GET-report-0x45 TLV (legacy)
+bool g_e7announce =
+	false; // false = no E7 awake-announce (real puck); true = announce host-awake (legacy)
 // Session-channel E1 host-frame keepalive. The real puck sends NO E1 on its session channel (the bonded
 // controller already knows the per-bond address and just resumes). OpenPuck still needs E1 because it runs
 // the SHARED "ibex" address, not the (un-reversed) per-bond address -- E1 is how the controller learns this

+ 8 - 4
OpenPuck/rf_link.h

@@ -34,9 +34,12 @@ extern uint8_t g_getParam; // GET report 0x45 param byte
 extern uint8_t g_e3mode;
 
 // Real-puck alignment toggles (default to the captured real-puck behavior; flip on the CDC console to A/B):
-extern bool g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
-extern bool g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
-extern bool g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
+extern bool
+	g_pollGet; // 'd': false=bare E3 poll (real puck), true=append GET-report-0x45 TLV (legacy)
+extern bool
+	g_e7announce; // 'n': false=no E7 awake-announce (real puck), true=announce host-awake (legacy)
+extern bool
+	g_e1keepalive; // 'm': session-channel E1 host-frame keepalive (ON by default; needed for shared addr)
 
 // full multi-line debug dump vs compact "I45 <hex>" stream
 extern bool g_connVerbose;
@@ -48,7 +51,8 @@ extern unsigned long g_connCooldown;
 // connected-mode state (reset by the 'k' console toggle)
 extern uint8_t g_connSt, g_connStep;
 extern uint16_t g_connPoll;
-extern uint32_t g_connF1; // count of 0xF1 input reports seen (aggregate across all slots)
+extern uint32_t
+	g_connF1; // count of 0xF1 input reports seen (aggregate across all slots)
 
 // last protocol version the controller reported in an F3 reply (0xFF=none) -- aggregate (last writer wins)
 extern uint8_t g_connF3v;

+ 7 - 4
OpenPuck/serial_console.cpp

@@ -177,7 +177,8 @@ void serialConsolePoll()
 				Serial.printf(
 					"# CONN mode %s: %s%s poll on ch%u (param=%02X). F1 seen=%lu\n",
 					g_connOn ? "ON" : "off",
-					g_e7announce ? "E7-awake[00 00] -> " : "",
+					g_e7announce ? "E7-awake[00 00] -> " :
+						       "",
 					g_pollGet ? "E3+GET-report-0x45" :
 						    "bare E3",
 					g_rfCh, g_getParam,
@@ -222,7 +223,8 @@ void serialConsolePoll()
 					saveCfg();
 					Serial.printf(
 						"# A/B + X/Y swap %s (Nintendo layout) [type %u]\n",
-						g_abSwap ? "ON" : "off", g_etype);
+						g_abSwap ? "ON" : "off",
+						g_etype);
 				} else
 					Serial.println(
 						"# A/B swap N/A in this mode (Steam/Lizard are not emulated types)");
@@ -382,8 +384,9 @@ void serialConsolePoll()
 				g_pollGet = !g_pollGet;
 				Serial.printf(
 					"# poll = %s (real puck sends BARE E3) - watch F1=/s new=/s\n",
-					g_pollGet ? "E3 + GET-report-0x45 TLV (legacy)" :
-						    "bare E3");
+					g_pollGet ?
+						"E3 + GET-report-0x45 TLV (legacy)" :
+						"bare E3");
 			} else if (line[0] == 'n') {
 				g_e7announce = !g_e7announce;
 				Serial.printf(

+ 4 - 2
OpenPuck/webusb_config.cpp

@@ -265,7 +265,8 @@ void webusbPoll()
 						else if (k == 4)
 							g_type[et].qamMap = v;
 						else if (k == 5)
-							g_type[et].abSwap = v ? 1 : 0;
+							g_type[et].abSwap =
+								v ? 1 : 0;
 						else if (k == 6)
 							g_type[et].padHaptics =
 								v ? 1 : 0;
@@ -289,7 +290,8 @@ void webusbPoll()
 				// Legacy single-value fields (4 abSwap, 5-8 back, 21 qam) edit the ACTIVE emulated type.
 				case 4:
 					if (g_etype < ET_COUNT) {
-						g_type[g_etype].abSwap = v ? 1 : 0;
+						g_type[g_etype].abSwap = v ? 1 :
+									     0;
 						applyActiveType();
 					}
 					break;

+ 40 - 20
puck_sniffer/puck_sniffer.ino

@@ -55,7 +55,8 @@ struct Cap;
 #define BONDFILE "/sniffbonds.bin"
 #define BOND_MAGIC 0x53424E32u // "SBN2"
 #define NBONDS 4
-#define BOND_NCH 6 // learned channels per bond (the map is ~4; a couple of spares)
+#define BOND_NCH \
+	6 // learned channels per bond (the map is ~4; a couple of spares)
 struct Bond {
 	uint8_t uuid[4]; // ibex_uuid key; all-zero = empty slot
 	uint8_t base[4]; // session ESB base
@@ -66,7 +67,8 @@ struct Bond {
 };
 static Bond g_bonds[NBONDS];
 static int g_curBond = -1; // index of the bond we're camped on (-1 = none)
-static bool g_bondDirty = false; // a learn changed the table -> flush to flash in loop()
+static bool g_bondDirty =
+	false; // a learn changed the table -> flush to flash in loop()
 static unsigned long g_lastBondSave = 0;
 
 static bool uuidSet(const uint8_t u[4])
@@ -153,7 +155,8 @@ struct Cap {
 	uint8_t ch, flags, rssi, match, n;
 	uint8_t b[96];
 };
-#define RINGN 1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
+#define RINGN \
+	1024 // ~2 s of headroom at 500 fps so a burst rides out a brief host hiccup without dropping
 static Cap g_ring[RINGN];
 static volatile uint16_t g_head = 0, g_tail = 0;
 static inline uint16_t rnext(uint16_t i)
@@ -168,7 +171,8 @@ static void tuneSession();
 static bool g_streaming = false; // app sent START -> emit packet frames
 // g_state/g_curCh are written by loop() (retune) and READ by the RADIO END ISR (which stamps each captured frame
 // with the current channel/state) -> volatile so the ISR never sees a cached value.
-static volatile uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
+static volatile uint8_t g_state =
+	0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
 static volatile uint8_t g_curCh = 2;
 static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
 static uint8_t g_sPrefix = 0x10;
@@ -201,7 +205,8 @@ static uint8_t g_advCh = 0, g_advBase[4] = { 0, 0, 0, 0 }, g_advPfx = 0;
 // this follows a QoS channel hop. Only after a couple of fully-dry sweeps do we fall back to discovery.
 static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
 static uint8_t g_sweepIdx = 0;
-static volatile uint8_t g_sweepDry = 0; // reset in the RADIO ISR on any session frame
+static volatile uint8_t g_sweepDry =
+	0; // reset in the RADIO ISR on any session frame
 
 // Gazell session-hunt: the puck's 5Hz E1/E2 keepalive trickles in on the primary channel even when the
 // controller isn't being actively polled, so "no packets" never fires the sweep. The ACTIVE session (E3 poll +
@@ -426,17 +431,28 @@ static void emitStatus()
 	uint8_t curN = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
 	uint8_t bondInfo = (uint8_t)((g_curBond >= 0 ? 0x80 : 0) |
 				     ((nbonds & 0x7) << 4) | (curN & 0xF));
-	uint8_t f[22] = { 0xC1,		  0xDE,
-			  g_state,	  g_curCh,
-			  g_sBase[0],	  g_sBase[1],
-			  g_sBase[2],	  g_sBase[3],
-			  g_sPrefix,	  (uint8_t)(g_streaming ? 1 : 0),
-			  (uint8_t)rx,	  (uint8_t)(rx >> 8),
-			  g_advCh,	  g_advBase[0],
-			  g_advBase[1],	  g_advBase[2],
-			  g_advBase[3],	  g_advPfx,
-			  g_lastMatch,	  (uint8_t)drops,
-			  (uint8_t)(drops >> 8), bondInfo };
+	uint8_t f[22] = { 0xC1,
+			  0xDE,
+			  g_state,
+			  g_curCh,
+			  g_sBase[0],
+			  g_sBase[1],
+			  g_sBase[2],
+			  g_sBase[3],
+			  g_sPrefix,
+			  (uint8_t)(g_streaming ? 1 : 0),
+			  (uint8_t)rx,
+			  (uint8_t)(rx >> 8),
+			  g_advCh,
+			  g_advBase[0],
+			  g_advBase[1],
+			  g_advBase[2],
+			  g_advBase[3],
+			  g_advPfx,
+			  g_lastMatch,
+			  (uint8_t)drops,
+			  (uint8_t)(drops >> 8),
+			  bondInfo };
 	usb_web.write(f, sizeof f);
 	usb_web.flush();
 }
@@ -451,9 +467,11 @@ static void consumeRing()
 	// bound per-call so the loop stays responsive; the 512-deep ring absorbs anything beyond this
 	uint16_t budget = 128;
 	while (budget-- && g_tail != g_head) {
-		Cap c = g_ring[g_tail]; // copy out before releasing the slot to the producer
+		Cap c = g_ring
+			[g_tail]; // copy out before releasing the slot to the producer
 		g_tail = rnext(g_tail);
-		bool retuned = processFrame(c); // may retune the radio (acquire / channel hop)
+		bool retuned = processFrame(
+			c); // may retune the radio (acquire / channel hop)
 		if (emit)
 			emitPacket(c);
 		// On a retune, stop draining this round: the remaining queued frames were captured on the OLD channel, and
@@ -586,7 +604,8 @@ void setup()
 		if (uuidSet(g_bonds[i].uuid))
 			count++;
 	if (count == 1)
-		campBond(b0); // sole known bond -> adopt immediately (unambiguous)
+		campBond(
+			b0); // sole known bond -> adopt immediately (unambiguous)
 	else
 		tuneDiscovery(); // 0 bonds, or several -> discover / wait for a uuid lock
 	g_lastRx = millis();
@@ -612,7 +631,8 @@ void loop()
 	    millis() - g_lastFrx > HUNT_NOFRX_MS &&
 	    millis() - g_huntMs > HUNT_DWELL_MS) {
 		g_huntMs = millis();
-		uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans : 0;
+		uint8_t learned = (g_curBond >= 0) ? g_bonds[g_curBond].nchans :
+						     0;
 		uint8_t total = learned + 1 + (uint8_t)sizeof HUNT_CH;
 		uint8_t slot = g_huntIdx;
 		uint8_t ch;