Ver Fonte

clang-format pass

Jariullah Safi há 4 meses atrás
pai
commit
40fdffb117
49 ficheiros alterados com 6567 adições e 3277 exclusões
  1. 179 103
      OpenPuck/OpenPuck.ino
  2. 33 15
      OpenPuck/bonds.cpp
  3. 12 5
      OpenPuck/bonds.h
  4. 3 3
      OpenPuck/build_info.h
  5. 135 59
      OpenPuck/config.cpp
  6. 59 33
      OpenPuck/config.h
  7. 25 14
      OpenPuck/controllers.cpp
  8. 33 13
      OpenPuck/controllers.h
  9. 197 69
      OpenPuck/gamepad_util.cpp
  10. 52 14
      OpenPuck/gamepad_util.h
  11. 410 229
      OpenPuck/haptics.cpp
  12. 39 24
      OpenPuck/haptics.h
  13. 12 7
      OpenPuck/identity.cpp
  14. 4 3
      OpenPuck/identity.h
  15. 104 62
      OpenPuck/mode_hidgyro.cpp
  16. 3 3
      OpenPuck/mode_hidgyro.h
  17. 170 63
      OpenPuck/mode_lizard.cpp
  18. 2 1
      OpenPuck/mode_lizard.h
  19. 150 92
      OpenPuck/mode_ps5.cpp
  20. 3 3
      OpenPuck/mode_ps5.h
  21. 162 64
      OpenPuck/mode_switch_hori.cpp
  22. 3 3
      OpenPuck/mode_switch_hori.h
  23. 584 305
      OpenPuck/mode_switch_pro.cpp
  24. 3 3
      OpenPuck/mode_switch_pro.h
  25. 371 119
      OpenPuck/mode_xinput.cpp
  26. 6 4
      OpenPuck/mode_xinput.h
  27. 445 257
      OpenPuck/puck_hid.cpp
  28. 13 7
      OpenPuck/puck_hid.h
  29. 90 51
      OpenPuck/radio.cpp
  30. 27 18
      OpenPuck/radio.h
  31. 481 242
      OpenPuck/rf_diag.cpp
  32. 18 17
      OpenPuck/rf_diag.h
  33. 633 292
      OpenPuck/rf_link.cpp
  34. 29 20
      OpenPuck/rf_link.h
  35. 368 74
      OpenPuck/serial_console.cpp
  36. 1 1
      OpenPuck/serial_console.h
  37. 23 14
      OpenPuck/status_led.cpp
  38. 8 6
      OpenPuck/status_led.h
  39. 9 3
      OpenPuck/triton.cpp
  40. 58 39
      OpenPuck/triton.h
  41. 8 6
      OpenPuck/wake_hid.cpp
  42. 1 1
      OpenPuck/wake_hid.h
  43. 255 104
      OpenPuck/webusb_config.cpp
  44. 1 1
      OpenPuck/webusb_config.h
  45. 1 0
      README.md
  46. 905 582
      pairtui/scmd.c
  47. 297 150
      puck_sniffer/puck_sniffer.ino
  48. 116 61
      puck_sniffer/radio.cpp
  49. 26 18
      puck_sniffer/radio.h

+ 179 - 103
OpenPuck/OpenPuck.ino

@@ -20,7 +20,7 @@
 using namespace Adafruit_LittleFS_Namespace;
 
 #include "config.h"
-#include "build_info.h"   // OPK_GIT_HASH (used to tag the one-time factory-reset build)
+#include "build_info.h" // OPK_GIT_HASH (used to tag the one-time factory-reset build)
 #include "identity.h"
 #include "bonds.h"
 #include "radio.h"
@@ -37,123 +37,199 @@ using namespace Adafruit_LittleFS_Namespace;
 #error "build with -DCFG_TUD_HID=4 (extra_flags): up to 4 HID interfaces per mode"
 #endif
 
-static uint8_t g_usbCfgDesc[512];   // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
+static uint8_t g_usbCfgDesc
+	[512]; // puck composite (4 HID + WebUSB) exceeds the default 256 B config buffer
 
-void setup() {
-  genSerial();
-  ledInit();
-  rfGenSessionAddr();   // per-device unique RF session address (advertised in the host frame; isolates pucks)
-  InternalFS.begin();
+void setup()
+{
+	genSerial();
+	ledInit();
+	rfGenSessionAddr(); // per-device unique RF session address (advertised in the host frame; isolates pucks)
+	InternalFS.begin();
 #if OPK_FACTORY_RESET
-  // Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
-  // then persist normally (tracked by a git-hash tag so it does NOT wipe every boot). See config.h / config.cpp.
-  factoryResetOnce(OPK_GIT_HASH);
+	// Recovery build (-DOPK_FACTORY_RESET=1): wipe ALL persistent storage ONCE on the first boot after flashing,
+	// then persist normally (tracked by a git-hash tag so it does NOT wipe every boot). See config.h / config.cpp.
+	factoryResetOnce(OPK_GIT_HASH);
 #endif
-  loadCfg(); g_xbox = !modeIsPuck(g_usbMode);   // load persisted config + decide USB presentation BEFORE registering interfaces
-  g_active = controllerFor(g_usbMode);
+	loadCfg();
+	g_xbox = !modeIsPuck(
+		g_usbMode); // load persisted config + decide USB presentation BEFORE registering interfaces
+	g_active = controllerFor(g_usbMode);
 
-  // ---- USB descriptor rebuild ----
-  // Puck mode NORMALLY drops the CDC serial console (clearConfiguration) to free a USB endpoint for the
-  // wake-mouse interface that lets it wake a sleeping Windows host. The one-shot debug arm (g_debugCdcThisBoot,
-  // set via the WebUSB panel / CDC 'D', auto-reverting after one boot) instead KEEPS the boot CDC composite
-  // for that boot and skips the wake mouse (no endpoint room for both). Clean modes always rebuild bare.
-  const bool puckMode = g_active->isPuck();
-  const bool keepCdc  = puckMode && g_debugCdcThisBoot;
-  // The "game" PlayStation modes enumerate as a CLEAN single HID gamepad so games that classify off the raw HID
-  // device (Fortnite/UE GameInput, Windows.Gaming.Input) recognise them as PlayStation. A real Sony pad is just
-  // the gamepad HID (+ a USB-audio interface); our extra wake-mouse HID and WebUSB vendor interface make those
-  // classifiers refuse the PS glyph path even with the correct VID/PID (SDL/Steam tolerate the extras, which is
-  // why Steam still sees the normal PS modes). In clean-PS modes we skip BOTH the wake mouse and WebUSB -- the
-  // cost is no config panel / host-wake; chord back to Steam (back-paddle 4 + A) to reach the panel. The normal
-  // MODE_PS5 / MODE_HIDGYRO keep wake + panel (use those when you need host-wake; the *_GAME ones for games).
-  const bool psClean  = modeIsCleanPS(g_usbMode);
-  USBDevice.detach(); delay(30);
-  if (keepCdc) {
-    USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);   // keep boot CDC composite (debug boot)
-  } else {
-    USBDevice.clearConfiguration();
-    USBDevice.setConfigurationBuffer(g_usbCfgDesc, sizeof g_usbCfgDesc);   // headroom over the default 256 B cap
-  }
+	// ---- USB descriptor rebuild ----
+	// Puck mode NORMALLY drops the CDC serial console (clearConfiguration) to free a USB endpoint for the
+	// wake-mouse interface that lets it wake a sleeping Windows host. The one-shot debug arm (g_debugCdcThisBoot,
+	// set via the WebUSB panel / CDC 'D', auto-reverting after one boot) instead KEEPS the boot CDC composite
+	// for that boot and skips the wake mouse (no endpoint room for both). Clean modes always rebuild bare.
+	const bool puckMode = g_active->isPuck();
+	const bool keepCdc = puckMode && g_debugCdcThisBoot;
+	// The "game" PlayStation modes enumerate as a CLEAN single HID gamepad so games that classify off the raw HID
+	// device (Fortnite/UE GameInput, Windows.Gaming.Input) recognise them as PlayStation. A real Sony pad is just
+	// the gamepad HID (+ a USB-audio interface); our extra wake-mouse HID and WebUSB vendor interface make those
+	// classifiers refuse the PS glyph path even with the correct VID/PID (SDL/Steam tolerate the extras, which is
+	// why Steam still sees the normal PS modes). In clean-PS modes we skip BOTH the wake mouse and WebUSB -- the
+	// cost is no config panel / host-wake; chord back to Steam (back-paddle 4 + A) to reach the panel. The normal
+	// MODE_PS5 / MODE_HIDGYRO keep wake + panel (use those when you need host-wake; the *_GAME ones for games).
+	const bool psClean = modeIsCleanPS(g_usbMode);
+	USBDevice.detach();
+	delay(30);
+	if (keepCdc) {
+		USBDevice.setConfigurationBuffer(
+			g_usbCfgDesc,
+			sizeof g_usbCfgDesc); // keep boot CDC composite (debug boot)
+	} else {
+		USBDevice.clearConfiguration();
+		USBDevice.setConfigurationBuffer(
+			g_usbCfgDesc,
+			sizeof g_usbCfgDesc); // headroom over the default 256 B cap
+	}
 
-  // 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 { snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit, MODE_SUFFIX[g_usbMode-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.
+	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 {
+		snprintf(g_usbSerial, sizeof g_usbSerial, "%s%c", g_unit,
+			 MODE_SUFFIX[g_usbMode - 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();   // register this mode's USB interface(s) + set VID/PID/strings
+	g_active->begin(); // register this mode's USB interface(s) + set VID/PID/strings
 
-  // 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();
-  // Enable USB Remote Wakeup (bit 5) so the host lets us signal wake-from-sleep. Bit 7 is always required.
-  USBDevice.setConfigurationAttribute(0x80 | 0x20);  // bmAttributes: required(0x80) | remote_wakeup(0x20)
-  USBDevice.attach();   // re-connect with the final descriptor (host re-reads it fresh -> deterministic enumeration)
-  Serial.begin(115200);
-  for (int i=0; i<300 && !USBDevice.mounted(); i++) delay(10);   // wait up to 3s for USB mount, but NEVER hang
-  if (USBDevice.suspended()){ USBDevice.remoteWakeup(); ledWakePulse(); }   // wake host if bus was sleeping when we (re-)attached
-  loadBonds();
-  hapticInit();   // clear relay/active flags + arm the reconnect block & initial stop burst
-  static const char* MODE_NAME[]={"STEAM(puck)","XBOX(xinput+mouse)","SWITCH(horipad)","LIZARD(puck kb/mouse)","SWITCH(pro+gyro)","PS5(dualsense)","HIDGYRO(ds4+motion)","PS5(dualsense,game/clean)","DS4(ds4,game/clean)"};
-  Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit, g_board, MODE_NAME[g_usbMode<=MODE_MAX?g_usbMode:0]);
-  if (puckMode) Serial.printf("# puck USB: %s\n", keepCdc ? "DEBUG boot (CDC console on, wake mouse off; reverts next boot)" : "normal (CDC off, wake mouse on)");
-  Serial.printf("# session addr %02X%02X%02X%02X/%02X ch%u (discovery on ibex/ch2)\n",
-                g_sessBase[0],g_sessBase[1],g_sessBase[2],g_sessBase[3],g_sessPrefix,g_sessCh);
-  // Hardware watchdog: if loop() ever stops feeding it (a wedged radio busy-wait, a HardFault spin, a blocked
-  // CDC write) the WDT resets the nRF52 after ~8s -- re-enumerating USB and re-initialising RF on its own, so a
-  // hang no longer needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.
-  NRF_WDT->CONFIG  = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) | (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
-  NRF_WDT->CRV     = 8UL * 32768UL - 1;     // timeout in 32.768 kHz ticks (~8 s)
-  NRF_WDT->RREN    = WDT_RREN_RR0_Msk;       // arm reload register 0
-  NRF_WDT->TASKS_START = 1;
+	// 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();
+	// Enable USB Remote Wakeup (bit 5) so the host lets us signal wake-from-sleep. Bit 7 is always required.
+	USBDevice.setConfigurationAttribute(
+		0x80 |
+		0x20); // bmAttributes: required(0x80) | remote_wakeup(0x20)
+	USBDevice.attach(); // re-connect with the final descriptor (host re-reads it fresh -> deterministic enumeration)
+	Serial.begin(115200);
+	for (int i = 0; i < 300 && !USBDevice.mounted(); i++)
+		delay(10); // wait up to 3s for USB mount, but NEVER hang
+	if (USBDevice.suspended()) {
+		USBDevice.remoteWakeup();
+		ledWakePulse();
+	} // wake host if bus was sleeping when we (re-)attached
+	loadBonds();
+	hapticInit(); // clear relay/active flags + arm the reconnect block & initial stop burst
+	static const char *MODE_NAME[] = {
+		"STEAM(puck)",	       "XBOX(xinput+mouse)",
+		"SWITCH(horipad)",     "LIZARD(puck kb/mouse)",
+		"SWITCH(pro+gyro)",    "PS5(dualsense)",
+		"HIDGYRO(ds4+motion)", "PS5(dualsense,game/clean)",
+		"DS4(ds4,game/clean)"
+	};
+	Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
+		      g_board,
+		      MODE_NAME[g_usbMode <= MODE_MAX ? g_usbMode : 0]);
+	if (puckMode)
+		Serial.printf(
+			"# puck USB: %s\n",
+			keepCdc ?
+				"DEBUG boot (CDC console on, wake mouse off; reverts next boot)" :
+				"normal (CDC off, wake mouse on)");
+	Serial.printf(
+		"# session addr %02X%02X%02X%02X/%02X ch%u (discovery on ibex/ch2)\n",
+		g_sessBase[0], g_sessBase[1], g_sessBase[2], g_sessBase[3],
+		g_sessPrefix, g_sessCh);
+	// Hardware watchdog: if loop() ever stops feeding it (a wedged radio busy-wait, a HardFault spin, a blocked
+	// CDC write) the WDT resets the nRF52 after ~8s -- re-enumerating USB and re-initialising RF on its own, so a
+	// hang no longer needs a physical replug. RUN keeps it counting in sleep; PAUSE freezes it under a debugger.
+	NRF_WDT->CONFIG = (WDT_CONFIG_HALT_Pause << WDT_CONFIG_HALT_Pos) |
+			  (WDT_CONFIG_SLEEP_Run << WDT_CONFIG_SLEEP_Pos);
+	NRF_WDT->CRV = 8UL * 32768UL - 1; // timeout in 32.768 kHz ticks (~8 s)
+	NRF_WDT->RREN = WDT_RREN_RR0_Msk; // arm reload register 0
+	NRF_WDT->TASKS_START = 1;
 }
 
 // loop-timing diagnostics: the poll rate is capped by the loop ITERATION time (the pacing wants 4000us but a
 // slow loop fires the poll only as often as it comes around). Per-section us is accumulated and, each second,
 // the average loop period + the slowest section are published for the WebUSB panel to pinpoint the culprit.
-uint16_t g_loopPeriodUs = 0;   // avg loop iteration time last second (1e6/iterations)
-uint8_t  g_loopWorst    = 0;   // index of the slowest section: 0=webusb 1=ctrl.task 2=serial 3=rfdiag 4=rflink 5=haptic 6=led
-uint16_t g_loopWorstUs  = 0;   // that section's avg us per iteration
-void loop() {
-  NRF_WDT->RR[0] = WDT_RR_RR_Reload;   // feed the watchdog each loop; if we ever stop, the ~8s WDT auto-resets us
-  if (g_dirty) { g_dirty = false; saveBonds(); }
+uint16_t g_loopPeriodUs =
+	0; // avg loop iteration time last second (1e6/iterations)
+uint8_t g_loopWorst =
+	0; // index of the slowest section: 0=webusb 1=ctrl.task 2=serial 3=rfdiag 4=rflink 5=haptic 6=led
+uint16_t g_loopWorstUs = 0; // that section's avg us per iteration
+void loop()
+{
+	NRF_WDT->RR[0] =
+		WDT_RR_RR_Reload; // feed the watchdog each loop; if we ever stop, the ~8s WDT auto-resets us
+	if (g_dirty) {
+		g_dirty = false;
+		saveBonds();
+	}
 #if OPK_LOG
-  // Diagnostic build: time each loop section so the panel can show the slowest one + the loop period.
-  static uint32_t acc[7] = {0}; static uint32_t loops = 0; static unsigned long secMs = 0;
-  uint32_t t;
-  t=micros(); webusbPoll();                   acc[0]+=(uint32_t)(micros()-t);
-  t=micros(); if (g_active) g_active->task(); acc[1]+=(uint32_t)(micros()-t);
-  t=micros(); serialConsolePoll();            acc[2]+=(uint32_t)(micros()-t);
-  t=micros(); rfDiagTask();                   acc[3]+=(uint32_t)(micros()-t);
-  t=micros(); rfLinkTask();                   acc[4]+=(uint32_t)(micros()-t);
-  t=micros(); hapticTask();                   acc[5]+=(uint32_t)(micros()-t);
-  t=micros(); ledTask();                      acc[6]+=(uint32_t)(micros()-t);
-  loops++;
-  if (millis()-secMs >= 1000) {
-    g_loopPeriodUs = loops ? (uint16_t)(1000000UL/loops) : 0;
-    uint8_t wi=0; uint32_t wv=0;
-    for (int i=0;i<7;i++){ if(acc[i]>wv){ wv=acc[i]; wi=i; } acc[i]=0; }
-    g_loopWorst = wi; g_loopWorstUs = loops ? (uint16_t)(wv/loops) : 0;
-    loops=0; secMs=millis();
-  }
+	// Diagnostic build: time each loop section so the panel can show the slowest one + the loop period.
+	static uint32_t acc[7] = { 0 };
+	static uint32_t loops = 0;
+	static unsigned long secMs = 0;
+	uint32_t t;
+	t = micros();
+	webusbPoll();
+	acc[0] += (uint32_t)(micros() - t);
+	t = micros();
+	if (g_active)
+		g_active->task();
+	acc[1] += (uint32_t)(micros() - t);
+	t = micros();
+	serialConsolePoll();
+	acc[2] += (uint32_t)(micros() - t);
+	t = micros();
+	rfDiagTask();
+	acc[3] += (uint32_t)(micros() - t);
+	t = micros();
+	rfLinkTask();
+	acc[4] += (uint32_t)(micros() - t);
+	t = micros();
+	hapticTask();
+	acc[5] += (uint32_t)(micros() - t);
+	t = micros();
+	ledTask();
+	acc[6] += (uint32_t)(micros() - t);
+	loops++;
+	if (millis() - secMs >= 1000) {
+		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
+		uint8_t wi = 0;
+		uint32_t wv = 0;
+		for (int i = 0; i < 7; i++) {
+			if (acc[i] > wv) {
+				wv = acc[i];
+				wi = i;
+			}
+			acc[i] = 0;
+		}
+		g_loopWorst = wi;
+		g_loopWorstUs = loops ? (uint16_t)(wv / loops) : 0;
+		loops = 0;
+		secMs = millis();
+	}
 #else
-  webusbPoll();
-  if (g_active) g_active->task();
-  serialConsolePoll();
-  rfDiagTask();
-  rfLinkTask();
-  hapticTask();
-  ledTask();
+	webusbPoll();
+	if (g_active)
+		g_active->task();
+	serialConsolePoll();
+	rfDiagTask();
+	rfLinkTask();
+	hapticTask();
+	ledTask();
 #endif
 }

+ 33 - 15
OpenPuck/bonds.cpp

@@ -4,27 +4,45 @@
 using namespace Adafruit_LittleFS_Namespace;
 
 Slot g_slot[NSLOT];
-int           g_connSlot = -1;
+int g_connSlot = -1;
 unsigned long g_connReplyMs = 0;
 volatile bool g_dirty = false;
-bool          g_pairing = false;
+bool g_pairing = false;
 
 #define BOND_FILE "/bonds.bin"
 
-bool recEmpty(const uint8_t *r) { for (int i = 0; i < 24; i++) if (r[i]) return false; return true; }
+bool recEmpty(const uint8_t *r)
+{
+	for (int i = 0; i < 24; i++)
+		if (r[i])
+			return false;
+	return true;
+}
 
-void saveBonds() {
-  InternalFS.remove(BOND_FILE);
-  File f(InternalFS);
-  if (f.open(BOND_FILE, FILE_O_WRITE)) {
-    for (int i = 0; i < NSLOT; i++) { uint8_t u = g_slot[i].used ? 1 : 0; f.write(&u, 1); f.write(g_slot[i].rec, 24); }
-    f.close();
-  }
+void saveBonds()
+{
+	InternalFS.remove(BOND_FILE);
+	File f(InternalFS);
+	if (f.open(BOND_FILE, FILE_O_WRITE)) {
+		for (int i = 0; i < NSLOT; i++) {
+			uint8_t u = g_slot[i].used ? 1 : 0;
+			f.write(&u, 1);
+			f.write(g_slot[i].rec, 24);
+		}
+		f.close();
+	}
 }
 
-void loadBonds() {
-  File f(InternalFS);
-  if (f.open(BOND_FILE, FILE_O_READ))
-    for (int i = 0; i < NSLOT; i++) { uint8_t u = 0; if (f.read(&u, 1) == 1) { g_slot[i].used = u; f.read(g_slot[i].rec, 24); } }
-  f.close();
+void loadBonds()
+{
+	File f(InternalFS);
+	if (f.open(BOND_FILE, FILE_O_READ))
+		for (int i = 0; i < NSLOT; i++) {
+			uint8_t u = 0;
+			if (f.read(&u, 1) == 1) {
+				g_slot[i].used = u;
+				f.read(g_slot[i].rec, 24);
+			}
+		}
+	f.close();
 }

+ 12 - 5
OpenPuck/bonds.h

@@ -11,13 +11,20 @@
 #define NSLOT 4
 
 // per-slot state (record = [8 uuid][16 serial]) + per-interface response staging
-struct Slot { uint8_t rec[24]; bool used; uint8_t resp[63]; uint16_t resp_len; };
+struct Slot {
+	uint8_t rec[24];
+	bool used;
+	uint8_t resp[63];
+	uint16_t resp_len;
+};
 extern Slot g_slot[NSLOT];
 
-extern int           g_connSlot;     // bonded slot being polled (== the USB-connected slot); -1 = none
-extern unsigned long g_connReplyMs;  // millis of last RF reply (link-alive timestamp)
-extern volatile bool g_dirty;        // bonds changed -> flush to flash from loop()
-extern bool          g_pairing;
+extern int
+	g_connSlot; // bonded slot being polled (== the USB-connected slot); -1 = none
+extern unsigned long
+	g_connReplyMs; // millis of last RF reply (link-alive timestamp)
+extern volatile bool g_dirty; // bonds changed -> flush to flash from loop()
+extern bool g_pairing;
 
 bool recEmpty(const uint8_t *r);
 void loadBonds();

+ 3 - 3
OpenPuck/build_info.h

@@ -9,9 +9,9 @@
 #pragma once
 
 #if !defined(OPK_GIT_HASH) && defined(__has_include)
-#  if __has_include("git_version.h")
-#    include "git_version.h"
-#  endif
+#if __has_include("git_version.h")
+#include "git_version.h"
+#endif
 #endif
 
 #ifndef OPK_GIT_HASH

+ 135 - 59
OpenPuck/config.cpp

@@ -5,70 +5,132 @@
 using namespace Adafruit_LittleFS_Namespace;
 
 uint8_t g_usbMode = 0;
-bool    g_xbox = false;
-uint8_t g_chordBtn[3] = { MODE_LIZARD, MODE_XBOX, MODE_SW_PRO };   // back4+B/X/Y -> these modes (A always STEAM); Y defaults to Switch Pro
-bool    g_persistMode = false;
+bool g_xbox = false;
+uint8_t g_chordBtn[3] = {
+	MODE_LIZARD, MODE_XBOX, MODE_SW_PRO
+}; // back4+B/X/Y -> these modes (A always STEAM); Y defaults to Switch Pro
+bool g_persistMode = false;
 uint8_t g_bootMode = 0xFF;
 
-bool          g_debugCdcThisBoot = false;
-static uint8_t g_debugCdc = 0;   // persisted one-shot arm, stored in Cfg.rsvd0 (1 = keep CDC for the next boot)
+bool g_debugCdcThisBoot = false;
+static uint8_t g_debugCdc =
+	0; // persisted one-shot arm, stored in Cfg.rsvd0 (1 = keep CDC for the next boot)
 
-int     g_mDiv = 64, g_mFric = 94;
+int g_mDiv = 64, g_mFric = 94;
 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)
-uint8_t g_qamMap = 0;            // 0 = default (unmapped, uses hardcoded behavior per mode)
-uint8_t g_rumbleScale = 200;     // rumble strength % (200 = double); adjustable from the WebUSB panel
+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)
+uint8_t g_qamMap =
+	0; // 0 = default (unmapped, uses hardcoded behavior per mode)
+uint8_t g_rumbleScale =
+	200; // rumble strength % (200 = double); adjustable from the WebUSB panel
 
 // poll rate is fixed. Faster than the controller can refresh wastes airtime; slower adds latency. Any rate
 // persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-#define CFG_MAGIC 0xC9   // bumped (rumbleScale): old cfg ignored -> clean defaults on first boot
-struct Cfg { uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100, persistMode, bootMode, chordBtn[3], qamMap, rumbleScale; };  // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
+#define CFG_MAGIC \
+	0xC9 // bumped (rumbleScale): old cfg ignored -> clean defaults on first boot
+struct Cfg {
+	uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100,
+		persistMode, bootMode, chordBtn[3], qamMap, rumbleScale;
+}; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
-void saveCfg(){
-  Cfg c={CFG_MAGIC,g_usbMode,(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};
-  InternalFS.remove(CFG_FILE); File f(InternalFS);
-  if(f.open(CFG_FILE,FILE_O_WRITE)){ f.write((uint8_t*)&c,sizeof c); f.close(); }
+void saveCfg()
+{
+	Cfg c = { CFG_MAGIC,
+		  g_usbMode,
+		  (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 };
+	InternalFS.remove(CFG_FILE);
+	File f(InternalFS);
+	if (f.open(CFG_FILE, FILE_O_WRITE)) {
+		f.write((uint8_t *)&c, sizeof c);
+		f.close();
+	}
 }
 
-void loadCfg(){
-  Cfg c; File f(InternalFS); bool consume=false;
-  if(f.open(CFG_FILE,FILE_O_READ)){
-    if(f.read((uint8_t*)&c,sizeof c)==(int)sizeof c && 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];
-      g_persistMode = c.persistMode?true:false;
-      // one-shot debug-CDC (Cfg.rsvd0): honor it for THIS boot, then consume so the next boot reverts to normal.
-      g_debugCdcThisBoot = c.rsvd0 ? true : false;
-      if(c.rsvd0){ g_debugCdc = 0; consume = true; }
-      // poll rate is fixed (g_pollUs const = POLL_US_DEFAULT). A stale rate from an older build is never
-      // applied; rewrite cfg so the persisted byte also matches the new default.
-      if(c.pollU100 != (uint8_t)(POLL_US_DEFAULT/100)) consume=true;
-      // boot-mode policy: a one-shot bootMode (set by an explicit switch when !persist) wins once and is then
-      // cleared; otherwise persist->last mode, else->Steam. (poll rate stays POLL_US_DEFAULT -- never restored from cfg.)
-      if(c.bootMode!=0xFF){ g_usbMode=modeValid(c.bootMode)?c.bootMode:0; consume=true; }
-      else                 g_usbMode = g_persistMode ? (modeValid(c.mode)?c.mode:0) : 0;
-      static const uint8_t CHORD_DEF[3]={MODE_LIZARD,MODE_XBOX,MODE_SW_PRO};
-      for(int i=0;i<3;i++) g_chordBtn[i]=modeValid(c.chordBtn[i])?c.chordBtn[i]:CHORD_DEF[i];
-      g_qamMap = c.qamMap;
-      g_rumbleScale = c.rumbleScale;   // 0 is a valid setting (rumble off)
-    }
-    f.close();
-  }
-  if(consume){ g_bootMode=0xFF; saveCfg(); }   // clear the one-shot so the NEXT cold boot reverts to the default/persist policy
+void loadCfg()
+{
+	Cfg c;
+	File f(InternalFS);
+	bool consume = false;
+	if (f.open(CFG_FILE, FILE_O_READ)) {
+		if (f.read((uint8_t *)&c, sizeof c) == (int)sizeof c &&
+		    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];
+			g_persistMode = c.persistMode ? true : false;
+			// one-shot debug-CDC (Cfg.rsvd0): honor it for THIS boot, then consume so the next boot reverts to normal.
+			g_debugCdcThisBoot = c.rsvd0 ? true : false;
+			if (c.rsvd0) {
+				g_debugCdc = 0;
+				consume = true;
+			}
+			// poll rate is fixed (g_pollUs const = POLL_US_DEFAULT). A stale rate from an older build is never
+			// applied; rewrite cfg so the persisted byte also matches the new default.
+			if (c.pollU100 != (uint8_t)(POLL_US_DEFAULT / 100))
+				consume = true;
+			// boot-mode policy: a one-shot bootMode (set by an explicit switch when !persist) wins once and is then
+			// cleared; otherwise persist->last mode, else->Steam. (poll rate stays POLL_US_DEFAULT -- never restored from cfg.)
+			if (c.bootMode != 0xFF) {
+				g_usbMode = modeValid(c.bootMode) ? c.bootMode :
+								    0;
+				consume = true;
+			} else
+				g_usbMode = g_persistMode ? (modeValid(c.mode) ?
+								     c.mode :
+								     0) :
+							    0;
+			static const uint8_t CHORD_DEF[3] = { MODE_LIZARD,
+							      MODE_XBOX,
+							      MODE_SW_PRO };
+			for (int i = 0; i < 3; i++)
+				g_chordBtn[i] = modeValid(c.chordBtn[i]) ?
+							c.chordBtn[i] :
+							CHORD_DEF[i];
+			g_qamMap = c.qamMap;
+			g_rumbleScale =
+				c.rumbleScale; // 0 is a valid setting (rumble off)
+		}
+		f.close();
+	}
+	if (consume) {
+		g_bootMode = 0xFF;
+		saveCfg();
+	} // clear the one-shot so the NEXT cold boot reverts to the default/persist policy
 }
 
-void saveMode(uint8_t m){
-  if(g_persistMode){ g_usbMode=m; g_bootMode=0xFF; }
-  else             { g_bootMode=m; }
-  saveCfg();
+void saveMode(uint8_t m)
+{
+	if (g_persistMode) {
+		g_usbMode = m;
+		g_bootMode = 0xFF;
+	} else {
+		g_bootMode = m;
+	}
+	saveCfg();
 }
 
-void armDebugCdcNextBoot(){ g_debugCdc = 1; saveCfg(); }   // next boot keeps CDC; loadCfg() consumes it after
+void armDebugCdcNextBoot()
+{
+	g_debugCdc = 1;
+	saveCfg();
+} // next boot keeps CDC; loadCfg() consumes it after
 
 // FULL factory wipe: erase ALL persistent storage in one shot -- cfg.bin (modes/tunables/chords), bonds.bin
 // (the paired-controller record), and anything else living in the internal LittleFS. format() reformats the
@@ -76,9 +138,11 @@ void armDebugCdcNextBoot(){ g_debugCdc = 1; saveCfg(); }   // next boot keeps CD
 // (NVIC_SystemReset): on the next boot loadCfg()/loadBonds() find no files and fall back to clean defaults,
 // and the controller must be re-paired. Destructive and irreversible -- gated behind explicit confirmation at
 // every call site (serial "ERASE-ALL", WebUSB op 0x0A magic, both with on-screen warnings).
-void factoryErase(){
-  InternalFS.begin();      // ensure mounted before we reformat (no-op if already up)
-  InternalFS.format();     // wipes cfg.bin + bonds.bin + the entire FS
+void factoryErase()
+{
+	InternalFS
+		.begin(); // ensure mounted before we reformat (no-op if already up)
+	InternalFS.format(); // wipes cfg.bin + bonds.bin + the entire FS
 }
 
 // One-time factory reset for the -DOPK_FACTORY_RESET recovery build. The point is to clear a bad config/bond
@@ -90,13 +154,25 @@ void factoryErase(){
 // Because the tag is keyed to the git hash, flashing a DIFFERENT build re-triggers the one-time wipe; re-flashing
 // the identical image does not (use the WebUSB/serial erase for an on-demand wipe). buildTag is OPK_GIT_HASH.
 #define RESET_TAG_FILE "/rsttag"
-void factoryResetOnce(const char* buildTag){
-  char tag[24] = {0};
-  { File f(InternalFS);
-    if (f.open(RESET_TAG_FILE, FILE_O_READ)) { int n=f.read((uint8_t*)tag, sizeof tag - 1); if(n>0) tag[n]=0; f.close(); } }
-  if (strncmp(tag, buildTag, sizeof tag - 1) == 0) return;   // this build already did its one-time reset -> persist
-  factoryErase();                                            // wipe cfg.bin + bonds.bin + the old tag
-  InternalFS.begin();                                        // remount the fresh FS
-  File g(InternalFS);                                        // stamp the tag so subsequent boots skip the wipe
-  if (g.open(RESET_TAG_FILE, FILE_O_WRITE)) { g.write((const uint8_t*)buildTag, strlen(buildTag)); g.close(); }
+void factoryResetOnce(const char *buildTag)
+{
+	char tag[24] = { 0 };
+	{
+		File f(InternalFS);
+		if (f.open(RESET_TAG_FILE, FILE_O_READ)) {
+			int n = f.read((uint8_t *)tag, sizeof tag - 1);
+			if (n > 0)
+				tag[n] = 0;
+			f.close();
+		}
+	}
+	if (strncmp(tag, buildTag, sizeof tag - 1) == 0)
+		return; // this build already did its one-time reset -> persist
+	factoryErase(); // wipe cfg.bin + bonds.bin + the old tag
+	InternalFS.begin(); // remount the fresh FS
+	File g(InternalFS); // stamp the tag so subsequent boots skip the wipe
+	if (g.open(RESET_TAG_FILE, FILE_O_WRITE)) {
+		g.write((const uint8_t *)buildTag, strlen(buildTag));
+		g.close();
+	}
 }

+ 59 - 33
OpenPuck/config.h

@@ -29,63 +29,89 @@
 
 // ---- 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
-#define MODE_PS5     5   // Sony DualSense (054C:0CE6) + gyro + split trackpad (+ wake mouse + WebUSB panel)
-#define MODE_HIDGYRO 6   // DS4-layout generic HID gamepad + gyro (+ wake mouse + WebUSB panel)
-#define MODE_PS5_GAME 7  // DualSense, CLEAN single-HID (no wake/WebUSB) so PC games classify it as PlayStation (Fortnite)
-#define MODE_DS4_GAME 8  // DS4, CLEAN single-HID (no wake/WebUSB) for game PlayStation classification
-#define MODE_MAX     8
+#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
+#define MODE_PS5 \
+	5 // Sony DualSense (054C:0CE6) + gyro + split trackpad (+ wake mouse + WebUSB panel)
+#define MODE_HIDGYRO \
+	6 // DS4-layout generic HID gamepad + gyro (+ wake mouse + WebUSB panel)
+#define MODE_PS5_GAME \
+	7 // DualSense, CLEAN single-HID (no wake/WebUSB) so PC games classify it as PlayStation (Fortnite)
+#define MODE_DS4_GAME \
+	8 // DS4, CLEAN single-HID (no wake/WebUSB) for game PlayStation classification
+#define MODE_MAX 8
 
 // 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
 // present). Cost: no config panel / host-wake while in these modes; chord back to Steam (back4 + A) for the panel.
-static inline bool modeIsCleanPS(uint8_t m){ return m==MODE_PS5_GAME || m==MODE_DS4_GAME; }
+static inline bool modeIsCleanPS(uint8_t m)
+{
+	return m == MODE_PS5_GAME || m == MODE_DS4_GAME;
+}
 
-static inline bool modeIsPuck(uint8_t m){ return m==MODE_STEAM || m==MODE_LIZARD; }
-static inline bool modeValid(uint8_t m){ return m<=MODE_MAX; }
+static inline bool modeIsPuck(uint8_t m)
+{
+	return m == MODE_STEAM || m == MODE_LIZARD;
+}
+static inline bool modeValid(uint8_t m)
+{
+	return m <= MODE_MAX;
+}
 
-extern uint8_t g_usbMode;       // loaded from flash at boot
-extern bool    g_xbox;          // true for all non-puck presentations (cached !modeIsPuck(g_usbMode))
-extern uint8_t g_chordBtn[3];   // back4+B/X/Y -> these modes (A always STEAM)
+extern uint8_t g_usbMode; // loaded from flash at boot
+extern bool
+	g_xbox; // true for all non-puck presentations (cached !modeIsPuck(g_usbMode))
+extern uint8_t g_chordBtn[3]; // back4+B/X/Y -> these modes (A always STEAM)
 
 // Mode persistence policy: by DEFAULT every fresh power-on/reconnect lands in STEAM mode (0). An explicit
 // mode switch still works for the session via a ONE-SHOT bootMode (honored once, then cleared, so the next
 // cold boot reverts to Steam). The WebUI "persist last mode" toggle (g_persistMode) instead remembers the
 // last selected mode across reboots.
-extern bool    g_persistMode;   // false (default) = always boot Steam; true = boot into last mode
-extern uint8_t g_bootMode;      // one-shot: boot into this mode once then clear (!persistMode + explicit switch)
+extern bool
+	g_persistMode; // false (default) = always boot Steam; true = boot into last mode
+extern uint8_t
+	g_bootMode; // one-shot: boot into this mode once then clear (!persistMode + explicit switch)
 
 // One-shot debug CDC. Puck mode normally DROPS the CDC serial console to free the USB endpoint its wake-mouse
 // interface needs (so puck can wake a sleeping Windows host). Arming this keeps CDC for the NEXT boot only --
 // dropping the wake mouse that boot -- so someone can attach the serial debugger; the boot after reverts to
 // normal automatically. Mirrors the g_bootMode one-shot. Armed from the WebUSB panel or CDC 'D' command.
-extern bool    g_debugCdcThisBoot;   // decision for THIS boot: true => keep CDC, skip the wake interface
-void armDebugCdcNextBoot();          // persist the one-shot (caller reboots)
+extern bool
+	g_debugCdcThisBoot; // decision for THIS boot: true => keep CDC, skip the wake interface
+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)
-extern uint8_t g_back[4];       // back paddles L4,R4,L5,R5 -> button codes (0..15 standard, 16=PS Touch Click, 17=PS5 Mute)
-extern uint8_t g_qamMap;        // QAM (3 dots) physical button -> same code space (0 = default/unmapped)
-extern uint8_t g_rumbleScale;   // rumble strength, percent of decoded amplitude (100 = 1x, 200 = 2x default), all modes
+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)
+extern uint8_t g_back
+	[4]; // back paddles L4,R4,L5,R5 -> button codes (0..15 standard, 16=PS Touch Click, 17=PS5 Mute)
+extern uint8_t
+	g_qamMap; // QAM (3 dots) physical button -> same code space (0 = default/unmapped)
+extern uint8_t
+	g_rumbleScale; // rumble strength, percent of decoded amplitude (100 = 1x, 200 = 2x default), all modes
 // Switch Pro motion settings. Persisted in their OWN flash file (mode_switch_pro.cpp), NOT in Cfg -- so changing
 // them never resets the rest of the config. Set from the WebUSB panel.
-extern uint8_t g_swProRate;     // Switch Pro report cadence: 0 = 66Hz (15ms, compat), 1 = 120Hz (8ms, DEFAULT), 2 = full (~250Hz)
-extern uint8_t g_swGyroScale10; // Switch Pro gyro sensitivity x10 (10 = 1.0x default; 5/15/20/25/30 = 0.5..3.0x)
-void swProSaveCfg();            // persist g_swProRate + g_swGyroScale10 to their flash file
+extern uint8_t
+	g_swProRate; // Switch Pro report cadence: 0 = 66Hz (15ms, compat), 1 = 120Hz (8ms, DEFAULT), 2 = full (~250Hz)
+extern uint8_t
+	g_swGyroScale10; // Switch Pro gyro sensitivity x10 (10 = 1.0x default; 5/15/20/25/30 = 0.5..3.0x)
+void swProSaveCfg(); // persist g_swProRate + g_swGyroScale10 to their flash file
 
-#define POLL_US_DEFAULT 4000u   // 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
-#define USB_STREAM_MS   4u      // host-side HID stream cadence for translated modes (~250 Hz)
-extern const uint32_t g_pollUs; // RF poll cadence (us). FIXED -- not configurable (see loadCfg).
+#define POLL_US_DEFAULT \
+	4000u // 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
+#define USB_STREAM_MS \
+	4u // host-side HID stream cadence for translated modes (~250 Hz)
+extern const uint32_t
+	g_pollUs; // RF poll cadence (us). FIXED -- not configurable (see loadCfg).
 
 // loop-timing diagnostics (defined in OpenPuck.ino) -- surfaced in the WebUSB status blob to find what caps
 // the poll rate: avg loop period, slowest section index, and that section's avg us/iteration.
 extern uint16_t g_loopPeriodUs;
-extern uint8_t  g_loopWorst;
+extern uint8_t g_loopWorst;
 extern uint16_t g_loopWorstUs;
 
 void loadCfg();
@@ -96,7 +122,7 @@ void saveCfg();
 void factoryErase();
 // One-time factory reset for the -DOPK_FACTORY_RESET recovery build: wipe ONCE on the first boot after flashing
 // (tracked by a git-hash tag file so it doesn't wipe on every boot), then persist normally. buildTag = OPK_GIT_HASH.
-void factoryResetOnce(const char* buildTag);
+void factoryResetOnce(const char *buildTag);
 // Mode switch (chord / WebUI): persist mode if the toggle is on, else arm a one-shot so this reboot lands in
 // the new mode but the next cold boot returns to Steam. Either way saveCfg + caller reboots.
 void saveMode(uint8_t m);

+ 25 - 14
OpenPuck/controllers.cpp

@@ -7,22 +7,33 @@
 #include "mode_ps5.h"
 #include "mode_hidgyro.h"
 
-IController* g_active = nullptr;
+IController *g_active = nullptr;
 
 // Map a USB-presentation mode to its singleton controller. Steam and Lizard share the puck controller (the
 // lizard-vs-forward decision is made per-report inside it). This is the one place that knows the mode->class
 // mapping; everything else dispatches through g_active.
-IController* controllerFor(uint8_t mode){
-  switch(mode){
-    case MODE_STEAM:   return &g_steamPuck;
-    case MODE_LIZARD:  return &g_steamPuck;
-    case MODE_XBOX:    return &g_xboxCtl;
-    case MODE_SW_HORI: return &g_switchHori;
-    case MODE_SW_PRO:  return &g_switchPro;
-    case MODE_PS5:     return &g_ps5Ctl;
-    case MODE_HIDGYRO: return &g_hidGyroCtl;
-    case MODE_PS5_GAME:return &g_ps5Ctl;     // same DualSense controller; setup() drops wake/WebUSB for clean enum
-    case MODE_DS4_GAME:return &g_hidGyroCtl; // same DS4 controller; setup() drops wake/WebUSB for clean enum
-    default:           return &g_steamPuck;
-  }
+IController *controllerFor(uint8_t mode)
+{
+	switch (mode) {
+	case MODE_STEAM:
+		return &g_steamPuck;
+	case MODE_LIZARD:
+		return &g_steamPuck;
+	case MODE_XBOX:
+		return &g_xboxCtl;
+	case MODE_SW_HORI:
+		return &g_switchHori;
+	case MODE_SW_PRO:
+		return &g_switchPro;
+	case MODE_PS5:
+		return &g_ps5Ctl;
+	case MODE_HIDGYRO:
+		return &g_hidGyroCtl;
+	case MODE_PS5_GAME:
+		return &g_ps5Ctl; // same DualSense controller; setup() drops wake/WebUSB for clean enum
+	case MODE_DS4_GAME:
+		return &g_hidGyroCtl; // same DS4 controller; setup() drops wake/WebUSB for clean enum
+	default:
+		return &g_steamPuck;
+	}
 }

+ 33 - 13
OpenPuck/controllers.h

@@ -18,21 +18,41 @@
 #include <stdint.h>
 
 class IController {
-public:
-  virtual ~IController() {}
-  virtual void begin() = 0;
-  virtual void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen) { (void)rep; (void)fresh; (void)bodyTlen; }
-  // Other controller->host input reports decoded from the F1 reply (NOT 0x45): the periodic power/battery status
-  // report 0x43, the status event 0x44. The real puck forwards these to Steam verbatim -- that's how Steam reads
-  // battery. rid = report id, data/n = body after the id. Default no-op (clean modes don't expose these reports).
-  virtual void onAuxReport(uint8_t rid, const uint8_t* data, uint8_t n) { (void)rid; (void)data; (void)n; }
-  virtual void task() {}
-  virtual void wakeEvent() {}   // a wake gesture fired while suspended; queue any post-resume input the host needs to actually wake
-  virtual bool isPuck() const { return false; }
+    public:
+	virtual ~IController()
+	{
+	}
+	virtual void begin() = 0;
+	virtual void onReport45(const uint8_t *rep, bool fresh,
+				uint8_t bodyTlen)
+	{
+		(void)rep;
+		(void)fresh;
+		(void)bodyTlen;
+	}
+	// Other controller->host input reports decoded from the F1 reply (NOT 0x45): the periodic power/battery status
+	// report 0x43, the status event 0x44. The real puck forwards these to Steam verbatim -- that's how Steam reads
+	// battery. rid = report id, data/n = body after the id. Default no-op (clean modes don't expose these reports).
+	virtual void onAuxReport(uint8_t rid, const uint8_t *data, uint8_t n)
+	{
+		(void)rid;
+		(void)data;
+		(void)n;
+	}
+	virtual void task()
+	{
+	}
+	virtual void wakeEvent()
+	{
+	} // a wake gesture fired while suspended; queue any post-resume input the host needs to actually wake
+	virtual bool isPuck() const
+	{
+		return false;
+	}
 };
 
 // The singleton for a given mode (nullptr if the mode is unknown). Defined in controllers.cpp.
-IController* controllerFor(uint8_t mode);
+IController *controllerFor(uint8_t mode);
 
 // The active controller for this boot (set in setup()). rf_link/loop dispatch through it.
-extern IController* g_active;
+extern IController *g_active;

+ 197 - 69
OpenPuck/gamepad_util.cpp

@@ -2,89 +2,217 @@
 #include "triton.h"
 #include "config.h"
 
-uint8_t swStick(int16_t v,bool invert){   // int16 (center 0) -> uint8 (center 0x80)
-  int32_t a = 0x80 + (invert ? -((int32_t)v>>8) : ((int32_t)v>>8));
-  if(a<0)a=0; if(a>255)a=255; return (uint8_t)a;
+uint8_t swStick(int16_t v, bool invert)
+{ // int16 (center 0) -> uint8 (center 0x80)
+	int32_t a = 0x80 + (invert ? -((int32_t)v >> 8) : ((int32_t)v >> 8));
+	if (a < 0)
+		a = 0;
+	if (a > 255)
+		a = 255;
+	return (uint8_t)a;
 }
 
 // Map Steam trackpad s16 coords into a 0..max axis (centered touch -> mid-range).
-uint16_t padNormU16(int16_t v, uint16_t maxv){
-  int32_t t=((int32_t)v+32768); if(t<0)t=0; if(t>65535)t=65535;
-  return (uint16_t)((t*(int32_t)maxv)/65535);
+uint16_t padNormU16(int16_t v, uint16_t maxv)
+{
+	int32_t t = ((int32_t)v + 32768);
+	if (t < 0)
+		t = 0;
+	if (t > 65535)
+		t = 65535;
+	return (uint16_t)((t * (int32_t)maxv) / 65535);
 }
-uint16_t touchHalfX(int16_t v, bool rightHalf){
-  uint16_t halfMax = TOUCH_PAD_W/2 - 1;
-  uint16_t x = padNormU16(v, halfMax);
-  return rightHalf ? (uint16_t)(TOUCH_PAD_W/2 + x) : x;
+uint16_t touchHalfX(int16_t v, bool rightHalf)
+{
+	uint16_t halfMax = TOUCH_PAD_W / 2 - 1;
+	uint16_t x = padNormU16(v, halfMax);
+	return rightHalf ? (uint16_t)(TOUCH_PAD_W / 2 + x) : x;
 }
-uint16_t touchYInv(int16_t v, uint16_t height){
-  uint16_t maxy = height - 1;
-  return (uint16_t)(maxy - padNormU16(v, maxy));
+uint16_t touchYInv(int16_t v, uint16_t height)
+{
+	uint16_t maxy = height - 1;
+	return (uint16_t)(maxy - padNormU16(v, maxy));
 }
-void touchPackPoint(uint8_t* base, int finger, bool touch, uint16_t x, uint16_t y){
-  uint8_t* f=base+finger*4;
-  if(!touch){ f[0]=0x80; f[1]=0; f[2]=0; f[3]=0; return; }
-  f[0]=(uint8_t)(finger&0x7F);
-  f[1]=(uint8_t)(x&0xFF);
-  f[2]=(uint8_t)(((x>>8)&0x0F)|((y&0x0F)<<4));
-  f[3]=(uint8_t)((y>>4)&0xFF);
+void touchPackPoint(uint8_t *base, int finger, bool touch, uint16_t x,
+		    uint16_t y)
+{
+	uint8_t *f = base + finger * 4;
+	if (!touch) {
+		f[0] = 0x80;
+		f[1] = 0;
+		f[2] = 0;
+		f[3] = 0;
+		return;
+	}
+	f[0] = (uint8_t)(finger & 0x7F);
+	f[1] = (uint8_t)(x & 0xFF);
+	f[2] = (uint8_t)(((x >> 8) & 0x0F) | ((y & 0x0F) << 4));
+	f[3] = (uint8_t)((y >> 4) & 0xFF);
 }
-void touchPackPads(uint8_t* pts, bool lTouch, bool rTouch,
-    uint16_t lx, uint16_t ly, uint16_t rx, uint16_t ry){
-  touchPackPoint(pts, 0, false, 0, 0);
-  touchPackPoint(pts, 1, false, 0, 0);
-  if(lTouch && rTouch){
-    touchPackPoint(pts, 0, true, lx, ly);
-    touchPackPoint(pts, 1, true, rx, ry);
-  } else if(lTouch){
-    touchPackPoint(pts, 0, true, lx, ly);
-  } else if(rTouch){
-    touchPackPoint(pts, 0, true, rx, ry);
-  }
+void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch, uint16_t lx,
+		   uint16_t ly, uint16_t rx, uint16_t ry)
+{
+	touchPackPoint(pts, 0, false, 0, 0);
+	touchPackPoint(pts, 1, false, 0, 0);
+	if (lTouch && rTouch) {
+		touchPackPoint(pts, 0, true, lx, ly);
+		touchPackPoint(pts, 1, true, rx, ry);
+	} else if (lTouch) {
+		touchPackPoint(pts, 0, true, lx, ly);
+	} else if (rTouch) {
+		touchPackPoint(pts, 0, true, rx, ry);
+	}
 }
-void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
-    uint16_t* lx, uint16_t* ly, uint16_t* rx, uint16_t* ry){
-  bool lt=(b&TB_LPADT)||(b&TB_LPADC), rt=(b&TB_RPADT)||(b&TB_RPADC);
-  *lx=touchHalfX(lpx, false); *ly=touchYInv(lpy, touchH);
-  *rx=touchHalfX(rpx, true);  *ry=touchYInv(rpy, touchH);
-  if(lt && !(b&TB_LPADT)){ *lx=TOUCH_PAD_W/4; *ly=touchH/2; }
-  if(rt && !(b&TB_RPADT)){ *rx=TOUCH_PAD_W/4*3; *ry=touchH/2; }
+void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy,
+		      int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
+		      uint16_t *rx, uint16_t *ry)
+{
+	bool lt = (b & TB_LPADT) || (b & TB_LPADC),
+	     rt = (b & TB_RPADT) || (b & TB_RPADC);
+	*lx = touchHalfX(lpx, false);
+	*ly = touchYInv(lpy, touchH);
+	*rx = touchHalfX(rpx, true);
+	*ry = touchYInv(rpy, touchH);
+	if (lt && !(b & TB_LPADT)) {
+		*lx = TOUCH_PAD_W / 4;
+		*ly = touchH / 2;
+	}
+	if (rt && !(b & TB_RPADT)) {
+		*rx = TOUCH_PAD_W / 4 * 3;
+		*ry = touchH / 2;
+	}
 }
 
 // Map g_back[] paddle code -> Steam button flags (same codes as codeToXB / codeToSwitch).
-static void psOrBackCode(uint32_t* b, uint8_t c){
-  switch(c){ case 1:*b|=TB_A; break; case 2:*b|=TB_B; break; case 3:*b|=TB_X; break; case 4:*b|=TB_Y; break;
-    case 5:*b|=TB_LB; break; case 6:*b|=TB_RB; break; case 7:*b|=TB_L3; break; case 8:*b|=TB_R3; break;
-    case 9:*b|=TB_VIEW; break; case 10:*b|=TB_QAM; break; case 11:*b|=TB_STEAM; break;
-    case 12:*b|=TB_DUP; break; case 13:*b|=TB_DDN; break; case 14:*b|=TB_DLF; break; case 15:*b|=TB_DRT; break;
-    case 16:*b|=TB_TOUCH; break; case 17:*b|=TB_MUTE; break; default: break; }
+static void psOrBackCode(uint32_t *b, uint8_t c)
+{
+	switch (c) {
+	case 1:
+		*b |= TB_A;
+		break;
+	case 2:
+		*b |= TB_B;
+		break;
+	case 3:
+		*b |= TB_X;
+		break;
+	case 4:
+		*b |= TB_Y;
+		break;
+	case 5:
+		*b |= TB_LB;
+		break;
+	case 6:
+		*b |= TB_RB;
+		break;
+	case 7:
+		*b |= TB_L3;
+		break;
+	case 8:
+		*b |= TB_R3;
+		break;
+	case 9:
+		*b |= TB_VIEW;
+		break;
+	case 10:
+		*b |= TB_QAM;
+		break;
+	case 11:
+		*b |= TB_STEAM;
+		break;
+	case 12:
+		*b |= TB_DUP;
+		break;
+	case 13:
+		*b |= TB_DDN;
+		break;
+	case 14:
+		*b |= TB_DLF;
+		break;
+	case 15:
+		*b |= TB_DRT;
+		break;
+	case 16:
+		*b |= TB_TOUCH;
+		break;
+	case 17:
+		*b |= TB_MUTE;
+		break;
+	default:
+		break;
+	}
 }
-uint32_t psButtonsFromSteam(uint32_t raw){
-  uint32_t b=raw;
-  if(g_qamMap && (b&TB_QAM)){ b &= ~(uint32_t)TB_QAM; b |= tritonFromCode(g_qamMap); }
-  if((b&CHORD_BACK4)==CHORD_BACK4) b&=~(uint32_t)(TB_A|TB_B|TB_X|TB_Y);
-  if(b&TB_L4) psOrBackCode(&b, g_back[0]); if(b&TB_R4) psOrBackCode(&b, g_back[1]);
-  if(b&TB_L5) psOrBackCode(&b, g_back[2]); if(b&TB_R5) psOrBackCode(&b, g_back[3]);
-  return b;
+uint32_t psButtonsFromSteam(uint32_t raw)
+{
+	uint32_t b = raw;
+	if (g_qamMap && (b & TB_QAM)) {
+		b &= ~(uint32_t)TB_QAM;
+		b |= tritonFromCode(g_qamMap);
+	}
+	if ((b & CHORD_BACK4) == CHORD_BACK4)
+		b &= ~(uint32_t)(TB_A | TB_B | TB_X | TB_Y);
+	if (b & TB_L4)
+		psOrBackCode(&b, g_back[0]);
+	if (b & TB_R4)
+		psOrBackCode(&b, g_back[1]);
+	if (b & TB_L5)
+		psOrBackCode(&b, g_back[2]);
+	if (b & TB_R5)
+		psOrBackCode(&b, g_back[3]);
+	return b;
 }
 // DualSense / DS4 buttons[1]: L1..R3, Create(Share), Options(Start)
-uint8_t psShouldersByte(uint32_t b){
-  return ((b&TB_LB)?0x01:0)|((b&TB_RB)?0x02:0)
-         |((g_in.lt>SW_TRIG_ON||(b&0x8000000u))?0x04:0)|((g_in.rt>SW_TRIG_ON||(b&0x800000u))?0x08:0)
-         |((b&TB_MENU)?0x10:0)|((b&TB_VIEW)?0x20:0)   // Menu=Options, View=Create/Share
-         |((b&TB_L3)?0x40:0)|((b&TB_R3)?0x80:0);
+uint8_t psShouldersByte(uint32_t b)
+{
+	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
+	       ((g_in.lt > SW_TRIG_ON || (b & 0x8000000u)) ? 0x04 : 0) |
+	       ((g_in.rt > SW_TRIG_ON || (b & 0x800000u)) ? 0x08 : 0) |
+	       ((b & TB_MENU) ? 0x10 : 0) |
+	       ((b & TB_VIEW) ? 0x20 : 0) // Menu=Options, View=Create/Share
+	       | ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
 }
-uint8_t psHatNibble(uint32_t b){
-  bool u=b&TB_DUP,d=b&TB_DDN,l=b&TB_DLF,r=b&TB_DRT;
-  if(u&&r)return 1; if(r&&d)return 3; if(d&&l)return 5; if(l&&u)return 7;
-  if(u)return 0; if(r)return 2; if(d)return 4; if(l)return 6; return 8;
+uint8_t psHatNibble(uint32_t b)
+{
+	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF, r = b & TB_DRT;
+	if (u && r)
+		return 1;
+	if (r && d)
+		return 3;
+	if (d && l)
+		return 5;
+	if (l && u)
+		return 7;
+	if (u)
+		return 0;
+	if (r)
+		return 2;
+	if (d)
+		return 4;
+	if (l)
+		return 6;
+	return 8;
 }
-uint8_t psFaceNibble(uint32_t b){
-  uint8_t f=0;
-  if(g_abSwap){
-    if(b&TB_A)f|=0x40; if(b&TB_B)f|=0x20; if(b&TB_X)f|=0x80; if(b&TB_Y)f|=0x10;
-  } else {
-    if(b&TB_A)f|=0x20; if(b&TB_B)f|=0x40; if(b&TB_X)f|=0x10; if(b&TB_Y)f|=0x80;
-  }
-  return f;
+uint8_t psFaceNibble(uint32_t b)
+{
+	uint8_t f = 0;
+	if (g_abSwap) {
+		if (b & TB_A)
+			f |= 0x40;
+		if (b & TB_B)
+			f |= 0x20;
+		if (b & TB_X)
+			f |= 0x80;
+		if (b & TB_Y)
+			f |= 0x10;
+	} else {
+		if (b & TB_A)
+			f |= 0x20;
+		if (b & TB_B)
+			f |= 0x40;
+		if (b & TB_X)
+			f |= 0x10;
+		if (b & TB_Y)
+			f |= 0x80;
+	}
+	return f;
 }

+ 52 - 14
OpenPuck/gamepad_util.h

@@ -17,23 +17,61 @@ uint8_t swStick(int16_t v, bool invert);
 uint16_t padNormU16(int16_t v, uint16_t maxv);
 uint16_t touchHalfX(int16_t v, bool rightHalf);
 uint16_t touchYInv(int16_t v, uint16_t height);
-void touchPackPoint(uint8_t* base, int finger, bool touch, uint16_t x, uint16_t y);
+void touchPackPoint(uint8_t *base, int finger, bool touch, uint16_t x,
+		    uint16_t y);
 // DualSense / DS4: many hosts only honor contact slot 0 -- put a lone pad in slot 0, both pads in 0+1.
-void touchPackPads(uint8_t* pts, bool lTouch, bool rTouch, uint16_t lx, uint16_t ly, uint16_t rx, uint16_t ry);
-void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy, int16_t rpx, int16_t rpy,
-    uint16_t* lx, uint16_t* ly, uint16_t* rx, uint16_t* ry);
+void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch, uint16_t lx,
+		   uint16_t ly, uint16_t rx, uint16_t ry);
+void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy,
+		      int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
+		      uint16_t *rx, uint16_t *ry);
 
 // Convert configurable button code -> TB_* flag (shared across modes).
-static inline uint32_t tritonFromCode(uint8_t c){
-  switch(c){ case 1:return TB_A; case 2:return TB_B; case 3:return TB_X; case 4:return TB_Y;
-    case 5:return TB_LB; case 6:return TB_RB; case 7:return TB_L3; case 8:return TB_R3;
-    case 9:return TB_VIEW; case 10:return TB_QAM; case 11:return TB_STEAM;
-    case 12:return TB_DUP; case 13:return TB_DDN; case 14:return TB_DLF; case 15:return TB_DRT;
-    case 16:return TB_TOUCH; case 17:return TB_MUTE; default:return 0; }
+static inline uint32_t tritonFromCode(uint8_t c)
+{
+	switch (c) {
+	case 1:
+		return TB_A;
+	case 2:
+		return TB_B;
+	case 3:
+		return TB_X;
+	case 4:
+		return TB_Y;
+	case 5:
+		return TB_LB;
+	case 6:
+		return TB_RB;
+	case 7:
+		return TB_L3;
+	case 8:
+		return TB_R3;
+	case 9:
+		return TB_VIEW;
+	case 10:
+		return TB_QAM;
+	case 11:
+		return TB_STEAM;
+	case 12:
+		return TB_DUP;
+	case 13:
+		return TB_DDN;
+	case 14:
+		return TB_DLF;
+	case 15:
+		return TB_DRT;
+	case 16:
+		return TB_TOUCH;
+	case 17:
+		return TB_MUTE;
+	default:
+		return 0;
+	}
 }
 
 // PlayStation-layout button packing (shared by mode_ps5 + mode_hidgyro).
-uint32_t psButtonsFromSteam(uint32_t raw);   // apply back-paddle + chord-guard + QAM remapping
-uint8_t  psShouldersByte(uint32_t b);        // L1..R3 + Create/Options byte
-uint8_t  psHatNibble(uint32_t b);            // d-pad -> 8-way hat (8 = neutral)
-uint8_t  psFaceNibble(uint32_t b);           // face buttons (with A/B + X/Y swap)
+uint32_t psButtonsFromSteam(
+	uint32_t raw); // apply back-paddle + chord-guard + QAM remapping
+uint8_t psShouldersByte(uint32_t b); // L1..R3 + Create/Options byte
+uint8_t psHatNibble(uint32_t b); // d-pad -> 8-way hat (8 = neutral)
+uint8_t psFaceNibble(uint32_t b); // face buttons (with A/B + X/Y swap)

+ 410 - 229
OpenPuck/haptics.cpp

@@ -2,18 +2,19 @@
 #include "bonds.h"
 #include "config.h"
 #include "rf_link.h"
-#include <Adafruit_TinyUSB.h>   // USBDevice.suspended() -> autonomous controller power-off on host sleep
+#include <Adafruit_TinyUSB.h> // USBDevice.suspended() -> autonomous controller power-off on host sleep
 #include <Arduino.h>
 #include <string.h>
 
-uint8_t          g_relayOp  = 0xE3;   // E3 poll
+uint8_t g_relayOp = 0xE3; // E3 poll
 // relay sub-TLV TYPE byte. Vestigial now: rfConnFlushRelay derives the on-air type from the report id (see the
 // CONFIRMED split there -- 0x05 for actuators/haptics <0x87, 0x01 for config/settings/LED >=0x87). Still exposed
 // over WebUSB (relaySub field) for the buzz-hunt diagnostics, but the flush path no longer reads it.
-uint8_t          g_relaySub = 0x05;
+uint8_t g_relaySub = 0x05;
 volatile uint8_t g_testHaptic = 0;
 volatile uint8_t g_hapticStop = 0;
-unsigned long    g_hapticBlockUntil = 0;   // drop Steam haptics briefly during reconnect settle
+unsigned long g_hapticBlockUntil =
+	0; // drop Steam haptics briefly during reconnect settle
 
 // Controller power-off. CONFIRMED from a real Windows USB capture of the Valve puck (shutoff.pcapng): Steam's
 // "turn off controller" is the single feature-0x01 command 0x9F with payload ASCII "off!" (6F 66 66 21). The
@@ -21,202 +22,335 @@ unsigned long    g_hapticBlockUntil = 0;   // drop Steam haptics briefly during
 // directly -- we relay it the same way (E3 SET sub-TLV). The wire relay is NO-ACK, so send a small burst: a
 // single lost frame must not leave the controller on. Driven by the Steam 0x9F passthrough (puck_hid), the
 // host-suspend edge (hapticTask), and the WebUSB test button (op 0x08).
-void hapticSendShutdown(){
-  static const uint8_t OFF[4] = {0x6f,0x66,0x66,0x21};   // "off!"
-  for (uint8_t i=0; i<HAPTIC_SHUTDOWN_SHOTS; i++) relayEnqueue(0x9F, OFF, sizeof OFF);
+void hapticSendShutdown()
+{
+	static const uint8_t OFF[4] = { 0x6f, 0x66, 0x66, 0x21 }; // "off!"
+	for (uint8_t i = 0; i < HAPTIC_SHUTDOWN_SHOTS; i++)
+		relayEnqueue(0x9F, OFF, sizeof OFF);
 }
 
-static unsigned long g_haptic82Ms = 0;     // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
-static bool          g_haptic82On = false; // a non-zero 0x82 haptic is currently active (awaiting host stop)
-static unsigned long g_rumble80Ms = 0;     // millis of last translated host rumble (0x80)
-static bool          g_rumble80On = false; // Steam/Triton rumble is latched on until an explicit zero report
-static unsigned long g_reinitAt = 0;       // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
-static uint8_t       g_reinitLeft = 0;     // how many re-init shots remain in this connect window
-static bool          g_hapClearArmed=false;// haptic activity happened -> arm a clear once it goes idle (catches a
-                                           // latch that engaged during/after use, even seconds after connect)
+static unsigned long g_haptic82Ms =
+	0; // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
+static bool g_haptic82On =
+	false; // a non-zero 0x82 haptic is currently active (awaiting host stop)
+static unsigned long g_rumble80Ms =
+	0; // millis of last translated host rumble (0x80)
+static bool g_rumble80On =
+	false; // Steam/Triton rumble is latched on until an explicit zero report
+static unsigned long g_reinitAt =
+	0; // when to fire the next post-reconnect haptic re-init (0 = none scheduled)
+static uint8_t g_reinitLeft =
+	0; // how many re-init shots remain in this connect window
+// haptic activity happened -> arm a clear once it goes idle (catches a
+// latch that engaged during/after use, even seconds after connect)
+static bool g_hapClearArmed = false;
 
 // ---- relay ring: multi-producer (USB ISR + loop-context console/xinput), single consumer (poll flush) ----
 // Producers serialize through a brief PRIMASK critical section (copy is <=62 bytes); the consumer only ever
 // touches the tail entry, which no producer writes while the ring isn't full -- so a flush can't be torn.
-struct RelayMsg { uint8_t rid, len; uint8_t data[RELAY_MAXP]; };
-#define RELAY_QLEN 32          // deep enough to hold a full Steam settings/LED transaction burst without loss
-static RelayMsg         g_rq[RELAY_QLEN];
-static volatile uint8_t g_rqHead = 0, g_rqTail = 0;   // head=next write, tail=next read; empty when equal
-static inline uint8_t rqNext(uint8_t i){ return (uint8_t)((i+1)%RELAY_QLEN); }
+struct RelayMsg {
+	uint8_t rid, len;
+	uint8_t data[RELAY_MAXP];
+};
+#define RELAY_QLEN \
+	32 // deep enough to hold a full Steam settings/LED transaction burst without loss
+static RelayMsg g_rq[RELAY_QLEN];
+static volatile uint8_t
+	g_rqHead = 0,
+	g_rqTail = 0; // head=next write, tail=next read; empty when equal
+static inline uint8_t rqNext(uint8_t i)
+{
+	return (uint8_t)((i + 1) % RELAY_QLEN);
+}
 
-bool relayPending(){ return g_rqHead != g_rqTail; }
-bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen){
-  if (plen > RELAY_MAXP) plen = RELAY_MAXP;            // 60B is the RF frame ceiling; longer can't be relayed
-  uint32_t pm = __get_PRIMASK(); __disable_irq();
-  uint8_t h = g_rqHead, nx = rqNext(h);
-  // Full -> evict the OLDEST entry, never the newest. Steam sends commands as bursts where the meaningful
-  // frame comes LAST (a settings transaction ends with its commit; a haptic stream ends with its stop). The
-  // old drop-newest policy threw away exactly those, so LED commits and haptic stops were the casualties --
-  // brightness never applied and a glide's final stop never reached the controller (stuck buzz). Dropping the
-  // oldest instead guarantees the most recent command always lands.
-  if (nx == g_rqTail) g_rqTail = rqNext(g_rqTail);
-  g_rq[h].rid = rid; g_rq[h].len = plen;
-  if (plen) memcpy(g_rq[h].data, payload, plen);
-  g_rqHead = nx;
-  // Any haptic relay (Steam OR Xbox rumble OR test) arms the idle-clear and refreshes its timer -- so the
-  // during-use buzz gets cleared in EVERY USB mode, not just Steam. The re-init's own 0x81/0x87 don't match.
-  if (rid==0x82 || rid==0x80){ g_haptic82Ms = millis(); g_hapClearArmed = true; }
-  __set_PRIMASK(pm); return true;
+bool relayPending()
+{
+	return g_rqHead != g_rqTail;
+}
+bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen)
+{
+	if (plen > RELAY_MAXP)
+		plen = RELAY_MAXP; // 60B is the RF frame ceiling; longer can't be relayed
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	uint8_t h = g_rqHead, nx = rqNext(h);
+	// Full -> evict the OLDEST entry, never the newest. Steam sends commands as bursts where the meaningful
+	// frame comes LAST (a settings transaction ends with its commit; a haptic stream ends with its stop). The
+	// old drop-newest policy threw away exactly those, so LED commits and haptic stops were the casualties --
+	// brightness never applied and a glide's final stop never reached the controller (stuck buzz). Dropping the
+	// oldest instead guarantees the most recent command always lands.
+	if (nx == g_rqTail)
+		g_rqTail = rqNext(g_rqTail);
+	g_rq[h].rid = rid;
+	g_rq[h].len = plen;
+	if (plen)
+		memcpy(g_rq[h].data, payload, plen);
+	g_rqHead = nx;
+	// Any haptic relay (Steam OR Xbox rumble OR test) arms the idle-clear and refreshes its timer -- so the
+	// during-use buzz gets cleared in EVERY USB mode, not just Steam. The re-init's own 0x81/0x87 don't match.
+	if (rid == 0x82 || rid == 0x80) {
+		g_haptic82Ms = millis();
+		g_hapClearArmed = true;
+	}
+	__set_PRIMASK(pm);
+	return true;
 }
 
 #if OPK_LOG
 // diagnostic capture: a ring of the last OUTPUT reports Steam sends (rid/slot/bytes/ms), dumped with 'H'.
 // Reproduce the whine, press Steam to stop it, then 'H' to see the ON stream + the exact OFF frame Steam sends.
-struct HapLog { uint32_t ms; uint8_t slot, rid, n, b[16]; };   // 16 payload bytes: capture full 0x87 settings frames
+struct HapLog {
+	uint32_t ms;
+	uint8_t slot, rid, n, b[16];
+}; // 16 payload bytes: capture full 0x87 settings frames
 // Big always-on ring: log EVERYTHING from boot (Steam writes, our TX-to-controller, link edges) so a rare
 // reconnect-buzz can be caught after the fact -- the trigger happens moments after the puck boots, while this
 // RAM is fresh, and we dump it once the panel reconnects. 4096 * 20B ~= 80KB (we have plenty of free RAM).
 #define HAPLOG_N 4096
-static HapLog   g_hapLog[HAPLOG_N];
+static HapLog g_hapLog[HAPLOG_N];
 static uint16_t g_hapHead = 0;
-static uint16_t g_hapTail = 0;  // live/dump drain cursor (loop-context reader; chases g_hapHead)
+static uint16_t g_hapTail =
+	0; // live/dump drain cursor (loop-context reader; chases g_hapHead)
 
-void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n){
-  // Written from the USB SET ISR (handleSet) AND loop context (relay flush / link edges) -> guard g_hapHead.
-  // Special slot markers for the diagnostic capture: 0xFE = a frame WE transmitted to the controller (TX
-  // relay); 0xFD = a link state edge (b[0]=1 up, 0 down). Real Steam writes use the interface index (0..3).
-  uint32_t pm = __get_PRIMASK(); __disable_irq();
-  HapLog &e = g_hapLog[g_hapHead]; e.ms = millis(); e.slot = slot; e.rid = rid; e.n = (uint8_t)(n>255?255:n);
-  for(int i=0;i<16;i++) e.b[i] = (i<(int)n) ? b[i] : 0;
-  g_hapHead = (uint8_t)((g_hapHead+1) % (sizeof g_hapLog / sizeof g_hapLog[0]));
-  __set_PRIMASK(pm);
+void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t *b, uint16_t n)
+{
+	// Written from the USB SET ISR (handleSet) AND loop context (relay flush / link edges) -> guard g_hapHead.
+	// Special slot markers for the diagnostic capture: 0xFE = a frame WE transmitted to the controller (TX
+	// relay); 0xFD = a link state edge (b[0]=1 up, 0 down). Real Steam writes use the interface index (0..3).
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	HapLog &e = g_hapLog[g_hapHead];
+	e.ms = millis();
+	e.slot = slot;
+	e.rid = rid;
+	e.n = (uint8_t)(n > 255 ? 255 : n);
+	for (int i = 0; i < 16; i++)
+		e.b[i] = (i < (int)n) ? b[i] : 0;
+	g_hapHead = (uint8_t)((g_hapHead + 1) %
+			      (sizeof g_hapLog / sizeof g_hapLog[0]));
+	__set_PRIMASK(pm);
 }
-void hapticDumpLog(){   // dump the captured history (oldest->newest) over CDC for the haptic-whine hunt
-  const uint16_t N=HAPLOG_N; uint32_t now=millis();
-  Serial.printf("# --- capture history (now=%lu, connSlot=%d) ---\n",(unsigned long)now,g_connSlot);
-  for(uint16_t i=0;i<N;i++){ HapLog &e=g_hapLog[(uint16_t)((g_hapHead+i)%N)]; if(!e.ms && !e.rid) continue;
-    Serial.printf("# -%lums if%u rid=%02X n=%u:",(unsigned long)(now-e.ms),e.slot,e.rid,e.n);
-    for(uint8_t j=0;j<16 && j<e.n;j++) Serial.printf(" %02X",e.b[j]); Serial.println(); }
-  Serial.println("# --- end ---");
+void hapticDumpLog()
+{ // dump the captured history (oldest->newest) over CDC for the haptic-whine hunt
+	const uint16_t N = HAPLOG_N;
+	uint32_t now = millis();
+	Serial.printf("# --- capture history (now=%lu, connSlot=%d) ---\n",
+		      (unsigned long)now, g_connSlot);
+	for (uint16_t i = 0; i < N; i++) {
+		HapLog &e = g_hapLog[(uint16_t)((g_hapHead + i) % N)];
+		if (!e.ms && !e.rid)
+			continue;
+		Serial.printf("# -%lums if%u rid=%02X n=%u:",
+			      (unsigned long)(now - e.ms), e.slot, e.rid, e.n);
+		for (uint8_t j = 0; j < 16 && j < e.n; j++)
+			Serial.printf(" %02X", e.b[j]);
+		Serial.println();
+	}
+	Serial.println("# --- end ---");
 }
 // ---- drain cursor: stream entries to the WebUSB panel. resetDrain(false)=from "now" (live only);
 //      resetDrain(true)=from the OLDEST entry (dump the whole ring from boot). pull skips empty slots. ----
-void hapLogResetDrain(bool fromBoot){
-  g_hapTail = fromBoot ? (uint16_t)((g_hapHead+1)%HAPLOG_N)   // oldest slot (the next-to-overwrite holds it)
-                       : g_hapHead;                            // "now"
+void hapLogResetDrain(bool fromBoot)
+{
+	g_hapTail =
+		fromBoot ?
+			(uint16_t)((g_hapHead + 1) %
+				   HAPLOG_N) // oldest slot (the next-to-overwrite holds it)
+			:
+			g_hapHead; // "now"
 }
-bool hapLogPull(uint32_t* logMs, uint8_t* slot, uint8_t* rid, uint8_t* n, uint8_t bytes16[16]){
-  while(g_hapTail != g_hapHead){
-    HapLog &e=g_hapLog[g_hapTail];
-    g_hapTail=(uint16_t)((g_hapTail+1)%HAPLOG_N);
-    if(!e.ms && !e.rid) continue;                            // skip empty slot (ring not yet full)
-    // Return the ABSOLUTE log time (millis since boot), not "age now". The panel drains in batches 100ms
-    // apart, so an age computed here would jump between batches; the panel computes age vs the newest entry.
-    *logMs=e.ms; *slot=e.slot; *rid=e.rid; *n=(e.n>16)?16:e.n; memcpy(bytes16,e.b,16);
-    return true;
-  }
-  return false;
+bool hapLogPull(uint32_t *logMs, uint8_t *slot, uint8_t *rid, uint8_t *n,
+		uint8_t bytes16[16])
+{
+	while (g_hapTail != g_hapHead) {
+		HapLog &e = g_hapLog[g_hapTail];
+		g_hapTail = (uint16_t)((g_hapTail + 1) % HAPLOG_N);
+		if (!e.ms && !e.rid)
+			continue; // skip empty slot (ring not yet full)
+		// Return the ABSOLUTE log time (millis since boot), not "age now". The panel drains in batches 100ms
+		// apart, so an age computed here would jump between batches; the panel computes age vs the newest entry.
+		*logMs = e.ms;
+		*slot = e.slot;
+		*rid = e.rid;
+		*n = (e.n > 16) ? 16 : e.n;
+		memcpy(bytes16, e.b, 16);
+		return true;
+	}
+	return false;
 }
 #endif // OPK_LOG
 
-bool hapticLinkUp(){
-  return g_connSlot>=0 && (millis()-g_connReplyMs) < 300;
+bool hapticLinkUp()
+{
+	return g_connSlot >= 0 && (millis() - g_connReplyMs) < 300;
 }
-bool haptic82Blocked(){
-  return !hapticLinkUp() || (g_hapticBlockUntil && (int32_t)(millis()-g_hapticBlockUntil) < 0);
+bool haptic82Blocked()
+{
+	return !hapticLinkUp() ||
+	       (g_hapticBlockUntil &&
+		(int32_t)(millis() - g_hapticBlockUntil) < 0);
 }
-bool hapticRelaySlotOk(int slot){
-  return g_connSlot>=0 && slot==g_connSlot;
+bool hapticRelaySlotOk(int slot)
+{
+	return g_connSlot >= 0 && slot == g_connSlot;
 }
-static bool haptic82PayloadOn(const uint8_t* p, uint16_t n){
-  if(n<3) return false;
-  for(uint16_t i=2;i<n;i++) if(p[i]) return true;   // observed form is [01 01 gain], but treat any trailing non-zero as active
-  return false;
+static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
+{
+	if (n < 3)
+		return false;
+	for (uint16_t i = 2; i < n; i++)
+		if (p[i])
+			return true; // observed form is [01 01 gain], but treat any trailing non-zero as active
+	return false;
 }
-static void hapticCancelPendingOn(){   // void queued ON entries (stale Steam haptics / translated rumble across a reconnect)
-  uint32_t pm = __get_PRIMASK(); __disable_irq();
-  for(uint8_t i=g_rqTail; i!=g_rqHead; i=rqNext(i)){
-    RelayMsg &m = g_rq[i];
-    if(m.rid==0x82){ bool on=false; for(uint8_t j=2;j<m.len;j++) if(m.data[j]){on=true;break;} if(on) m.rid=0; }
-    if(m.rid==0x80){ bool on=false; for(uint8_t j=0;j<m.len;j++) if(m.data[j]){on=true;break;} if(on) m.rid=0; }
-  }
-  __set_PRIMASK(pm);
+static void hapticCancelPendingOn()
+{ // void queued ON entries (stale Steam haptics / translated rumble across a reconnect)
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	for (uint8_t i = g_rqTail; i != g_rqHead; i = rqNext(i)) {
+		RelayMsg &m = g_rq[i];
+		if (m.rid == 0x82) {
+			bool on = false;
+			for (uint8_t j = 2; j < m.len; j++)
+				if (m.data[j]) {
+					on = true;
+					break;
+				}
+			if (on)
+				m.rid = 0;
+		}
+		if (m.rid == 0x80) {
+			bool on = false;
+			for (uint8_t j = 0; j < m.len; j++)
+				if (m.data[j]) {
+					on = true;
+					break;
+				}
+			if (on)
+				m.rid = 0;
+		}
+	}
+	__set_PRIMASK(pm);
 }
-void haptic82HostReport(const uint8_t* p, uint16_t n){
-  if(n<3) return;
-  g_haptic82Ms = millis();
-  g_hapClearArmed = true;   // any haptic activity arms a clear when it next goes idle (kills a latch from this use)
-  // Track on/off only. Do NOT synthesize a stop burst when Steam's own stop arrives: that stop is already
-  // forwarded verbatim, so adding 0x82-zero frames on top just makes the controller see the stop several times
-  // over. Each 0x82 is a discrete pad click, so the extra frames are exactly the spurious end-of-movement
-  // "click"/buzz that the real puck never produces. (Connect-time clearing still runs in hapticTask().)
-  g_haptic82On = haptic82PayloadOn(p,n);
+void haptic82HostReport(const uint8_t *p, uint16_t n)
+{
+	if (n < 3)
+		return;
+	g_haptic82Ms = millis();
+	g_hapClearArmed =
+		true; // any haptic activity arms a clear when it next goes idle (kills a latch from this use)
+	// Track on/off only. Do NOT synthesize a stop burst when Steam's own stop arrives: that stop is already
+	// forwarded verbatim, so adding 0x82-zero frames on top just makes the controller see the stop several times
+	// over. Each 0x82 is a discrete pad click, so the extra frames are exactly the spurious end-of-movement
+	// "click"/buzz that the real puck never produces. (Connect-time clearing still runs in hapticTask().)
+	g_haptic82On = haptic82PayloadOn(p, n);
 }
-bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq){
-  if(g_rumbleScale!=100){            // user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
-    uint32_t l=(uint32_t)lowFreq*g_rumbleScale/100, h=(uint32_t)highFreq*g_rumbleScale/100;
-    lowFreq=(l>0xFFFF)?0xFFFF:(uint16_t)l; highFreq=(h>0xFFFF)?0xFFFF:(uint16_t)h;
-  }
-  bool on = lowFreq || highFreq;
-  if(on && haptic82Blocked()) return false;       // same settle gate as native Steam haptics
-  if(!on && !hapticLinkUp()) return false;
+bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq)
+{
+	if (g_rumbleScale !=
+	    100) { // user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
+		uint32_t l = (uint32_t)lowFreq * g_rumbleScale / 100,
+			 h = (uint32_t)highFreq * g_rumbleScale / 100;
+		lowFreq = (l > 0xFFFF) ? 0xFFFF : (uint16_t)l;
+		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
+	}
+	bool on = lowFreq || highFreq;
+	if (on && haptic82Blocked())
+		return false; // same settle gate as native Steam haptics
+	if (!on && !hapticLinkUp())
+		return false;
 
-  // SDL's current Steam/Triton structs define output report 0x80 as:
-  //   type, uint16 intensity, {uint16 speed, int8 gain} left/right.
-  // We map conventional gamepad low/high-frequency motors to left/right speeds and use max as intensity.
-  uint16_t intensity = lowFreq > highFreq ? lowFreq : highFreq;
-  uint8_t p[9];
-  p[0] = on ? 0x04 : 0x00;   // haptic_type_t::HAPTIC_TYPE_RUMBLE; 0 is the off/zero report
-  p[1] = (uint8_t)(intensity & 0xFF); p[2] = (uint8_t)(intensity >> 8);
-  p[3] = (uint8_t)(lowFreq   & 0xFF); p[4] = (uint8_t)(lowFreq   >> 8); p[5] = 0;
-  p[6] = (uint8_t)(highFreq  & 0xFF); p[7] = (uint8_t)(highFreq  >> 8); p[8] = 0;
-  if(!relayEnqueue(0x80, p, sizeof p)) return false;
-  g_rumble80Ms = millis();
-  g_rumble80On = on;
-  return true;
+	// SDL's current Steam/Triton structs define output report 0x80 as:
+	//   type, uint16 intensity, {uint16 speed, int8 gain} left/right.
+	// We map conventional gamepad low/high-frequency motors to left/right speeds and use max as intensity.
+	uint16_t intensity = lowFreq > highFreq ? lowFreq : highFreq;
+	uint8_t p[9];
+	p[0] = on ? 0x04 :
+		    0x00; // haptic_type_t::HAPTIC_TYPE_RUMBLE; 0 is the off/zero report
+	p[1] = (uint8_t)(intensity & 0xFF);
+	p[2] = (uint8_t)(intensity >> 8);
+	p[3] = (uint8_t)(lowFreq & 0xFF);
+	p[4] = (uint8_t)(lowFreq >> 8);
+	p[5] = 0;
+	p[6] = (uint8_t)(highFreq & 0xFF);
+	p[7] = (uint8_t)(highFreq >> 8);
+	p[8] = 0;
+	if (!relayEnqueue(0x80, p, sizeof p))
+		return false;
+	g_rumble80Ms = millis();
+	g_rumble80On = on;
+	return true;
 }
 // Queue a pending test-haptic / stop relay (runs inside the poll cadence -- never at raw loop rate).
-void rfConnQueueHapticRelay(){
-  if(relayPending()) return;            // host relays first; injectables wait for an idle cycle
-  static const uint8_t HAP_ON[3]  = {0x01,0x01,0xF7};
-  static const uint8_t HAP_OFF[3] = {0x01,0x01,0x00};
-  if(g_testHaptic){
-    if(relayEnqueue(0x82, HAP_ON, 3)) g_testHaptic--;
-  } else if(g_hapticStop && !g_xbox){
-    if(relayEnqueue(0x82, HAP_OFF, 3)) g_hapticStop--;
-  }
+void rfConnQueueHapticRelay()
+{
+	if (relayPending())
+		return; // host relays first; injectables wait for an idle cycle
+	static const uint8_t HAP_ON[3] = { 0x01, 0x01, 0xF7 };
+	static const uint8_t HAP_OFF[3] = { 0x01, 0x01, 0x00 };
+	if (g_testHaptic) {
+		if (relayEnqueue(0x82, HAP_ON, 3))
+			g_testHaptic--;
+	} else if (g_hapticStop && !g_xbox) {
+		if (relayEnqueue(0x82, HAP_OFF, 3))
+			g_hapticStop--;
+	}
 }
-void rfConnFlushRelay(uint8_t ch, uint8_t s1){
-  while(g_rqTail != g_rqHead){
-    RelayMsg &m = g_rq[g_rqTail];
-    if(m.rid){                          // rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
-      uint8_t rl = m.len; if(rl > RELAY_MAXP) rl = RELAY_MAXP;
-      // On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs (puck_sniffer): a command LANDS on
-      // the controller only with the type-01 + inner-len form  E3 [2+rl][01][rid][innerlen][data]; the legacy
-      // form  E3 [1+rl][05][rid][data]  (no inner-len) is what the long-working build shipped, and the controller
-      // DISCARDS any 0x87+ command sent that way (it reads data[0] as the length).
-      //
-      // We WHITELIST the landing form to EXACTLY the two commands that need it -- and nothing else:
-      //   * LED brightness  -- report 0x87 whose first register byte is 0x2D
-      //   * controller power-off -- report 0x9F ("off!")
-      // Everything else keeps the legacy form. This is the crux: Steam ALSO sends 0x87 passthrough writes during
-      // normal play -- the haptic-config block (reg 0x30 = IMU/subsystem enable, 0x34/0x35 = haptic amplitude). On
-      // the legacy framing the controller discards them, which is WHY the gyro keeps streaming (a landed 0x30
-      // FREEZES the IMU) and the haptics stay quiet (landed 0x34/0x35 = the connect buzz). Landing ALL 0x87 (the
-      // naive "split on >=0x87") regresses BOTH: stuck gyro + buzz. So keep this list tight.
-      bool land01 = (m.rid==0x9F) || (m.rid==0x87 && rl>=1 && m.data[0]==0x2D);
-      uint8_t p[5+RELAY_MAXP], plen;
-      if (land01){
-        p[0]=g_relayOp; p[1]=(uint8_t)(2+rl); p[2]=0x01; p[3]=m.rid; p[4]=rl; memcpy(p+5, m.data, rl); plen=(uint8_t)(5+rl);
-      } else {
-        p[0]=g_relayOp; p[1]=(uint8_t)(1+rl); p[2]=0x05; p[3]=m.rid;          memcpy(p+4, m.data, rl); plen=(uint8_t)(4+rl);
-      }
-      hapLogAdd(0xFE, m.rid, m.data, rl);   // log what we actually TX to the controller (slot 0xFE) for the buzz hunt
-      g_rqTail = rqNext(g_rqTail);      // copied out -> release the slot before the TX
-      // s1 carries a PID distinct from the GET poll that follows (caller cycles it), so the controller's ESB
-      // dedup never mistakes the GET for a retransmit of this relay. 80us RX window: the relay is NO-ACK, the
-      // controller sends nothing back, so don't burn the full ~1.2ms reply window (that was halving the poll
-      // rate during haptics).
-      rfConnTx(ch,s1,p,plen,80);
-      return;                           // ONE relay per poll cycle (matches the real puck's pacing)
-    }
-    g_rqTail = rqNext(g_rqTail);
-  }
+void rfConnFlushRelay(uint8_t ch, uint8_t s1)
+{
+	while (g_rqTail != g_rqHead) {
+		RelayMsg &m = g_rq[g_rqTail];
+		if (m.rid) { // rid 0 = entry voided by hapticCancelPendingOn -> skip, take the next
+			uint8_t rl = m.len;
+			if (rl > RELAY_MAXP)
+				rl = RELAY_MAXP;
+			// On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs (puck_sniffer): a command LANDS on
+			// the controller only with the type-01 + inner-len form  E3 [2+rl][01][rid][innerlen][data]; the legacy
+			// form  E3 [1+rl][05][rid][data]  (no inner-len) is what the long-working build shipped, and the controller
+			// DISCARDS any 0x87+ command sent that way (it reads data[0] as the length).
+			//
+			// We WHITELIST the landing form to EXACTLY the two commands that need it -- and nothing else:
+			//   * LED brightness  -- report 0x87 whose first register byte is 0x2D
+			//   * controller power-off -- report 0x9F ("off!")
+			// Everything else keeps the legacy form. This is the crux: Steam ALSO sends 0x87 passthrough writes during
+			// normal play -- the haptic-config block (reg 0x30 = IMU/subsystem enable, 0x34/0x35 = haptic amplitude). On
+			// the legacy framing the controller discards them, which is WHY the gyro keeps streaming (a landed 0x30
+			// FREEZES the IMU) and the haptics stay quiet (landed 0x34/0x35 = the connect buzz). Landing ALL 0x87 (the
+			// naive "split on >=0x87") regresses BOTH: stuck gyro + buzz. So keep this list tight.
+			bool land01 =
+				(m.rid == 0x9F) ||
+				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
+			uint8_t p[5 + RELAY_MAXP], plen;
+			if (land01) {
+				p[0] = g_relayOp;
+				p[1] = (uint8_t)(2 + rl);
+				p[2] = 0x01;
+				p[3] = m.rid;
+				p[4] = rl;
+				memcpy(p + 5, m.data, rl);
+				plen = (uint8_t)(5 + rl);
+			} else {
+				p[0] = g_relayOp;
+				p[1] = (uint8_t)(1 + rl);
+				p[2] = 0x05;
+				p[3] = m.rid;
+				memcpy(p + 4, m.data, rl);
+				plen = (uint8_t)(4 + rl);
+			}
+			hapLogAdd(
+				0xFE, m.rid, m.data,
+				rl); // log what we actually TX to the controller (slot 0xFE) for the buzz hunt
+			g_rqTail = rqNext(
+				g_rqTail); // copied out -> release the slot before the TX
+			// s1 carries a PID distinct from the GET poll that follows (caller cycles it), so the controller's ESB
+			// dedup never mistakes the GET for a retransmit of this relay. 80us RX window: the relay is NO-ACK, the
+			// controller sends nothing back, so don't burn the full ~1.2ms reply window (that was halving the poll
+			// rate during haptics).
+			rfConnTx(ch, s1, p, plen, 80);
+			return; // ONE relay per poll cycle (matches the real puck's pacing)
+		}
+		g_rqTail = rqNext(g_rqTail);
+	}
 }
 
 // Haptic-subsystem RE-INIT: the exact sequence Steam sends (captured on hardware) when it (re)takes control,
@@ -229,75 +363,122 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1){
 // when its first register is brightness 0x2D -- so the controller DISCARDS them, exactly as on the long-working
 // build. They are kept verbatim so this path matches that build byte-for-byte; the effective re-init is the
 // three 0x81 frames, which is what cleared the buzz there. (Landing the 0x30 here would freeze the gyro.)
-void hapticReinit(){
-  static const uint8_t H30[]={0x30,0x00,0x00,0x07,0x07,0x00,0x08,0x07,0x00,0x31,0x02,0x00,0x52,0x03,0x00};
-  static const uint8_t H18[]={0x18,0x00,0x00,0x2e,0x00,0x00,0x34,0xff,0xff,0x35,0xff,0xff,0x34,0xff,0xff};
-  static const uint8_t H35[]={0x35,0xff,0xff,0x2e,0x00,0x00};
-  static const uint8_t T81A[]={0x00,0x00,0x00,0x00,0x00,0x00,0x00};
-  static const uint8_t T81B[]={0x01,0x00,0x00,0x00,0x00,0x00,0x00};
-  relayEnqueue(0x81, nullptr, 0);            // reset action (FUN_0001f554) -- Steam sends this first
-  relayEnqueue(0x87, H30, sizeof H30);
-  relayEnqueue(0x87, H18, sizeof H18);       // haptic config (enabled/amplifier/gain): the part that clears a latch
-  relayEnqueue(0x87, H35, sizeof H35);
-  relayEnqueue(0x81, T81A, sizeof T81A);
-  relayEnqueue(0x81, T81B, sizeof T81B);
+void hapticReinit()
+{
+	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
+				       0x00, 0x08, 0x07, 0x00, 0x31,
+				       0x02, 0x00, 0x52, 0x03, 0x00 };
+	static const uint8_t H18[] = { 0x18, 0x00, 0x00, 0x2e, 0x00,
+				       0x00, 0x34, 0xff, 0xff, 0x35,
+				       0xff, 0xff, 0x34, 0xff, 0xff };
+	static const uint8_t H35[] = { 0x35, 0xff, 0xff, 0x2e, 0x00, 0x00 };
+	static const uint8_t T81A[] = {
+		0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+	};
+	static const uint8_t T81B[] = {
+		0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+	};
+	relayEnqueue(
+		0x81, nullptr,
+		0); // reset action (FUN_0001f554) -- Steam sends this first
+	relayEnqueue(0x87, H30, sizeof H30);
+	relayEnqueue(
+		0x87, H18,
+		sizeof H18); // haptic config (enabled/amplifier/gain): the part that clears a latch
+	relayEnqueue(0x87, H35, sizeof H35);
+	relayEnqueue(0x81, T81A, sizeof T81A);
+	relayEnqueue(0x81, T81B, sizeof T81B);
 }
-void hapticInit(){
-  g_rqHead = g_rqTail = 0; g_haptic82On=false; g_rumble80On=false;
-  g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // boot: block stale Steam 0x82 until link stable
-  // NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
-  // zero-gain [01 01 00] "stop". Injecting our invented stop frame (which the real puck never sends) at
-  // boot/connect is unfaithful and, given the controller's "haptic script already active -> ignore" behavior,
-  // a likelier cause of the connect-time buzz than a cure (the buzz happened WITH this burst in place). Be a
-  // pure pass-through of Steam's haptics, like the real puck.
-  g_hapticStop = 0;
+void hapticInit()
+{
+	g_rqHead = g_rqTail = 0;
+	g_haptic82On = false;
+	g_rumble80On = false;
+	g_hapticBlockUntil =
+		millis() +
+		HAPTIC_RECONNECT_BLOCK_MS; // boot: block stale Steam 0x82 until link stable
+	// NO fabricated stop burst. USB capture proves Steam only ever sends 0x82 [01 01 f7] pulses -- never a
+	// zero-gain [01 01 00] "stop". Injecting our invented stop frame (which the real puck never sends) at
+	// boot/connect is unfaithful and, given the controller's "haptic script already active -> ignore" behavior,
+	// a likelier cause of the connect-time buzz than a cure (the buzz happened WITH this burst in place). Be a
+	// pure pass-through of Steam's haptics, like the real puck.
+	g_hapticStop = 0;
 }
 // Arm the post-(re)connect haptic block + schedule the clearing re-init. Called from rf_link the moment a
 // controller reply arrives after a gap (the reliable reconnect signal), and as a backup on hapticTask's
 // link-up edge. Idempotent -- safe to call repeatedly.
-void hapticOnReconnect(){
-  g_hapticBlockUntil = millis() + HAPTIC_RECONNECT_BLOCK_MS;   // no haptics relayed for the next 3s
-  g_haptic82On = false; g_rumble80On = false;
-  hapticCancelPendingOn();                                     // drop any haptic ON queued before the link came up
-  // Be PROACTIVE: start re-initing the haptic engine right after the link is up and repeat across the whole
-  // settle window, instead of waiting for the block to end. The brief connect buzz engages early (during the
-  // block, controller-internal), so a single late shot misses it -- frequent early resets cut it short or
-  // preempt it. The re-init is settings only (no haptic play), and Steam haptics are blocked the whole time,
-  // so repeating it can't itself buzz.
-  g_reinitAt = millis() + 200u;                               // first reset ~200ms after (re)connect
-  g_reinitLeft = HAPTIC_REINIT_SHOTS;                          // then every HAPTIC_REINIT_GAP_MS across the window
-  uint8_t mk=2; hapLogAdd(0xFD, 0xEE, &mk, 1);                 // capture marker: RECONNECT detected (block+reinit armed)
+void hapticOnReconnect()
+{
+	g_hapticBlockUntil =
+		millis() +
+		HAPTIC_RECONNECT_BLOCK_MS; // no haptics relayed for the next 3s
+	g_haptic82On = false;
+	g_rumble80On = false;
+	hapticCancelPendingOn(); // drop any haptic ON queued before the link came up
+	// Be PROACTIVE: start re-initing the haptic engine right after the link is up and repeat across the whole
+	// settle window, instead of waiting for the block to end. The brief connect buzz engages early (during the
+	// block, controller-internal), so a single late shot misses it -- frequent early resets cut it short or
+	// preempt it. The re-init is settings only (no haptic play), and Steam haptics are blocked the whole time,
+	// so repeating it can't itself buzz.
+	g_reinitAt = millis() + 200u; // first reset ~200ms after (re)connect
+	g_reinitLeft =
+		HAPTIC_REINIT_SHOTS; // then every HAPTIC_REINIT_GAP_MS across the window
+	uint8_t mk = 2;
+	hapLogAdd(0xFD, 0xEE, &mk,
+		  1); // capture marker: RECONNECT detected (block+reinit armed)
 }
-void hapticTask(){
-  static bool wasHapticLinkUp=false;
-  bool up=hapticLinkUp();
-  // Link-edge markers are diagnostic only now -- the block/re-init is armed reliably from rf_link
-  // (hapticOnReconnect) on the first reply after a gap, which fires even when this 300ms edge doesn't.
-  if(up && !wasHapticLinkUp){ uint8_t mk=1; hapLogAdd(0xFD,0xEE,&mk,1); hapticOnReconnect(); }
-  if(!up && wasHapticLinkUp){ uint8_t mk=0; hapLogAdd(0xFD,0xEE,&mk,1); }
-  wasHapticLinkUp=up;
-  if(g_reinitAt && up && (int32_t)(millis()-g_reinitAt) >= 0){   // proactive haptic re-init across the connect window
-    hapticReinit();
-    g_reinitAt = (g_reinitLeft && --g_reinitLeft) ? (millis()+HAPTIC_REINIT_GAP_MS) : 0;
-  }
-  // Controller power-off on host SLEEP: send the power-off command (0x9F "off!") the instant the USB bus
-  // suspends, like the real puck. BUT only when USB power (VBUS) is still present -- i.e. a genuine host sleep,
-  // NOT a cable unplug. Pulling the dongle ALSO trips the suspend edge (in the brief window it runs on residual
-  // power), and we must NOT kill the controller then; it should only power off on a shutdown command or a real
-  // host sleep. VBUSDETECT is 1 while the cable still delivers 5V, 0 once unplugged. wasSusp starts true so a
-  // boot-into-suspended state never false-fires.
-  static bool wasSusp=true; bool susp=USBDevice.suspended();
-  bool vbus = (NRF_POWER->USBREGSTATUS & POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0;
-  if (susp && !wasSusp && vbus) hapticSendShutdown();
-  wasSusp=susp;
-  // Steam-mode: host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics
-  // are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
-  // click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.
-  if (!g_xbox && g_haptic82On && millis()-g_haptic82Ms > HAPTIC_QUIET_MS) g_haptic82On=false;
-  if (g_rumble80On && millis()-g_rumble80Ms > 2500u) hapticSteamRumble(0, 0);
-  // Haptic activity has gone idle for a while -> fire one re-init to clear any latch it left behind (the buzz
-  // that engages during/after use and won't self-clear, incl. after a mode switch). Fires only after a quiet
-  // gap, so it never interrupts active haptics; the brightness-less re-init is silent (settings, no play, no
-  // LED). Runs in ALL modes (the controller-side latch is mode-independent).
-  if (g_hapClearArmed && (millis()-g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS){ g_hapClearArmed=false; hapticReinit(); }
+void hapticTask()
+{
+	static bool wasHapticLinkUp = false;
+	bool up = hapticLinkUp();
+	// Link-edge markers are diagnostic only now -- the block/re-init is armed reliably from rf_link
+	// (hapticOnReconnect) on the first reply after a gap, which fires even when this 300ms edge doesn't.
+	if (up && !wasHapticLinkUp) {
+		uint8_t mk = 1;
+		hapLogAdd(0xFD, 0xEE, &mk, 1);
+		hapticOnReconnect();
+	}
+	if (!up && wasHapticLinkUp) {
+		uint8_t mk = 0;
+		hapLogAdd(0xFD, 0xEE, &mk, 1);
+	}
+	wasHapticLinkUp = up;
+	if (g_reinitAt && up &&
+	    (int32_t)(millis() - g_reinitAt) >=
+		    0) { // proactive haptic re-init across the connect window
+		hapticReinit();
+		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
+				     (millis() + HAPTIC_REINIT_GAP_MS) :
+				     0;
+	}
+	// Controller power-off on host SLEEP: send the power-off command (0x9F "off!") the instant the USB bus
+	// suspends, like the real puck. BUT only when USB power (VBUS) is still present -- i.e. a genuine host sleep,
+	// NOT a cable unplug. Pulling the dongle ALSO trips the suspend edge (in the brief window it runs on residual
+	// power), and we must NOT kill the controller then; it should only power off on a shutdown command or a real
+	// host sleep. VBUSDETECT is 1 while the cable still delivers 5V, 0 once unplugged. wasSusp starts true so a
+	// boot-into-suspended state never false-fires.
+	static bool wasSusp = true;
+	bool susp = USBDevice.suspended();
+	bool vbus = (NRF_POWER->USBREGSTATUS &
+		     POWER_USBREGSTATUS_VBUSDETECT_Msk) != 0;
+	if (susp && !wasSusp && vbus)
+		hapticSendShutdown();
+	wasSusp = susp;
+	// Steam-mode: host went quiet -> mark the 0x82 stream inactive. Do NOT synthesize a stop: trackpad haptics
+	// are one-shot pulses, so firing a 0x82-zero ~HAPTIC_QUIET_MS after a swipe ends is the extra end-of-movement
+	// click the real puck doesn't make. Steam forwards its own stop for any sustained haptic.
+	if (!g_xbox && g_haptic82On &&
+	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
+		g_haptic82On = false;
+	if (g_rumble80On && millis() - g_rumble80Ms > 2500u)
+		hapticSteamRumble(0, 0);
+	// Haptic activity has gone idle for a while -> fire one re-init to clear any latch it left behind (the buzz
+	// that engages during/after use and won't self-clear, incl. after a mode switch). Fires only after a quiet
+	// gap, so it never interrupts active haptics; the brightness-less re-init is silent (settings, no play, no
+	// LED). Runs in ALL modes (the controller-side latch is mode-independent).
+	if (g_hapClearArmed &&
+	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
+		g_hapClearArmed = false;
+		hapticReinit();
+	}
 }

+ 39 - 24
OpenPuck/haptics.h

@@ -19,21 +19,21 @@
 // flapping link. The g_hapLog ring captures recent OUTPUT reports for the 'H' dump.
 #pragma once
 #include <stdint.h>
-#include "config.h"   // OPK_LOG
+#include "config.h" // OPK_LOG
 
 // after this much host silence, consider the current 0x82 haptic stream inactive
-#define HAPTIC_QUIET_MS    300u
+#define HAPTIC_QUIET_MS 300u
 // Block ALL haptic relays for this long after a (re)connect: a just-powered-on controller's haptic engine
 // isn't ready, and feeding it haptics in that window leaves it in a degraded/latched state (slow/stuck/missing
 // haptics until a re-init). 3s gives it time to initialize. (Was 1500.)
 #define HAPTIC_RECONNECT_BLOCK_MS 3000u
 // 0x82-zero relays per stop event (sent at poll cadence -- not loop rate)
-#define HAPTIC_STOP_BURST  4u
+#define HAPTIC_STOP_BURST 4u
 // max relayed payload bytes per entry: RF frame = [E3][len][05][rid][payload] and MAXLEN=64 -> 60
 #define RELAY_MAXP 60u
 // Proactive post-(re)connect haptic re-init: this many shots, this far apart, starting ~200ms after the link
 // comes up -- covers ~0.2s..3s so the brief controller-side connect buzz gets reset before it can sustain.
-#define HAPTIC_REINIT_SHOTS  8u
+#define HAPTIC_REINIT_SHOTS 8u
 #define HAPTIC_REINIT_GAP_MS 350u
 // After haptic activity, if it's been idle this long, fire one clear-re-init -- kills a latch that engaged
 // during use (a buzz that starts seconds after connect and won't self-clear). Long enough not to fire between
@@ -46,36 +46,51 @@
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // Enqueue one host->controller report: rid = report/command id, payload = the bytes AFTER [cmd][len] (what
 // goes on the air). ISR-safe (brief PRIMASK critical section). Returns false (dropped) when the ring is full.
-bool relayEnqueue(uint8_t rid, const uint8_t* payload, uint8_t plen);
-bool relayPending();                 // anything still queued (xinput uses it to pace rumble re-queues)
-extern uint8_t          g_relayOp;   // relay frame opcode (E3 poll)
-extern uint8_t          g_relaySub;  // relay sub-TLV type byte = SET
-extern volatile uint8_t g_testHaptic;// 't<n>' injects n test haptics for the buzz hunt
-extern volatile uint8_t g_hapticStop;// pending haptic-STOP frames to relay (kill a latched whine)
-extern unsigned long    g_hapticBlockUntil;
-void hapticSendShutdown();           // relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
+bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen);
+bool relayPending(); // anything still queued (xinput uses it to pace rumble re-queues)
+extern uint8_t g_relayOp; // relay frame opcode (E3 poll)
+extern uint8_t g_relaySub; // relay sub-TLV type byte = SET
+extern volatile uint8_t
+	g_testHaptic; // 't<n>' injects n test haptics for the buzz hunt
+extern volatile uint8_t
+	g_hapticStop; // pending haptic-STOP frames to relay (kill a latched whine)
+extern unsigned long g_hapticBlockUntil;
+void hapticSendShutdown(); // relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
 
 // ---- diagnostic capture (compiled in only when OPK_LOG): a ring of recent host->controller commands +
 //      link/TX markers, dumped over WebUSB. No-ops in a production build so call sites vanish. ----
 #if OPK_LOG
-void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t* b, uint16_t n);
-void hapticDumpLog();   // 'H' console dump of the recent OUTPUT-report history
+void hapLogAdd(uint8_t slot, uint8_t rid, const uint8_t *b, uint16_t n);
+void hapticDumpLog(); // 'H' console dump of the recent OUTPUT-report history
 // WebUSB capture drain: resetDrain(true) rewinds to the OLDEST entry (dump the whole ring from boot);
 // resetDrain(false) starts at "now" (live only). pull yields each entry once, oldest->newest, skipping empties.
-void    hapLogResetDrain(bool fromBoot);
-bool    hapLogPull(uint32_t* logMs, uint8_t* slot, uint8_t* rid, uint8_t* n, uint8_t bytes16[16]);
+void hapLogResetDrain(bool fromBoot);
+bool hapLogPull(uint32_t *logMs, uint8_t *slot, uint8_t *rid, uint8_t *n,
+		uint8_t bytes16[16]);
 #else
-static inline void hapLogAdd(uint8_t, uint8_t, const uint8_t*, uint16_t){}
-static inline void hapticDumpLog(){}
-static inline void hapLogResetDrain(bool){}
-static inline bool hapLogPull(uint32_t*, uint8_t*, uint8_t*, uint8_t*, uint8_t*){ return false; }
+static inline void hapLogAdd(uint8_t, uint8_t, const uint8_t *, uint16_t)
+{
+}
+static inline void hapticDumpLog()
+{
+}
+static inline void hapLogResetDrain(bool)
+{
+}
+static inline bool hapLogPull(uint32_t *, uint8_t *, uint8_t *, uint8_t *,
+			      uint8_t *)
+{
+	return false;
+}
 #endif
 
 bool hapticLinkUp();
 bool haptic82Blocked();
 bool hapticRelaySlotOk(int slot);
-void haptic82HostReport(const uint8_t* p, uint16_t n);
-bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq);   // queue Steam/Triton output report 0x80 rumble
+void haptic82HostReport(const uint8_t *p, uint16_t n);
+bool hapticSteamRumble(
+	uint16_t lowFreq,
+	uint16_t highFreq); // queue Steam/Triton output report 0x80 rumble
 
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
@@ -83,8 +98,8 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq);   // queue Steam/Tr
 void rfConnQueueHapticRelay();
 void rfConnFlushRelay(uint8_t ch, uint8_t s1);
 
-void hapticInit();   // boot reset: clear relay/active flags, arm the reconnect block
-void hapticTask();   // per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
+void hapticInit(); // boot reset: clear relay/active flags, arm the reconnect block
+void hapticTask(); // per-loop upkeep: link-edge markers + steam 0x82 quiet timeout + fires the scheduled re-init
 void hapticReinit(); // replay Steam's haptic-subsystem re-init to the controller -> clears a latched/stuck buzz
 // Called from rf_link the instant a controller (re)connect is detected (an F-reply after a gap): blocks haptic
 // relays for HAPTIC_RECONNECT_BLOCK_MS and schedules a re-init just after, to keep the freshly-booted

+ 12 - 7
OpenPuck/identity.cpp

@@ -7,12 +7,17 @@ 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 };
+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];
-  snprintf(g_unit,  sizeof g_unit,  "FXB99602%05lX", (unsigned long)(id & 0xFFFFF));
-  snprintf(g_board, sizeof g_board, "MXB99602%05lX", (unsigned long)(id & 0xFFFFF));
+void genSerial()
+{
+	uint32_t id = NRF_FICR->DEVICEID[0] ^ NRF_FICR->DEVICEID[1];
+	snprintf(g_unit, sizeof g_unit, "FXB99602%05lX",
+		 (unsigned long)(id & 0xFFFFF));
+	snprintf(g_board, sizeof g_board, "MXB99602%05lX",
+		 (unsigned long)(id & 0xFFFFF));
 }

+ 4 - 3
OpenPuck/identity.h

@@ -5,9 +5,10 @@
 #pragma once
 #include <stdint.h>
 
-extern char g_unit[16];        // "FXB99602xxxxx"
-extern char g_board[16];       // "MXB99602xxxxx"
-extern char g_usbSerial[18];   // per-mode USB serial (Steam uses g_unit; others get a suffix)
+extern char g_unit[16]; // "FXB99602xxxxx"
+extern char g_board[16]; // "MXB99602xxxxx"
+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];

+ 104 - 62
OpenPuck/mode_hidgyro.cpp

@@ -9,75 +9,117 @@
 
 HidGyroController g_hidGyroCtl;
 
-static const uint8_t GYRO_HID_DESC[]={
-  0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
-  0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x04,0x81,0x02,0x09,0x39,0x15,0x00,0x25,
-  0x07,0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,0x04,0x95,0x01,0x81,0x42,0x65,0x00,
-  0x05,0x09,0x19,0x01,0x29,0x0E,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0E,0x81,0x02,
-  0x06,0x00,0xFF,0x09,0x20,0x75,0x06,0x95,0x01,0x15,0x00,0x25,0x7F,0x81,0x02,0x05,
-  0x01,0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x02,0x81,0x02,
-  0x06,0x00,0xFF,0x09,0x21,0x95,0x36,0x81,0x02,0x85,0x05,0x09,0x22,0x95,0x1F,0x91,
-  0x02,0x85,0x04,0x09,0x23,0x95,0x24,0xB1,0x02,0x85,0x02,0x09,0x24,0x95,0x24,0xB1,
-  0x02,0xC0
+static const uint8_t GYRO_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
+	0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95,
+	0x04, 0x81, 0x02, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46,
+	0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x65, 0x00,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20, 0x75, 0x06, 0x95,
+	0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05, 0x01, 0x09, 0x33, 0x09,
+	0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02,
+	0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81, 0x02, 0x85, 0x05, 0x09,
+	0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09, 0x23, 0x95, 0x24, 0xB1,
+	0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1, 0x02, 0xC0
 };
 #define DS4_TOUCH_H 942
-#define DS4_STATUS_USB 0x1B   // cable + level 11 (full)
-static unsigned long g_gyroLastMs=0;
+#define DS4_STATUS_USB 0x1B // cable + level 11 (full)
+static unsigned long g_gyroLastMs = 0;
 static Adafruit_USBD_HID g_hidGyro;
 
-static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_t n){
-  if(type!=HID_REPORT_TYPE_OUTPUT || n<1) return;
-  uint8_t id; const uint8_t* p; uint16_t pn;
-  if(rid==0){ id=b[0]; p=b+1; pn=(uint16_t)(n-1); }
-  else      { id=rid;  p=b;   pn=n;               }
-  // DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
-  if(id!=0x05 || pn<5) return;
-  hapticSteamRumble((uint16_t)p[4]*257u, (uint16_t)p[3]*257u);   // DS4: left=low, right=high
+static void hidGyroSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
+		       uint16_t n)
+{
+	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
+		return;
+	uint8_t id;
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		id = b[0];
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} else {
+		id = rid;
+		p = b;
+		pn = n;
+	}
+	// DS4 USB effects: report 0x05, magic at byte 0, effects block starts at byte 3.
+	if (id != 0x05 || pn < 5)
+		return;
+	hapticSteamRumble((uint16_t)p[4] * 257u,
+			  (uint16_t)p[3] * 257u); // DS4: left=low, right=high
 }
 
-static void hidGyroBuild(uint8_t out[63]){
-  uint32_t b=psButtonsFromSteam(g_in.buttons);
-  bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
-  memset(out,0,63);
-  out[0]=swStick(g_in.lx,false); out[1]=swStick(g_in.ly,true);
-  out[2]=swStick(g_in.rx,false); out[3]=swStick(g_in.ry,true);
-  out[4]=psHatNibble(b)|psFaceNibble(b);
-  out[5]=psShouldersByte(b);
-  static uint8_t ctr=0; out[6]=((ctr++&0x0F)<<4)|((b&TB_TOUCH||b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_STEAM)?0x01:0);
-  out[7]=g_in.lt; out[8]=g_in.rt;
-  out[12]=g_in.gx&0xFF; out[13]=g_in.gx>>8;
-  out[14]=g_in.gy&0xFF; out[15]=g_in.gy>>8;
-  out[16]=(-g_in.gz)&0xFF; out[17]=(-g_in.gz)>>8;
-  out[18]=g_in.ax&0xFF; out[19]=g_in.ax>>8;
-  out[20]=g_in.ay&0xFF; out[21]=g_in.ay>>8;
-  out[22]=g_in.az&0xFF; out[23]=g_in.az>>8;
-  out[29]=DS4_STATUS_USB;
-  if(lTouch || rTouch){
-    uint16_t lx, ly, rx, ry;
-    steamPadsToTouch(b, DS4_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx, g_in.rpy, &lx, &ly, &rx, &ry);
-    static uint8_t tstamp=0;
-    out[32]=1; out[33]=tstamp++;
-    touchPackPads(out+34, lTouch, rTouch, lx, ly, rx, ry);
-  } else {
-    out[32]=0;
-    touchPackPads(out+34, false, false, 0, 0, 0, 0);   // contact 0x80 -- memset(0) reads as touch @0,0
-  }
+static void hidGyroBuild(uint8_t out[63])
+{
+	uint32_t b = psButtonsFromSteam(g_in.buttons);
+	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
+	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
+	memset(out, 0, 63);
+	out[0] = swStick(g_in.lx, false);
+	out[1] = swStick(g_in.ly, true);
+	out[2] = swStick(g_in.rx, false);
+	out[3] = swStick(g_in.ry, true);
+	out[4] = psHatNibble(b) | psFaceNibble(b);
+	out[5] = psShouldersByte(b);
+	static uint8_t ctr = 0;
+	out[6] = ((ctr++ & 0x0F) << 4) |
+		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
+		 ((b & TB_STEAM) ? 0x01 : 0);
+	out[7] = g_in.lt;
+	out[8] = g_in.rt;
+	out[12] = g_in.gx & 0xFF;
+	out[13] = g_in.gx >> 8;
+	out[14] = g_in.gy & 0xFF;
+	out[15] = g_in.gy >> 8;
+	out[16] = (-g_in.gz) & 0xFF;
+	out[17] = (-g_in.gz) >> 8;
+	out[18] = g_in.ax & 0xFF;
+	out[19] = g_in.ax >> 8;
+	out[20] = g_in.ay & 0xFF;
+	out[21] = g_in.ay >> 8;
+	out[22] = g_in.az & 0xFF;
+	out[23] = g_in.az >> 8;
+	out[29] = DS4_STATUS_USB;
+	if (lTouch || rTouch) {
+		uint16_t lx, ly, rx, ry;
+		steamPadsToTouch(b, DS4_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx,
+				 g_in.rpy, &lx, &ly, &rx, &ry);
+		static uint8_t tstamp = 0;
+		out[32] = 1;
+		out[33] = tstamp++;
+		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
+	} else {
+		out[32] = 0;
+		touchPackPads(
+			out + 34, false, false, 0, 0, 0,
+			0); // contact 0x80 -- memset(0) reads as touch @0,0
+	}
 }
 
-void HidGyroController::begin(){
-  USBDevice.setID(0x054C, 0x05C4);
-  USBDevice.setDeviceVersion(0x0104);   // bumped: clean-DS4 is now a separate mode (DS4_GAME); normal HIDGYRO keeps wake+WebUSB. Host re-reads config by VID:PID:serial (per-mode suffix) -- bump invalidates any cached 0x0103.
-  USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
-  USBDevice.setProductDescriptor("Wireless Controller");
-  g_hidGyro.enableOutEndpoint(true);
-  g_hidGyro.setReportCallback(NULL, hidGyroSet);
-  g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
-  g_hidGyro.setPollInterval(1);   // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-  g_hidGyro.begin();
+void HidGyroController::begin()
+{
+	USBDevice.setID(0x054C, 0x05C4);
+	USBDevice.setDeviceVersion(
+		0x0104); // bumped: clean-DS4 is now a separate mode (DS4_GAME); normal HIDGYRO keeps wake+WebUSB. Host re-reads config by VID:PID:serial (per-mode suffix) -- bump invalidates any cached 0x0103.
+	USBDevice.setManufacturerDescriptor("Sony Computer Entertainment");
+	USBDevice.setProductDescriptor("Wireless Controller");
+	g_hidGyro.enableOutEndpoint(true);
+	g_hidGyro.setReportCallback(NULL, hidGyroSet);
+	g_hidGyro.setReportDescriptor(GYRO_HID_DESC, sizeof GYRO_HID_DESC);
+	g_hidGyro.setPollInterval(
+		1); // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
+	g_hidGyro.begin();
 }
-void HidGyroController::task(){
-  if(!g_hidGyro.ready()) return;
-  if(millis()-g_gyroLastMs<USB_STREAM_MS) return; g_gyroLastMs=millis();
-  uint8_t p[63]; hidGyroBuild(p);
-  g_hidGyro.sendReport(0x01, p, sizeof p);
+void HidGyroController::task()
+{
+	if (!g_hidGyro.ready())
+		return;
+	if (millis() - g_gyroLastMs < USB_STREAM_MS)
+		return;
+	g_gyroLastMs = millis();
+	uint8_t p[63];
+	hidGyroBuild(p);
+	g_hidGyro.sendReport(0x01, p, sizeof p);
 }

+ 3 - 3
OpenPuck/mode_hidgyro.h

@@ -7,8 +7,8 @@
 #include "controllers.h"
 
 class HidGyroController : public IController {
-public:
-  void begin() override;
-  void task() override;
+    public:
+	void begin() override;
+	void task() override;
 };
 extern HidGyroController g_hidGyroCtl;

+ 170 - 63
OpenPuck/mode_lizard.cpp

@@ -2,67 +2,174 @@
 #include "triton.h"
 #include "config.h"
 
-void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev, uint8_t mrid, uint8_t krid){
-  uint32_t b=btnsOf(r);
-  bool qamHeld = (b & TB_QAM) != 0;
-  // --- right pad -> mouse motion with glide (mirrors mode_xinput's rfXboxMouse) ---
-  static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0;
-  bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
-  if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
-  float mxf=vx/(float)(g_mDiv*10)+rmx, myf=-(vy/(float)(g_mDiv*10))+rmy;   // Y inverted; *10 = desktop-cursor sensitivity (g_mDiv 64 -> eff 640). Lower g_mDiv via WebUI slider = faster.
-  int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy;          // sub-pixel carry
-  if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
-  float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0;   // friction = glide/decay
-  // --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
-  static int ply=0; static bool plt=false; static float sacc=0;
-  bool ltouch=b&TB_LPADT; int ly=s16off(r,18);
-  if(ltouch){ if(plt){ sacc += (ly-ply)/(float)(g_mDiv*24); } ply=ly; } else sacc=0; plt=ltouch;
-  int dw=(int)sacc; sacc-=dw; if(dw>15)dw=15; if(dw<-15)dw=-15;   // finger up = wheel up (positive)
-  // --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
-  // trigU8 (full-range scale + saturate) so a full pull reaches ~0xFF; a raw >>8 tops out ~0x80 and never
-  // crosses the threshold, leaving the trigger clicks dead.
-  uint8_t rtrig=trigU8(u16off(r,6)), ltrig=trigU8(u16off(r,4));
-  uint8_t mbtn=0;
-  if((b&TB_RPADC)||rtrig>180) mbtn|=1;   // right trigger -> left (primary) click
-  if(ltrig>180)               mbtn|=2;   // left trigger  -> right click
-  if(b&TB_LPADC)              mbtn|=4;
-  static uint8_t pmbtn=0;
-  if(dx||dy||dw||mbtn!=pmbtn){ pmbtn=mbtn;
-    hid_mouse_report_t m; m.buttons=mbtn; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=(int8_t)dw; m.pan=0;
-    if(mdev->ready()) mdev->sendReport(mrid,&m,sizeof m);
-  }
-  // --- keyboard: modifiers + up to 6 keycodes ---
-  uint8_t mod=0, kc[6]={0,0,0,0,0,0}, nk=0;
-  if(b&TB_LB) mod|=KEYBOARD_MODIFIER_LEFTCTRL;
-  if(b&TB_RB) mod|=KEYBOARD_MODIFIER_LEFTALT;
-  #define LZK(cond,code) do{ if((cond)&&nk<6) kc[nk++]=(code); }while(0)
-  LZK(b&TB_A,    HID_KEY_ENTER);
-  LZK(b&TB_B,    HID_KEY_ESCAPE);
-  LZK(b&TB_X,    HID_KEY_PAGE_UP);
-  LZK(b&TB_Y,    HID_KEY_PAGE_DOWN);
-  LZK(b&TB_VIEW, (g_usbMode==MODE_LIZARD)?HID_KEY_ESCAPE:HID_KEY_TAB);
-  LZK(b&TB_MENU, (g_usbMode==MODE_LIZARD)?HID_KEY_TAB:HID_KEY_ESCAPE);
-  int sx=s16off(r,8), sy=s16off(r,10);   // left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
-  LZK((b&TB_DUP)||sy> 12000, HID_KEY_ARROW_UP);
-  LZK((b&TB_DDN)||sy<-12000, HID_KEY_ARROW_DOWN);
-  LZK((b&TB_DLF)||sx<-12000, HID_KEY_ARROW_LEFT);
-  LZK((b&TB_DRT)||sx> 12000, HID_KEY_ARROW_RIGHT);
-  #undef LZK
-  if(g_usbMode == MODE_LIZARD){
-    static bool prevL5=false, prevR5=false;
-    bool mh = (b & TB_STEAM) || qamHeld;
-    bool nL5 = mh && (b & TB_L5), nR5 = mh && (b & TB_R5);
-    if (nL5 && !prevL5){ uint8_t cc=0x02; if(mdev->ready()) mdev->sendReport(0x03,&cc,1); }
-    if (nR5 && !prevR5){ uint8_t cc=0x01; if(mdev->ready()) mdev->sendReport(0x03,&cc,1); }
-    if ((!nL5 && prevL5) || (!nR5 && prevR5)){ uint8_t cc=0x00; if(mdev->ready()) mdev->sendReport(0x03,&cc,1); }
-    prevL5=nL5; prevR5=nR5;
-    if(mh && (b & TB_X)){ mod=KEYBOARD_MODIFIER_LEFTGUI|KEYBOARD_MODIFIER_LEFTCTRL; kc[0]=HID_KEY_O; kc[1]=0; kc[2]=0; kc[3]=0; kc[4]=0; kc[5]=0; nk=1; }
-    if(mh && (b & TB_L4)){ mod=KEYBOARD_MODIFIER_LEFTCTRL|KEYBOARD_MODIFIER_LEFTALT; kc[0]=HID_KEY_DELETE; kc[1]=0; kc[2]=0; kc[3]=0; kc[4]=0; kc[5]=0; nk=1; }
-  }
-  static uint8_t pmod=0, pkc[6]={0,0,0,0,0,0};
-  bool chg=(mod!=pmod); for(int i=0;i<6;i++) if(kc[i]!=pkc[i]) chg=true;
-  if(chg){ pmod=mod; for(int i=0;i<6;i++) pkc[i]=kc[i];
-    uint8_t krep[8]={mod,0,kc[0],kc[1],kc[2],kc[3],kc[4],kc[5]};
-    if(kdev->ready()) kdev->sendReport(krid,krep,8);
-  }
+void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
+	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid)
+{
+	uint32_t b = btnsOf(r);
+	bool qamHeld = (b & TB_QAM) != 0;
+	// --- right pad -> mouse motion with glide (mirrors mode_xinput's rfXboxMouse) ---
+	static int prx = 0, pry = 0;
+	static bool prt = false;
+	static float vx = 0, vy = 0, rmx = 0, rmy = 0;
+	bool rtouch = b & TB_RPADT;
+	int rx = s16off(r, 22), ry = s16off(r, 24);
+	if (rtouch) {
+		if (prt) {
+			vx += (rx - prx);
+			vy += (ry - pry);
+		}
+		prx = rx;
+		pry = ry;
+	}
+	prt = rtouch;
+	float mxf = vx / (float)(g_mDiv * 10) + rmx,
+	      myf = -(vy / (float)(g_mDiv * 10)) +
+		    rmy; // Y inverted; *10 = desktop-cursor sensitivity (g_mDiv 64 -> eff 640). Lower g_mDiv via WebUI slider = faster.
+	int dx = (int)mxf, dy = (int)myf;
+	rmx = mxf - dx;
+	rmy = myf - dy; // sub-pixel carry
+	if (dx > 127)
+		dx = 127;
+	if (dx < -127)
+		dx = -127;
+	if (dy > 127)
+		dy = 127;
+	if (dy < -127)
+		dy = -127;
+	float f = g_mFric / 100.0f;
+	vx *= f;
+	vy *= f;
+	if (vx > -1 && vx < 1)
+		vx = 0;
+	if (vy > -1 && vy < 1)
+		vy = 0; // friction = glide/decay
+	// --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
+	static int ply = 0;
+	static bool plt = false;
+	static float sacc = 0;
+	bool ltouch = b & TB_LPADT;
+	int ly = s16off(r, 18);
+	if (ltouch) {
+		if (plt) {
+			sacc += (ly - ply) / (float)(g_mDiv * 24);
+		}
+		ply = ly;
+	} else
+		sacc = 0;
+	plt = ltouch;
+	int dw = (int)sacc;
+	sacc -= dw;
+	if (dw > 15)
+		dw = 15;
+	if (dw < -15)
+		dw = -15; // finger up = wheel up (positive)
+	// --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
+	// trigU8 (full-range scale + saturate) so a full pull reaches ~0xFF; a raw >>8 tops out ~0x80 and never
+	// crosses the threshold, leaving the trigger clicks dead.
+	uint8_t rtrig = trigU8(u16off(r, 6)), ltrig = trigU8(u16off(r, 4));
+	uint8_t mbtn = 0;
+	if ((b & TB_RPADC) || rtrig > 180)
+		mbtn |= 1; // right trigger -> left (primary) click
+	if (ltrig > 180)
+		mbtn |= 2; // left trigger  -> right click
+	if (b & TB_LPADC)
+		mbtn |= 4;
+	static uint8_t pmbtn = 0;
+	if (dx || dy || dw || mbtn != pmbtn) {
+		pmbtn = mbtn;
+		hid_mouse_report_t m;
+		m.buttons = mbtn;
+		m.x = (int8_t)dx;
+		m.y = (int8_t)dy;
+		m.wheel = (int8_t)dw;
+		m.pan = 0;
+		if (mdev->ready())
+			mdev->sendReport(mrid, &m, sizeof m);
+	}
+	// --- keyboard: modifiers + up to 6 keycodes ---
+	uint8_t mod = 0, kc[6] = { 0, 0, 0, 0, 0, 0 }, nk = 0;
+	if (b & TB_LB)
+		mod |= KEYBOARD_MODIFIER_LEFTCTRL;
+	if (b & TB_RB)
+		mod |= KEYBOARD_MODIFIER_LEFTALT;
+#define LZK(cond, code)                    \
+	do {                               \
+		if ((cond) && nk < 6)      \
+			kc[nk++] = (code); \
+	} while (0)
+	LZK(b & TB_A, HID_KEY_ENTER);
+	LZK(b & TB_B, HID_KEY_ESCAPE);
+	LZK(b & TB_X, HID_KEY_PAGE_UP);
+	LZK(b & TB_Y, HID_KEY_PAGE_DOWN);
+	LZK(b & TB_VIEW,
+	    (g_usbMode == MODE_LIZARD) ? HID_KEY_ESCAPE : HID_KEY_TAB);
+	LZK(b & TB_MENU,
+	    (g_usbMode == MODE_LIZARD) ? HID_KEY_TAB : HID_KEY_ESCAPE);
+	int sx = s16off(r, 8),
+	    sy = s16off(
+		    r,
+		    10); // left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
+	LZK((b & TB_DUP) || sy > 12000, HID_KEY_ARROW_UP);
+	LZK((b & TB_DDN) || sy < -12000, HID_KEY_ARROW_DOWN);
+	LZK((b & TB_DLF) || sx < -12000, HID_KEY_ARROW_LEFT);
+	LZK((b & TB_DRT) || sx > 12000, HID_KEY_ARROW_RIGHT);
+#undef LZK
+	if (g_usbMode == MODE_LIZARD) {
+		static bool prevL5 = false, prevR5 = false;
+		bool mh = (b & TB_STEAM) || qamHeld;
+		bool nL5 = mh && (b & TB_L5), nR5 = mh && (b & TB_R5);
+		if (nL5 && !prevL5) {
+			uint8_t cc = 0x02;
+			if (mdev->ready())
+				mdev->sendReport(0x03, &cc, 1);
+		}
+		if (nR5 && !prevR5) {
+			uint8_t cc = 0x01;
+			if (mdev->ready())
+				mdev->sendReport(0x03, &cc, 1);
+		}
+		if ((!nL5 && prevL5) || (!nR5 && prevR5)) {
+			uint8_t cc = 0x00;
+			if (mdev->ready())
+				mdev->sendReport(0x03, &cc, 1);
+		}
+		prevL5 = nL5;
+		prevR5 = nR5;
+		if (mh && (b & TB_X)) {
+			mod = KEYBOARD_MODIFIER_LEFTGUI |
+			      KEYBOARD_MODIFIER_LEFTCTRL;
+			kc[0] = HID_KEY_O;
+			kc[1] = 0;
+			kc[2] = 0;
+			kc[3] = 0;
+			kc[4] = 0;
+			kc[5] = 0;
+			nk = 1;
+		}
+		if (mh && (b & TB_L4)) {
+			mod = KEYBOARD_MODIFIER_LEFTCTRL |
+			      KEYBOARD_MODIFIER_LEFTALT;
+			kc[0] = HID_KEY_DELETE;
+			kc[1] = 0;
+			kc[2] = 0;
+			kc[3] = 0;
+			kc[4] = 0;
+			kc[5] = 0;
+			nk = 1;
+		}
+	}
+	static uint8_t pmod = 0, pkc[6] = { 0, 0, 0, 0, 0, 0 };
+	bool chg = (mod != pmod);
+	for (int i = 0; i < 6; i++)
+		if (kc[i] != pkc[i])
+			chg = true;
+	if (chg) {
+		pmod = mod;
+		for (int i = 0; i < 6; i++)
+			pkc[i] = kc[i];
+		uint8_t krep[8] = { mod,   0,	  kc[0], kc[1],
+				    kc[2], kc[3], kc[4], kc[5] };
+		if (kdev->ready())
+			kdev->sendReport(krid, krep, 8);
+	}
 }

+ 2 - 1
OpenPuck/mode_lizard.h

@@ -10,4 +10,5 @@
 #include <Adafruit_TinyUSB.h>
 #include <stdint.h>
 
-void rfLizard(const uint8_t* r, Adafruit_USBD_HID* mdev, Adafruit_USBD_HID* kdev, uint8_t mrid, uint8_t krid);
+void rfLizard(const uint8_t *r, Adafruit_USBD_HID *mdev,
+	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid);

+ 150 - 92
OpenPuck/mode_ps5.cpp

@@ -9,106 +9,164 @@
 
 Ps5Controller g_ps5Ctl;
 
-static const uint8_t PS5_HID_DESC[]={
-  0x05,0x01,0x09,0x05,0xA1,0x01,0x85,0x01,0x09,0x30,0x09,0x31,0x09,0x32,0x09,0x35,
-  0x09,0x33,0x09,0x34,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x06,0x81,0x02,0x06,
-  0x00,0xFF,0x09,0x20,0x95,0x01,0x81,0x02,0x05,0x01,0x09,0x39,0x15,0x00,0x25,0x07,
-  0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,0x04,0x95,0x01,0x81,0x42,0x65,0x00,0x05,
-  0x09,0x19,0x01,0x29,0x0F,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0F,0x81,0x02,0x06,
-  0x00,0xFF,0x09,0x21,0x95,0x0D,0x81,0x02,0x06,0x00,0xFF,0x09,0x22,0x15,0x00,0x26,
-  0xFF,0x00,0x75,0x08,0x95,0x34,0x81,0x02,0x85,0x02,0x09,0x23,0x95,0x2F,0x91,0x02,
-  0x85,0x05,0x09,0x33,0x95,0x28,0xB1,0x02,0x85,0x08,0x09,0x34,0x95,0x2F,0xB1,0x02,
-  0x85,0x09,0x09,0x24,0x95,0x13,0xB1,0x02,0x85,0x0A,0x09,0x25,0x95,0x1A,0xB1,0x02,
-  0x85,0x20,0x09,0x26,0x95,0x3F,0xB1,0x02,0x85,0x21,0x09,0x27,0x95,0x04,0xB1,0x02,
-  0x85,0x22,0x09,0x40,0x95,0x3F,0xB1,0x02,0x85,0x80,0x09,0x28,0x95,0x3F,0xB1,0x02,
-  0x85,0x81,0x09,0x29,0x95,0x3F,0xB1,0x02,0x85,0x82,0x09,0x2A,0x95,0x09,0xB1,0x02,
-  0x85,0x83,0x09,0x2B,0x95,0x3F,0xB1,0x02,0x85,0x84,0x09,0x2C,0x95,0x3F,0xB1,0x02,
-  0x85,0x85,0x09,0x2D,0x95,0x02,0xB1,0x02,0x85,0xA0,0x09,0x2E,0x95,0x01,0xB1,0x02,
-  0x85,0xE0,0x09,0x2F,0x95,0x3F,0xB1,0x02,0x85,0xF0,0x09,0x30,0x95,0x3F,0xB1,0x02,
-  0x85,0xF1,0x09,0x31,0x95,0x3F,0xB1,0x02,0x85,0xF2,0x09,0x32,0x95,0x0F,0xB1,0x02,
-  0x85,0xF4,0x09,0x35,0x95,0x3F,0xB1,0x02,0x85,0xF5,0x09,0x36,0x95,0x03,0xB1,0x02,0xC0
+static const uint8_t PS5_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
+	0x09, 0x32, 0x09, 0x35, 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF,
+	0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20,
+	0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
+	0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81,
+	0x42, 0x65, 0x00, 0x05, 0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x21,
+	0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23,
+	0x95, 0x2F, 0x91, 0x02, 0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02,
+	0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02, 0x85, 0x09, 0x09, 0x24,
+	0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
+	0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27,
+	0x95, 0x04, 0xB1, 0x02, 0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x81, 0x09, 0x29,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
+	0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02,
+	0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xE0, 0x09, 0x2F,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32,
+	0x95, 0x0F, 0xB1, 0x02, 0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02, 0xC0
 };
 #define PS5_TOUCH_H 1080
-#define PS5_STATUS_USB 0x1A   // charging + level 10 (~100%)
-static unsigned long g_ps5LastMs=0;
+#define PS5_STATUS_USB 0x1A // charging + level 10 (~100%)
+static unsigned long g_ps5LastMs = 0;
 static Adafruit_USBD_HID g_ps5;
 
-static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t* buf, uint16_t reqlen){
-  if(type!=HID_REPORT_TYPE_FEATURE || !buf || reqlen==0) return 0;
-  memset(buf,0,reqlen);
-  switch(rid){
-    case 0x03: {                         // capabilities: identify as DualSense-capable (SDL checks byte 2 == 0x28)
-      if(reqlen<47) return 0;
-      buf[0]=0x00; buf[1]=0x28; buf[2]=0x01; buf[3]=0x00;
-      buf[4]=0x0E;                       // sensors + lightbar + vibration capability bits
-      return 47;
-    }
-    case 0x05: {                         // calibration: neutral-ish non-zero block so SDL's read succeeds
-      uint16_t n = reqlen<40 ? reqlen : 40;
-      for(uint16_t i=0;i<n;i++) buf[i]=0;
-      return n;
-    }
-    case 0x09: {                         // serial number feature
-      uint16_t n = reqlen<20 ? reqlen : 20;
-      for(uint16_t i=0;i<n && i<12;i++) buf[i]=(uint8_t)("010203040506"[i]);
-      return n;
-    }
-    case 0x20: {                         // firmware info
-      uint16_t n = reqlen<63 ? reqlen : 63;
-      buf[0]=0x01; buf[1]=0x00;          // minimal non-zero firmware version
-      return n;
-    }
-    default:
-      return 0;
-  }
+static uint16_t ps5Get(uint8_t rid, hid_report_type_t type, uint8_t *buf,
+		       uint16_t reqlen)
+{
+	if (type != HID_REPORT_TYPE_FEATURE || !buf || reqlen == 0)
+		return 0;
+	memset(buf, 0, reqlen);
+	switch (rid) {
+	case 0x03: { // capabilities: identify as DualSense-capable (SDL checks byte 2 == 0x28)
+		if (reqlen < 47)
+			return 0;
+		buf[0] = 0x00;
+		buf[1] = 0x28;
+		buf[2] = 0x01;
+		buf[3] = 0x00;
+		buf[4] = 0x0E; // sensors + lightbar + vibration capability bits
+		return 47;
+	}
+	case 0x05: { // calibration: neutral-ish non-zero block so SDL's read succeeds
+		uint16_t n = reqlen < 40 ? reqlen : 40;
+		for (uint16_t i = 0; i < n; i++)
+			buf[i] = 0;
+		return n;
+	}
+	case 0x09: { // serial number feature
+		uint16_t n = reqlen < 20 ? reqlen : 20;
+		for (uint16_t i = 0; i < n && i < 12; i++)
+			buf[i] = (uint8_t)("010203040506"[i]);
+		return n;
+	}
+	case 0x20: { // firmware info
+		uint16_t n = reqlen < 63 ? reqlen : 63;
+		buf[0] = 0x01;
+		buf[1] = 0x00; // minimal non-zero firmware version
+		return n;
+	}
+	default:
+		return 0;
+	}
 }
-static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_t n){
-  if(type!=HID_REPORT_TYPE_OUTPUT || n<1) return;
-  uint8_t id; const uint8_t* p; uint16_t pn;
-  if(rid==0){ id=b[0]; p=b+1; pn=(uint16_t)(n-1); }
-  else if(rid==0x02 && b[0]==0x02 && n>=5){ id=rid; p=b+1; pn=(uint16_t)(n-1); } // some paths leave report id in b
-  else      { id=rid;  p=b;   pn=n;               }
-  if(id!=0x02 || pn<4) return;
-  hapticSteamRumble((uint16_t)p[3]*257u, (uint16_t)p[2]*257u);   // DualSense: left=low, right=high
+static void ps5Set(uint8_t rid, hid_report_type_t type, uint8_t const *b,
+		   uint16_t n)
+{
+	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
+		return;
+	uint8_t id;
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		id = b[0];
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} else if (rid == 0x02 && b[0] == 0x02 && n >= 5) {
+		id = rid;
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} // some paths leave report id in b
+	else {
+		id = rid;
+		p = b;
+		pn = n;
+	}
+	if (id != 0x02 || pn < 4)
+		return;
+	hapticSteamRumble((uint16_t)p[3] * 257u,
+			  (uint16_t)p[2] *
+				  257u); // DualSense: left=low, right=high
 }
 
-static void ps5Build(uint8_t out[63]){
-  uint32_t b=psButtonsFromSteam(g_in.buttons);
-  bool lTouch=(b&TB_LPADT)||(b&TB_LPADC), rTouch=(b&TB_RPADT)||(b&TB_RPADC);
-  memset(out,0,63);
-  out[0]=swStick(g_in.lx,false); out[1]=swStick(g_in.ly,true);
-  out[2]=swStick(g_in.rx,false); out[3]=swStick(g_in.ry,true);
-  out[4]=g_in.lt; out[5]=g_in.rt;
-  static uint8_t seq=0; out[6]=seq++;
-  out[7]=psHatNibble(b)|psFaceNibble(b);
-  out[8]=psShouldersByte(b);
-  out[9]=((b&TB_STEAM)?0x01:0)|((b&TB_TOUCH||b&TB_LPADC||b&TB_RPADC)?0x02:0)|((b&TB_MUTE)?0x04:0);
-  out[15]=g_in.gx&0xFF; out[16]=g_in.gx>>8;
-  out[17]=(-g_in.gz)&0xFF; out[18]=(-g_in.gz)>>8;
-  out[19]=g_in.gy&0xFF; out[20]=g_in.gy>>8;
-  out[21]=g_in.ax&0xFF; out[22]=g_in.ax>>8;
-  out[23]=g_in.ay&0xFF; out[24]=g_in.ay>>8;
-  out[25]=g_in.az&0xFF; out[26]=g_in.az>>8;
-  uint16_t lx, ly, rx, ry;
-  steamPadsToTouch(b, PS5_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx, g_in.rpy, &lx, &ly, &rx, &ry);
-  touchPackPads(out+32, lTouch, rTouch, lx, ly, rx, ry);
-  out[52]=PS5_STATUS_USB;
+static void ps5Build(uint8_t out[63])
+{
+	uint32_t b = psButtonsFromSteam(g_in.buttons);
+	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
+	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
+	memset(out, 0, 63);
+	out[0] = swStick(g_in.lx, false);
+	out[1] = swStick(g_in.ly, true);
+	out[2] = swStick(g_in.rx, false);
+	out[3] = swStick(g_in.ry, true);
+	out[4] = g_in.lt;
+	out[5] = g_in.rt;
+	static uint8_t seq = 0;
+	out[6] = seq++;
+	out[7] = psHatNibble(b) | psFaceNibble(b);
+	out[8] = psShouldersByte(b);
+	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
+		 ((b & TB_TOUCH || b & TB_LPADC || b & TB_RPADC) ? 0x02 : 0) |
+		 ((b & TB_MUTE) ? 0x04 : 0);
+	out[15] = g_in.gx & 0xFF;
+	out[16] = g_in.gx >> 8;
+	out[17] = (-g_in.gz) & 0xFF;
+	out[18] = (-g_in.gz) >> 8;
+	out[19] = g_in.gy & 0xFF;
+	out[20] = g_in.gy >> 8;
+	out[21] = g_in.ax & 0xFF;
+	out[22] = g_in.ax >> 8;
+	out[23] = g_in.ay & 0xFF;
+	out[24] = g_in.ay >> 8;
+	out[25] = g_in.az & 0xFF;
+	out[26] = g_in.az >> 8;
+	uint16_t lx, ly, rx, ry;
+	steamPadsToTouch(b, PS5_TOUCH_H, g_in.lpx, g_in.lpy, g_in.rpx, g_in.rpy,
+			 &lx, &ly, &rx, &ry);
+	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
+	out[52] = PS5_STATUS_USB;
 }
 
-void Ps5Controller::begin(){
-  USBDevice.setID(0x054C, 0x0CE6);
-  USBDevice.setDeviceVersion(0x0104);   // bumped: clean-PS is now a separate mode (PS5_GAME); normal PS5 keeps wake+WebUSB. Host re-reads config by VID:PID:serial (per-mode suffix) -- bump invalidates any cached 0x0103.
-  USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
-  USBDevice.setProductDescriptor("DualSense Wireless Controller");
-  g_ps5.enableOutEndpoint(true);
-  g_ps5.setReportCallback(ps5Get, ps5Set);
-  g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
-  g_ps5.setPollInterval(1);   // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-  g_ps5.begin();
+void Ps5Controller::begin()
+{
+	USBDevice.setID(0x054C, 0x0CE6);
+	USBDevice.setDeviceVersion(
+		0x0104); // bumped: clean-PS is now a separate mode (PS5_GAME); normal PS5 keeps wake+WebUSB. Host re-reads config by VID:PID:serial (per-mode suffix) -- bump invalidates any cached 0x0103.
+	USBDevice.setManufacturerDescriptor("Sony Interactive Entertainment");
+	USBDevice.setProductDescriptor("DualSense Wireless Controller");
+	g_ps5.enableOutEndpoint(true);
+	g_ps5.setReportCallback(ps5Get, ps5Set);
+	g_ps5.setReportDescriptor(PS5_HID_DESC, sizeof PS5_HID_DESC);
+	g_ps5.setPollInterval(
+		1); // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
+	g_ps5.begin();
 }
-void Ps5Controller::task(){
-  if(!g_ps5.ready()) return;
-  if(millis()-g_ps5LastMs<USB_STREAM_MS) return; g_ps5LastMs=millis();
-  uint8_t p[63]; ps5Build(p);
-  g_ps5.sendReport(0x01, p, sizeof p);
+void Ps5Controller::task()
+{
+	if (!g_ps5.ready())
+		return;
+	if (millis() - g_ps5LastMs < USB_STREAM_MS)
+		return;
+	g_ps5LastMs = millis();
+	uint8_t p[63];
+	ps5Build(p);
+	g_ps5.sendReport(0x01, p, sizeof p);
 }

+ 3 - 3
OpenPuck/mode_ps5.h

@@ -7,8 +7,8 @@
 #include "controllers.h"
 
 class Ps5Controller : public IController {
-public:
-  void begin() override;
-  void task() override;
+    public:
+	void begin() override;
+	void task() override;
 };
 extern Ps5Controller g_ps5Ctl;

+ 162 - 64
OpenPuck/mode_switch_hori.cpp

@@ -8,78 +8,176 @@
 SwitchHoriController g_switchHori;
 
 // 8-byte input report: [btn_lo][btn_hi][hat][LX][LY][RX][RY][vendor], sticks uint8 center 0x80.
-static const uint8_t SWITCH_HID_DESC[]={
-  0x05,0x01, 0x09,0x05, 0xA1,0x01,           // Usage Page (Generic Desktop), Usage (Game Pad), Collection (App)
-  0x15,0x00, 0x25,0x01, 0x35,0x00, 0x45,0x01, 0x75,0x01, 0x95,0x10,   // 16 x 1-bit buttons
-  0x05,0x09, 0x19,0x01, 0x29,0x10, 0x81,0x02,
-  0x05,0x01, 0x25,0x07, 0x46,0x3B,0x01, 0x75,0x04, 0x95,0x01, 0x65,0x14, 0x09,0x39, 0x81,0x42,  // hat switch (4 bits)
-  0x65,0x00, 0x95,0x01, 0x81,0x01,           // 4-bit padding to a byte
-  0x26,0xFF,0x00, 0x46,0xFF,0x00, 0x09,0x30, 0x09,0x31, 0x09,0x32, 0x09,0x35, 0x75,0x08, 0x95,0x04, 0x81,0x02, // X,Y,Z,Rz
-  0x06,0x00,0xFF, 0x09,0x20, 0x95,0x01, 0x81,0x02,           // vendor byte (input)
-  0x0A,0x21,0x26, 0x95,0x08, 0x91,0x02,                      // vendor (output, 8 bytes; unused)
-  0xC0
+static const uint8_t SWITCH_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, // Usage Page (Generic Desktop), Usage (Game Pad), Collection (App)
+	0x15, 0x00, 0x25, 0x01, 0x35, 0x00, 0x45, 0x01, 0x75, 0x01,
+	0x95, 0x10, // 16 x 1-bit buttons
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x10, 0x81, 0x02, 0x05, 0x01,
+	0x25, 0x07, 0x46, 0x3B, 0x01, 0x75, 0x04, 0x95, 0x01, 0x65,
+	0x14, 0x09, 0x39, 0x81, 0x42, // hat switch (4 bits)
+	0x65, 0x00, 0x95, 0x01, 0x81, 0x01, // 4-bit padding to a byte
+	0x26, 0xFF, 0x00, 0x46, 0xFF, 0x00, 0x09, 0x30, 0x09, 0x31,
+	0x09, 0x32, 0x09, 0x35, 0x75, 0x08, 0x95, 0x04, 0x81, 0x02, // X,Y,Z,Rz
+	0x06, 0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02, // vendor byte (input)
+	0x0A, 0x21, 0x26, 0x95, 0x08, 0x91, 0x02, // vendor (output, 8 bytes; unused)
+	0xC0
 };
-static Adafruit_USBD_HID g_switch;   // HORIPAD gamepad HID interface
-static unsigned long g_swLastMs=0;
+static Adafruit_USBD_HID g_switch; // HORIPAD gamepad HID interface
+static unsigned long g_swLastMs = 0;
 
 // back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
-static uint16_t codeToSwitch(uint8_t c, uint16_t fA,uint16_t fB,uint16_t fX,uint16_t fY){
-  switch(c){ case 1:return fA; case 2:return fB; case 3:return fX; case 4:return fY;
-    case 5:return 0x10; case 6:return 0x20; case 7:return 0x400; case 8:return 0x800;
-    case 9:return 0x100; case 10:return 0x200; case 11:return 0x1000; default:return 0; }
+static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
+			     uint16_t fY)
+{
+	switch (c) {
+	case 1:
+		return fA;
+	case 2:
+		return fB;
+	case 3:
+		return fX;
+	case 4:
+		return fY;
+	case 5:
+		return 0x10;
+	case 6:
+		return 0x20;
+	case 7:
+		return 0x400;
+	case 8:
+		return 0x800;
+	case 9:
+		return 0x100;
+	case 10:
+		return 0x200;
+	case 11:
+		return 0x1000;
+	default:
+		return 0;
+	}
 }
 // Back-paddle code 12..15 map to D-pad Up/Down/Left/Right; fold them into the hat direction flags.
-static inline void backCodeToHatDirs(uint8_t c, bool& u, bool& d, bool& l, bool& r){
-  if(c==12) u=true;
-  else if(c==13) d=true;
-  else if(c==14) l=true;
-  else if(c==15) r=true;
+static inline void backCodeToHatDirs(uint8_t c, bool &u, bool &d, bool &l,
+				     bool &r)
+{
+	if (c == 12)
+		u = true;
+	else if (c == 13)
+		d = true;
+	else if (c == 14)
+		l = true;
+	else if (c == 15)
+		r = true;
 }
 // HORIPAD/Switch button bits: Y=1 B=2 A=4 X=8 L=10 R=20 ZL=40 ZR=80 Minus=100 Plus=200 LClick=400 RClick=800 Home=1000 Capture=2000
-static void switchBuildHoripad(uint8_t out[8]){
-  uint32_t b=g_in.buttons; uint16_t btn=0;
-  if(g_qamMap && (b&TB_QAM)){ b &= ~(uint32_t)TB_QAM; b |= tritonFromCode(g_qamMap); }
-  // 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);
-  // face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)
-  uint16_t fY = g_abSwap?0x08:0x01, fB = g_abSwap?0x04:0x02, fA = g_abSwap?0x02:0x04, fX = g_abSwap?0x01:0x08;
-  if(b&TB_Y)btn|=fY; if(b&TB_B)btn|=fB; if(b&TB_A)btn|=fA; if(b&TB_X)btn|=fX;
-  if(b&TB_LB)btn|=0x10; if(b&TB_RB)btn|=0x20;                 // L, R
-  // ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
-  if((g_in.lt>=SW_TRIG_ON)||(b&0x8000000u))btn|=0x40; if((g_in.rt>=SW_TRIG_ON)||(b&0x800000u))btn|=0x80;
-  if(b&TB_MENU)btn|=0x100; if(b&TB_VIEW)btn|=0x200;           // Minus, Plus
-  if(b&TB_L3)btn|=0x400; if(b&TB_R3)btn|=0x800;               // LClick, RClick
-  if(b&TB_STEAM)btn|=0x1000;                                  // Home
-  // back paddles -> configurable mapping (same g_back[] as Xbox: default L4->LB R4->RB L5->L3 R5->R3)
-  if(b&TB_L4)btn|=codeToSwitch(g_back[0],fA,fB,fX,fY); if(b&TB_R4)btn|=codeToSwitch(g_back[1],fA,fB,fX,fY);
-  if(b&TB_L5)btn|=codeToSwitch(g_back[2],fA,fB,fX,fY); if(b&TB_R5)btn|=codeToSwitch(g_back[3],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) backCodeToHatDirs(g_back[0],u,d,l,r);
-  if(b&TB_R4) backCodeToHatDirs(g_back[1],u,d,l,r);
-  if(b&TB_L5) backCodeToHatDirs(g_back[2],u,d,l,r);
-  if(b&TB_R5) backCodeToHatDirs(g_back[3],u,d,l,r);
-  uint8_t hat=8;
-  if(u&&r)hat=1; else if(r&&d)hat=3; else if(d&&l)hat=5; else if(l&&u)hat=7;
-  else if(u)hat=0; else if(r)hat=2; else if(d)hat=4; else if(l)hat=6;
-  out[0]=btn&0xFF; out[1]=btn>>8; out[2]=hat;
-  out[3]=swStick(g_in.lx,false); out[4]=swStick(g_in.ly,true);  // HID Y is down-positive -> invert
-  out[5]=swStick(g_in.rx,false); out[6]=swStick(g_in.ry,true);
-  out[7]=0;
+static void switchBuildHoripad(uint8_t out[8])
+{
+	uint32_t b = g_in.buttons;
+	uint16_t btn = 0;
+	if (g_qamMap && (b & TB_QAM)) {
+		b &= ~(uint32_t)TB_QAM;
+		b |= tritonFromCode(g_qamMap);
+	}
+	// 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);
+	// face buttons with optional A/B + X/Y swap (Nintendo physical-vs-label layout)
+	uint16_t fY = g_abSwap ? 0x08 : 0x01, fB = g_abSwap ? 0x04 : 0x02,
+		 fA = g_abSwap ? 0x02 : 0x04, fX = g_abSwap ? 0x01 : 0x08;
+	if (b & TB_Y)
+		btn |= fY;
+	if (b & TB_B)
+		btn |= fB;
+	if (b & TB_A)
+		btn |= fA;
+	if (b & TB_X)
+		btn |= fX;
+	if (b & TB_LB)
+		btn |= 0x10;
+	if (b & TB_RB)
+		btn |= 0x20; // L, R
+	// ZL/ZR digital: trip on the analog threshold (activates early) OR the full-press click bit
+	if ((g_in.lt >= SW_TRIG_ON) || (b & 0x8000000u))
+		btn |= 0x40;
+	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+		btn |= 0x80;
+	if (b & TB_MENU)
+		btn |= 0x100;
+	if (b & TB_VIEW)
+		btn |= 0x200; // Minus, Plus
+	if (b & TB_L3)
+		btn |= 0x400;
+	if (b & TB_R3)
+		btn |= 0x800; // LClick, RClick
+	if (b & TB_STEAM)
+		btn |= 0x1000; // Home
+	// back paddles -> configurable mapping (same g_back[] as Xbox: default L4->LB R4->RB L5->L3 R5->R3)
+	if (b & TB_L4)
+		btn |= codeToSwitch(g_back[0], fA, fB, fX, fY);
+	if (b & TB_R4)
+		btn |= codeToSwitch(g_back[1], fA, fB, fX, fY);
+	if (b & TB_L5)
+		btn |= codeToSwitch(g_back[2], fA, fB, fX, fY);
+	if (b & TB_R5)
+		btn |= codeToSwitch(g_back[3], 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)
+		backCodeToHatDirs(g_back[0], u, d, l, r);
+	if (b & TB_R4)
+		backCodeToHatDirs(g_back[1], u, d, l, r);
+	if (b & TB_L5)
+		backCodeToHatDirs(g_back[2], u, d, l, r);
+	if (b & TB_R5)
+		backCodeToHatDirs(g_back[3], u, d, l, r);
+	uint8_t hat = 8;
+	if (u && r)
+		hat = 1;
+	else if (r && d)
+		hat = 3;
+	else if (d && l)
+		hat = 5;
+	else if (l && u)
+		hat = 7;
+	else if (u)
+		hat = 0;
+	else if (r)
+		hat = 2;
+	else if (d)
+		hat = 4;
+	else if (l)
+		hat = 6;
+	out[0] = btn & 0xFF;
+	out[1] = btn >> 8;
+	out[2] = hat;
+	out[3] = swStick(g_in.lx, false);
+	out[4] = swStick(g_in.ly, true); // HID Y is down-positive -> invert
+	out[5] = swStick(g_in.rx, false);
+	out[6] = swStick(g_in.ry, true);
+	out[7] = 0;
 }
 
-void SwitchHoriController::begin(){
-  USBDevice.setID(0x0F0D, 0x0092);
-  USBDevice.setDeviceVersion(0x0202);   // bumped from 0x0201 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
-  USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
-  USBDevice.setProductDescriptor("POKKEN CONTROLLER");
-  g_switch.enableOutEndpoint(true);
-  g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
-  g_switch.setPollInterval(1);   // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-  g_switch.begin();
+void SwitchHoriController::begin()
+{
+	USBDevice.setID(0x0F0D, 0x0092);
+	USBDevice.setDeviceVersion(
+		0x0202); // bumped from 0x0201 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
+	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
+	USBDevice.setProductDescriptor("POKKEN CONTROLLER");
+	g_switch.enableOutEndpoint(true);
+	g_switch.setReportDescriptor(SWITCH_HID_DESC, sizeof SWITCH_HID_DESC);
+	g_switch.setPollInterval(
+		1); // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
+	g_switch.begin();
 }
-void SwitchHoriController::task(){   // stream the 8-byte HORIPAD report at ~250Hz (no handshake needed)
-  if(!g_switch.ready()) return;
-  if(millis()-g_swLastMs < USB_STREAM_MS) return; g_swLastMs=millis();
-  uint8_t p[8]; switchBuildHoripad(p);
-  g_switch.sendReport(0, p, sizeof p);   // report-id-less descriptor
+void SwitchHoriController::task()
+{ // stream the 8-byte HORIPAD report at ~250Hz (no handshake needed)
+	if (!g_switch.ready())
+		return;
+	if (millis() - g_swLastMs < USB_STREAM_MS)
+		return;
+	g_swLastMs = millis();
+	uint8_t p[8];
+	switchBuildHoripad(p);
+	g_switch.sendReport(0, p, sizeof p); // report-id-less descriptor
 }

+ 3 - 3
OpenPuck/mode_switch_hori.h

@@ -8,8 +8,8 @@
 #include "controllers.h"
 
 class SwitchHoriController : public IController {
-public:
-  void begin() override;
-  void task() override;
+    public:
+	void begin() override;
+	void task() override;
 };
 extern SwitchHoriController g_switchHori;

+ 584 - 305
OpenPuck/mode_switch_pro.cpp

@@ -16,204 +16,364 @@ SwitchProController g_switchPro;
 // (3 samples / 15 ms genuine); too high a rate over-integrates the gyro. With the accel scale fixed (gravity-
 // correction now works) 120 Hz is drift-free and lower-latency, so it is the DEFAULT. 66 Hz is the genuine
 // 2wiCC-matching compat fallback; "full" is the 4 ms PC rate for those who want lowest latency and tolerate it.
-#define SW_PRO_REPORT_MS    15u   // 66 Hz
-#define SW_PRO_REPORT_MS_120 8u   // ~120 Hz
-#define SW_ACCEL_DIV         4    // SC2 accel is +/-2g (16384/g); /4 -> the genuine Pro +/-8g (4096/g) the Switch cal expects
+#define SW_PRO_REPORT_MS 15u // 66 Hz
+#define SW_PRO_REPORT_MS_120 8u // ~120 Hz
+#define SW_ACCEL_DIV \
+	4 // SC2 accel is +/-2g (16384/g); /4 -> the genuine Pro +/-8g (4096/g) the Switch cal expects
 
-uint8_t g_swProRate = 1;         // 0 = 66Hz, 1 = 120Hz (default), 2 = full (~250Hz / USB_STREAM_MS)
-uint8_t g_swGyroScale10 = 10;    // gyro sensitivity x10 (10 = 1.0x)
+uint8_t g_swProRate =
+	1; // 0 = 66Hz, 1 = 120Hz (default), 2 = full (~250Hz / USB_STREAM_MS)
+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.
 #define SWPRO_CFG_FILE "/swprocfg.bin"
-void swProSaveCfg(){
-  uint8_t b[3] = { 0x01, g_swProRate, g_swGyroScale10 };   // [ver][rate 0/1/2][gyroScale x10]
-  InternalFS.remove(SWPRO_CFG_FILE); File f(InternalFS);
-  if(f.open(SWPRO_CFG_FILE, FILE_O_WRITE)){ f.write(b,sizeof b); f.close(); }
+void swProSaveCfg()
+{
+	uint8_t b[3] = { 0x01, g_swProRate,
+			 g_swGyroScale10 }; // [ver][rate 0/1/2][gyroScale x10]
+	InternalFS.remove(SWPRO_CFG_FILE);
+	File f(InternalFS);
+	if (f.open(SWPRO_CFG_FILE, FILE_O_WRITE)) {
+		f.write(b, sizeof b);
+		f.close();
+	}
 }
-static void swProLoadCfg(){
-  File f(InternalFS); uint8_t b[3];
-  if(f.open(SWPRO_CFG_FILE, FILE_O_READ)){
-    if(f.read(b,3)==3 && b[0]==0x01){
-      g_swProRate = (b[1]<=2) ? b[1] : 1;   // 0=66Hz, 1=120Hz, 2=full; bad value -> default 120Hz
-      g_swGyroScale10 = (b[2]>=5 && b[2]<=30) ? b[2] : 10;   // sane bounds (0.5x..3.0x)
-    }
-    f.close();
-  }
+static void swProLoadCfg()
+{
+	File f(InternalFS);
+	uint8_t b[3];
+	if (f.open(SWPRO_CFG_FILE, FILE_O_READ)) {
+		if (f.read(b, 3) == 3 && b[0] == 0x01) {
+			g_swProRate =
+				(b[1] <= 2) ?
+					b[1] :
+					1; // 0=66Hz, 1=120Hz, 2=full; bad value -> default 120Hz
+			g_swGyroScale10 =
+				(b[2] >= 5 && b[2] <= 30) ?
+					b[2] :
+					10; // sane bounds (0.5x..3.0x)
+		}
+		f.close();
+	}
 }
 // Scale a gyro axis by g_swGyroScale10/10, clamped to int16 (3x can saturate at high rates -- expected).
-static int16_t gscale(int16_t v){
-  if(g_swGyroScale10==10) return v;
-  int32_t s = (int32_t)v * (int32_t)g_swGyroScale10 / 10;
-  if(s>32767) s=32767; else if(s<-32768) s=-32768;
-  return (int16_t)s;
+static int16_t gscale(int16_t v)
+{
+	if (g_swGyroScale10 == 10)
+		return v;
+	int32_t s = (int32_t)v * (int32_t)g_swGyroScale10 / 10;
+	if (s > 32767)
+		s = 32767;
+	else if (s < -32768)
+		s = -32768;
+	return (int16_t)s;
 }
 
-static const uint8_t SWPRO_HID_DESC[]={
-  0x05,0x01,0x15,0x00,0x09,0x04,0xA1,0x01,0x85,0x30,0x05,0x01,0x05,0x09,0x19,0x01,
-  0x29,0x0A,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x0A,0x55,0x00,0x65,0x00,0x81,0x02,
-  0x05,0x09,0x19,0x0B,0x29,0x0E,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x04,0x81,0x02,
-  0x75,0x01,0x95,0x02,0x81,0x03,0x0B,0x01,0x00,0x01,0x00,0xA1,0x00,0x0B,0x30,0x00,
-  0x01,0x00,0x0B,0x31,0x00,0x01,0x00,0x0B,0x32,0x00,0x01,0x00,0x0B,0x35,0x00,0x01,
-  0x00,0x15,0x00,0x27,0xFF,0xFF,0x00,0x00,0x75,0x10,0x95,0x04,0x81,0x02,0xC0,0x0B,
-  0x39,0x00,0x01,0x00,0x15,0x00,0x25,0x07,0x35,0x00,0x46,0x3B,0x01,0x65,0x14,0x75,
-  0x04,0x95,0x01,0x81,0x42,0x05,0x09,0x19,0x0F,0x29,0x12,0x15,0x00,0x25,0x01,0x75,
-  0x01,0x95,0x04,0x81,0x02,0x75,0x08,0x95,0x34,0x81,0x03,0x06,0x00,0xFF,0x85,0x21,
-  0x09,0x01,0x75,0x08,0x95,0x3F,0x81,0x03,0x85,0x81,0x09,0x02,0x75,0x08,0x95,0x3F,
-  0x81,0x03,0x85,0x01,0x09,0x03,0x75,0x08,0x95,0x3F,0x91,0x83,0x85,0x10,0x09,0x04,
-  0x75,0x08,0x95,0x3F,0x91,0x83,0x85,0x80,0x09,0x05,0x75,0x08,0x95,0x3F,0x91,0x83,
-  0x85,0x82,0x09,0x06,0x75,0x08,0x95,0x3F,0x91,0x83,0xC0
+static const uint8_t SWPRO_HID_DESC[] = {
+	0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0B,
+	0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
+	0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1,
+	0x00, 0x0B, 0x30, 0x00, 0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B,
+	0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01, 0x00, 0x15, 0x00, 0x27,
+	0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
+	0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B,
+	0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x05, 0x09, 0x19,
+	0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81,
+	0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
+	0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02,
+	0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08,
+	0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04, 0x75, 0x08, 0x95, 0x3F,
+	0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
+	0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0
 };
-#define JC_BTN_Y      (1u<<0)
-#define JC_BTN_X      (1u<<1)
-#define JC_BTN_B      (1u<<2)
-#define JC_BTN_A      (1u<<3)
-#define JC_BTN_R      (1u<<6)
-#define JC_BTN_ZR     (1u<<7)
-#define JC_BTN_MINUS  (1u<<8)
-#define JC_BTN_PLUS   (1u<<9)
-#define JC_BTN_RSTICK (1u<<10)
-#define JC_BTN_LSTICK (1u<<11)
-#define JC_BTN_HOME   (1u<<12)
-#define JC_BTN_DOWN   (1u<<16)
-#define JC_BTN_UP     (1u<<17)
-#define JC_BTN_RIGHT  (1u<<18)
-#define JC_BTN_LEFT   (1u<<19)
-#define JC_BTN_L      (1u<<22)
-#define JC_BTN_ZL     (1u<<23)
-static uint32_t codeToJc(uint8_t c, uint32_t fA,uint32_t fB,uint32_t fX,uint32_t fY){
-  switch(c){ case 1:return fA; case 2:return fB; case 3:return fX; case 4:return fY;
-    case 5:return JC_BTN_L; case 6:return JC_BTN_R; case 7:return JC_BTN_LSTICK; case 8:return JC_BTN_RSTICK;
-    case 9:return JC_BTN_MINUS; case 10:return JC_BTN_PLUS; case 11:return JC_BTN_HOME;
-    case 12:return JC_BTN_UP; case 13:return JC_BTN_DOWN; case 14:return JC_BTN_LEFT; case 15:return JC_BTN_RIGHT; default:return 0; }
+#define JC_BTN_Y (1u << 0)
+#define JC_BTN_X (1u << 1)
+#define JC_BTN_B (1u << 2)
+#define JC_BTN_A (1u << 3)
+#define JC_BTN_R (1u << 6)
+#define JC_BTN_ZR (1u << 7)
+#define JC_BTN_MINUS (1u << 8)
+#define JC_BTN_PLUS (1u << 9)
+#define JC_BTN_RSTICK (1u << 10)
+#define JC_BTN_LSTICK (1u << 11)
+#define JC_BTN_HOME (1u << 12)
+#define JC_BTN_DOWN (1u << 16)
+#define JC_BTN_UP (1u << 17)
+#define JC_BTN_RIGHT (1u << 18)
+#define JC_BTN_LEFT (1u << 19)
+#define JC_BTN_L (1u << 22)
+#define JC_BTN_ZL (1u << 23)
+static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
+			 uint32_t fY)
+{
+	switch (c) {
+	case 1:
+		return fA;
+	case 2:
+		return fB;
+	case 3:
+		return fX;
+	case 4:
+		return fY;
+	case 5:
+		return JC_BTN_L;
+	case 6:
+		return JC_BTN_R;
+	case 7:
+		return JC_BTN_LSTICK;
+	case 8:
+		return JC_BTN_RSTICK;
+	case 9:
+		return JC_BTN_MINUS;
+	case 10:
+		return JC_BTN_PLUS;
+	case 11:
+		return JC_BTN_HOME;
+	case 12:
+		return JC_BTN_UP;
+	case 13:
+		return JC_BTN_DOWN;
+	case 14:
+		return JC_BTN_LEFT;
+	case 15:
+		return JC_BTN_RIGHT;
+	default:
+		return 0;
+	}
 }
-static unsigned long g_swProLastMs=0;
+static unsigned long g_swProLastMs = 0;
 static Adafruit_USBD_HID g_swPro;
-static uint8_t g_swProReportMode=0;   // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
-static const uint8_t JC_MAC[6]={0x7C,0xBB,0x8A,0x00,0x00,0x01};   // synthetic but stable controller MAC
-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
-  // magnitude for the Steam motor, so pull the two amplitude fields: HF amp = r[1]>>1, LF amp = r[3]&0x3F.
-  // Every canonical idle/neutral frame -- 00 01 40 40, 00 00 01 40, and all-zero -- decodes to 0 this way, so a
-  // steady idle rumble stream maps to "off" instead of latching the motor on. NO forced floor: 0 amplitude -> 0.
-  uint8_t hf = r[1] >> 1;          // high-band amplitude (neutral 0x01 -> 0)
-  uint8_t lf = r[3] & 0x3F;        // low-band amplitude  (neutral 0x40 -> 0)
-  uint8_t a  = hf > lf ? hf : lf;  // 0..0x7F
-  return (uint16_t)a << 9;         // scale to ~16-bit motor speed
+static uint8_t g_swProReportMode =
+	0; // 0 until the host's subcommand 0x03 selects 0x30 -> THEN we stream input
+static const uint8_t JC_MAC[6] = {
+	0x7C, 0xBB, 0x8A, 0x00, 0x00, 0x01
+}; // synthetic but stable controller MAC
+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
+	// magnitude for the Steam motor, so pull the two amplitude fields: HF amp = r[1]>>1, LF amp = r[3]&0x3F.
+	// Every canonical idle/neutral frame -- 00 01 40 40, 00 00 01 40, and all-zero -- decodes to 0 this way, so a
+	// steady idle rumble stream maps to "off" instead of latching the motor on. NO forced floor: 0 amplitude -> 0.
+	uint8_t hf = r[1] >> 1; // high-band amplitude (neutral 0x01 -> 0)
+	uint8_t lf = r[3] & 0x3F; // low-band amplitude  (neutral 0x40 -> 0)
+	uint8_t a = hf > lf ? hf : lf; // 0..0x7F
+	return (uint16_t)a << 9; // scale to ~16-bit motor speed
 }
-static void jcRumble(const uint8_t* p, uint16_t pn){
-  if(pn<9) return;                       // [timer][left rumble x4][right rumble x4]
-  uint16_t lo=jcRumbleAmp(p+1), hi=jcRumbleAmp(p+5);
-  static uint16_t lastLo=0, lastHi=0;
-  if(lo==lastLo && hi==lastHi) return;   // only relay on change: the Switch streams rumble every frame, so
-  lastLo=lo; lastHi=hi;                  // re-sending unchanged values would flood the RF relay and loop the motor
-  hapticSteamRumble(lo, hi);
+static void jcRumble(const uint8_t *p, uint16_t pn)
+{
+	if (pn < 9)
+		return; // [timer][left rumble x4][right rumble x4]
+	uint16_t lo = jcRumbleAmp(p + 1), hi = jcRumbleAmp(p + 5);
+	static uint16_t lastLo = 0, lastHi = 0;
+	if (lo == lastLo && hi == lastHi)
+		return; // only relay on change: the Switch streams rumble every frame, so
+	lastLo = lo;
+	lastHi =
+		hi; // re-sending unchanged values would flood the RF relay and loop the motor
+	hapticSteamRumble(lo, hi);
 }
-static int jcStick12(int16_t v, bool inv){   // steam int16 (center 0) -> 12-bit (center 0x800), clamped
-  int a = 2048 + (inv ? -((int)v>>4) : ((int)v>>4));
-  return a<0?0:(a>4095?4095:a);
+static int jcStick12(int16_t v, bool inv)
+{ // steam int16 (center 0) -> 12-bit (center 0x800), clamped
+	int a = 2048 + (inv ? -((int)v >> 4) : ((int)v >> 4));
+	return a < 0 ? 0 : (a > 4095 ? 4095 : a);
 }
-static void jcPackStick(uint8_t s[3], int16_t x, int16_t y){   // pack two 12-bit axes into 3 bytes (both normal; Switch Y polarity is opposite DS4)
-  int X=jcStick12(x,false), Y=jcStick12(y,false);
-  s[0]=(uint8_t)(X&0xFF);
-  s[1]=(uint8_t)(((Y&0x0F)<<4)|((X>>8)&0x0F));
-  s[2]=(uint8_t)((Y>>4)&0xFF);
+static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
+{ // pack two 12-bit axes into 3 bytes (both normal; Switch Y polarity is opposite DS4)
+	int X = jcStick12(x, false), Y = jcStick12(y, false);
+	s[0] = (uint8_t)(X & 0xFF);
+	s[1] = (uint8_t)(((Y & 0x0F) << 4) | ((X >> 8) & 0x0F));
+	s[2] = (uint8_t)((Y >> 4) & 0xFF);
 }
 // Standard input-report prefix [0..11] (timer, battery/conn, 3 button bytes, both packed sticks, vibrator),
 // shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
-static uint8_t g_jcTimer=0;
-static void jcInputPrefix(uint8_t* out){
-  uint32_t b=g_in.buttons;
-  if(g_qamMap && (b&TB_QAM)){ b &= ~(uint32_t)TB_QAM; b |= tritonFromCode(g_qamMap); }
-  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, fB=g_abSwap?JC_BTN_A:JC_BTN_B;
-  uint32_t fX=g_abSwap?JC_BTN_Y:JC_BTN_X, fY=g_abSwap?JC_BTN_X:JC_BTN_Y;
-  uint32_t jc=0;
-  if(b&TB_Y)jc|=fY; if(b&TB_B)jc|=fB; if(b&TB_A)jc|=fA; if(b&TB_X)jc|=fX;
-  if(b&TB_LB)jc|=JC_BTN_L; if(b&TB_RB)jc|=JC_BTN_R;
-  if((g_in.lt>=SW_TRIG_ON)||(b&0x8000000u))jc|=JC_BTN_ZL; if((g_in.rt>=SW_TRIG_ON)||(b&0x800000u))jc|=JC_BTN_ZR;
-  if(b&TB_VIEW)jc|=JC_BTN_PLUS; if(b&TB_MENU)jc|=JC_BTN_MINUS;
-  if(b&TB_L3)jc|=JC_BTN_LSTICK; if(b&TB_R3)jc|=JC_BTN_RSTICK;
-  if(b&TB_STEAM)jc|=JC_BTN_HOME;
-  if(b&TB_DDN)jc|=JC_BTN_DOWN; if(b&TB_DUP)jc|=JC_BTN_UP;
-  if(b&TB_DRT)jc|=JC_BTN_RIGHT; if(b&TB_DLF)jc|=JC_BTN_LEFT;
-  if(b&TB_L4)jc|=codeToJc(g_back[0],fA,fB,fX,fY); if(b&TB_R4)jc|=codeToJc(g_back[1],fA,fB,fX,fY);
-  if(b&TB_L5)jc|=codeToJc(g_back[2],fA,fB,fX,fY); if(b&TB_R5)jc|=codeToJc(g_back[3],fA,fB,fX,fY);
-  out[0]=g_jcTimer++;
-  out[1]=0x91;   // battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
-                 // A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too. (2wiCC parity)
-  out[2]=(uint8_t)(jc); out[3]=(uint8_t)(jc>>8); out[4]=(uint8_t)(jc>>16);
-  out[3]|=0x80;  // 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.
-  jcPackStick(out+5, g_in.lx, g_in.ly);
-  jcPackStick(out+8, g_in.rx, g_in.ry);
-  out[11]=0x09;  // rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
+static uint8_t g_jcTimer = 0;
+static void jcInputPrefix(uint8_t *out)
+{
+	uint32_t b = g_in.buttons;
+	if (g_qamMap && (b & TB_QAM)) {
+		b &= ~(uint32_t)TB_QAM;
+		b |= tritonFromCode(g_qamMap);
+	}
+	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,
+		 fB = g_abSwap ? JC_BTN_A : JC_BTN_B;
+	uint32_t fX = g_abSwap ? JC_BTN_Y : JC_BTN_X,
+		 fY = g_abSwap ? JC_BTN_X : JC_BTN_Y;
+	uint32_t jc = 0;
+	if (b & TB_Y)
+		jc |= fY;
+	if (b & TB_B)
+		jc |= fB;
+	if (b & TB_A)
+		jc |= fA;
+	if (b & TB_X)
+		jc |= fX;
+	if (b & TB_LB)
+		jc |= JC_BTN_L;
+	if (b & TB_RB)
+		jc |= JC_BTN_R;
+	if ((g_in.lt >= SW_TRIG_ON) || (b & 0x8000000u))
+		jc |= JC_BTN_ZL;
+	if ((g_in.rt >= SW_TRIG_ON) || (b & 0x800000u))
+		jc |= JC_BTN_ZR;
+	if (b & TB_VIEW)
+		jc |= JC_BTN_PLUS;
+	if (b & TB_MENU)
+		jc |= JC_BTN_MINUS;
+	if (b & TB_L3)
+		jc |= JC_BTN_LSTICK;
+	if (b & TB_R3)
+		jc |= JC_BTN_RSTICK;
+	if (b & TB_STEAM)
+		jc |= JC_BTN_HOME;
+	if (b & TB_DDN)
+		jc |= JC_BTN_DOWN;
+	if (b & TB_DUP)
+		jc |= JC_BTN_UP;
+	if (b & TB_DRT)
+		jc |= JC_BTN_RIGHT;
+	if (b & TB_DLF)
+		jc |= JC_BTN_LEFT;
+	if (b & TB_L4)
+		jc |= codeToJc(g_back[0], fA, fB, fX, fY);
+	if (b & TB_R4)
+		jc |= codeToJc(g_back[1], fA, fB, fX, fY);
+	if (b & TB_L5)
+		jc |= codeToJc(g_back[2], fA, fB, fX, fY);
+	if (b & TB_R5)
+		jc |= codeToJc(g_back[3], fA, fB, fX, fY);
+	out[0] = g_jcTimer++;
+	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
+	// A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too. (2wiCC parity)
+	out[1] = 0x91;
+	out[2] = (uint8_t)(jc);
+	out[3] = (uint8_t)(jc >> 8);
+	out[4] = (uint8_t)(jc >> 16);
+	// 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.lx, g_in.ly);
+	jcPackStick(out + 8, g_in.rx, g_in.ry);
+	out[11] =
+		0x09; // rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 }
-static void switchProBuild(uint8_t out[63]){
-  memset(out,0,63);
-  jcInputPrefix(out);
-  // Gyro slot order follows hid-nintendo: raw+6 = ROLL, raw+8 = PITCH, raw+10 = YAW. Steam-validated source
-  // routing (a proper rotation, det +1): SwitchX<-+gy, SwitchY<--gx, SwitchZ<-+gz.
-  //
-  // CRITICAL: the accel MUST use the SAME signed permutation as the gyro. Steam reads the gyro raw and ignores
-  // this, but the Switch console FUSES accel (gravity) with gyro to anchor absolute orientation -- and if the
-  // accel frame has the opposite handedness, the fusion's gravity-correction term points the wrong way and the
-  // attitude estimate latches into a rotated solution (the intermittent ~45deg axis-mixing, per-unit roll bias,
-  // and "only a replug clears it" behaviour). So accel Y must be -ax to match the gyro's -gx pitch axis; with
-  // that, accel is also det +1 and resting gravity lands cleanly on Switch Z (the up axis).
-  // The SC2 accelerometer is +/-2g full-scale (~16384 counts = 1g; measured |a|=16364 at rest). The factory cal we
-  // present (sensitivity 0x4000) tells the Switch it's a genuine +/-8g Pro Controller (4096 counts = 1g), so divide
-  // by SW_ACCEL_DIV=4 to report ~1g of gravity. Without this the console reads gravity as ~4g, REJECTS the accel for
-  // drift correction (it must be linear accel, not gravity), and the gyro's roll error accumulates into the slow
-  // ~45deg lean. Gyro is left at native scale (aiming sensitivity is right); only the accel magnitude was wrong.
-  int16_t aX=(int16_t)(g_in.ay/SW_ACCEL_DIV);            // accel X <- +g_in.ay /4   (matches gyro +gy)
-  int16_t aY=(int16_t)((-(int16_t)g_in.ax)/SW_ACCEL_DIV);// accel Y <- -g_in.ax /4   (matches gyro -gx)
-  int16_t aZ=(int16_t)(g_in.az/SW_ACCEL_DIV);            // accel Z <- +g_in.az /4   (matches gyro +gz)
-  int16_t groll =gscale((int16_t)g_in.gy);            // gyro outputs scaled by the user sensitivity factor
-  int16_t gpitch=gscale((int16_t)(-(int16_t)g_in.gx));
-  int16_t gyaw  =gscale((int16_t)g_in.gz);
-  for(int k=0;k<3;k++){
-    int o=12+k*12;
-    out[o+0]=aX&0xFF;       out[o+1]=(aX>>8)&0xFF;      // accel X (Switch)
-    out[o+2]=aY&0xFF;       out[o+3]=(aY>>8)&0xFF;      // accel Y (Switch)
-    out[o+4]=aZ&0xFF;       out[o+5]=(aZ>>8)&0xFF;      // accel Z (Switch)
-    out[o+6]=groll&0xFF;    out[o+7]=(groll>>8)&0xFF;   // gyro ROLL  <- +g_in.gy (scaled)
-    out[o+8]=gpitch&0xFF;   out[o+9]=(gpitch>>8)&0xFF;  // gyro PITCH <- -g_in.gx (scaled)
-    out[o+10]=gyaw&0xFF;    out[o+11]=(gyaw>>8)&0xFF;   // gyro YAW   <- +g_in.gz (scaled)
-  }
+static void switchProBuild(uint8_t out[63])
+{
+	memset(out, 0, 63);
+	jcInputPrefix(out);
+	// Gyro slot order follows hid-nintendo: raw+6 = ROLL, raw+8 = PITCH, raw+10 = YAW. Steam-validated source
+	// routing (a proper rotation, det +1): SwitchX<-+gy, SwitchY<--gx, SwitchZ<-+gz.
+	//
+	// CRITICAL: the accel MUST use the SAME signed permutation as the gyro. Steam reads the gyro raw and ignores
+	// this, but the Switch console FUSES accel (gravity) with gyro to anchor absolute orientation -- and if the
+	// accel frame has the opposite handedness, the fusion's gravity-correction term points the wrong way and the
+	// attitude estimate latches into a rotated solution (the intermittent ~45deg axis-mixing, per-unit roll bias,
+	// and "only a replug clears it" behaviour). So accel Y must be -ax to match the gyro's -gx pitch axis; with
+	// that, accel is also det +1 and resting gravity lands cleanly on Switch Z (the up axis).
+	// The SC2 accelerometer is +/-2g full-scale (~16384 counts = 1g; measured |a|=16364 at rest). The factory cal we
+	// present (sensitivity 0x4000) tells the Switch it's a genuine +/-8g Pro Controller (4096 counts = 1g), so divide
+	// by SW_ACCEL_DIV=4 to report ~1g of gravity. Without this the console reads gravity as ~4g, REJECTS the accel for
+	// drift correction (it must be linear accel, not gravity), and the gyro's roll error accumulates into the slow
+	// ~45deg lean. Gyro is left at native scale (aiming sensitivity is right); only the accel magnitude was wrong.
+	int16_t aX =
+		(int16_t)(g_in.ay /
+			  SW_ACCEL_DIV); // accel X <- +g_in.ay /4   (matches gyro +gy)
+	int16_t aY =
+		(int16_t)((-(int16_t)g_in.ax) /
+			  SW_ACCEL_DIV); // accel Y <- -g_in.ax /4   (matches gyro -gx)
+	int16_t aZ =
+		(int16_t)(g_in.az /
+			  SW_ACCEL_DIV); // accel Z <- +g_in.az /4   (matches gyro +gz)
+	int16_t groll = gscale(
+		(int16_t)g_in
+			.gy); // gyro outputs scaled by the user sensitivity factor
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in.gx));
+	int16_t gyaw = gscale((int16_t)g_in.gz);
+	for (int k = 0; k < 3; k++) {
+		int o = 12 + k * 12;
+		out[o + 0] = aX & 0xFF;
+		out[o + 1] = (aX >> 8) & 0xFF; // accel X (Switch)
+		out[o + 2] = aY & 0xFF;
+		out[o + 3] = (aY >> 8) & 0xFF; // accel Y (Switch)
+		out[o + 4] = aZ & 0xFF;
+		out[o + 5] = (aZ >> 8) & 0xFF; // accel Z (Switch)
+		out[o + 6] = groll & 0xFF;
+		out[o + 7] = (groll >> 8) &
+			     0xFF; // gyro ROLL  <- +g_in.gy (scaled)
+		out[o + 8] = gpitch & 0xFF;
+		out[o + 9] = (gpitch >> 8) &
+			     0xFF; // gyro PITCH <- -g_in.gx (scaled)
+		out[o + 10] = gyaw & 0xFF;
+		out[o + 11] = (gyaw >> 8) &
+			      0xFF; // gyro YAW   <- +g_in.gz (scaled)
+	}
 }
 // --- Canonical factory SPI dumps the host reads for calibration. Neutral IMU + centered sticks so a fresh
 // "device" calibrates sane; user-cal regions (0x80xx) read 0xFF so the host falls back to these factory blocks.
-static const uint8_t SPI_IMU_CAL[24]={0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x40,0x00,0x40,0x00,0x40,
-                                      0x00,0x00,0x00,0x00,0x00,0x00, 0x3B,0x34,0x3B,0x34,0x3B,0x34};
-static const uint8_t SPI_PARAMS1[24]={0x50,0xFD,0x00,0x00,0xC6,0x0F, 0x0F,0x30,0x61,0x96,0x30,0xF3,
-                                      0xD4,0x14,0x54,0x41,0x15,0x54, 0xC7,0x79,0x9C,0x33,0x36,0x63};
-static const uint8_t SPI_PARAMS2[18]={0x0F,0x30,0x61,0x96,0x30,0xF3, 0xD4,0x14,0x54,0x41,0x15,0x54,
-                                      0xC7,0x79,0x9C,0x33,0x36,0x63};
-static const uint8_t SPI_COLOR[13]={0x32,0x32,0x32, 0xE6,0xE6,0xE6, 0x32,0x32,0x32, 0x32,0x32,0x32, 0xFF};
-static uint8_t g_spiStickCal[18];   // built at boot: left[9]+right[9] factory stick calibration (packed 12-bit)
-static void jcPack12(uint8_t* o9, const uint16_t v[6]){   // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
-  o9[0]=v[0]&0xFF;                       o9[1]=((v[1]&0x0F)<<4)|((v[0]>>8)&0x0F); o9[2]=(v[1]>>4)&0xFF;
-  o9[3]=v[2]&0xFF;                       o9[4]=((v[3]&0x0F)<<4)|((v[2]>>8)&0x0F); o9[5]=(v[3]>>4)&0xFF;
-  o9[6]=v[4]&0xFF;                       o9[7]=((v[5]&0x0F)<<4)|((v[4]>>8)&0x0F); o9[8]=(v[5]>>4)&0xFF;
+static const uint8_t SPI_IMU_CAL[24] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					 0x00, 0x40, 0x00, 0x40, 0x00, 0x40,
+					 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					 0x3B, 0x34, 0x3B, 0x34, 0x3B, 0x34 };
+static const uint8_t SPI_PARAMS1[24] = { 0x50, 0xFD, 0x00, 0x00, 0xC6, 0x0F,
+					 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
+					 0xD4, 0x14, 0x54, 0x41, 0x15, 0x54,
+					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
+static const uint8_t SPI_PARAMS2[18] = { 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
+					 0xD4, 0x14, 0x54, 0x41, 0x15, 0x54,
+					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
+static const uint8_t SPI_COLOR[13] = { 0x32, 0x32, 0x32, 0xE6, 0xE6, 0xE6, 0x32,
+				       0x32, 0x32, 0x32, 0x32, 0x32, 0xFF };
+static uint8_t g_spiStickCal
+	[18]; // built at boot: left[9]+right[9] factory stick calibration (packed 12-bit)
+static void jcPack12(uint8_t *o9, const uint16_t v[6])
+{ // pack 6 12-bit values into 9 bytes (Switch stick-cal format)
+	o9[0] = v[0] & 0xFF;
+	o9[1] = ((v[1] & 0x0F) << 4) | ((v[0] >> 8) & 0x0F);
+	o9[2] = (v[1] >> 4) & 0xFF;
+	o9[3] = v[2] & 0xFF;
+	o9[4] = ((v[3] & 0x0F) << 4) | ((v[2] >> 8) & 0x0F);
+	o9[5] = (v[3] >> 4) & 0xFF;
+	o9[6] = v[4] & 0xFF;
+	o9[7] = ((v[5] & 0x0F) << 4) | ((v[4] >> 8) & 0x0F);
+	o9[8] = (v[5] >> 4) & 0xFF;
 }
-static void jcBuildStickCal(){
-  const uint16_t C=2048, R=1800;   // center, +/- range per axis
-  uint16_t L[6]={R,R, C,C, R,R};   // left  order: max(x,y), center(x,y), min(x,y)
-  uint16_t Rr[6]={C,C, R,R, R,R};  // right order: center(x,y), min(x,y), max(x,y)
-  jcPack12(&g_spiStickCal[0], L); jcPack12(&g_spiStickCal[9], Rr);
+static void jcBuildStickCal()
+{
+	const uint16_t C = 2048, R = 1800; // center, +/- range per axis
+	uint16_t L[6] = {
+		R, R, C, C, R, R
+	}; // left  order: max(x,y), center(x,y), min(x,y)
+	uint16_t Rr[6] = {
+		C, C, R, R, R, R
+	}; // right order: center(x,y), min(x,y), max(x,y)
+	jcPack12(&g_spiStickCal[0], L);
+	jcPack12(&g_spiStickCal[9], Rr);
 }
 // Manual-pairing (subcommand 0x01) reply payloads. A real Switch runs this 3-stage BT key exchange over USB so it
 // can register the pad (Steam/hid-nintendo never do, which is why this was previously unneeded). The data is the
 // canonical hid handshake blob; the Switch only validates the shape, not the key contents. Each is the 31-byte
 // reply body that follows the 0x21 input prefix + ack(0x81) + echoed-subcommand(0x01). (adapted from 2wiCC)
-static const uint8_t BT_PAIR_1[31]={   // type 1: echo type + controller BT addr + "Pro Controller" name
-  0x01, JC_MAC[0],JC_MAC[1],JC_MAC[2],JC_MAC[3],JC_MAC[4],JC_MAC[5], 0x00,0x25,0x08,
-  0x50,0x72,0x6F,0x20,0x43,0x6F,0x6E,0x74,0x72,0x6F,0x6C,0x6C,0x65,0x72,   // "Pro Controller"
-  0x00,0x00,0x00,0x00,0x00,0x68};
-static const uint8_t BT_PAIR_2[31]={   // type 2: LTK exchange
-  0x02, 0xE5,0xC8,0xE4,0x92,0x05,0xFF,0xC9,0x8A,0x7D,0xEA,0x15,0xF6,0x19,0xBA,0x82,0x13,
-  0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00};
-static const uint8_t BT_PAIR_3[31]={0x03};   // type 3: save pairing (all zero body)
+static const uint8_t BT_PAIR_1
+	[31] = { // type 1: echo type + controller BT addr + "Pro Controller" name
+		0x01,	   JC_MAC[0], JC_MAC[1], JC_MAC[2], JC_MAC[3],
+		JC_MAC[4], JC_MAC[5], 0x00,	 0x25,	    0x08,
+		0x50,	   0x72,      0x6F,	 0x20,	    0x43,
+		0x6F,	   0x6E,      0x74,	 0x72,	    0x6F,
+		0x6C,	   0x6C,      0x65,	 0x72, // "Pro Controller"
+		0x00,	   0x00,      0x00,	 0x00,	    0x00,
+		0x68
+	};
+static const uint8_t BT_PAIR_2[31] = { // type 2: LTK exchange
+	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
+	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t BT_PAIR_3[31] = {
+	0x03
+}; // type 3: save pairing (all zero body)
 // User-calibration SPI mirror (0x8000-0x80FF). A real Pro Controller stores the gyro/accel (and stick) calibration
 // the Switch writes during "Calibrate Motion Controls" here, then reads it back and applies it -- that's how a
 // resting IMU/neutral offset gets cancelled. We previously DISCARDED SPI writes and returned 0xFF (blank), so the
@@ -222,163 +382,282 @@ static const uint8_t BT_PAIR_3[31]={0x03};   // type 3: save pairing (all zero b
 // it survives unplug/replug. 0xFF = blank -> Switch falls back to our factory block at 0x6020 (default until cal'd).
 #define SWCAL_FILE "/swimucal.bin"
 static uint8_t g_userCal[0x100];
-static void loadUserCal(){
-  memset(g_userCal,0xFF,sizeof g_userCal);
-  File f(InternalFS);
-  if(f.open(SWCAL_FILE, FILE_O_READ)){ f.read(g_userCal,sizeof g_userCal); f.close(); }
+static void loadUserCal()
+{
+	memset(g_userCal, 0xFF, sizeof g_userCal);
+	File f(InternalFS);
+	if (f.open(SWCAL_FILE, FILE_O_READ)) {
+		f.read(g_userCal, sizeof g_userCal);
+		f.close();
+	}
 }
-static void saveUserCal(){
-  InternalFS.remove(SWCAL_FILE); File f(InternalFS);
-  if(f.open(SWCAL_FILE, FILE_O_WRITE)){ f.write(g_userCal,sizeof g_userCal); f.close(); }
+static void saveUserCal()
+{
+	InternalFS.remove(SWCAL_FILE);
+	File f(InternalFS);
+	if (f.open(SWCAL_FILE, FILE_O_WRITE)) {
+		f.write(g_userCal, sizeof g_userCal);
+		f.close();
+	}
 }
 // jcSpiWrite runs in the USB ISR (via jcSet). NEVER do flash I/O here -- a blocking LittleFS erase/write in the
 // interrupt wedges USB + the RF poll and corrupts state (device drops into a bad state needing a replug). Only
 // update the RAM mirror and flag it dirty; task() (main loop) does the actual save, debounced so a calibration
 // write-burst coalesces into one flash write.
-static volatile bool          g_userCalDirty=false;
-static volatile unsigned long g_userCalDirtyMs=0;
-static void jcSpiWrite(uint32_t addr, uint8_t len, const uint8_t* data, uint16_t avail){
-  bool changed=false;
-  for(uint8_t i=0;i<len && i<avail;i++){
-    uint32_t a=addr+i;
-    if(a>=0x8000 && a<0x8100){ if(g_userCal[a-0x8000]!=data[i]){ g_userCal[a-0x8000]=data[i]; changed=true; } }
-  }
-  if(changed){ g_userCalDirty=true; g_userCalDirtyMs=millis(); }   // defer the flash write to task()
+static volatile bool g_userCalDirty = false;
+static volatile unsigned long g_userCalDirtyMs = 0;
+static void jcSpiWrite(uint32_t addr, uint8_t len, const uint8_t *data,
+		       uint16_t avail)
+{
+	bool changed = false;
+	for (uint8_t i = 0; i < len && i < avail; i++) {
+		uint32_t a = addr + i;
+		if (a >= 0x8000 && a < 0x8100) {
+			if (g_userCal[a - 0x8000] != data[i]) {
+				g_userCal[a - 0x8000] = data[i];
+				changed = true;
+			}
+		}
+	}
+	if (changed) {
+		g_userCalDirty = true;
+		g_userCalDirtyMs = millis();
+	} // defer the flash write to task()
 }
-static void spiRead(uint32_t addr, uint8_t len, uint8_t* dst){
-  for(uint8_t i=0;i<len;i++){
-    uint32_t a=addr+i; uint8_t v=0xFF;
-    if     (a>=0x6020 && a<0x6020+24) v=SPI_IMU_CAL[a-0x6020];
-    else if(a>=0x603D && a<0x603D+18) v=g_spiStickCal[a-0x603D];
-    else if(a>=0x6050 && a<0x6050+13) v=SPI_COLOR[a-0x6050];
-    else if(a>=0x6080 && a<0x6080+24) v=SPI_PARAMS1[a-0x6080];
-    else if(a>=0x6098 && a<0x6098+18) v=SPI_PARAMS2[a-0x6098];
-    else if(a>=0x8000 && a<0x8100)    v=g_userCal[a-0x8000];   // persisted user cal (0xFF blank -> factory fallback)
-    dst[i]=v;
-  }
+static void spiRead(uint32_t addr, uint8_t len, uint8_t *dst)
+{
+	for (uint8_t i = 0; i < len; i++) {
+		uint32_t a = addr + i;
+		uint8_t v = 0xFF;
+		if (a >= 0x6020 && a < 0x6020 + 24)
+			v = SPI_IMU_CAL[a - 0x6020];
+		else if (a >= 0x603D && a < 0x603D + 18)
+			v = g_spiStickCal[a - 0x603D];
+		else if (a >= 0x6050 && a < 0x6050 + 13)
+			v = SPI_COLOR[a - 0x6050];
+		else if (a >= 0x6080 && a < 0x6080 + 24)
+			v = SPI_PARAMS1[a - 0x6080];
+		else if (a >= 0x6098 && a < 0x6098 + 18)
+			v = SPI_PARAMS2[a - 0x6098];
+		else if (a >= 0x8000 && a < 0x8100)
+			v = g_userCal[a -
+				      0x8000]; // persisted user cal (0xFF blank -> factory fallback)
+		dst[i] = v;
+	}
 }
 // Reply FIFO: the host's handshake/subcommand reports arrive in the USB ISR (jcSet); we enqueue the canonical
 // 0x81/0x21 reply and drain it from task() where sendReport is safe. SPSC ring, volatile indices. Every reply
 // is sent as a full 63-byte report (64 with the id): macOS IOHIDManager silently DROPS any input report shorter
 // than the descriptor-declared length (0x3F), so short 0x81/0x21 replies never reached the host.
 #define JC_REPLEN 63
-struct JcRep { uint8_t rid; uint8_t data[JC_REPLEN]; };
+struct JcRep {
+	uint8_t rid;
+	uint8_t data[JC_REPLEN];
+};
 #define JCQ_N 8
-static volatile uint8_t g_jcQh=0, g_jcQt=0;
+static volatile uint8_t g_jcQh = 0, g_jcQt = 0;
 static JcRep g_jcQ[JCQ_N];
-static void jcEnq(uint8_t rid, const uint8_t* d, uint8_t len){
-  uint8_t nt=(uint8_t)((g_jcQt+1)%JCQ_N);
-  if(nt==g_jcQh) return;                 // full -> drop; the host re-requests on timeout
-  if(len>JC_REPLEN) len=JC_REPLEN;
-  g_jcQ[g_jcQt].rid=rid;
-  memset(g_jcQ[g_jcQt].data,0,JC_REPLEN); memcpy(g_jcQ[g_jcQt].data,d,len);   // zero-pad to full report length
-  g_jcQt=nt;
+static void jcEnq(uint8_t rid, const uint8_t *d, uint8_t len)
+{
+	uint8_t nt = (uint8_t)((g_jcQt + 1) % JCQ_N);
+	if (nt == g_jcQh)
+		return; // full -> drop; the host re-requests on timeout
+	if (len > JC_REPLEN)
+		len = JC_REPLEN;
+	g_jcQ[g_jcQt].rid = rid;
+	memset(g_jcQ[g_jcQt].data, 0, JC_REPLEN);
+	memcpy(g_jcQ[g_jcQt].data, d, len); // zero-pad to full report length
+	g_jcQt = nt;
 }
 // Build the 0x21 reply (standard input prefix + ACK + echoed subcommand id + reply data) for a report-0x01 subcommand.
-static void jcSubcmd(uint8_t sub, const uint8_t* args, uint16_t alen){
-  uint8_t p[JC_REPLEN]; memset(p,0,sizeof p);
-  jcInputPrefix(p);
-  p[13]=sub;
-  switch(sub){
-    case 0x01: {                       // manual BT pairing: 3-stage key exchange a real Switch runs over USB
-      const uint8_t* d=BT_PAIR_3; uint8_t t=(alen>=1)?args[0]:3;
-      if(t==1) d=BT_PAIR_1; else if(t==2) d=BT_PAIR_2;
-      p[12]=0x81;                      // pairing ACK
-      memcpy(&p[14], d, 31);
-      break; }
-    case 0x02:                         // request device info
-      p[12]=0x82;
-      p[14]=0x03; p[15]=0x48;          // firmware version 3.72 (genuine Pro Controller value)
-      p[16]=0x03;                      // controller type: Pro Controller
-      p[17]=0x02;                      // fixed
-      memcpy(&p[18], JC_MAC, 6);
-      p[24]=0x01;                      // colors stored in SPI (0x6050)
-      p[25]=0x01;                      // fixed
-      break;
-    case 0x10: {                       // SPI flash read -> echo [addr][len] then the data
-      if(alen<5){ p[12]=0x80; break; }
-      uint32_t a=(uint32_t)args[0]|((uint32_t)args[1]<<8)|((uint32_t)args[2]<<16)|((uint32_t)args[3]<<24);
-      uint8_t rl=args[4]; if(rl>0x1D) rl=0x1D;
-      p[12]=0x90;
-      p[14]=args[0]; p[15]=args[1]; p[16]=args[2]; p[17]=args[3]; p[18]=rl;
-      spiRead(a, rl, &p[19]);
-      break; }
-    case 0x11:                         // SPI flash write -> persist user calibration so Switch motion-cal sticks
-      if(alen>=5){
-        uint32_t a=(uint32_t)args[0]|((uint32_t)args[1]<<8)|((uint32_t)args[2]<<16)|((uint32_t)args[3]<<24);
-        jcSpiWrite(a, args[4], &args[5], (alen>5)?(uint16_t)(alen-5):0);
-      }
-      p[12]=0x80; break;               // write ACK
-    case 0x03:                         // set input report mode (0x30 = standard full) -> begin streaming
-      if(alen>=1 && args[0]==0x30) g_swProReportMode=0x30;
-      p[12]=0x80; break;
-    case 0x04:                         // trigger buttons elapsed time -> canned reply (genuine pad returns data)
-      p[12]=0x83;
-      p[14]=0x00; p[15]=0xCC; p[16]=0x00; p[17]=0xEE; p[18]=0x00; p[19]=0xFF;
-      break;
-    case 0x21: p[12]=0xA0; break;      // set NFC/IR config
-    default:   p[12]=0x80; break;      // 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
-  }
-  jcEnq(0x21, p, JC_REPLEN);
+static void jcSubcmd(uint8_t sub, const uint8_t *args, uint16_t alen)
+{
+	uint8_t p[JC_REPLEN];
+	memset(p, 0, sizeof p);
+	jcInputPrefix(p);
+	p[13] = sub;
+	switch (sub) {
+	case 0x01: { // manual BT pairing: 3-stage key exchange a real Switch runs over USB
+		const uint8_t *d = BT_PAIR_3;
+		uint8_t t = (alen >= 1) ? args[0] : 3;
+		if (t == 1)
+			d = BT_PAIR_1;
+		else if (t == 2)
+			d = BT_PAIR_2;
+		p[12] = 0x81; // pairing ACK
+		memcpy(&p[14], d, 31);
+		break;
+	}
+	case 0x02: // request device info
+		p[12] = 0x82;
+		p[14] = 0x03;
+		p[15] = 0x48; // firmware version 3.72 (genuine Pro Controller value)
+		p[16] = 0x03; // controller type: Pro Controller
+		p[17] = 0x02; // fixed
+		memcpy(&p[18], JC_MAC, 6);
+		p[24] = 0x01; // colors stored in SPI (0x6050)
+		p[25] = 0x01; // fixed
+		break;
+	case 0x10: { // SPI flash read -> echo [addr][len] then the data
+		if (alen < 5) {
+			p[12] = 0x80;
+			break;
+		}
+		uint32_t a = (uint32_t)args[0] | ((uint32_t)args[1] << 8) |
+			     ((uint32_t)args[2] << 16) |
+			     ((uint32_t)args[3] << 24);
+		uint8_t rl = args[4];
+		if (rl > 0x1D)
+			rl = 0x1D;
+		p[12] = 0x90;
+		p[14] = args[0];
+		p[15] = args[1];
+		p[16] = args[2];
+		p[17] = args[3];
+		p[18] = rl;
+		spiRead(a, rl, &p[19]);
+		break;
+	}
+	case 0x11: // SPI flash write -> persist user calibration so Switch motion-cal sticks
+		if (alen >= 5) {
+			uint32_t a = (uint32_t)args[0] |
+				     ((uint32_t)args[1] << 8) |
+				     ((uint32_t)args[2] << 16) |
+				     ((uint32_t)args[3] << 24);
+			jcSpiWrite(a, args[4], &args[5],
+				   (alen > 5) ? (uint16_t)(alen - 5) : 0);
+		}
+		p[12] = 0x80;
+		break; // write ACK
+	case 0x03: // set input report mode (0x30 = standard full) -> begin streaming
+		if (alen >= 1 && args[0] == 0x30)
+			g_swProReportMode = 0x30;
+		p[12] = 0x80;
+		break;
+	case 0x04: // trigger buttons elapsed time -> canned reply (genuine pad returns data)
+		p[12] = 0x83;
+		p[14] = 0x00;
+		p[15] = 0xCC;
+		p[16] = 0x00;
+		p[17] = 0xEE;
+		p[18] = 0x00;
+		p[19] = 0xFF;
+		break;
+	case 0x21:
+		p[12] = 0xA0;
+		break; // set NFC/IR config
+	default:
+		p[12] = 0x80;
+		break; // 0x06/0x08/0x30/0x38/0x40/0x41/0x48/... generic positive ACK
+	}
+	jcEnq(0x21, p, JC_REPLEN);
 }
 // USB-ISR callback for the Pro Controller's OUT endpoint. Data arrives via the interrupt OUT endpoint, where
 // this TinyUSB core passes report_id=0 and leaves the real report id in buffer[0] (it does NOT strip it). Also
 // accept the control SET_REPORT shape (id already in `rid`) for safety. report 0x80 = USB handshake; 0x01 = subcommand.
-static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const* b, uint16_t n){
-  if(type!=HID_REPORT_TYPE_OUTPUT || n<1) return;
-  uint8_t id; const uint8_t* p; uint16_t pn;
-  if(rid==0){ id=b[0]; p=b+1; pn=(uint16_t)(n-1); }   // EP-OUT: id is the first payload byte
-  else      { id=rid;  p=b;   pn=n;               }   // control SET_REPORT: id already split out
-  if(id==0x80){
-    if(pn<1) return;
-    if(p[0]==0x01){ uint8_t d[9]={0x01,0x00,0x03,JC_MAC[0],JC_MAC[1],JC_MAC[2],JC_MAC[3],JC_MAC[4],JC_MAC[5]}; jcEnq(0x81,d,9); } // device type + MAC
-    else if(p[0]==0x02){ uint8_t d[1]={0x02}; jcEnq(0x81,d,1); }   // handshake
-    else if(p[0]==0x03){ uint8_t d[1]={0x03}; jcEnq(0x81,d,1); }   // set baudrate
-    // 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
-    return;
-  }
-  if(id==0x01){                        // [timer][rumble x8][subcmd][args...]
-    if(pn<10) return;
-    jcRumble(p, pn);
-    jcSubcmd(p[9], p+10, (pn>10)?(uint16_t)(pn-10):0);
-    return;
-  }
-  if(id==0x10){ jcRumble(p, pn); return; }
-  // report 0x82: ignored
+static void jcSet(uint8_t rid, hid_report_type_t type, uint8_t const *b,
+		  uint16_t n)
+{
+	if (type != HID_REPORT_TYPE_OUTPUT || n < 1)
+		return;
+	uint8_t id;
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		id = b[0];
+		p = b + 1;
+		pn = (uint16_t)(n - 1);
+	} // EP-OUT: id is the first payload byte
+	else {
+		id = rid;
+		p = b;
+		pn = n;
+	} // control SET_REPORT: id already split out
+	if (id == 0x80) {
+		if (pn < 1)
+			return;
+		if (p[0] == 0x01) {
+			uint8_t d[9] = { 0x01,	    0x00,      0x03,
+					 JC_MAC[0], JC_MAC[1], JC_MAC[2],
+					 JC_MAC[3], JC_MAC[4], JC_MAC[5] };
+			jcEnq(0x81, d, 9);
+		} // device type + MAC
+		else if (p[0] == 0x02) {
+			uint8_t d[1] = { 0x02 };
+			jcEnq(0x81, d, 1);
+		} // handshake
+		else if (p[0] == 0x03) {
+			uint8_t d[1] = { 0x03 };
+			jcEnq(0x81, d, 1);
+		} // set baudrate
+		// 0x04 force-USB / 0x05 enable-timeout / 0x06 reset: no reply expected
+		return;
+	}
+	if (id == 0x01) { // [timer][rumble x8][subcmd][args...]
+		if (pn < 10)
+			return;
+		jcRumble(p, pn);
+		jcSubcmd(p[9], p + 10, (pn > 10) ? (uint16_t)(pn - 10) : 0);
+		return;
+	}
+	if (id == 0x10) {
+		jcRumble(p, pn);
+		return;
+	}
+	// report 0x82: ignored
 }
 
-void SwitchProController::begin(){
-  USBDevice.setID(0x057E, 0x2009);
-  USBDevice.setDeviceVersion(0x0212);   // bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
-  USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
-  USBDevice.setProductDescriptor("Pro Controller");
-  jcBuildStickCal();
-  loadUserCal();   // restore any persisted Switch motion calibration (per-unit IMU bias correction)
-  swProLoadCfg();  // restore report-rate + gyro-scale settings
-  g_swPro.enableOutEndpoint(true);
-  g_swPro.setReportCallback(NULL, jcSet);   // answer the Nintendo USB handshake + subcommands (else Steam never binds it)
-  g_swPro.setReportDescriptor(SWPRO_HID_DESC, sizeof SWPRO_HID_DESC);
-  g_swPro.setPollInterval(1);   // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
-  g_swPro.begin();
+void SwitchProController::begin()
+{
+	USBDevice.setID(0x057E, 0x2009);
+	USBDevice.setDeviceVersion(
+		0x0212); // bumped from 0x0211 for 1ms poll interval (Windows caches config by VID:PID:bcdDevice)
+	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
+	USBDevice.setProductDescriptor("Pro Controller");
+	jcBuildStickCal();
+	loadUserCal(); // restore any persisted Switch motion calibration (per-unit IMU bias correction)
+	swProLoadCfg(); // restore report-rate + gyro-scale settings
+	g_swPro.enableOutEndpoint(true);
+	g_swPro.setReportCallback(
+		NULL,
+		jcSet); // answer the Nintendo USB handshake + subcommands (else Steam never binds it)
+	g_swPro.setReportDescriptor(SWPRO_HID_DESC, sizeof SWPRO_HID_DESC);
+	g_swPro.setPollInterval(
+		1); // 1ms bInterval so the RF rate is the only latency limit (matches Xbox)
+	g_swPro.begin();
 }
-void SwitchProController::task(){
-  if(!g_swPro.ready()) return;
-  // Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst is one save).
-  if(g_userCalDirty && (unsigned long)(millis()-g_userCalDirtyMs) > 250u){ g_userCalDirty=false; saveUserCal(); }
-  if(g_jcQh!=g_jcQt){                                   // drain handshake/subcommand replies first (ordered)
-    JcRep* r=&g_jcQ[g_jcQh];
-    if(g_swPro.sendReport(r->rid, r->data, JC_REPLEN)) g_jcQh=(uint8_t)((g_jcQh+1)%JCQ_N);
-    return;                                             // one report per call; the rest next loop
-  }
-  if(g_swProReportMode!=0x30) return;                   // not until the host has finished init + selected 0x30
-  // Stream the standard full report at ~66 Hz (15 ms), NOT the 250 Hz USB_STREAM_MS the PC modes use. The Switch
-  // integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores the timer byte and
-  // assumes a 3-samples-per-15ms genuine cadence). At 250 Hz x 3 samples = 750 samples/s it credits ~3.75 s of
-  // gyro rotation per real second, so any residual gyro bias accumulates ~3.75x too fast -> the slow orientation
-  // lean that builds over minutes and resets on replug. 15 ms matches the genuine push (and 2wiCC's ~60 Hz /
-  // MissionControl), making integration 1:1. (Buttons/sticks on the Switch are genuine-rate-limited anyway.)
-  uint32_t interval = (g_swProRate==2) ? USB_STREAM_MS : (g_swProRate==1) ? SW_PRO_REPORT_MS_120 : SW_PRO_REPORT_MS;
-  if(millis()-g_swProLastMs<interval) return; g_swProLastMs=millis();
-  uint8_t p[63]; switchProBuild(p);
-  g_swPro.sendReport(0x30, p, sizeof p);
+void SwitchProController::task()
+{
+	if (!g_swPro.ready())
+		return;
+	// Deferred user-cal flash write (queued by the USB ISR; debounced so a calibration write-burst is one save).
+	if (g_userCalDirty &&
+	    (unsigned long)(millis() - g_userCalDirtyMs) > 250u) {
+		g_userCalDirty = false;
+		saveUserCal();
+	}
+	if (g_jcQh !=
+	    g_jcQt) { // drain handshake/subcommand replies first (ordered)
+		JcRep *r = &g_jcQ[g_jcQh];
+		if (g_swPro.sendReport(r->rid, r->data, JC_REPLEN))
+			g_jcQh = (uint8_t)((g_jcQh + 1) % JCQ_N);
+		return; // one report per call; the rest next loop
+	}
+	if (g_swProReportMode != 0x30)
+		return; // not until the host has finished init + selected 0x30
+	// Stream the standard full report at ~66 Hz (15 ms), NOT the 250 Hz USB_STREAM_MS the PC modes use. The Switch
+	// integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores the timer byte and
+	// assumes a 3-samples-per-15ms genuine cadence). At 250 Hz x 3 samples = 750 samples/s it credits ~3.75 s of
+	// gyro rotation per real second, so any residual gyro bias accumulates ~3.75x too fast -> the slow orientation
+	// lean that builds over minutes and resets on replug. 15 ms matches the genuine push (and 2wiCC's ~60 Hz /
+	// MissionControl), making integration 1:1. (Buttons/sticks on the Switch are genuine-rate-limited anyway.)
+	uint32_t interval = (g_swProRate == 2) ? USB_STREAM_MS :
+			    (g_swProRate == 1) ? SW_PRO_REPORT_MS_120 :
+						 SW_PRO_REPORT_MS;
+	if (millis() - g_swProLastMs < interval)
+		return;
+	g_swProLastMs = millis();
+	uint8_t p[63];
+	switchProBuild(p);
+	g_swPro.sendReport(0x30, p, sizeof p);
 }

+ 3 - 3
OpenPuck/mode_switch_pro.h

@@ -10,8 +10,8 @@
 #include "controllers.h"
 
 class SwitchProController : public IController {
-public:
-  void begin() override;
-  void task() override;
+    public:
+	void begin() override;
+	void task() override;
 };
 extern SwitchProController g_switchPro;

+ 371 - 119
OpenPuck/mode_xinput.cpp

@@ -15,157 +15,409 @@ extern "C" {
 XboxController g_xboxCtl;
 
 // XInput button bits
-enum { XB_DUP=0x0001,XB_DDOWN=0x0002,XB_DLEFT=0x0004,XB_DRIGHT=0x0008, XB_START=0x0010,XB_BACK=0x0020,
-       XB_L3=0x0040,XB_R3=0x0080, XB_LB=0x0100,XB_RB=0x0200,XB_GUIDE=0x0400, XB_A=0x1000,XB_B=0x2000,XB_X=0x4000,XB_Y=0x8000 };
+enum {
+	XB_DUP = 0x0001,
+	XB_DDOWN = 0x0002,
+	XB_DLEFT = 0x0004,
+	XB_DRIGHT = 0x0008,
+	XB_START = 0x0010,
+	XB_BACK = 0x0020,
+	XB_L3 = 0x0040,
+	XB_R3 = 0x0080,
+	XB_LB = 0x0100,
+	XB_RB = 0x0200,
+	XB_GUIDE = 0x0400,
+	XB_A = 0x1000,
+	XB_B = 0x2000,
+	XB_X = 0x4000,
+	XB_Y = 0x8000
+};
 
 // ===================== XInput custom TinyUSB class driver =====================
 // Custom class driver (registered via usbd_app_driver_get_cb) + an Adafruit_USBD_Interface subclass that emits
 // the interface + 0x21-blob + 2 endpoints. (Verified against Adafruit nRF52 1.7.0 / TinyUSB 0.18.)
-#define XINPUT_DESC_LEN (9 + 17 + 7 + 7)   // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
-static uint8_t g_xiItf=0xFF, g_xiEpIn=0, g_xiEpOut=0;
+#define XINPUT_DESC_LEN \
+	(9 + 17 + 7 + 7) // interface(9)+vendor0x21(17)+IN ep(7)+OUT ep(7) = 40
+static uint8_t g_xiItf = 0xFF, g_xiEpIn = 0, g_xiEpOut = 0;
 static uint8_t g_xiInBuf[32], g_xiOutBuf[32];
 
 // XInput rumble strengths from the host's OUT packet; translated to Steam/Triton report 0x80.
 static volatile uint16_t g_rumbleLow = 0, g_rumbleHigh = 0;
-static volatile unsigned long g_rumbleMs = 0;   // millis of last rumble OUT packet (stuck-rumble watchdog)
-#define RUMBLE_STUCK_MS 2500u   // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop without cutting normal short rumbles)
+static volatile unsigned long g_rumbleMs =
+	0; // millis of last rumble OUT packet (stuck-rumble watchdog)
+#define RUMBLE_STUCK_MS \
+	2500u // release a held rumble if no OUT packet refreshes it for this long (covers a lost stop without cutting normal short rumbles)
 
-static void xi_init(void){}
-static bool xi_deinit(void){return true;}
-static void xi_reset(uint8_t rhport){(void)rhport; g_xiEpIn=g_xiEpOut=0;}
-static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const* itf, uint16_t max_len){
-  if(!(itf->bInterfaceClass==0xFF && itf->bInterfaceSubClass==0x5D && itf->bInterfaceProtocol==0x01)) return 0;
-  g_xiItf=itf->bInterfaceNumber;
-  uint8_t const* p=(uint8_t const*)itf; uint8_t const* end=p+max_len; uint16_t used=itf->bLength; p+=itf->bLength;
-  uint8_t opened=0;
-  while(p<end && opened<itf->bNumEndpoints){
-    uint8_t blen=p[0], btype=p[1];
-    if(btype==TUSB_DESC_ENDPOINT){ tusb_desc_endpoint_t const* ep=(tusb_desc_endpoint_t const*)p;
-      usbd_edpt_open(rhport,ep);
-      if(tu_edpt_dir(ep->bEndpointAddress)==TUSB_DIR_IN) g_xiEpIn=ep->bEndpointAddress; else g_xiEpOut=ep->bEndpointAddress;
-      opened++; }
-    used+=blen; p+=blen;
-  }
-  if(g_xiEpOut) usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);  // arm OUT (rumble/LED)
-  return used;
+static void xi_init(void)
+{
+}
+static bool xi_deinit(void)
+{
+	return true;
+}
+static void xi_reset(uint8_t rhport)
+{
+	(void)rhport;
+	g_xiEpIn = g_xiEpOut = 0;
+}
+static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
+			uint16_t max_len)
+{
+	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
+	      itf->bInterfaceProtocol == 0x01))
+		return 0;
+	g_xiItf = itf->bInterfaceNumber;
+	uint8_t const *p = (uint8_t const *)itf;
+	uint8_t const *end = p + max_len;
+	uint16_t used = itf->bLength;
+	p += itf->bLength;
+	uint8_t opened = 0;
+	while (p < end && opened < itf->bNumEndpoints) {
+		uint8_t blen = p[0], btype = p[1];
+		if (btype == TUSB_DESC_ENDPOINT) {
+			tusb_desc_endpoint_t const *ep =
+				(tusb_desc_endpoint_t const *)p;
+			usbd_edpt_open(rhport, ep);
+			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
+				g_xiEpIn = ep->bEndpointAddress;
+			else
+				g_xiEpOut = ep->bEndpointAddress;
+			opened++;
+		}
+		used += blen;
+		p += blen;
+	}
+	if (g_xiEpOut)
+		usbd_edpt_xfer(rhport, g_xiEpOut, g_xiOutBuf,
+			       sizeof g_xiOutBuf); // arm OUT (rumble/LED)
+	return used;
+}
+static bool xi_ctrl(uint8_t rhport, uint8_t stage,
+		    tusb_control_request_t const *req)
+{
+	(void)rhport;
+	(void)req;
+	return stage != CONTROL_STAGE_SETUP;
+}
+static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t res, uint32_t n)
+{
+	(void)res;
+	if (ep == g_xiEpOut) {
+		// XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
+		if (n >= 5 && g_xiOutBuf[0] == 0x00 && g_xiOutBuf[1] == 0x08) {
+			uint8_t big = g_xiOutBuf[3], sml = g_xiOutBuf[4];
+			g_rumbleLow = (uint16_t)big * 257u;
+			g_rumbleHigh = (uint16_t)sml * 257u;
+			g_rumbleMs =
+				millis(); // stamp for the stuck-rumble watchdog
+			hapticSteamRumble(g_rumbleLow, g_rumbleHigh);
+		}
+		usbd_edpt_xfer(rhport, g_xiEpOut, g_xiOutBuf,
+			       sizeof g_xiOutBuf); // re-arm OUT
+	}
+	return true;
 }
-static bool xi_ctrl(uint8_t rhport,uint8_t stage,tusb_control_request_t const* req){(void)rhport;(void)req; return stage!=CONTROL_STAGE_SETUP;}
-static bool xi_xfer(uint8_t rhport,uint8_t ep,xfer_result_t res,uint32_t n){(void)res;
-  if(ep==g_xiEpOut){
-    // XInput rumble packet: [00][08][00][bigMotor][smallMotor][00][00][00]; LED pkt is [01][03][led]
-    if(n>=5 && g_xiOutBuf[0]==0x00 && g_xiOutBuf[1]==0x08){
-      uint8_t big=g_xiOutBuf[3], sml=g_xiOutBuf[4];
-      g_rumbleLow=(uint16_t)big*257u; g_rumbleHigh=(uint16_t)sml*257u; g_rumbleMs = millis();   // stamp for the stuck-rumble watchdog
-      hapticSteamRumble(g_rumbleLow, g_rumbleHigh);
-    }
-    usbd_edpt_xfer(rhport,g_xiEpOut,g_xiOutBuf,sizeof g_xiOutBuf);    // re-arm OUT
-  }
-  return true; }
 static const usbd_class_driver_t g_xiDriver = {
-#if CFG_TUSB_DEBUG>=2
-  .name="XINPUT",
+#if CFG_TUSB_DEBUG >= 2
+	.name = "XINPUT",
 #endif
-  .init=xi_init, .deinit=xi_deinit, .reset=xi_reset, .open=xi_open,
-  .control_xfer_cb=xi_ctrl, .xfer_cb=xi_xfer, .sof=NULL };
-extern "C" const usbd_class_driver_t* usbd_app_driver_get_cb(uint8_t* count){ *count=1; return &g_xiDriver; }
+	.init = xi_init,
+	.deinit = xi_deinit,
+	.reset = xi_reset,
+	.open = xi_open,
+	.control_xfer_cb = xi_ctrl,
+	.xfer_cb = xi_xfer,
+	.sof = NULL
+};
+extern "C" const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *count)
+{
+	*count = 1;
+	return &g_xiDriver;
+}
 
 class Adafruit_USBD_XInput : public Adafruit_USBD_Interface {
-public:
-  uint16_t getInterfaceDescriptor(uint8_t, uint8_t* buf, uint16_t bufsize) override {
-    if(!buf) return XINPUT_DESC_LEN; if(bufsize<XINPUT_DESC_LEN) return 0;
-    uint8_t itfnum=TinyUSBDevice.allocInterface(1);
-    uint8_t epin=TinyUSBDevice.allocEndpoint(TUSB_DIR_IN), epout=TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
-    const uint8_t t[XINPUT_DESC_LEN]={
-      9,TUSB_DESC_INTERFACE,itfnum,0x00,0x02,0xFF,0x5D,0x01,_strid,
-      0x11,0x21,0x00,0x01,0x01,0x25,epin,0x14,0x00,0x00,0x00,0x00,0x13,epout,0x08,0x00,0x00,
-      7,TUSB_DESC_ENDPOINT,epin, TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),1,   // bInterval 1ms (1000Hz) so the RF rate is the only limit
-      7,TUSB_DESC_ENDPOINT,epout,TUSB_XFER_INTERRUPT,U16_TO_U8S_LE(0x20),8 };
-    memcpy(buf,t,XINPUT_DESC_LEN); return XINPUT_DESC_LEN;
-  }
-  bool begin(){ return TinyUSBDevice.addInterface(*this); }
+    public:
+	uint16_t getInterfaceDescriptor(uint8_t, uint8_t *buf,
+					uint16_t bufsize) override
+	{
+		if (!buf)
+			return XINPUT_DESC_LEN;
+		if (bufsize < XINPUT_DESC_LEN)
+			return 0;
+		uint8_t itfnum = TinyUSBDevice.allocInterface(1);
+		uint8_t epin = TinyUSBDevice.allocEndpoint(TUSB_DIR_IN),
+			epout = TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+		const uint8_t t[XINPUT_DESC_LEN] = {
+			9,
+			TUSB_DESC_INTERFACE,
+			itfnum,
+			0x00,
+			0x02,
+			0xFF,
+			0x5D,
+			0x01,
+			_strid,
+			0x11,
+			0x21,
+			0x00,
+			0x01,
+			0x01,
+			0x25,
+			epin,
+			0x14,
+			0x00,
+			0x00,
+			0x00,
+			0x00,
+			0x13,
+			epout,
+			0x08,
+			0x00,
+			0x00,
+			7,
+			TUSB_DESC_ENDPOINT,
+			epin,
+			TUSB_XFER_INTERRUPT,
+			U16_TO_U8S_LE(0x20),
+			1, // bInterval 1ms (1000Hz) so the RF rate is the only limit
+			7,
+			TUSB_DESC_ENDPOINT,
+			epout,
+			TUSB_XFER_INTERRUPT,
+			U16_TO_U8S_LE(0x20),
+			8
+		};
+		memcpy(buf, t, XINPUT_DESC_LEN);
+		return XINPUT_DESC_LEN;
+	}
+	bool begin()
+	{
+		return TinyUSBDevice.addInterface(*this);
+	}
 };
 static Adafruit_USBD_XInput g_xinput;
-static void xinputSend(uint16_t buttons,uint8_t lt,uint8_t rt,int16_t lx,int16_t ly,int16_t rx,int16_t ry){
-  if(!tud_mounted() || g_xiEpIn==0 || usbd_edpt_busy(0,g_xiEpIn)) return;
-  uint8_t* r=g_xiInBuf;
-  r[0]=0x00; r[1]=0x14; r[2]=buttons&0xFF; r[3]=buttons>>8; r[4]=lt; r[5]=rt;
-  r[6]=lx&0xFF; r[7]=lx>>8; r[8]=ly&0xFF; r[9]=ly>>8; r[10]=rx&0xFF; r[11]=rx>>8; r[12]=ry&0xFF; r[13]=ry>>8;
-  memset(r+14,0,6);
-  if(usbd_edpt_claim(0,g_xiEpIn)){ if(!usbd_edpt_xfer(0,g_xiEpIn,r,20)) usbd_edpt_release(0,g_xiEpIn); }
+static void xinputSend(uint16_t buttons, uint8_t lt, uint8_t rt, int16_t lx,
+		       int16_t ly, int16_t rx, int16_t ry)
+{
+	if (!tud_mounted() || g_xiEpIn == 0 || usbd_edpt_busy(0, g_xiEpIn))
+		return;
+	uint8_t *r = g_xiInBuf;
+	r[0] = 0x00;
+	r[1] = 0x14;
+	r[2] = buttons & 0xFF;
+	r[3] = buttons >> 8;
+	r[4] = lt;
+	r[5] = rt;
+	r[6] = lx & 0xFF;
+	r[7] = lx >> 8;
+	r[8] = ly & 0xFF;
+	r[9] = ly >> 8;
+	r[10] = rx & 0xFF;
+	r[11] = rx >> 8;
+	r[12] = ry & 0xFF;
+	r[13] = ry >> 8;
+	memset(r + 14, 0, 6);
+	if (usbd_edpt_claim(0, g_xiEpIn)) {
+		if (!usbd_edpt_xfer(0, g_xiEpIn, r, 20))
+			usbd_edpt_release(0, g_xiEpIn);
+	}
 }
 
 // ===================== right-pad mouse interface =====================
 static const uint8_t MOUSE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
-static Adafruit_USBD_HID g_mouse;   // Xbox-mode mouse interface (right trackpad)
+static Adafruit_USBD_HID g_mouse; // Xbox-mode mouse interface (right trackpad)
 
 // ===================== report 0x45 -> XInput + mouse =====================
 // button code (g_back[], g_abSwap targets) -> legacy XInput bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=Start 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright
-static uint16_t codeToXB(uint8_t c){
-  switch(c){ case 1:return XB_A; case 2:return XB_B; case 3:return XB_X; case 4:return XB_Y;
-    case 5:return XB_LB; case 6:return XB_RB; case 7:return XB_L3; case 8:return XB_R3;
-    case 9:return XB_BACK; case 10:return XB_START; case 11:return XB_GUIDE;
-    case 12:return XB_DUP; case 13:return XB_DDOWN; case 14:return XB_DLEFT; case 15:return XB_DRIGHT; default:return 0; }
+static uint16_t codeToXB(uint8_t c)
+{
+	switch (c) {
+	case 1:
+		return XB_A;
+	case 2:
+		return XB_B;
+	case 3:
+		return XB_X;
+	case 4:
+		return XB_Y;
+	case 5:
+		return XB_LB;
+	case 6:
+		return XB_RB;
+	case 7:
+		return XB_L3;
+	case 8:
+		return XB_R3;
+	case 9:
+		return XB_BACK;
+	case 10:
+		return XB_START;
+	case 11:
+		return XB_GUIDE;
+	case 12:
+		return XB_DUP;
+	case 13:
+		return XB_DDOWN;
+	case 14:
+		return XB_DLEFT;
+	case 15:
+		return XB_DRIGHT;
+	default:
+		return 0;
+	}
 }
-static void rfXboxGamepad(const uint8_t* r){
-  uint32_t b=btnsOf(r);
-  if(g_qamMap && (b&TB_QAM)){ b &= ~(uint32_t)TB_QAM; b |= tritonFromCode(g_qamMap); }
-  uint16_t btn=0;                                   // map Triton buttons -> XInput button word
-  if(b&TB_DUP)btn|=XB_DUP;   if(b&TB_DDN)btn|=XB_DDOWN; if(b&TB_DLF)btn|=XB_DLEFT; if(b&TB_DRT)btn|=XB_DRIGHT;
-  if(b&TB_VIEW)btn|=XB_START; if(b&TB_MENU)btn|=XB_BACK; if(b&TB_STEAM)btn|=XB_GUIDE;
-  if(b&TB_LB)btn|=XB_LB;  if(b&TB_RB)btn|=XB_RB;
-  if(b&TB_L3)btn|=XB_L3;  if(b&TB_R3)btn|=XB_R3;
-  // face buttons, with optional A/B + X/Y swap (Nintendo layout)
-  uint16_t fA=g_abSwap?XB_B:XB_A, fB=g_abSwap?XB_A:XB_B, fX=g_abSwap?XB_Y:XB_X, fY=g_abSwap?XB_X:XB_Y;
-  if(b&TB_A)btn|=fA; if(b&TB_B)btn|=fB; if(b&TB_X)btn|=fX; if(b&TB_Y)btn|=fY;
-  // back paddles -> configurable mapping (default L4->LB, R4->RB, L5->L3, R5->R3)
-  if(b&TB_L4)btn|=codeToXB(g_back[0]); if(b&TB_R4)btn|=codeToXB(g_back[1]);
-  if(b&TB_L5)btn|=codeToXB(g_back[2]); if(b&TB_R5)btn|=codeToXB(g_back[3]);
-  uint8_t lt=trigU8(u16off(r,4)), rt=trigU8(u16off(r,6));   // triggers u16 (half-scale) -> full-range u8
-  xinputSend(btn, lt, rt, (int16_t)s16off(r,8), (int16_t)s16off(r,10),   // L stick X/Y
-                          (int16_t)s16off(r,12), (int16_t)s16off(r,14)); // R stick X/Y
+static void rfXboxGamepad(const uint8_t *r)
+{
+	uint32_t b = btnsOf(r);
+	if (g_qamMap && (b & TB_QAM)) {
+		b &= ~(uint32_t)TB_QAM;
+		b |= tritonFromCode(g_qamMap);
+	}
+	uint16_t btn = 0; // map Triton buttons -> XInput button word
+	if (b & TB_DUP)
+		btn |= XB_DUP;
+	if (b & TB_DDN)
+		btn |= XB_DDOWN;
+	if (b & TB_DLF)
+		btn |= XB_DLEFT;
+	if (b & TB_DRT)
+		btn |= XB_DRIGHT;
+	if (b & TB_VIEW)
+		btn |= XB_START;
+	if (b & TB_MENU)
+		btn |= XB_BACK;
+	if (b & TB_STEAM)
+		btn |= XB_GUIDE;
+	if (b & TB_LB)
+		btn |= XB_LB;
+	if (b & TB_RB)
+		btn |= XB_RB;
+	if (b & TB_L3)
+		btn |= XB_L3;
+	if (b & TB_R3)
+		btn |= XB_R3;
+	// face buttons, with optional A/B + X/Y swap (Nintendo layout)
+	uint16_t fA = g_abSwap ? XB_B : XB_A, fB = g_abSwap ? XB_A : XB_B,
+		 fX = g_abSwap ? XB_Y : XB_X, fY = g_abSwap ? XB_X : XB_Y;
+	if (b & TB_A)
+		btn |= fA;
+	if (b & TB_B)
+		btn |= fB;
+	if (b & TB_X)
+		btn |= fX;
+	if (b & TB_Y)
+		btn |= fY;
+	// back paddles -> configurable mapping (default L4->LB, R4->RB, L5->L3, R5->R3)
+	if (b & TB_L4)
+		btn |= codeToXB(g_back[0]);
+	if (b & TB_R4)
+		btn |= codeToXB(g_back[1]);
+	if (b & TB_L5)
+		btn |= codeToXB(g_back[2]);
+	if (b & TB_R5)
+		btn |= codeToXB(g_back[3]);
+	uint8_t lt = trigU8(u16off(r, 4)),
+		rt = trigU8(u16off(
+			r, 6)); // triggers u16 (half-scale) -> full-range u8
+	xinputSend(btn, lt, rt, (int16_t)s16off(r, 8),
+		   (int16_t)s16off(r, 10), // L stick X/Y
+		   (int16_t)s16off(r, 12),
+		   (int16_t)s16off(r, 14)); // R stick X/Y
 }
 // Right pad -> mouse, riding ALONGSIDE the XInput gamepad on a second HID-mouse interface. Same glide model as
 // Lizard's right pad. Purely a USB-side translation -- touches NOTHING about the RF poll or relay. RPad click =
 // left button, LPad click = right.
-static void rfXboxMouse(const uint8_t* r){
-  uint32_t b=btnsOf(r);
-  static int prx=0,pry=0; static bool prt=false; static float vx=0,vy=0,rmx=0,rmy=0; static uint8_t pmb=0;
-  bool rtouch=b&TB_RPADT; int rx=s16off(r,22), ry=s16off(r,24);
-  if(rtouch){ if(prt){ vx+=(rx-prx); vy+=(ry-pry); } prx=rx; pry=ry; } prt=rtouch;
-  float mxf=vx/(float)(g_mDiv*10)+rmx, myf=-(vy/(float)(g_mDiv*10))+rmy;   // Y inverted for screen coords
-  int dx=(int)mxf, dy=(int)myf; rmx=mxf-dx; rmy=myf-dy;                    // sub-pixel carry
-  if(dx>127)dx=127; if(dx<-127)dx=-127; if(dy>127)dy=127; if(dy<-127)dy=-127;
-  float f=g_mFric/100.0f; vx*=f; vy*=f; if(vx>-1&&vx<1)vx=0; if(vy>-1&&vy<1)vy=0;   // friction = glide/decay
-  uint8_t mb=((b&TB_RPADC)?1:0)|((b&TB_LPADC)?2:0);  // RPad click = left, LPad click = right
-  if(dx||dy||mb!=pmb){ pmb=mb;
-    hid_mouse_report_t m; m.buttons=mb; m.x=(int8_t)dx; m.y=(int8_t)dy; m.wheel=0; m.pan=0;
-    if(g_mouse.ready()) g_mouse.sendReport(0,&m,sizeof m);
-  }
+static void rfXboxMouse(const uint8_t *r)
+{
+	uint32_t b = btnsOf(r);
+	static int prx = 0, pry = 0;
+	static bool prt = false;
+	static float vx = 0, vy = 0, rmx = 0, rmy = 0;
+	static uint8_t pmb = 0;
+	bool rtouch = b & TB_RPADT;
+	int rx = s16off(r, 22), ry = s16off(r, 24);
+	if (rtouch) {
+		if (prt) {
+			vx += (rx - prx);
+			vy += (ry - pry);
+		}
+		prx = rx;
+		pry = ry;
+	}
+	prt = rtouch;
+	float mxf = vx / (float)(g_mDiv * 10) + rmx,
+	      myf = -(vy / (float)(g_mDiv * 10)) +
+		    rmy; // Y inverted for screen coords
+	int dx = (int)mxf, dy = (int)myf;
+	rmx = mxf - dx;
+	rmy = myf - dy; // sub-pixel carry
+	if (dx > 127)
+		dx = 127;
+	if (dx < -127)
+		dx = -127;
+	if (dy > 127)
+		dy = 127;
+	if (dy < -127)
+		dy = -127;
+	float f = g_mFric / 100.0f;
+	vx *= f;
+	vy *= f;
+	if (vx > -1 && vx < 1)
+		vx = 0;
+	if (vy > -1 && vy < 1)
+		vy = 0; // friction = glide/decay
+	uint8_t mb = ((b & TB_RPADC) ? 1 : 0) |
+		     ((b & TB_LPADC) ?
+			      2 :
+			      0); // RPad click = left, LPad click = right
+	if (dx || dy || mb != pmb) {
+		pmb = mb;
+		hid_mouse_report_t m;
+		m.buttons = mb;
+		m.x = (int8_t)dx;
+		m.y = (int8_t)dy;
+		m.wheel = 0;
+		m.pan = 0;
+		if (g_mouse.ready())
+			g_mouse.sendReport(0, &m, sizeof m);
+	}
 }
 
 // ===================== IController =====================
-void XboxController::begin(){
-  g_rumbleLow=g_rumbleHigh=0;
-  USBDevice.setID(0x045E, 0x028E);   // device-level 045E:028E match -> Windows xusb / SDL / Linux xpad all bind it
-  // bcdDevice 0x0115 (was 0x0114): Windows caches the config descriptor by VID:PID:bcdDevice, so any change to
-  // this mode's interfaces (here: the wake-mouse interface) MUST bump bcdDevice or Windows serves a stale
-  // descriptor and the change is invisible (no manual Device-Manager cache-clear should ever be needed).
-  USBDevice.setDeviceVersion(0x0115);
-  USBDevice.setManufacturerDescriptor("Microsoft");
-  USBDevice.setProductDescriptor("Controller");
-  g_xinput.setStringDescriptor("Controller"); g_xinput.begin();   // XInput vendor interface (FF/5D/01) -> MI_00
-  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();
+void XboxController::begin()
+{
+	g_rumbleLow = g_rumbleHigh = 0;
+	USBDevice.setID(
+		0x045E,
+		0x028E); // device-level 045E:028E match -> Windows xusb / SDL / Linux xpad all bind it
+	// bcdDevice 0x0115 (was 0x0114): Windows caches the config descriptor by VID:PID:bcdDevice, so any change to
+	// this mode's interfaces (here: the wake-mouse interface) MUST bump bcdDevice or Windows serves a stale
+	// descriptor and the change is invisible (no manual Device-Manager cache-clear should ever be needed).
+	USBDevice.setDeviceVersion(0x0115);
+	USBDevice.setManufacturerDescriptor("Microsoft");
+	USBDevice.setProductDescriptor("Controller");
+	g_xinput.setStringDescriptor("Controller");
+	g_xinput.begin(); // XInput vendor interface (FF/5D/01) -> MI_00
+	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();
 }
-void XboxController::onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen){
-  (void)fresh; (void)bodyTlen;
-  rfXboxGamepad(rep); rfXboxMouse(rep);   // standard gamepad + right-pad mouse (2nd interface)
+void XboxController::onReport45(const uint8_t *rep, bool fresh,
+				uint8_t bodyTlen)
+{
+	(void)fresh;
+	(void)bodyTlen;
+	rfXboxGamepad(rep);
+	rfXboxMouse(rep); // standard gamepad + right-pad mouse (2nd interface)
 }
 // Lost-stop watchdog: Steam/Triton rumble is latched, so force a zero report if the host stops refreshing.
-void XboxController::task(){
-  if ((g_rumbleLow || g_rumbleHigh) && millis()-g_rumbleMs > RUMBLE_STUCK_MS) {
-    g_rumbleLow=g_rumbleHigh=0;
-    hapticSteamRumble(0, 0);
-  }
+void XboxController::task()
+{
+	if ((g_rumbleLow || g_rumbleHigh) &&
+	    millis() - g_rumbleMs > RUMBLE_STUCK_MS) {
+		g_rumbleLow = g_rumbleHigh = 0;
+		hapticSteamRumble(0, 0);
+	}
 }

+ 6 - 4
OpenPuck/mode_xinput.h

@@ -10,9 +10,11 @@
 #include <stdint.h>
 
 class XboxController : public IController {
-public:
-  void begin() override;
-  void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen) override;
-  void task() override;   // legacy XInput rumble -> haptic relay + stuck-rumble watchdog
+    public:
+	void begin() override;
+	void onReport45(const uint8_t *rep, bool fresh,
+			uint8_t bodyTlen) override;
+	void
+	task() override; // legacy XInput rumble -> haptic relay + stuck-rumble watchdog
 };
 extern XboxController g_xboxCtl;

+ 445 - 257
OpenPuck/puck_hid.cpp

@@ -16,61 +16,76 @@ SteamPuckController g_steamPuck;
 // ---- cloned puck HID report descriptor: mouse(0x40)+keyboard(0x41)+vendor(FF00) with the 63-byte FEATURE
 //      command reports on report id 1/2. Each of the 4 interfaces uses this. ----
 static const uint8_t PUCK_HID_DESC[] = {
-  0x05,0x01,0x09,0x02,0xA1,0x01,0x85,0x40,0x09,0x01,0xA1,0x00,0x05,0x09,0x19,0x01,
-  0x29,0x02,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,
-  0x81,0x01,0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7F,0x75,0x08,0x95,0x02,
-  0x81,0x06,0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0C,0x0A,0x38,0x02,0x95,0x01,0x81,
-  0x06,0xC0,0xC0,0x05,0x01,0x09,0x06,0xA1,0x01,0x85,0x41,0x05,0x07,0x19,0xE0,0x29,
-  0xE7,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,
-  0x65,0x15,0x00,0x25,0x65,0x75,0x08,0x95,0x06,0x81,0x00,0xC0,0x06,0x00,0xFF,0x09,
-  0x01,0xA1,0x01,0x85,0x42,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x35,0x09,0x42,
-  0x81,0x02,0x85,0x44,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x05,0x09,0x44,0x81,
-  0x02,0x85,0x79,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x01,0x09,0x79,0x81,0x02,
-  0x85,0x43,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x0E,0x09,0x43,0x81,0x02,0x85,
-  0x7B,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x0C,0x09,0x7B,0x81,0x02,0x85,0x45,
-  0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x2D,0x09,0x45,0x81,0x02,0x85,0x80,0x15,
-  0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x09,0x09,0x80,0x91,0x02,0x85,0x81,0x15,0x00,
-  0x26,0xFF,0x00,0x75,0x08,0x95,0x07,0x09,0x81,0x91,0x02,0x85,0x82,0x15,0x00,0x26,
-  0xFF,0x00,0x75,0x08,0x95,0x03,0x09,0x82,0x91,0x02,0x85,0x83,0x15,0x00,0x26,0xFF,
-  0x00,0x75,0x08,0x95,0x09,0x09,0x83,0x91,0x02,0x85,0x84,0x15,0x00,0x26,0xFF,0x00,
-  0x75,0x08,0x95,0x08,0x09,0x84,0x91,0x02,0x85,0x85,0x15,0x00,0x26,0xFF,0x00,0x75,
-  0x08,0x95,0x03,0x09,0x85,0x91,0x02,0x85,0x86,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,
-  0x95,0x03,0x09,0x86,0x91,0x02,0x85,0x87,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,
-  0x3F,0x09,0x87,0x91,0x02,0x85,0x89,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x3F,
-  0x09,0x89,0x91,0x02,0x85,0x88,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x3F,0x09,
-  0x88,0x91,0x02,0x85,0x01,0x95,0x3F,0x09,0x01,0xB1,0x02,0x85,0x02,0x95,0x3F,0x09,
-  0x01,0xB1,0x02,0xC0
+	0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
+	0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
+	0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
+	0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
+	0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
+	0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
+	0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
+	0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
+	0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
+	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
+	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
+	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
+	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
+	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0
 };
 
 // Pure MODE_LIZARD only: same puck/lizard descriptor plus a tiny Consumer Control report for media keys.
 // MODE_STEAM must keep PUCK_HID_DESC verbatim so Steam's controller/haptics path sees the normal puck shape.
 static const uint8_t PUCK_LIZARD_HID_DESC[] = {
-  0x05,0x01,0x09,0x02,0xA1,0x01,0x85,0x40,0x09,0x01,0xA1,0x00,0x05,0x09,0x19,0x01,
-  0x29,0x02,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,
-  0x81,0x01,0x05,0x01,0x09,0x30,0x09,0x31,0x15,0x81,0x25,0x7F,0x75,0x08,0x95,0x02,
-  0x81,0x06,0x95,0x01,0x09,0x38,0x81,0x06,0x05,0x0C,0x0A,0x38,0x02,0x95,0x01,0x81,
-  0x06,0xC0,0xC0,0x05,0x01,0x09,0x06,0xA1,0x01,0x85,0x41,0x05,0x07,0x19,0xE0,0x29,
-  0xE7,0x15,0x00,0x25,0x01,0x75,0x01,0x95,0x08,0x81,0x02,0x81,0x01,0x19,0x00,0x29,
-  0x65,0x15,0x00,0x25,0x65,0x75,0x08,0x95,0x06,0x81,0x00,0xC0,0x06,0x00,0xFF,0x09,
-  0x01,0xA1,0x01,0x85,0x42,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x35,0x09,0x42,
-  0x81,0x02,0x85,0x44,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x05,0x09,0x44,0x81,
-  0x02,0x85,0x79,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x01,0x09,0x79,0x81,0x02,
-  0x85,0x43,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x0E,0x09,0x43,0x81,0x02,0x85,
-  0x7B,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x0C,0x09,0x7B,0x81,0x02,0x85,0x45,
-  0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x2D,0x09,0x45,0x81,0x02,0x85,0x80,0x15,
-  0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x09,0x09,0x80,0x91,0x02,0x85,0x81,0x15,0x00,
-  0x26,0xFF,0x00,0x75,0x08,0x95,0x07,0x09,0x81,0x91,0x02,0x85,0x82,0x15,0x00,0x26,
-  0xFF,0x00,0x75,0x08,0x95,0x03,0x09,0x82,0x91,0x02,0x85,0x83,0x15,0x00,0x26,0xFF,
-  0x00,0x75,0x08,0x95,0x09,0x09,0x83,0x91,0x02,0x85,0x84,0x15,0x00,0x26,0xFF,0x00,
-  0x75,0x08,0x95,0x08,0x09,0x84,0x91,0x02,0x85,0x85,0x15,0x00,0x26,0xFF,0x00,0x75,
-  0x08,0x95,0x03,0x09,0x85,0x91,0x02,0x85,0x86,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,
-  0x95,0x03,0x09,0x86,0x91,0x02,0x85,0x87,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,
-  0x3F,0x09,0x87,0x91,0x02,0x85,0x89,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x3F,
-  0x09,0x89,0x91,0x02,0x85,0x88,0x15,0x00,0x26,0xFF,0x00,0x75,0x08,0x95,0x3F,0x09,
-  0x88,0x91,0x02,0x85,0x01,0x95,0x3F,0x09,0x01,0xB1,0x02,0x85,0x02,0x95,0x3F,0x09,
-  0x01,0xB1,0x02,0xC0,
-  0x05,0x0C,0x09,0x01,0xA1,0x01,0x85,0x03,0x15,0x00,0x25,0x01,0x09,0xE9,0x09,0xEA,
-  0x75,0x01,0x95,0x02,0x81,0x02,0x75,0x06,0x95,0x01,0x81,0x01,0xC0
+	0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x85, 0x40, 0x09, 0x01, 0xA1, 0x00,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x02, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x02, 0x81, 0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0x05, 0x01,
+	0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02,
+	0x81, 0x06, 0x95, 0x01, 0x09, 0x38, 0x81, 0x06, 0x05, 0x0C, 0x0A, 0x38,
+	0x02, 0x95, 0x01, 0x81, 0x06, 0xC0, 0xC0, 0x05, 0x01, 0x09, 0x06, 0xA1,
+	0x01, 0x85, 0x41, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x81, 0x01, 0x19, 0x00, 0x29,
+	0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, 0x95, 0x06, 0x81, 0x00, 0xC0,
+	0x06, 0x00, 0xFF, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x42, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x35, 0x09, 0x42, 0x81, 0x02, 0x85, 0x44,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x05, 0x09, 0x44, 0x81,
+	0x02, 0x85, 0x79, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x01,
+	0x09, 0x79, 0x81, 0x02, 0x85, 0x43, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
+	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
+	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
+	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
+	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
+	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
+	0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x03, 0x15, 0x00, 0x25, 0x01,
+	0x09, 0xE9, 0x09, 0xEA, 0x75, 0x01, 0x95, 0x02, 0x81, 0x02, 0x75, 0x06,
+	0x95, 0x01, 0x81, 0x01, 0xC0
 };
 
 static Adafruit_USBD_HID hid[NSLOT];
@@ -79,19 +94,32 @@ static Adafruit_USBD_HID hid[NSLOT];
 // Steam, while running, re-sends settings report 0x87 (lizard-off) every ~3s as a heartbeat (captured on HW),
 // and ANY OUTPUT report likewise stamps g_steamAliveMs. When the heartbeat stops we fall back to lizard, so the
 // controller drives desktop keyboard+mouse whenever Steam isn't running. MODE_LIZARD forces lizard always.
-static unsigned long g_steamAliveMs = 0;   // millis of last Steam OUTPUT/settings write; 0 at boot => lizard until Steam appears
+static unsigned long g_steamAliveMs =
+	0; // millis of last Steam OUTPUT/settings write; 0 at boot => lizard until Steam appears
 // Fall back to lizard this long after Steam's ~3s settings heartbeat stops. Keep >2x the cadence (7s): the
 // haptic relay is gated by !lizardActive(), so a shorter window lets a single jittered/delayed heartbeat flip
 // lizard mid-session while Steam is still running -> a haptic that arrives in that window gets gated out
 // (dropped/missing haptic). 7s tolerates one missed beat. (This matches main; do not shorten without a lizard
 // trigger that doesn't depend on the heartbeat timeout.)
 #define LIZARD_WD_MS 7000u
-static bool g_autoLizard = true;            // master switch; false => Steam mode always forwards 0x45
+static bool g_autoLizard =
+	true; // master switch; false => Steam mode always forwards 0x45
 // Single source of truth, shared by the USB input path AND the haptic relay gate: if we ever relay a 0x82 to
 // the controller while presenting lizard (Steam isn't reading 0x45 back), Steam loops the same haptic -> buzz.
-static inline bool steamDrivingGamepad(){ return g_steamAliveMs && (millis()-g_steamAliveMs < LIZARD_WD_MS); }
-static inline bool lizardActive(){ return modeIsPuck(g_usbMode) && (g_usbMode==MODE_LIZARD || (g_autoLizard && !steamDrivingGamepad())); }
-static inline void hostStampAlive(){ g_steamAliveMs = millis(); }
+static inline bool steamDrivingGamepad()
+{
+	return g_steamAliveMs && (millis() - g_steamAliveMs < LIZARD_WD_MS);
+}
+static inline bool lizardActive()
+{
+	return modeIsPuck(g_usbMode) &&
+	       (g_usbMode == MODE_LIZARD ||
+		(g_autoLizard && !steamDrivingGamepad()));
+}
+static inline void hostStampAlive()
+{
+	g_steamAliveMs = millis();
+}
 // Right after the host resumes from suspend, MUTE input forwarding briefly. Otherwise the controller's input
 // in that instant (a trackpad click/trigger, or residual button state from the wake gesture) gets forwarded
 // as a real click/keypress into the just-woken desktop -- which was activating the highlighted Start tile
@@ -101,176 +129,300 @@ static unsigned long g_resumeMs = 0;
 
 // ===================== puck feature command channel =====================
 // `slot` is the interface index (interface N == bond slot N).
-static void handleSet(int slot, uint8_t rid, hid_report_type_t type, uint8_t const *b, uint16_t n) {
-  if (type == HID_REPORT_TYPE_OUTPUT) {   // Steam OUTPUT reports 0x80-0x89. The haptic/actuator reports (0x80-0x86)
-    // are relayed to the controller, and ONLY when they arrive on the CONNECTED slot's interface. We have one
-    // controller but expose 4 puck slots, and a report aimed at a DIFFERENT slot made the controller buzz at
-    // random -> that is what the slot gate below fixes. (This used to also clamp to 0x82-ONLY, which silently
-    // dropped the ping / grip / test haptics: those ride other report IDs such as 0x85/0x86, so they never
-    // reached the controller.) The 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and are not
-    // pushed here -- 0x87 (lizard-off/settings) reaches the controller through the feature-0x01 passthrough path.
-    if (rid >= 0x80 && rid <= 0x89) {
-      hapLogAdd((uint8_t)slot, rid, b, n);   // capture ALL OUTPUT reports (even un-relayed) for the 'H' dump
-      hostStampAlive();   // ANY Steam OUTPUT report (not just the 0x87 heartbeat) means Steam is present and
-                                   // driving -> leave lizard for gamepad NOW, so a haptic that arrives before the
-                                   // first 0x87 doesn't get relayed while we're still presenting lizard (-> buzz loop).
-    }
-    // Post-resume mute also gates haptics: while onReport45 is muted Steam reads NO 0x45 back, which is the
-    // exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop). Same logic
-    // as the lizard gate; the mute is short (1.5s), so a dropped wake chime is the cheap end of the trade.
-    bool muted = g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS;
-    if (rid >= 0x80 && rid <= 0x86 && n >= 1 && hapticRelaySlotOk(slot) && !lizardActive() && !muted) {  // wrap as a SET sub-TLV like the report-01 path
-      if (!haptic82Blocked()) {
-        relayEnqueue(rid, b, (uint8_t)(n > RELAY_MAXP ? RELAY_MAXP : n));
-        // Track on/off from what was actually RELAYED (= the controller's believed state), not from every host
-        // report: a BLOCKED stop must leave "on" set (the controller may be latched -> reconnect stop-burst),
-        // and a blocked ON must not set it (nothing reached the controller -> no spurious burst clicks).
-        if (rid == 0x82) haptic82HostReport(b, n);
-      }
-    }
-    if (Serial.availableForWrite() > 80) {                      // log so we can see what Steam actually sends (e.g. glide haptics)
-      Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid, n);
-      for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
-      Serial.println();
-    }
-    return;
-  }
-  if (type != HID_REPORT_TYPE_FEATURE || n < 1) return;
-  Slot &S = g_slot[slot];
-  uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
-  const uint8_t *pl = b + 2; uint16_t pln = (n >= 2) ? n - 2 : 0;
-  if (cmd >= 0x80 && cmd <= 0x89) hostStampAlive();   // settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
-  // Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
-  // confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown()
-  // bursts it for NO-ACK reliability (the single hook the test button + host-suspend also drive).
-  if (rid == 1 && cmd == 0x9F) hapticSendShutdown();
-  if (rid == 1 && n >= 2) {   // report 0x01 = raw passthrough -> queue for RF relay to the controller
-    hapLogAdd((uint8_t)slot, cmd, b, n);   // capture EVERY relayed feature-1 command for the WebUSB capture view
-                                           // (haptics, LED SET_LED_COLOR, 0x87 settings, 0x9F power-off). The log
-                                           // shows cmd as "rid"; bytes start [cmd][len]...  Was gated to 0x80-0x89,
-                                           // which hid exactly the LED/power-off frames we now need to see.
-    bool haptic82 = (cmd == 0x82 && len <= pln);
-    bool muted = g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS;   // see the OUTPUT path: no haptics while Steam can't read 0x45 back
-    bool relayOk = hapticRelaySlotOk(slot) && !(haptic82 && (lizardActive() || muted));  // never push haptics to the controller while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
-    if (relayOk && (!haptic82 || !haptic82Blocked())) {
-      // Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's multi-register
-      // 0x87 settings blocks (LED brightness) and calibration writes are longer than the old 18B cap, and the
-      // chopped frames were why those settings never landed on the controller.
-      uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
-      if (len > RELAY_MAXP && Serial.availableForWrite() > 60)
-        Serial.printf("# RELAY TRUNC cmd=%02X len=%u>%u\n", cmd, len, (unsigned)RELAY_MAXP);
-      relayEnqueue(cmd, pl, rl);
-      if (haptic82) haptic82HostReport(pl, len);  // track from RELAYED frames only (see the OUTPUT path)
-    }
-  }
-  if (Serial.availableForWrite() > 80) {   // log host feature writes (non-blocking)
-    Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid, cmd, len);
-    for (uint16_t i = 0; i < n && i < 14; i++) Serial.printf(" %02X", b[i]);
-    Serial.println();
-  }
-  memset(S.resp, 0, sizeof S.resp); S.resp_len = 0;
-  switch (cmd) {
-    case 0x83:
-      S.resp[0] = 0x83; S.resp[1] = sizeof ATTR83; memcpy(S.resp + 2, ATTR83, sizeof ATTR83); S.resp_len = 63; break;
-    case 0xAE: {
-      uint8_t idx = pln > 0 ? pl[0] : 1; const char *s = (idx == 0) ? g_board : (idx == 1) ? g_unit : "NA";
-      S.resp[0] = 0xAE; S.resp[1] = 0x14; S.resp[2] = idx; memset(S.resp + 3, 0, 60); memcpy(S.resp + 3, s, strlen(s)); S.resp_len = 63; break; }
-    case 0xB4:    // connection/version state per slot: value 0x02 = controller connected, 0x01 = not
-      hostStampAlive();   // SDL Triton polls this on init; treat like Steam contact so we forward 0x45
-      S.resp[0] = 0xB4; S.resp[1] = 0x01;
-      S.resp[2] = (slot == g_connSlot && !g_xbox && (millis() - g_connReplyMs < 500)) ? 0x02 : 0x01;
-      S.resp_len = 63; break;
-    case 0xAD:
-      g_pairing = (pln > 0 && pl[0] != 0); Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
-      S.resp[0] = 0xAD; S.resp[1] = 0; S.resp_len = 63; break;
-    case 0xA2:                                   // write/clear THIS interface's slot
-      if (len >= 24 && pln >= 24) {
-        if (recEmpty(pl)) { S.used = false; memset(S.rec, 0, 24); }
-        else { memcpy(S.rec, pl, 24); S.used = true; }
-        g_dirty = true; Serial.printf("# slot %d %s\n", slot, recEmpty(pl) ? "cleared" : "bonded");
-      }
-      S.resp[0] = 0xA2; S.resp[1] = 0; S.resp_len = 63; break;
-    case 0xA3:                                   // read THIS interface's slot
-      S.resp[0] = 0xA3; S.resp[1] = 0x18; memset(S.resp + 2, 0, 24);
-      if (S.used) memcpy(S.resp + 2, S.rec, 24); S.resp_len = 63; break;
-    default:
-      S.resp[0] = cmd; S.resp[1] = len; if (pln) memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln); S.resp_len = 63; break;
-  }
+static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
+		      uint8_t const *b, uint16_t n)
+{
+	if (type ==
+	    HID_REPORT_TYPE_OUTPUT) { // Steam OUTPUT reports 0x80-0x89. The haptic/actuator reports (0x80-0x86)
+		// are relayed to the controller, and ONLY when they arrive on the CONNECTED slot's interface. We have one
+		// controller but expose 4 puck slots, and a report aimed at a DIFFERENT slot made the controller buzz at
+		// random -> that is what the slot gate below fixes. (This used to also clamp to 0x82-ONLY, which silently
+		// dropped the ping / grip / test haptics: those ride other report IDs such as 0x85/0x86, so they never
+		// reached the controller.) The 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and are not
+		// pushed here -- 0x87 (lizard-off/settings) reaches the controller through the feature-0x01 passthrough path.
+		if (rid >= 0x80 && rid <= 0x89) {
+			hapLogAdd(
+				(uint8_t)slot, rid, b,
+				n); // capture ALL OUTPUT reports (even un-relayed) for the 'H' dump
+			hostStampAlive(); // ANY Steam OUTPUT report (not just the 0x87 heartbeat) means Steam is present and
+				// driving -> leave lizard for gamepad NOW, so a haptic that arrives before the
+				// first 0x87 doesn't get relayed while we're still presenting lizard (-> buzz loop).
+		}
+		// Post-resume mute also gates haptics: while onReport45 is muted Steam reads NO 0x45 back, which is the
+		// exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop). Same logic
+		// as the lizard gate; the mute is short (1.5s), so a dropped wake chime is the cheap end of the trade.
+		bool muted = g_resumeMs &&
+			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
+		if (rid >= 0x80 && rid <= 0x86 && n >= 1 &&
+		    hapticRelaySlotOk(slot) && !lizardActive() &&
+		    !muted) { // wrap as a SET sub-TLV like the report-01 path
+			if (!haptic82Blocked()) {
+				relayEnqueue(rid, b,
+					     (uint8_t)(n > RELAY_MAXP ?
+							       RELAY_MAXP :
+							       n));
+				// Track on/off from what was actually RELAYED (= the controller's believed state), not from every host
+				// report: a BLOCKED stop must leave "on" set (the controller may be latched -> reconnect stop-burst),
+				// and a blocked ON must not set it (nothing reached the controller -> no spurious burst clicks).
+				if (rid == 0x82)
+					haptic82HostReport(b, n);
+			}
+		}
+		if (Serial.availableForWrite() >
+		    80) { // log so we can see what Steam actually sends (e.g. glide haptics)
+			Serial.printf("# OUT if%d rid=%02X n=%u:", slot, rid,
+				      n);
+			for (uint16_t i = 0; i < n && i < 14; i++)
+				Serial.printf(" %02X", b[i]);
+			Serial.println();
+		}
+		return;
+	}
+	if (type != HID_REPORT_TYPE_FEATURE || n < 1)
+		return;
+	Slot &S = g_slot[slot];
+	uint8_t cmd = b[0], len = (n > 1) ? b[1] : 0;
+	const uint8_t *pl = b + 2;
+	uint16_t pln = (n >= 2) ? n - 2 : 0;
+	if (cmd >= 0x80 && cmd <= 0x89)
+		hostStampAlive(); // settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
+	// Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
+	// confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown()
+	// bursts it for NO-ACK reliability (the single hook the test button + host-suspend also drive).
+	if (rid == 1 && cmd == 0x9F)
+		hapticSendShutdown();
+	if (rid == 1 &&
+	    n >= 2) { // report 0x01 = raw passthrough -> queue for RF relay to the controller
+		// capture EVERY relayed feature-1 command for the WebUSB capture view
+		// (haptics, LED SET_LED_COLOR, 0x87 settings, 0x9F power-off). The log
+		// shows cmd as "rid"; bytes start [cmd][len]...  Was gated to 0x80-0x89,
+		// which hid exactly the LED/power-off frames we now need to see.
+		hapLogAdd((uint8_t)slot, cmd, b, n);
+		bool haptic82 = (cmd == 0x82 && len <= pln);
+		bool muted =
+			g_resumeMs &&
+			millis() - g_resumeMs <
+				POST_RESUME_MUTE_MS; // see the OUTPUT path: no haptics while Steam can't read 0x45 back
+		bool relayOk =
+			hapticRelaySlotOk(slot) &&
+			!(haptic82 &&
+			  (lizardActive() ||
+			   muted)); // never push haptics to the controller while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
+		if (relayOk && (!haptic82 || !haptic82Blocked())) {
+			// Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's multi-register
+			// 0x87 settings blocks (LED brightness) and calibration writes are longer than the old 18B cap, and the
+			// chopped frames were why those settings never landed on the controller.
+			uint8_t rl = (len <= pln) ? len : (uint8_t)pln;
+			if (len > RELAY_MAXP && Serial.availableForWrite() > 60)
+				Serial.printf(
+					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
+					cmd, len, (unsigned)RELAY_MAXP);
+			relayEnqueue(cmd, pl, rl);
+			if (haptic82)
+				haptic82HostReport(
+					pl,
+					len); // track from RELAYED frames only (see the OUTPUT path)
+		}
+	}
+	if (Serial.availableForWrite() >
+	    80) { // log host feature writes (non-blocking)
+		Serial.printf("# SET if%d rid=%02X cmd=%02X len=%u:", slot, rid,
+			      cmd, len);
+		for (uint16_t i = 0; i < n && i < 14; i++)
+			Serial.printf(" %02X", b[i]);
+		Serial.println();
+	}
+	memset(S.resp, 0, sizeof S.resp);
+	S.resp_len = 0;
+	switch (cmd) {
+	case 0x83:
+		S.resp[0] = 0x83;
+		S.resp[1] = sizeof ATTR83;
+		memcpy(S.resp + 2, ATTR83, sizeof ATTR83);
+		S.resp_len = 63;
+		break;
+	case 0xAE: {
+		uint8_t idx = pln > 0 ? pl[0] : 1;
+		const char *s = (idx == 0) ? g_board :
+				(idx == 1) ? g_unit :
+					     "NA";
+		S.resp[0] = 0xAE;
+		S.resp[1] = 0x14;
+		S.resp[2] = idx;
+		memset(S.resp + 3, 0, 60);
+		memcpy(S.resp + 3, s, strlen(s));
+		S.resp_len = 63;
+		break;
+	}
+	case 0xB4: // connection/version state per slot: value 0x02 = controller connected, 0x01 = not
+		hostStampAlive(); // SDL Triton polls this on init; treat like Steam contact so we forward 0x45
+		S.resp[0] = 0xB4;
+		S.resp[1] = 0x01;
+		S.resp[2] = (slot == g_connSlot && !g_xbox &&
+			     (millis() - g_connReplyMs < 500)) ?
+				    0x02 :
+				    0x01;
+		S.resp_len = 63;
+		break;
+	case 0xAD:
+		g_pairing = (pln > 0 && pl[0] != 0);
+		Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
+		S.resp[0] = 0xAD;
+		S.resp[1] = 0;
+		S.resp_len = 63;
+		break;
+	case 0xA2: // write/clear THIS interface's slot
+		if (len >= 24 && pln >= 24) {
+			if (recEmpty(pl)) {
+				S.used = false;
+				memset(S.rec, 0, 24);
+			} else {
+				memcpy(S.rec, pl, 24);
+				S.used = true;
+			}
+			g_dirty = true;
+			Serial.printf("# slot %d %s\n", slot,
+				      recEmpty(pl) ? "cleared" : "bonded");
+		}
+		S.resp[0] = 0xA2;
+		S.resp[1] = 0;
+		S.resp_len = 63;
+		break;
+	case 0xA3: // read THIS interface's slot
+		S.resp[0] = 0xA3;
+		S.resp[1] = 0x18;
+		memset(S.resp + 2, 0, 24);
+		if (S.used)
+			memcpy(S.resp + 2, S.rec, 24);
+		S.resp_len = 63;
+		break;
+	default:
+		S.resp[0] = cmd;
+		S.resp[1] = len;
+		if (pln)
+			memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln);
+		S.resp_len = 63;
+		break;
+	}
 }
-static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type, uint8_t *buf, uint16_t reqlen) {
-  (void)rid;
-  if (type != HID_REPORT_TYPE_FEATURE) return 0;
-  if (g_usbMode == MODE_STEAM) hostStampAlive();   // host reading feature data => gamepad consumer active (Steam/SDL)
-  Slot &S = g_slot[slot];
-  uint16_t n = S.resp_len ? S.resp_len : 63; if (n > reqlen) n = reqlen;
-  memcpy(buf, S.resp, n); return n;
+static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
+			  uint8_t *buf, uint16_t reqlen)
+{
+	(void)rid;
+	if (type != HID_REPORT_TYPE_FEATURE)
+		return 0;
+	if (g_usbMode == MODE_STEAM)
+		hostStampAlive(); // host reading feature data => gamepad consumer active (Steam/SDL)
+	Slot &S = g_slot[slot];
+	uint16_t n = S.resp_len ? S.resp_len : 63;
+	if (n > reqlen)
+		n = reqlen;
+	memcpy(buf, S.resp, n);
+	return n;
 }
 
 // one callback pair per interface (the Adafruit core routes by interface to the matching object)
-#define SLOTCB(N) \
-  static void setcb##N(uint8_t r, hid_report_type_t t, uint8_t const *b, uint16_t n) { handleSet(N, r, t, b, n); } \
-  static uint16_t getcb##N(uint8_t r, hid_report_type_t t, uint8_t *bf, uint16_t rl) { return handleGet(N, r, t, bf, rl); }
-SLOTCB(0) SLOTCB(1) SLOTCB(2) SLOTCB(3)
+#define SLOTCB(N)                                                              \
+	static void setcb##N(uint8_t r, hid_report_type_t t, uint8_t const *b, \
+			     uint16_t n)                                       \
+	{                                                                      \
+		handleSet(N, r, t, b, n);                                      \
+	}                                                                      \
+	static uint16_t getcb##N(uint8_t r, hid_report_type_t t, uint8_t *bf,  \
+				 uint16_t rl)                                  \
+	{                                                                      \
+		return handleGet(N, r, t, bf, rl);                             \
+	}
+// clang-format off
+SLOTCB(0)
+SLOTCB(1)
+SLOTCB(2)
+SLOTCB(3)
+// clang-format on
 typedef uint16_t (*getcb_t)(uint8_t, hid_report_type_t, uint8_t *, uint16_t);
 typedef void (*setcb_t)(uint8_t, hid_report_type_t, uint8_t const *, uint16_t);
 static getcb_t GETCB[NSLOT] = { getcb0, getcb1, getcb2, getcb3 };
 static setcb_t SETCB[NSLOT] = { setcb0, setcb1, setcb2, setcb3 };
 
 // ===================== IController =====================
-void SteamPuckController::begin(){
-  USBDevice.setID(0x28DE, 0x1304);
-  // Distinct bcdDevice so Windows keys a FRESH usbflags entry (cache is VID:PID:bcdDevice) and actually runs
-  // MS OS 2.0 / WinUSB binding for the WebUSB vendor interface -- instead of reusing a stale "no WinUSB" entry
-  // tied to the real Steam Controller (28DE:1304), which has no WebUSB interface. The normal (wake-mouse) and
-  // one-shot debug (CDC) boots present DIFFERENT interface sets, so they use DIFFERENT bcdDevice values --
-  // otherwise Windows would serve one's cached descriptor for the other when you reboot between them.
-  USBDevice.setDeviceVersion(g_debugCdcThisBoot ? 0x0212 : (g_usbMode == MODE_LIZARD ? 0x0213 : 0x0211));
-  USBDevice.setManufacturerDescriptor("Valve Software");
-  USBDevice.setProductDescriptor("Steam Controller Puck");
-  const uint8_t* desc = (g_usbMode == MODE_LIZARD) ? PUCK_LIZARD_HID_DESC : PUCK_HID_DESC;
-  const uint16_t descLen = (g_usbMode == MODE_LIZARD) ? sizeof PUCK_LIZARD_HID_DESC : sizeof PUCK_HID_DESC;
-  for (int i = 0; i < NSLOT; i++) {
-    hid[i].setReportDescriptor(desc, descLen);
-    hid[i].setReportCallback(GETCB[i], SETCB[i]);
-    hid[i].setPollInterval(1);       // 1ms USB poll (was default 10ms = 100/s cap -> choppy)
-    hid[i].begin();
-  }
+void SteamPuckController::begin()
+{
+	USBDevice.setID(0x28DE, 0x1304);
+	// Distinct bcdDevice so Windows keys a FRESH usbflags entry (cache is VID:PID:bcdDevice) and actually runs
+	// MS OS 2.0 / WinUSB binding for the WebUSB vendor interface -- instead of reusing a stale "no WinUSB" entry
+	// tied to the real Steam Controller (28DE:1304), which has no WebUSB interface. The normal (wake-mouse) and
+	// one-shot debug (CDC) boots present DIFFERENT interface sets, so they use DIFFERENT bcdDevice values --
+	// otherwise Windows would serve one's cached descriptor for the other when you reboot between them.
+	USBDevice.setDeviceVersion(
+		g_debugCdcThisBoot ?
+			0x0212 :
+			(g_usbMode == MODE_LIZARD ? 0x0213 : 0x0211));
+	USBDevice.setManufacturerDescriptor("Valve Software");
+	USBDevice.setProductDescriptor("Steam Controller Puck");
+	const uint8_t *desc = (g_usbMode == MODE_LIZARD) ?
+				      PUCK_LIZARD_HID_DESC :
+				      PUCK_HID_DESC;
+	const uint16_t descLen = (g_usbMode == MODE_LIZARD) ?
+					 sizeof PUCK_LIZARD_HID_DESC :
+					 sizeof PUCK_HID_DESC;
+	for (int i = 0; i < NSLOT; i++) {
+		hid[i].setReportDescriptor(desc, descLen);
+		hid[i].setReportCallback(GETCB[i], SETCB[i]);
+		hid[i].setPollInterval(
+			1); // 1ms USB poll (was default 10ms = 100/s cap -> choppy)
+		hid[i].begin();
+	}
 }
 
 // Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. This is a
 // PURELY USB-SIDE decision -- it changes nothing about the RF poll or the host->controller relay.
-void SteamPuckController::onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen){
-  // Host asleep -> forward NOTHING. While suspended, every sendReport attempt (a lizard-mouse report from a
-  // trackpad graze, a 0x45 forward) can translate into a host wake -- which made the PC wake on any controller
-  // movement and nearly impossible to keep asleep. Waking is an explicit gesture only (Steam-button short press
-  // / controller connect), handled in rf_link.cpp via the device-level USB resume signal.
-  if (USBDevice.suspended()) return;
-  // Just woke? Hold off forwarding for a beat so the wake gesture's residual controller input doesn't click /
-  // type into the freshly-woken desktop (it was launching Edge from the Start menu on every wake).
-  if (g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS) return;
-  if (g_connSlot < 0 || g_connSlot >= NSLOT) return;
-  if (lizardActive()){
-    rfLizard(rep, &hid[g_connSlot], &hid[g_connSlot], 0x40, 0x41);
-  } else {
-    uint8_t blen = bodyTlen - 1; if(blen>45)blen=45;   // body after the 0x45 id byte
-    // forward the puck's raw pad coords untouched -- Steam does its own interpolation/smoothing. Forward only
-    // FRESH reports (the real puck dedupes -> Steam gets a clean unique stream; sending stale repeats makes
-    // Steam's velocity/smoothing stair-step). g_fwdNewOnly toggles for A/B.
-    if((fresh || !g_fwdNewOnly) && g_slot[g_connSlot].used && hid[g_connSlot].ready())
-      hid[g_connSlot].sendReport(0x45, rep+1, blen);   // Steam/SDL Triton: input report 0x45
-  }
+void SteamPuckController::onReport45(const uint8_t *rep, bool fresh,
+				     uint8_t bodyTlen)
+{
+	// Host asleep -> forward NOTHING. While suspended, every sendReport attempt (a lizard-mouse report from a
+	// trackpad graze, a 0x45 forward) can translate into a host wake -- which made the PC wake on any controller
+	// movement and nearly impossible to keep asleep. Waking is an explicit gesture only (Steam-button short press
+	// / controller connect), handled in rf_link.cpp via the device-level USB resume signal.
+	if (USBDevice.suspended())
+		return;
+	// Just woke? Hold off forwarding for a beat so the wake gesture's residual controller input doesn't click /
+	// type into the freshly-woken desktop (it was launching Edge from the Start menu on every wake).
+	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
+		return;
+	if (g_connSlot < 0 || g_connSlot >= NSLOT)
+		return;
+	if (lizardActive()) {
+		rfLizard(rep, &hid[g_connSlot], &hid[g_connSlot], 0x40, 0x41);
+	} else {
+		uint8_t blen = bodyTlen - 1;
+		if (blen > 45)
+			blen = 45; // body after the 0x45 id byte
+		// forward the puck's raw pad coords untouched -- Steam does its own interpolation/smoothing. Forward only
+		// FRESH reports (the real puck dedupes -> Steam gets a clean unique stream; sending stale repeats makes
+		// Steam's velocity/smoothing stair-step). g_fwdNewOnly toggles for A/B.
+		if ((fresh || !g_fwdNewOnly) && g_slot[g_connSlot].used &&
+		    hid[g_connSlot].ready())
+			hid[g_connSlot].sendReport(
+				0x45, rep + 1,
+				blen); // Steam/SDL Triton: input report 0x45
+	}
 }
 
 // Forward the controller's NON-input status reports (0x43 power/battery, 0x44) to Steam verbatim -- the real
 // puck does this and it's how Steam reads battery. OpenPuck used to drop everything but 0x45, so Steam never got
 // the power report (-> default/unknown battery). Same host-asleep / post-resume gating as 0x45; no lizard path
 // (status reports aren't input, so they forward regardless of the lizard decision).
-void SteamPuckController::onAuxReport(uint8_t rid, const uint8_t* data, uint8_t n){
-  if (USBDevice.suspended()) return;
-  if (g_resumeMs && millis()-g_resumeMs < POST_RESUME_MUTE_MS) return;
-  if (g_connSlot < 0 || g_connSlot >= NSLOT) return;
-  if (g_slot[g_connSlot].used && hid[g_connSlot].ready())
-    hid[g_connSlot].sendReport(rid, data, n);
+void SteamPuckController::onAuxReport(uint8_t rid, const uint8_t *data,
+				      uint8_t n)
+{
+	if (USBDevice.suspended())
+		return;
+	if (g_resumeMs && millis() - g_resumeMs < POST_RESUME_MUTE_MS)
+		return;
+	if (g_connSlot < 0 || g_connSlot >= NSLOT)
+		return;
+	if (g_slot[g_connSlot].used && hid[g_connSlot].ready())
+		hid[g_connSlot].sendReport(rid, data, n);
 }
 
 // ---- wake nudge: a bare USB resume signal is NOT enough to wake some hosts (Windows in particular) -- they
@@ -280,64 +432,100 @@ void SteamPuckController::onAuxReport(uint8_t rid, const uint8_t* data, uint8_t
 // also sent a click / space / Ctrl; those either activated the focused item or weren't needed. Queued by
 // wakeEvent() (rf_link, on a Steam short press / controller connect while suspended); reports can't cross a
 // suspended bus, so it's delivered once the bus has resumed.
-static uint8_t       g_nudgeStep = 0;      // 0=idle; 1=jiggle+, 2=jiggle-
+static uint8_t g_nudgeStep = 0; // 0=idle; 1=jiggle+, 2=jiggle-
 static unsigned long g_nudgeMs = 0;
 #define NUDGE_JIGGLE_PX 10
-void SteamPuckController::wakeEvent(){
-  g_nudgeStep = 1; g_nudgeMs = millis();
+void SteamPuckController::wakeEvent()
+{
+	g_nudgeStep = 1;
+	g_nudgeMs = millis();
 }
-static void wakeNudgeTask(){
-  if(!g_nudgeStep) return;
-  if(millis()-g_nudgeMs > 5000){ g_nudgeStep=0; return; }            // bus never resumed -> drop the nudge
-  if(USBDevice.suspended()) return;                                   // wait for resume; reports can't cross a suspended bus
-  if(g_connSlot<0 || g_connSlot>=NSLOT || !hid[g_connSlot].ready()) return;
-  static unsigned long stepMs=0;
-  if(millis()-stepMs < 15) return;                                    // pace the edges
-  stepMs=millis();
-  hid_mouse_report_t m; m.buttons=0; m.x=(g_nudgeStep==1)?NUDGE_JIGGLE_PX:-NUDGE_JIGGLE_PX; m.y=0; m.wheel=0; m.pan=0;
-  hid[g_connSlot].sendReport(0x40,&m,sizeof m);                       // jiggle right, then back
-  g_nudgeStep = (g_nudgeStep>=2) ? 0 : (uint8_t)(g_nudgeStep+1);
+static void wakeNudgeTask()
+{
+	if (!g_nudgeStep)
+		return;
+	if (millis() - g_nudgeMs > 5000) {
+		g_nudgeStep = 0;
+		return;
+	} // bus never resumed -> drop the nudge
+	if (USBDevice.suspended())
+		return; // wait for resume; reports can't cross a suspended bus
+	if (g_connSlot < 0 || g_connSlot >= NSLOT || !hid[g_connSlot].ready())
+		return;
+	static unsigned long stepMs = 0;
+	if (millis() - stepMs < 15)
+		return; // pace the edges
+	stepMs = millis();
+	hid_mouse_report_t m;
+	m.buttons = 0;
+	m.x = (g_nudgeStep == 1) ? NUDGE_JIGGLE_PX : -NUDGE_JIGGLE_PX;
+	m.y = 0;
+	m.wheel = 0;
+	m.pan = 0;
+	hid[g_connSlot].sendReport(0x40, &m,
+				   sizeof m); // jiggle right, then back
+	g_nudgeStep = (g_nudgeStep >= 2) ? 0 : (uint8_t)(g_nudgeStep + 1);
 }
 
 // USB connection presentation (like the real dongle): report 0x79 = connection state (01=disc, 02=conn),
 // edge-triggered, + periodic 0x7B status. Live-captured: this is what Steam reads to mark the controller
 // connected. Without it Steam shows disconnected even though 0x45 input is streaming.
-void SteamPuckController::task(){
-  wakeNudgeTask();
-  { static bool wasSusp=false; bool susp=USBDevice.suspended();   // stamp the suspended->active edge for the post-resume mute
-    if(wasSusp && !susp) g_resumeMs=millis();
-    wasSusp=susp; }
-  if (USBDevice.suspended()) return;   // no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
-  static bool usbConn=false; static unsigned long last79=0, last7B=0, connEdgeMs=0;
-  bool conn = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
-  if (g_connSlot>=0 && g_connSlot<NSLOT && hid[g_connSlot].ready()){
-    // 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
-    // settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; the old
-    // unconditional forever-resend could re-trigger Steam's connect handling (connect chime) every 750ms
-    // before Steam started consuming 0x45 -> the connect-time loop of haptic buzzes that only stopped on the
-    // first real input (the Steam button). Resending until acked still covers "Steam missed the edge".
-    bool steamAcked = g_steamAliveMs && (int32_t)(g_steamAliveMs - connEdgeMs) >= 0;
-    if (conn!=usbConn || (conn && !steamAcked && millis()-last79>=750)){
-      if (conn && !usbConn) connEdgeMs = millis();
-      uint8_t st=conn?0x02:0x01; hid[g_connSlot].sendReport(0x79,&st,1); usbConn=conn; last79=millis();
-    } else if (conn && millis()-last7B>=2000){
-      // 0x7B status, live-captured template. Byte 8 is the controller->puck signal strength as signed dBm
-      // (capture showed 0xDD = -35; replaying it verbatim is why Steam pinned -35dBm forever) -- patch in the
-      // smoothed RSSI the radio now samples on each controller reply (rf_link). 0 = no sample yet -> keep the
-      // capture value rather than report garbage.
-      //
-      // CALIBRATION: our raw RSSISAMPLE reads ~RSSI_DBM_OFFSET dB lower than the real Valve puck at the same
-      // distance (the Pro Micro's PCB-trace antenna vs Valve's tuned front-end). The 2Mbit ESB link has ~55dB
-      // of margin, so a -75dBm reading still works across a house -- but Steam's bar maps raw dBm to "weak"
-      // long before that. Adding the offset lines our close-range value up with the puck's captured -35 so the
-      // bar tracks usable range instead of antenna gain. Clamp keeps it in a sane window (no wrap/garbage).
-      uint8_t s7b[12]={0xF7,0x01,0x89,0x00,0x00,0x00,0x03,0x00,0xDD,0x00,0x3A,0x02};
-      if (g_linkRssi){
-        int mag = (int)g_linkRssi - RSSI_DBM_OFFSET;
-        if (mag < 25) mag = 25; else if (mag > 95) mag = 95;
-        s7b[8]=(uint8_t)(0u-(uint8_t)mag);
-      }
-      hid[g_connSlot].sendReport(0x7B,s7b,12); last7B=millis();
-    }
-  } else if(!conn) usbConn=false;
+void SteamPuckController::task()
+{
+	wakeNudgeTask();
+	{
+		static bool wasSusp = false;
+		bool susp =
+			USBDevice.suspended(); // stamp the suspended->active edge for the post-resume mute
+		if (wasSusp && !susp)
+			g_resumeMs = millis();
+		wasSusp = susp;
+	}
+	if (USBDevice.suspended())
+		return; // no periodic 0x79/0x7B while the host sleeps -- those sends can wake it too
+	static bool usbConn = false;
+	static unsigned long last79 = 0, last7B = 0, connEdgeMs = 0;
+	bool conn = (g_connSlot >= 0 && millis() - g_connReplyMs < 300);
+	if (g_connSlot >= 0 && g_connSlot < NSLOT && hid[g_connSlot].ready()) {
+		// 0x79 connection state: on edge, then repeated every 750ms ONLY until Steam reacts (its first OUTPUT/
+		// settings write after the edge -- g_steamAliveMs). The real puck sends 0x79 ONCE, edge-triggered; the old
+		// unconditional forever-resend could re-trigger Steam's connect handling (connect chime) every 750ms
+		// before Steam started consuming 0x45 -> the connect-time loop of haptic buzzes that only stopped on the
+		// first real input (the Steam button). Resending until acked still covers "Steam missed the edge".
+		bool steamAcked = g_steamAliveMs &&
+				  (int32_t)(g_steamAliveMs - connEdgeMs) >= 0;
+		if (conn != usbConn ||
+		    (conn && !steamAcked && millis() - last79 >= 750)) {
+			if (conn && !usbConn)
+				connEdgeMs = millis();
+			uint8_t st = conn ? 0x02 : 0x01;
+			hid[g_connSlot].sendReport(0x79, &st, 1);
+			usbConn = conn;
+			last79 = millis();
+		} else if (conn && millis() - last7B >= 2000) {
+			// 0x7B status, live-captured template. Byte 8 is the controller->puck signal strength as signed dBm
+			// (capture showed 0xDD = -35; replaying it verbatim is why Steam pinned -35dBm forever) -- patch in the
+			// smoothed RSSI the radio now samples on each controller reply (rf_link). 0 = no sample yet -> keep the
+			// capture value rather than report garbage.
+			//
+			// CALIBRATION: our raw RSSISAMPLE reads ~RSSI_DBM_OFFSET dB lower than the real Valve puck at the same
+			// distance (the Pro Micro's PCB-trace antenna vs Valve's tuned front-end). The 2Mbit ESB link has ~55dB
+			// of margin, so a -75dBm reading still works across a house -- but Steam's bar maps raw dBm to "weak"
+			// long before that. Adding the offset lines our close-range value up with the puck's captured -35 so the
+			// bar tracks usable range instead of antenna gain. Clamp keeps it in a sane window (no wrap/garbage).
+			uint8_t s7b[12] = { 0xF7, 0x01, 0x89, 0x00, 0x00, 0x00,
+					    0x03, 0x00, 0xDD, 0x00, 0x3A, 0x02 };
+			if (g_linkRssi) {
+				int mag = (int)g_linkRssi - RSSI_DBM_OFFSET;
+				if (mag < 25)
+					mag = 25;
+				else if (mag > 95)
+					mag = 95;
+				s7b[8] = (uint8_t)(0u - (uint8_t)mag);
+			}
+			hid[g_connSlot].sendReport(0x7B, s7b, 12);
+			last7B = millis();
+		}
+	} else if (!conn)
+		usbConn = false;
 }

+ 13 - 7
OpenPuck/puck_hid.h

@@ -19,12 +19,18 @@
 extern uint8_t g_fwdNewOnly;
 
 class SteamPuckController : public IController {
-public:
-  void begin() override;
-  void onReport45(const uint8_t* rep, bool fresh, uint8_t bodyTlen) override;
-  void onAuxReport(uint8_t rid, const uint8_t* data, uint8_t n) override;   // forward controller 0x43/0x44 status to Steam
-  void task() override;
-  void wakeEvent() override;   // queue the harmless post-resume wake nudge (mouse jiggle + Ctrl tap)
-  bool isPuck() const override { return true; }
+    public:
+	void begin() override;
+	void onReport45(const uint8_t *rep, bool fresh,
+			uint8_t bodyTlen) override;
+	void onAuxReport(uint8_t rid, const uint8_t *data, uint8_t n)
+		override; // forward controller 0x43/0x44 status to Steam
+	void task() override;
+	void wakeEvent()
+		override; // queue the harmless post-resume wake nudge (mouse jiggle + Ctrl tap)
+	bool isPuck() const override
+	{
+		return true;
+	}
 };
 extern SteamPuckController g_steamPuck;

+ 90 - 51
OpenPuck/radio.cpp

@@ -2,74 +2,113 @@
 
 // Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key that precedes the "ibex" string is a
 // DIFFERENT key, NOT the discovery address (confirmed via PTR_s_ibexesb_common=0x56f99 -> "ibex...").
-const uint8_t PAIR_BASE[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
-uint8_t  g_rfPrefix = 0x10;
-uint8_t  g_rfCh = 2;
-uint8_t  g_rfBase[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
+const uint8_t PAIR_BASE[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
+uint8_t g_rfPrefix = 0x10;
+uint8_t g_rfCh = 2;
+uint8_t g_rfBase[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
 // ch18 measured clean (RSSI 95/90, PER 0) on the REAL PUCK's own channel scan and is in its active hop set
 // {18,2,80}; trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
-uint8_t  g_sessCh = 18;
+uint8_t g_sessCh = 18;
 
 // pre-init to "ibex"/0x10 as a safe default; rfGenSessionAddr() overwrites with the per-device value at boot.
-uint8_t  g_sessBase[4] = {0x69, 0x62, 0x65, 0x78};
-uint8_t  g_sessPrefix = 0x10;
+uint8_t g_sessBase[4] = { 0x69, 0x62, 0x65, 0x78 };
+uint8_t g_sessPrefix = 0x10;
 
-void rfGenSessionAddr(){
-  // Mix the 64-bit FICR DEVICEID into a 4-byte base + a prefix. Deterministic per chip (stable across reboots,
-  // debuggable) and unique across chips (DEVICEID is unique) -> two OpenPucks never collide on the connected
-  // session. Avoid degenerate address bytes (0x00/0xFF) for clean preamble correlation, and never reproduce
-  // the shared discovery base "ibex" (else the isolation is lost).
-  uint32_t h  = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
-  uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^ NRF_FICR->DEVICEID[0];
-  for(int i=0;i<4;i++){ uint8_t b=(uint8_t)(h>>(i*8)); if(b==0x00||b==0xFF) b^=0x5A; g_sessBase[i]=b; }
-  uint8_t p=(uint8_t)(h2>>16); if(p==0x00||p==0xFF) p=0x5C; g_sessPrefix=p;
-  if(g_sessBase[0]==g_rfBase[0] && g_sessBase[1]==g_rfBase[1] &&
-     g_sessBase[2]==g_rfBase[2] && g_sessBase[3]==g_rfBase[3]) g_sessBase[0]^=0x80;   // collided with "ibex"
+void rfGenSessionAddr()
+{
+	// Mix the 64-bit FICR DEVICEID into a 4-byte base + a prefix. Deterministic per chip (stable across reboots,
+	// debuggable) and unique across chips (DEVICEID is unique) -> two OpenPucks never collide on the connected
+	// session. Avoid degenerate address bytes (0x00/0xFF) for clean preamble correlation, and never reproduce
+	// the shared discovery base "ibex" (else the isolation is lost).
+	uint32_t h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^
+		     NRF_FICR->DEVICEID[1];
+	uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^
+		      NRF_FICR->DEVICEID[0];
+	for (int i = 0; i < 4; i++) {
+		uint8_t b = (uint8_t)(h >> (i * 8));
+		if (b == 0x00 || b == 0xFF)
+			b ^= 0x5A;
+		g_sessBase[i] = b;
+	}
+	uint8_t p = (uint8_t)(h2 >> 16);
+	if (p == 0x00 || p == 0xFF)
+		p = 0x5C;
+	g_sessPrefix = p;
+	if (g_sessBase[0] == g_rfBase[0] && g_sessBase[1] == g_rfBase[1] &&
+	    g_sessBase[2] == g_rfBase[2] && g_sessBase[3] == g_rfBase[3])
+		g_sessBase[0] ^= 0x80; // collided with "ibex"
 }
 
-uint8_t  rfrx[100], rftx[100];
+uint8_t rfrx[100], rftx[100];
 uint32_t g_rfRxCount = 0;
 
 // HYBRID puck-link config (firmware-derived, definitive). DECODED from real-puck capture: puck TX = Ble_2Mbit,
 // ENDIAN=Big (MSB-first), NO whitening, addr "ibex", CRC16 0x11021/0xFFFF; frame = FUN_00027e9a (static length).
 uint32_t g_crcinit = 0xFFFF;
-uint8_t  g_whiteiv = 37;
-uint8_t  g_mode    = RADIO_MODE_MODE_Ble_2Mbit;   // <-- was the bug (1Mbit). Real puck = 2Mbit.
-uint32_t g_pcnf0   = 0x00030008UL;                // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
-uint8_t  g_statlen = 0x20;
-uint32_t g_pcnf1   = 0x01040060;                  // ENDIAN=Big, BALEN4, MAXLEN=96 (was 64 -> truncated the controller's
-                                                  // 66-byte 0x43-battery-augmented F1 -> CRC-fail -> battery never arrived)
+uint8_t g_whiteiv = 37;
+uint8_t g_mode =
+	RADIO_MODE_MODE_Ble_2Mbit; // <-- was the bug (1Mbit). Real puck = 2Mbit.
+uint32_t g_pcnf0 =
+	0x00030008UL; // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+uint8_t g_statlen = 0x20;
+// ENDIAN=Big, BALEN4, MAXLEN=96 (was 64 -> truncated the controller's
+// 66-byte 0x43-battery-augmented F1 -> CRC-fail -> battery never arrived)
+uint32_t g_pcnf1 = 0x01040060;
 uint32_t g_crcpoly = 0x11021UL;
-uint16_t g_crccnf  = 0x2;                          // CRC16, address included
-uint8_t  g_pid     = 0;
-uint8_t  g_balen   = 4;                            // ESB 5-byte addr
-bool     g_prefixRaw = false;
+uint16_t g_crccnf = 0x2; // CRC16, address included
+uint8_t g_pid = 0;
+uint8_t g_balen = 4; // ESB 5-byte addr
+bool g_prefixRaw = false;
 
-uint8_t rfBitrev8(uint8_t x){ uint8_t y=0; for(int i=0;i<8;i++){y=(y<<1)|(x&1);x>>=1;} return y; }
+uint8_t rfBitrev8(uint8_t x)
+{
+	uint8_t y = 0;
+	for (int i = 0; i < 8; i++) {
+		y = (y << 1) | (x & 1);
+		x >>= 1;
+	}
+	return y;
+}
 
-void rfSetAddr(const uint8_t b4[4], uint8_t prefix){
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
-  NRF_RADIO->BASE0   = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
-  NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 1u<<0;
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix)
+{
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(b4[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1u << 0;
 }
 
-void rfConfig(uint8_t ch){
-  NRF_RADIO->TASKS_DISABLE = 1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED = 0;
-  NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);   // Ble_2Mbit (tunable 'M')
-  NRF_RADIO->FREQUENCY = ch;
-  NRF_RADIO->TXPOWER   = (RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+void rfConfig(uint8_t ch)
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = ((uint32_t)g_mode
+			   << RADIO_MODE_MODE_Pos); // Ble_2Mbit (tunable 'M')
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->TXPOWER =
+		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
 #if defined(RADIO_MODECNF0_RU_Fast)
-  NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
 #endif
-  NRF_RADIO->PCNF0 = g_pcnf0;       // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
-  NRF_RADIO->PCNF1 = g_pcnf1 ? g_pcnf1 :
-        ((1u<<RADIO_PCNF1_ENDIAN_Pos)              // ENDIAN=Big (puck transmits MSB-first)
-        |((uint32_t)g_balen<<RADIO_PCNF1_BALEN_Pos)
-        |((uint32_t)g_statlen<<RADIO_PCNF1_STATLEN_Pos)
-        |((uint32_t)g_statlen<<RADIO_PCNF1_MAXLEN_Pos));   // WHITEEN=0
-  NRF_RADIO->CRCCNF = g_crccnf;     // CRC16, address included
-  NRF_RADIO->CRCPOLY = g_crcpoly; NRF_RADIO->CRCINIT = g_crcinit;
-  NRF_RADIO->DATAWHITEIV = g_whiteiv;
-  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->PCNF0 =
+		g_pcnf0; // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
+	NRF_RADIO->PCNF1 =
+		g_pcnf1 ?
+			g_pcnf1 :
+			((1u
+			  << RADIO_PCNF1_ENDIAN_Pos) // ENDIAN=Big (puck transmits MSB-first)
+			 | ((uint32_t)g_balen << RADIO_PCNF1_BALEN_Pos) |
+			 ((uint32_t)g_statlen << RADIO_PCNF1_STATLEN_Pos) |
+			 ((uint32_t)g_statlen
+			  << RADIO_PCNF1_MAXLEN_Pos)); // WHITEEN=0
+	NRF_RADIO->CRCCNF = g_crccnf; // CRC16, address included
+	NRF_RADIO->CRCPOLY = g_crcpoly;
+	NRF_RADIO->CRCINIT = g_crcinit;
+	NRF_RADIO->DATAWHITEIV = g_whiteiv;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
 }

+ 27 - 18
OpenPuck/radio.h

@@ -14,39 +14,48 @@
 // Bounded radio-disable wait: NEVER spin forever. A wedged RADIO peripheral would otherwise hang the whole
 // main loop (and USB stops being serviced -> device "dies" until replug). On timeout we bail and continue;
 // the next rfConfig re-inits the radio.
-#define RWAIT_DISABLED() do{ uint32_t _w=micros(); while(!NRF_RADIO->EVENTS_DISABLED && (uint32_t)(micros()-_w)<3000){} }while(0)
+#define RWAIT_DISABLED()                                   \
+	do {                                               \
+		uint32_t _w = micros();                    \
+		while (!NRF_RADIO->EVENTS_DISABLED &&      \
+		       (uint32_t)(micros() - _w) < 3000) { \
+		}                                          \
+	} while (0)
 
 // Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
 //   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
-extern const uint8_t PAIR_BASE[4];   // "ibex"
-extern uint8_t  g_rfPrefix;          // discovery prefix (rodata byte @0x56f98)
-extern uint8_t  g_rfCh;              // current TX/RX channel (hopped during beacon/poll)
-extern uint8_t  g_rfBase[4];         // "ibex"
-extern uint8_t  g_sessCh;            // connected-session channel: a CLEAN data channel off the congested adv ch2
+extern const uint8_t PAIR_BASE[4]; // "ibex"
+extern uint8_t g_rfPrefix; // discovery prefix (rodata byte @0x56f98)
+extern uint8_t g_rfCh; // current TX/RX channel (hopped during beacon/poll)
+extern uint8_t g_rfBase[4]; // "ibex"
+extern uint8_t
+	g_sessCh; // connected-session channel: a CLEAN data channel off the congested adv ch2
 
 // Per-device SESSION address (base+prefix). Discovery/rendezvous stays on the SHARED "ibex" address (g_rfBase)
 // so any controller can find us; the host frame then advertises THIS unique address and the controller adopts
 // it for the connected session. Two OpenPucks therefore never share an on-air session address -> no crosstalk,
 // no spurious cross-wake. Derived from the FICR DEVICEID (stable per chip, unique across chips).
-extern uint8_t  g_sessBase[4];
-extern uint8_t  g_sessPrefix;
+extern uint8_t g_sessBase[4];
+extern uint8_t g_sessPrefix;
 void rfGenSessionAddr();
 
-extern uint8_t  rfrx[100], rftx[100];  // RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
+extern uint8_t rfrx[100],
+	rftx[100]; // RADIO DMA buffers (>= MAXLEN+2; the controller's 0x43-augmented F1 is ~66B)
 extern uint32_t g_rfRxCount;
 
 // ---- tunable radio parameters (CDC console M/0/2/i/w/I/P/N/T/A) ----
 extern uint32_t g_crcinit;
-extern uint8_t  g_whiteiv;
-extern uint8_t  g_mode;     // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
-extern uint32_t g_pcnf0;    // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
-extern uint8_t  g_statlen;  // static-mode payload length
-extern uint32_t g_pcnf1;    // ENDIAN=Big, BALEN4, MAXLEN64 -- CRC-VALIDATED
+extern uint8_t g_whiteiv;
+extern uint8_t g_mode; // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
+extern uint32_t g_pcnf0; // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
+extern uint8_t g_statlen; // static-mode payload length
+extern uint32_t g_pcnf1; // ENDIAN=Big, BALEN4, MAXLEN64 -- CRC-VALIDATED
 extern uint32_t g_crcpoly;
-extern uint16_t g_crccnf;   // CRC16, address included
-extern uint8_t  g_pid;
-extern uint8_t  g_balen;    // ESB 5-byte addr
-extern bool     g_prefixRaw;// false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+extern uint16_t g_crccnf; // CRC16, address included
+extern uint8_t g_pid;
+extern uint8_t g_balen; // ESB 5-byte addr
+extern bool
+	g_prefixRaw; // false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
 
 uint8_t rfBitrev8(uint8_t x);
 // Access address (IBEX FUN_00037530): BASE0 = bitrev8 each base byte packed big-endian; PREFIX0 = raw or

+ 481 - 242
OpenPuck/rf_diag.cpp

@@ -4,304 +4,543 @@
 #include <Arduino.h>
 #include <string.h>
 
-bool    g_rfListen = false;
-bool    g_rfBeacon = false;
+bool g_rfListen = false;
+bool g_rfBeacon = false;
 uint8_t g_plen = 0x18, g_s1incl = 0;
-bool    g_rfRaw = false;
-bool    g_rfSweep = false;
-bool    g_rfCap = false, g_rfCapOne = false, g_rfReplay = false;
+bool g_rfRaw = false;
+bool g_rfSweep = false;
+bool g_rfCap = false, g_rfCapOne = false, g_rfReplay = false;
 uint8_t g_replayLen = 0;
-bool    g_rfAuto = false;
+bool g_rfAuto = false;
 uint8_t g_capV = 0, g_cfgIdx = 0;
-bool    g_rfRespond = false;
-bool    g_sniff = false;
+bool g_rfRespond = false;
+bool g_sniff = false;
 uint8_t g_sniffPh = 0, g_sniffPark = 0;
 
-static uint8_t  g_seq = 0;
+static uint8_t g_seq = 0;
 static unsigned long g_lastBeacon = 0, g_lastHop = 0, g_lastCfg = 0;
-static uint8_t  g_replay[48];
+static uint8_t g_replay[48];
 static uint32_t g_capPass = 0;
 static uint32_t g_advCount = 0;
-static uint8_t  g_hostTx[48]; static int g_hostLen=0;
-static uint32_t g_sniffN=0;
-static uint8_t  g_sbase[4]={0,0,0,0}, g_sprefix=0; static uint8_t g_schan[3]={78,2,80}; static uint8_t g_schi=0;
+static uint8_t g_hostTx[48];
+static int g_hostLen = 0;
+static uint32_t g_sniffN = 0;
+static uint8_t g_sbase[4] = { 0, 0, 0, 0 }, g_sprefix = 0;
+static uint8_t g_schan[3] = { 78, 2, 80 };
+static uint8_t g_schi = 0;
 
 // ===================== promiscuous raw capture (calibration only) =====================
 // preamble-match on 0x55 bytes, fixed-length grab, no CRC. Catches any 2Mbit packet on a channel so we can
 // read the controller's reconnect addr/prefix/framing.
 #define RAWCAP 48
-void rfRawStart(uint8_t ch){
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->MODE=(RADIO_MODE_MODE_Nrf_2Mbit<<RADIO_MODE_MODE_Pos);
+void rfRawStart(uint8_t ch)
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = (RADIO_MODE_MODE_Nrf_2Mbit << RADIO_MODE_MODE_Pos);
 #if defined(RADIO_MODECNF0_RU_Fast)
-  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
 #endif
-  NRF_RADIO->FREQUENCY=ch; NRF_RADIO->TXPOWER=(RADIO_TXPOWER_TXPOWER_0dBm<<RADIO_TXPOWER_TXPOWER_Pos);
-  NRF_RADIO->CRCCNF=0; NRF_RADIO->PCNF0=0;
-  NRF_RADIO->PCNF1=(RADIO_PCNF1_WHITEEN_Disabled<<RADIO_PCNF1_WHITEEN_Pos)
-                 |(RADIO_PCNF1_ENDIAN_Big<<RADIO_PCNF1_ENDIAN_Pos)
-                 |(2u<<RADIO_PCNF1_BALEN_Pos)|(RAWCAP<<RADIO_PCNF1_STATLEN_Pos)|(RAWCAP<<RADIO_PCNF1_MAXLEN_Pos);
-  NRF_RADIO->BASE0=0x55555555; NRF_RADIO->PREFIX0=0x00000055;
-  NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1; NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->TXPOWER =
+		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+	NRF_RADIO->CRCCNF = 0;
+	NRF_RADIO->PCNF0 = 0;
+	NRF_RADIO->PCNF1 =
+		(RADIO_PCNF1_WHITEEN_Disabled << RADIO_PCNF1_WHITEEN_Pos) |
+		(RADIO_PCNF1_ENDIAN_Big << RADIO_PCNF1_ENDIAN_Pos) |
+		(2u << RADIO_PCNF1_BALEN_Pos) |
+		(RAWCAP << RADIO_PCNF1_STATLEN_Pos) |
+		(RAWCAP << RADIO_PCNF1_MAXLEN_Pos);
+	NRF_RADIO->BASE0 = 0x55555555;
+	NRF_RADIO->PREFIX0 = 0x00000055;
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
 }
 
 // ===================== CRC-validating framing sweep =====================
 // Data looked bit-reversed (not whitened). Firmware-correct: ESB DPL (S0L0, LFLEN6or8, S1LEN3), BALEN4
 // (5-byte addr), CRC8/16 addr-included. Sweep PCNF0{LFLEN6,8} x CRC{8,16} x BALEN{4,3} x ENDIAN{Big,Lit}.
-static const uint32_t CAPP0B[]={0x00030006,0x00030008};  // S1LEN3 + LFLEN6 / LFLEN8 (dynamic length)
-static const uint32_t CAPCRCB[][3]={{0x1,0x107,0xFF},{0x2,0x11021,0xFFFF}};
-void rfCapStart(uint8_t ch){
-  uint8_t ci=g_capV&1;
-  uint8_t balen=((g_capV>>1)&1)?3:4;            // BALEN 4 first
-  uint8_t endbig=((g_capV>>2)&1)^1;             // ENDIAN Big first
-  uint32_t p0=CAPP0B[(g_capV>>3)&1];
-  const uint32_t* cr=CAPCRCB[ci];
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->MODE=(RADIO_MODE_MODE_Ble_2Mbit<<RADIO_MODE_MODE_Pos);
+static const uint32_t CAPP0B[] = {
+	0x00030006, 0x00030008
+}; // S1LEN3 + LFLEN6 / LFLEN8 (dynamic length)
+static const uint32_t CAPCRCB[][3] = { { 0x1, 0x107, 0xFF },
+				       { 0x2, 0x11021, 0xFFFF } };
+void rfCapStart(uint8_t ch)
+{
+	uint8_t ci = g_capV & 1;
+	uint8_t balen = ((g_capV >> 1) & 1) ? 3 : 4; // BALEN 4 first
+	uint8_t endbig = ((g_capV >> 2) & 1) ^ 1; // ENDIAN Big first
+	uint32_t p0 = CAPP0B[(g_capV >> 3) & 1];
+	const uint32_t *cr = CAPCRCB[ci];
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = (RADIO_MODE_MODE_Ble_2Mbit << RADIO_MODE_MODE_Pos);
 #if defined(RADIO_MODECNF0_RU_Fast)
-  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
 #endif
-  NRF_RADIO->FREQUENCY=ch;
-  NRF_RADIO->PCNF0=p0;
-  NRF_RADIO->PCNF1=((uint32_t)endbig<<RADIO_PCNF1_ENDIAN_Pos)|((uint32_t)balen<<RADIO_PCNF1_BALEN_Pos)
-                  |(60u<<RADIO_PCNF1_MAXLEN_Pos);
-  NRF_RADIO->CRCCNF=cr[0]; NRF_RADIO->CRCPOLY=cr[1]; NRF_RADIO->CRCINIT=cr[2];
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(g_rfBase[i]);
-  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->PREFIX0=rfBitrev8(g_rfPrefix); NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1;
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; memset(rfrx,0,8);
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->PCNF0 = p0;
+	NRF_RADIO->PCNF1 = ((uint32_t)endbig << RADIO_PCNF1_ENDIAN_Pos) |
+			   ((uint32_t)balen << RADIO_PCNF1_BALEN_Pos) |
+			   (60u << RADIO_PCNF1_MAXLEN_Pos);
+	NRF_RADIO->CRCCNF = cr[0];
+	NRF_RADIO->CRCPOLY = cr[1];
+	NRF_RADIO->CRCINIT = cr[2];
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(g_rfBase[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = rfBitrev8(g_rfPrefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	memset(rfrx, 0, 8);
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
 }
 // REPLAY: capture one real-puck frame (raw, ENDIAN=Little) then re-transmit it VERBATIM (same bits, addr, ch)
 // to impersonate the puck bit-for-bit -- sidesteps unknown framing/CRC. Controller must be bonded to the REAL
 // puck (replayed frame carries real-puck uuids + its valid CRC).
-static void rfCapPoll(){
-  if(!g_rfCap) return;
-  if(NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
-    g_capPass++;
-    if(g_rfCapOne){ memcpy(g_replay,rfrx,sizeof g_replay); g_replayLen=32; g_rfCapOne=false; g_rfCap=false;
-      Serial.printf("# captured replay frame (%uB): ",g_replayLen);
-      for(int i=0;i<g_replayLen;i++)Serial.printf("%02X",g_replay[i]); Serial.println(); return; }
-    if(NRF_RADIO->CRCSTATUS&1){
-      uint8_t ci=g_capV&1; uint8_t balen=((g_capV>>1)&1)?3:4; uint8_t eb=((g_capV>>2)&1)^1;
-      uint32_t p0=CAPP0B[(g_capV>>3)&1];
-      Serial.printf("@@@ CRCOK PCNF0=%lX BALEN=%u ENDIAN=%s POLY=%lX: ",(unsigned long)p0,balen,
-        eb?"Big":"Lit",(unsigned long)CAPCRCB[ci][1]);
-      for(int i=0;i<28;i++) Serial.printf("%02X",rfrx[i]); Serial.println();
-    }
-  }
+static void rfCapPoll()
+{
+	if (!g_rfCap)
+		return;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		g_capPass++;
+		if (g_rfCapOne) {
+			memcpy(g_replay, rfrx, sizeof g_replay);
+			g_replayLen = 32;
+			g_rfCapOne = false;
+			g_rfCap = false;
+			Serial.printf("# captured replay frame (%uB): ",
+				      g_replayLen);
+			for (int i = 0; i < g_replayLen; i++)
+				Serial.printf("%02X", g_replay[i]);
+			Serial.println();
+			return;
+		}
+		if (NRF_RADIO->CRCSTATUS & 1) {
+			uint8_t ci = g_capV & 1;
+			uint8_t balen = ((g_capV >> 1) & 1) ? 3 : 4;
+			uint8_t eb = ((g_capV >> 2) & 1) ^ 1;
+			uint32_t p0 = CAPP0B[(g_capV >> 3) & 1];
+			Serial.printf(
+				"@@@ CRCOK PCNF0=%lX BALEN=%u ENDIAN=%s POLY=%lX: ",
+				(unsigned long)p0, balen, eb ? "Big" : "Lit",
+				(unsigned long)CAPCRCB[ci][1]);
+			for (int i = 0; i < 28; i++)
+				Serial.printf("%02X", rfrx[i]);
+			Serial.println();
+		}
+	}
 }
-void rfReplayOnce(){
-  if(!g_replayLen) return;
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->MODE=(RADIO_MODE_MODE_Ble_2Mbit<<RADIO_MODE_MODE_Pos);
+void rfReplayOnce()
+{
+	if (!g_replayLen)
+		return;
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = (RADIO_MODE_MODE_Ble_2Mbit << RADIO_MODE_MODE_Pos);
 #if defined(RADIO_MODECNF0_RU_Fast)
-  NRF_RADIO->MODECNF0=(RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
 #endif
-  NRF_RADIO->FREQUENCY=2;                                          // ch2
-  NRF_RADIO->PCNF0=0;                                              // static, ENDIAN=Little -> reproduce on-air bits
-  NRF_RADIO->PCNF1=(3u<<RADIO_PCNF1_BALEN_Pos)|((uint32_t)g_replayLen<<RADIO_PCNF1_STATLEN_Pos)|((uint32_t)g_replayLen<<RADIO_PCNF1_MAXLEN_Pos);
-  NRF_RADIO->CRCCNF=0;                                            // CRC already inside the captured bytes
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(g_rfBase[i]);
-  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->PREFIX0=rfBitrev8(g_rfPrefix); NRF_RADIO->TXADDRESS=0;
-  NRF_RADIO->PACKETPTR=(uint32_t)g_replay;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
-  NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
-  RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+	NRF_RADIO->FREQUENCY = 2; // ch2
+	NRF_RADIO->PCNF0 = 0; // static, ENDIAN=Little -> reproduce on-air bits
+	NRF_RADIO->PCNF1 = (3u << RADIO_PCNF1_BALEN_Pos) |
+			   ((uint32_t)g_replayLen << RADIO_PCNF1_STATLEN_Pos) |
+			   ((uint32_t)g_replayLen << RADIO_PCNF1_MAXLEN_Pos);
+	NRF_RADIO->CRCCNF = 0; // CRC already inside the captured bytes
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(g_rfBase[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = rfBitrev8(g_rfPrefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)g_replay;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
 }
 
 // ===================== address/CRC listen =====================
-void rfListenStart(){
-  rfConfig(g_rfCh); rfSetAddr(g_rfBase, g_rfPrefix);
-  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
-  NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END = 0; NRF_RADIO->TASKS_RXEN = 1;
-  g_rfListen = true;
-  Serial.printf("# RF listen ch%u base %02X%02X%02X%02X prefix %02X\n",
-                g_rfCh, g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3], g_rfPrefix);
+void rfListenStart()
+{
+	rfConfig(g_rfCh);
+	rfSetAddr(g_rfBase, g_rfPrefix);
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	g_rfListen = true;
+	Serial.printf("# RF listen ch%u base %02X%02X%02X%02X prefix %02X\n",
+		      g_rfCh, g_rfBase[0], g_rfBase[1], g_rfBase[2],
+		      g_rfBase[3], g_rfPrefix);
 }
-static void rfPoll(){
-  if (g_rfRaw){
-    if (NRF_RADIO->EVENTS_END){
-      NRF_RADIO->EVENTS_END = 0;
-      // filter noise: require some non-0x55/0xAA/0x00 structure
-      int nz=0; for(int i=0;i<RAWCAP;i++){uint8_t b=rfrx[i]; if(b&&b!=0x55&&b!=0xAA&&b!=0xFF)nz++;}
-      if (nz>=6){
-        g_rfRxCount++;
-        Serial.printf("RAW#%lu ch%u: ", (unsigned long)g_rfRxCount, g_rfCh);
-        for (int i=0;i<RAWCAP;i++) Serial.printf("%02X", rfrx[i]);
-        Serial.println();
-      }
-    }
-    return;
-  }
-  if (!g_rfListen) return;
-  if (NRF_RADIO->EVENTS_END){
-    NRF_RADIO->EVENTS_END = 0;
-    bool crcok = NRF_RADIO->CRCSTATUS & 1;
-    uint8_t len = rfrx[0];
-    if (len && len < 64){
-      g_rfRxCount++;
-      Serial.printf("RF#%lu ch%u crc%d len%u: ", (unsigned long)g_rfRxCount, g_rfCh, crcok, len);
-      for (uint8_t i=0;i<=len && i<40;i++) Serial.printf("%02X ", rfrx[i]);
-      Serial.println();
-    }
-  }
+static void rfPoll()
+{
+	if (g_rfRaw) {
+		if (NRF_RADIO->EVENTS_END) {
+			NRF_RADIO->EVENTS_END = 0;
+			// filter noise: require some non-0x55/0xAA/0x00 structure
+			int nz = 0;
+			for (int i = 0; i < RAWCAP; i++) {
+				uint8_t b = rfrx[i];
+				if (b && b != 0x55 && b != 0xAA && b != 0xFF)
+					nz++;
+			}
+			if (nz >= 6) {
+				g_rfRxCount++;
+				Serial.printf("RAW#%lu ch%u: ",
+					      (unsigned long)g_rfRxCount,
+					      g_rfCh);
+				for (int i = 0; i < RAWCAP; i++)
+					Serial.printf("%02X", rfrx[i]);
+				Serial.println();
+			}
+		}
+		return;
+	}
+	if (!g_rfListen)
+		return;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t len = rfrx[0];
+		if (len && len < 64) {
+			g_rfRxCount++;
+			Serial.printf("RF#%lu ch%u crc%d len%u: ",
+				      (unsigned long)g_rfRxCount, g_rfCh, crcok,
+				      len);
+			for (uint8_t i = 0; i <= len && i < 40; i++)
+				Serial.printf("%02X ", rfrx[i]);
+			Serial.println();
+		}
+	}
 }
 
 // ===================== autosweep candidate radio configs =====================
 // Cycle candidate radio configs while beaconing, so ONE controller search window covers the space. S0LEN=1 /
 // CRC16 0x11021 / addr "ibex" held fixed; sweep the residual unknowns (PHY, whitening, BALEN, prefix).
-struct RfCfg { uint8_t mode; uint32_t pcnf0, pcnf1; uint8_t whiteiv; uint16_t crccnf; bool prefRaw; const char* tag; };
+struct RfCfg {
+	uint8_t mode;
+	uint32_t pcnf0, pcnf1;
+	uint8_t whiteiv;
+	uint16_t crccnf;
+	bool prefRaw;
+	const char *tag;
+};
 static const RfCfg SWEEP[] = {
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, false, "1M BALEN4 whiv37 pfxRev"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0,  0x2, false, "1M BALEN4 whOFF pfxRev"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, true,  "1M BALEN4 whiv37 pfxRAW"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0,  0x2, true,  "1M BALEN4 whOFF pfxRAW"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02030040, 37, 0x2, false, "1M BALEN3 whiv37 pfxRev"},
-  {RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00030040, 0,  0x2, true,  "1M BALEN3 whOFF pfxRAW"},
-  {RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x02040040, 37, 0x2, false, "2M BALEN4 whiv37 pfxRev"},
-  {RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x00040040, 0,  0x2, true,  "2M BALEN4 whOFF pfxRAW"},
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, false,
+	  "1M BALEN4 whiv37 pfxRev" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0, 0x2, false,
+	  "1M BALEN4 whOFF pfxRev" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02040040, 37, 0x2, true,
+	  "1M BALEN4 whiv37 pfxRAW" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00040040, 0, 0x2, true,
+	  "1M BALEN4 whOFF pfxRAW" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x02030040, 37, 0x2, false,
+	  "1M BALEN3 whiv37 pfxRev" },
+	{ RADIO_MODE_MODE_Ble_1Mbit, 0x00100108, 0x00030040, 0, 0x2, true,
+	  "1M BALEN3 whOFF pfxRAW" },
+	{ RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x02040040, 37, 0x2, false,
+	  "2M BALEN4 whiv37 pfxRev" },
+	{ RADIO_MODE_MODE_Ble_2Mbit, 0x00100108, 0x00040040, 0, 0x2, true,
+	  "2M BALEN4 whOFF pfxRAW" },
 };
-void applyCfg(uint8_t i){
-  const RfCfg &c = SWEEP[i];
-  g_mode=c.mode; g_pcnf0=c.pcnf0; g_pcnf1=c.pcnf1; g_whiteiv=c.whiteiv; g_crccnf=c.crccnf; g_prefixRaw=c.prefRaw;
-  Serial.printf("# cfg[%u] %s\n", i, c.tag);
+void applyCfg(uint8_t i)
+{
+	const RfCfg &c = SWEEP[i];
+	g_mode = c.mode;
+	g_pcnf0 = c.pcnf0;
+	g_pcnf1 = c.pcnf1;
+	g_whiteiv = c.whiteiv;
+	g_crccnf = c.crccnf;
+	g_prefixRaw = c.prefRaw;
+	Serial.printf("# cfg[%u] %s\n", i, c.tag);
 }
-#define SWEEP_N (sizeof SWEEP/sizeof SWEEP[0])
+#define SWEEP_N (sizeof SWEEP / sizeof SWEEP[0])
 
 // ===================== dongle beacon experiment =====================
 // Dongle is PTX: TX a beacon [01][seq][..][E2@5] then RX the controller's response (0xF0...).
-void rfBeaconOnce(){
-  uint8_t pl[48]; memset(pl,0,sizeof pl);
-  pl[0]=0x01; pl[1]=g_seq++; pl[5]=0xE2;                 // dongle beacon (FUN_00027ed8 format)
-  rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix);
-  if (g_s1incl) NRF_RADIO->PCNF0 |= (1u<<RADIO_PCNF0_S1INCL_Pos);
-  // RAM: [LENGTH][ (S1 if S1INCL) ][payload]
-  rftx[0]=0; rftx[1]=g_plen; memcpy(rftx+2,pl,g_plen);
-  NRF_RADIO->PACKETPTR=(uint32_t)rftx;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
-  NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
-  RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  // RX window for the controller's response
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<500){}
-  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
-    bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
-    if (len && len<64){ g_rfRxCount++;
-      Serial.printf("RESP#%lu crc%d len%u: ",(unsigned long)g_rfRxCount,crcok,len);
-      for(uint8_t i=0;i<=len&&i<40;i++)Serial.printf("%02X ",rfrx[i]); Serial.println();
-    }
-  }
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+void rfBeaconOnce()
+{
+	uint8_t pl[48];
+	memset(pl, 0, sizeof pl);
+	pl[0] = 0x01;
+	pl[1] = g_seq++;
+	pl[5] = 0xE2; // dongle beacon (FUN_00027ed8 format)
+	rfConfig(g_rfCh);
+	rfSetAddr(g_rfBase, g_rfPrefix);
+	if (g_s1incl)
+		NRF_RADIO->PCNF0 |= (1u << RADIO_PCNF0_S1INCL_Pos);
+	// RAM: [LENGTH][ (S1 if S1INCL) ][payload]
+	rftx[0] = 0;
+	rftx[1] = g_plen;
+	memcpy(rftx + 2, pl, g_plen);
+	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	// RX window for the controller's response
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	rfrx[0] = 0;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	uint32_t t0 = micros();
+	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < 500) {
+	}
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t len = rfrx[0];
+		if (len && len < 64) {
+			g_rfRxCount++;
+			Serial.printf("RESP#%lu crc%d len%u: ",
+				      (unsigned long)g_rfRxCount, crcok, len);
+			for (uint8_t i = 0; i <= len && i < 40; i++)
+				Serial.printf("%02X ", rfrx[i]);
+			Serial.println();
+		}
+	}
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
 }
 
 // ===================== scan-then-respond (dongle role) =====================
 // Listen on ch2 for the controller's advertisement, and the instant one arrives, TX the host frame (its ack).
-static void buildHostTx(){
-  int slot=-1; for(int i=0;i<NSLOT;i++) if(g_slot[i].used){slot=i;break;}
-  memset(g_hostTx,0,sizeof g_hostTx); g_hostLen=0; if(slot<0) return;
-  // S0LEN=1 RAM layout: [0]=S0=0x12, [1]=LENGTH, raw RAM offsets validated by controller.
-  memset(g_hostTx,0,sizeof g_hostTx);
-  g_hostTx[0]=0x12; g_hostTx[1]=0x20; g_hostTx[3]=1; g_hostTx[5]=0xE1;
-  memcpy(g_hostTx+6,g_slot[slot].rec,4); memcpy(g_hostTx+10,g_slot[slot].rec+4,4);
-  g_hostTx[0xe]=2; memcpy(g_hostTx+0x12,g_rfBase,4); g_hostTx[0x16]=g_rfPrefix;
-  g_hostLen=0x22;
+static void buildHostTx()
+{
+	int slot = -1;
+	for (int i = 0; i < NSLOT; i++)
+		if (g_slot[i].used) {
+			slot = i;
+			break;
+		}
+	memset(g_hostTx, 0, sizeof g_hostTx);
+	g_hostLen = 0;
+	if (slot < 0)
+		return;
+	// S0LEN=1 RAM layout: [0]=S0=0x12, [1]=LENGTH, raw RAM offsets validated by controller.
+	memset(g_hostTx, 0, sizeof g_hostTx);
+	g_hostTx[0] = 0x12;
+	g_hostTx[1] = 0x20;
+	g_hostTx[3] = 1;
+	g_hostTx[5] = 0xE1;
+	memcpy(g_hostTx + 6, g_slot[slot].rec, 4);
+	memcpy(g_hostTx + 10, g_slot[slot].rec + 4, 4);
+	g_hostTx[0xe] = 2;
+	memcpy(g_hostTx + 0x12, g_rfBase, 4);
+	g_hostTx[0x16] = g_rfPrefix;
+	g_hostLen = 0x22;
 }
-void rfRespondStart(){
-  buildHostTx();
-  rfConfig(2); rfSetAddr(g_rfBase,g_rfPrefix);            // RX ch2 / 91A2A793
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1; g_rfRespond=true;
-  Serial.printf("# RESPOND mode: RX ch2/91A2A793, reply host frame (len%d)\n", g_hostLen);
+void rfRespondStart()
+{
+	buildHostTx();
+	rfConfig(2);
+	rfSetAddr(g_rfBase, g_rfPrefix); // RX ch2 / 91A2A793
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	g_rfRespond = true;
+	Serial.printf(
+		"# RESPOND mode: RX ch2/91A2A793, reply host frame (len%d)\n",
+		g_hostLen);
 }
-static void rfRespondPoll(){
-  if(!g_rfRespond) return;
-  if(NRF_RADIO->EVENTS_END){
-    NRF_RADIO->EVENTS_END=0; uint8_t len=rfrx[0];
-    if(len && len<64){
-      g_advCount++;
-      // reply: TX the host frame immediately (ack)
-      NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-      NRF_RADIO->PACKETPTR=(uint32_t)g_hostTx;
-      NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
-      NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
-      RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-      Serial.printf("ADV#%lu len%u: ",(unsigned long)g_advCount,len);
-      for(uint8_t i=0;i<=len&&i<32;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
-      // re-arm RX
-      NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
-      NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-      NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-    }
-  }
+static void rfRespondPoll()
+{
+	if (!g_rfRespond)
+		return;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		uint8_t len = rfrx[0];
+		if (len && len < 64) {
+			g_advCount++;
+			// reply: TX the host frame immediately (ack)
+			NRF_RADIO->TASKS_DISABLE = 1;
+			RWAIT_DISABLED();
+			NRF_RADIO->EVENTS_DISABLED = 0;
+			NRF_RADIO->PACKETPTR = (uint32_t)g_hostTx;
+			NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+					    RADIO_SHORTS_END_DISABLE_Msk;
+			NRF_RADIO->EVENTS_DISABLED = 0;
+			NRF_RADIO->TASKS_TXEN = 1;
+			RWAIT_DISABLED();
+			NRF_RADIO->EVENTS_DISABLED = 0;
+			Serial.printf("ADV#%lu len%u: ",
+				      (unsigned long)g_advCount, len);
+			for (uint8_t i = 0; i <= len && i < 32; i++)
+				Serial.printf("%02X", rfrx[i]);
+			Serial.println();
+			// re-arm RX
+			NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+			NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+					    RADIO_SHORTS_END_START_Msk;
+			NRF_RADIO->EVENTS_END = 0;
+			NRF_RADIO->TASKS_RXEN = 1;
+		}
+	}
 }
 
 // ===================== live-session sniffer =====================
 // phase 0 RX the real puck's host frame on "ibex"/ch2 to learn the random session base/prefix/channel; phase 1
 // RX that session address on the primary channel to capture the live connected exchange (E3 poll + F1 reply).
-void rfSniffStart(){
-  uint8_t ch = (g_sniffPh==0)?2:(g_sniffPark?g_sniffPark:g_schan[0]);   // PARK (no hop): primary carries the traffic
-  const uint8_t* base = (g_sniffPh==0)?g_rfBase:g_sbase;
-  uint8_t pfx = (g_sniffPh==0)?g_rfPrefix:g_sprefix;
-  rfConfig(ch);                         // Ble_2Mbit, PCNF0=0x30008, ENDIAN big, BALEN4, CRC16 0x11021
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(base[i]);
-  NRF_RADIO->BASE0=((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->PREFIX0=rfBitrev8(pfx); NRF_RADIO->TXADDRESS=0; NRF_RADIO->RXADDRESSES=1;
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; memset(rfrx,0,4);
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
+void rfSniffStart()
+{
+	uint8_t ch =
+		(g_sniffPh == 0) ?
+			2 :
+			(g_sniffPark ?
+				 g_sniffPark :
+				 g_schan[0]); // PARK (no hop): primary carries the traffic
+	const uint8_t *base = (g_sniffPh == 0) ? g_rfBase : g_sbase;
+	uint8_t pfx = (g_sniffPh == 0) ? g_rfPrefix : g_sprefix;
+	rfConfig(
+		ch); // Ble_2Mbit, PCNF0=0x30008, ENDIAN big, BALEN4, CRC16 0x11021
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(base[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = rfBitrev8(pfx);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	memset(rfrx, 0, 4);
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
 }
-static void rfSniffPoll(){
-  if(!g_sniff) return;
-  if(NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
-    bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
-    if(crcok && len && len<40){
-      g_sniffN++;
-      if(g_sniffPh==0 && rfrx[2]==0xE1){           // host frame -> learn session params + DUMP FULL (app-data may ride here)
-        memcpy(g_sbase,rfrx+15,4); g_sprefix=rfrx[19]; g_schan[0]=rfrx[11];
-        Serial.printf("# HOSTFRAME ch=%u sbase=%02X%02X%02X%02X prefix=%02X len%u FULL: ",
-          rfrx[11],g_sbase[0],g_sbase[1],g_sbase[2],g_sbase[3],g_sprefix,len);
-        { uint8_t dn=(len+2<64)?len+2:64; for(uint8_t i=0;i<dn;i++)Serial.printf("%02X",rfrx[i]); } Serial.println();
-        g_sniffPh=1; g_schi=0; rfSniffStart();
-      } else if(g_sniffPh==1){
-        uint8_t ty=rfrx[2];                          // 0xF1=input, 0x12/0xE1/0xE4=puck host-frame w/ app-data
-        const char* tag = (ty==0xF1)?"<<<INPUT":(ty==0x12||ty==0xE1||ty==0xE4)?">>>HOSTFR":"  pkt";
-        Serial.printf("%s SNIFF#%lu ch%u len%u: ",tag,(unsigned long)g_sniffN,g_schan[g_schi],len);
-        uint8_t dn=(len+2<64)?len+2:64;              // FULL frame (app-data TLVs sit near the end)
-        for(uint8_t i=0;i<dn;i++)Serial.printf("%02X",rfrx[i]); Serial.println();
-      }
-    }
-  }
+static void rfSniffPoll()
+{
+	if (!g_sniff)
+		return;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t len = rfrx[0];
+		if (crcok && len && len < 40) {
+			g_sniffN++;
+			if (g_sniffPh == 0 &&
+			    rfrx[2] ==
+				    0xE1) { // host frame -> learn session params + DUMP FULL (app-data may ride here)
+				memcpy(g_sbase, rfrx + 15, 4);
+				g_sprefix = rfrx[19];
+				g_schan[0] = rfrx[11];
+				Serial.printf(
+					"# HOSTFRAME ch=%u sbase=%02X%02X%02X%02X prefix=%02X len%u FULL: ",
+					rfrx[11], g_sbase[0], g_sbase[1],
+					g_sbase[2], g_sbase[3], g_sprefix, len);
+				{
+					uint8_t dn = (len + 2 < 64) ? len + 2 :
+								      64;
+					for (uint8_t i = 0; i < dn; i++)
+						Serial.printf("%02X", rfrx[i]);
+				}
+				Serial.println();
+				g_sniffPh = 1;
+				g_schi = 0;
+				rfSniffStart();
+			} else if (g_sniffPh == 1) {
+				uint8_t ty =
+					rfrx[2]; // 0xF1=input, 0x12/0xE1/0xE4=puck host-frame w/ app-data
+				const char *tag = (ty == 0xF1) ? "<<<INPUT" :
+						  (ty == 0x12 || ty == 0xE1 ||
+						   ty == 0xE4) ?
+								 ">>>HOSTFR" :
+								 "  pkt";
+				Serial.printf("%s SNIFF#%lu ch%u len%u: ", tag,
+					      (unsigned long)g_sniffN,
+					      g_schan[g_schi], len);
+				uint8_t dn =
+					(len + 2 < 64) ?
+						len + 2 :
+						64; // FULL frame (app-data TLVs sit near the end)
+				for (uint8_t i = 0; i < dn; i++)
+					Serial.printf("%02X", rfrx[i]);
+				Serial.println();
+			}
+		}
+	}
 }
 
-void rfDiagTask(){
-  rfPoll();
-  rfRespondPoll();
-  rfCapPoll();
-  if (g_rfCap && millis() - g_lastHop >= 90) {    // sweep config combos on ch2, CRC-validate
-    g_lastHop = millis(); g_capV = (uint8_t)(g_capV+1); rfCapStart(g_rfCh);
-  }
-  if (g_rfBeacon && millis() - g_lastBeacon >= 5) { g_lastBeacon = millis(); rfBeaconOnce(); }
-  if (g_rfSweep && millis() - g_lastHop >= 60) { g_lastHop = millis(); g_rfCh += 2; if (g_rfCh > 80) g_rfCh = 2; rfRawStart(g_rfCh); }
-  if (g_rfAuto && millis() - g_lastCfg >= 500) {   // advance to next candidate config every 500ms
-    g_lastCfg = millis(); g_cfgIdx = (g_cfgIdx + 1) % SWEEP_N; applyCfg(g_cfgIdx);
-  }
-  if (g_rfReplay) { rfReplayOnce(); }   // bit-perfect replay of a captured real-puck frame on ch2
-  rfSniffPoll();
-  if (g_rfRespond && millis() - g_lastHop >= 25) {          // scan the 3 BLE adv channels w/ matched whitening
-    g_lastHop = millis();
-    static const uint8_t advf[3]={2,26,80}, advw[3]={37,38,39}; static uint8_t ai=0; ai=(ai+1)%3;
-    g_rfCh=advf[ai]; g_whiteiv=advw[ai];
-    rfConfig(g_rfCh); rfSetAddr(g_rfBase,g_rfPrefix); NRF_RADIO->PACKETPTR=(uint32_t)rfrx;
-    NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_START_Msk; NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-  }
+void rfDiagTask()
+{
+	rfPoll();
+	rfRespondPoll();
+	rfCapPoll();
+	if (g_rfCap && millis() - g_lastHop >=
+			       90) { // sweep config combos on ch2, CRC-validate
+		g_lastHop = millis();
+		g_capV = (uint8_t)(g_capV + 1);
+		rfCapStart(g_rfCh);
+	}
+	if (g_rfBeacon && millis() - g_lastBeacon >= 5) {
+		g_lastBeacon = millis();
+		rfBeaconOnce();
+	}
+	if (g_rfSweep && millis() - g_lastHop >= 60) {
+		g_lastHop = millis();
+		g_rfCh += 2;
+		if (g_rfCh > 80)
+			g_rfCh = 2;
+		rfRawStart(g_rfCh);
+	}
+	if (g_rfAuto &&
+	    millis() - g_lastCfg >=
+		    500) { // advance to next candidate config every 500ms
+		g_lastCfg = millis();
+		g_cfgIdx = (g_cfgIdx + 1) % SWEEP_N;
+		applyCfg(g_cfgIdx);
+	}
+	if (g_rfReplay) {
+		rfReplayOnce();
+	} // bit-perfect replay of a captured real-puck frame on ch2
+	rfSniffPoll();
+	if (g_rfRespond &&
+	    millis() - g_lastHop >=
+		    25) { // scan the 3 BLE adv channels w/ matched whitening
+		g_lastHop = millis();
+		static const uint8_t advf[3] = { 2, 26, 80 },
+				     advw[3] = { 37, 38, 39 };
+		static uint8_t ai = 0;
+		ai = (ai + 1) % 3;
+		g_rfCh = advf[ai];
+		g_whiteiv = advw[ai];
+		rfConfig(g_rfCh);
+		rfSetAddr(g_rfBase, g_rfPrefix);
+		NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+		NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+				    RADIO_SHORTS_END_START_Msk;
+		NRF_RADIO->EVENTS_END = 0;
+		NRF_RADIO->TASKS_RXEN = 1;
+	}
 }

+ 18 - 17
OpenPuck/rf_diag.h

@@ -9,28 +9,29 @@
 #include <stdint.h>
 
 // ---- mode flags (toggled by the CDC console; mutually managed there) ----
-extern bool    g_rfListen;
-extern bool    g_rfBeacon;
-extern uint8_t g_plen, g_s1incl;   // dongle-beacon payload length / S1INCL toggle
-extern bool    g_rfRaw;
-extern bool    g_rfSweep;
-extern bool    g_rfCap, g_rfCapOne, g_rfReplay;
+extern bool g_rfListen;
+extern bool g_rfBeacon;
+extern uint8_t g_plen, g_s1incl; // dongle-beacon payload length / S1INCL toggle
+extern bool g_rfRaw;
+extern bool g_rfSweep;
+extern bool g_rfCap, g_rfCapOne, g_rfReplay;
 extern uint8_t g_replayLen;
-extern bool    g_rfAuto;
+extern bool g_rfAuto;
 extern uint8_t g_capV, g_cfgIdx;
-extern bool    g_rfRespond;
-extern bool    g_sniff;
+extern bool g_rfRespond;
+extern bool g_sniff;
 extern uint8_t g_sniffPh, g_sniffPark;
 
 // ---- start hooks (called from the console on command) ----
-void rfListenStart();          // RX + log on the discovery addr/channel
-void rfRawStart(uint8_t ch);   // promiscuous preamble-match capture (no CRC)
-void rfCapStart(uint8_t ch);   // CRC-validating framing sweep (cycles config combos)
-void rfReplayOnce();           // bit-exact re-transmit of a captured real-puck frame
-void rfBeaconOnce();           // dongle beacon experiment ([01][seq][..][E2@5])
-void applyCfg(uint8_t i);      // apply autosweep candidate radio config i
-void rfRespondStart();         // scan-then-respond (RX ch2 adv, reply host frame)
-void rfSniffStart();           // session sniffer (learn session params, then capture the live exchange)
+void rfListenStart(); // RX + log on the discovery addr/channel
+void rfRawStart(uint8_t ch); // promiscuous preamble-match capture (no CRC)
+void rfCapStart(
+	uint8_t ch); // CRC-validating framing sweep (cycles config combos)
+void rfReplayOnce(); // bit-exact re-transmit of a captured real-puck frame
+void rfBeaconOnce(); // dongle beacon experiment ([01][seq][..][E2@5])
+void applyCfg(uint8_t i); // apply autosweep candidate radio config i
+void rfRespondStart(); // scan-then-respond (RX ch2 adv, reply host frame)
+void rfSniffStart(); // session sniffer (learn session params, then capture the live exchange)
 
 // Per-loop: service whichever diag mode is active.
 void rfDiagTask();

+ 633 - 292
OpenPuck/rf_link.cpp

@@ -10,47 +10,58 @@
 #include <Arduino.h>
 #include <string.h>
 
-bool     g_rfHost = true;
-bool     g_connOn = true;
-uint8_t  g_connType = 0xE7;     // start with protocol-version handshake, then 0xE3
-uint8_t  g_e7b = 0;             // 0=current(slow/awake), 1=test protocol-version-1. 'V<n>' to toggle.
-uint8_t  g_connLen = 0x08;
-uint8_t  g_getParam = 0x00;     // GET report 0x45 param byte (try 0x00, fall back 0x2D) - 'q' cmd
-uint8_t  g_e3mode = 1;          // DEFAULT 1: cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID) -- THE rate fix. 'e<n>' to A/B.
-bool     g_connVerbose = false;
-uint32_t g_rxWin = 1200;        // poll RX-window (us); shorter=more polls/s but may miss DELAYED replies. Tunable 'r'.
+bool g_rfHost = true;
+bool g_connOn = true;
+uint8_t g_connType = 0xE7; // start with protocol-version handshake, then 0xE3
+uint8_t g_e7b =
+	0; // 0=current(slow/awake), 1=test protocol-version-1. 'V<n>' to toggle.
+uint8_t g_connLen = 0x08;
+uint8_t g_getParam =
+	0x00; // GET report 0x45 param byte (try 0x00, fall back 0x2D) - 'q' cmd
+uint8_t g_e3mode =
+	1; // DEFAULT 1: cycling the ESB PID drains the controller's report queue (~400 new/s vs ~60 with a fixed PID) -- THE rate fix. 'e<n>' to A/B.
+bool g_connVerbose = false;
+uint32_t g_rxWin =
+	1200; // poll RX-window (us); shorter=more polls/s but may miss DELAYED replies. Tunable 'r'.
 unsigned long g_connCooldown = 0;
 
-uint8_t  g_connSt = 0;          // 0=announce awake, 1=poll loop
-uint8_t  g_connStep = 0;        // repeat counter within a state
-uint16_t g_connPoll = 0;        // poll counter (re-assert awake every 32nd)
+uint8_t g_connSt = 0; // 0=announce awake, 1=poll loop
+uint8_t g_connStep = 0; // repeat counter within a state
+uint16_t g_connPoll = 0; // poll counter (re-assert awake every 32nd)
 uint32_t g_connF1 = 0;
-uint8_t  g_connF3v = 0xFF;
+uint8_t g_connF3v = 0xFF;
 
-uint8_t  g_qos = 0;
+uint8_t g_qos = 0;
 // clean, spread channels (from the puck's RSSI/PER scan)
-static const uint8_t g_hopCand[] = {18,46,76,22,68};
-uint8_t  g_hopIdx = 0;
+static const uint8_t g_hopCand[] = { 18, 46, 76, 22, 68 };
+uint8_t g_hopIdx = 0;
 volatile uint16_t g_qosBad = 0;
 unsigned long g_qosCheckMs = 0, g_qosLastHopMs = 0;
 
 uint16_t g_f1ps = 0;
 uint16_t g_newps = 0;
-uint16_t g_pollsps = 0;   // polls/s (GET+relay TXs) last second -- distinguishes loop-starvation from reply-loss
-uint16_t g_pollPeriodUs = 0;   // MEASURED avg us between GET-poll fires (vs intended g_pollUs) -- ground truth
-static uint32_t g_pollDtSum = 0; static uint16_t g_pollDtCnt = 0;
-volatile uint8_t g_linkRssi = 0;   // smoothed |dBm| of the controller's replies (0 = none yet)
-volatile uint8_t g_battery  = 0;   // battery % from the controller's report 0x43 (body[1]); 0 = none yet
+uint16_t g_pollsps =
+	0; // polls/s (GET+relay TXs) last second -- distinguishes loop-starvation from reply-loss
+uint16_t g_pollPeriodUs =
+	0; // MEASURED avg us between GET-poll fires (vs intended g_pollUs) -- ground truth
+static uint32_t g_pollDtSum = 0;
+static uint16_t g_pollDtCnt = 0;
+volatile uint8_t g_linkRssi =
+	0; // smoothed |dBm| of the controller's replies (0 = none yet)
+volatile uint8_t g_battery =
+	0; // battery % from the controller's report 0x43 (body[1]); 0 = none yet
 
 // ---- internal counters / timers ----
-static uint8_t  g_e3pid = 0;
-static uint32_t g_stPoll=0, g_stF1=0, g_stF3=0; static unsigned long g_stMs=0;
-static uint8_t  g_lastSeq=0; static uint32_t g_stNew=0;
-static uint32_t g_stCrc=0, g_stNoRx=0;
-static uint32_t g_chF1[3]={0,0,0};
-static uint32_t g_lastPollUs=0;
+static uint8_t g_e3pid = 0;
+static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
+static unsigned long g_stMs = 0;
+static uint8_t g_lastSeq = 0;
+static uint32_t g_stNew = 0;
+static uint32_t g_stCrc = 0, g_stNoRx = 0;
+static uint32_t g_chF1[3] = { 0, 0, 0 };
+static uint32_t g_lastPollUs = 0;
 static uint32_t g_connRx = 0;
-static unsigned long g_lastSessBeacon=0, g_lastDisc=0;
+static unsigned long g_lastSessBeacon = 0, g_lastDisc = 0;
 static unsigned long g_lastStream = 0;
 
 // HOST FRAME the bonded controller waits for (IBEX FUN_00019000 verify: b[0]=0x12, b[5]=0xE1, b[6..10]=
@@ -60,278 +71,608 @@ static unsigned long g_lastStream = 0;
 // searching controller looks; =false sends it on this puck's unique SESSION address (the keepalive once the
 // controller has adopted the session). EITHER way the payload advertises the session base/prefix/channel, so
 // the controller always learns the unique address to connect on.
-static void rfHostFrameOnce(int slot, bool discovery){
-  if (slot<0||slot>=NSLOT||!g_slot[slot].used) return;
-  uint8_t *rec = g_slot[slot].rec;                       // [proteus_uuid 4][ibex_uuid 4][serial 16]
-  // CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)]. payload:
-  // [0]=0xE1, [1..5]=proteus_uuid LE, [5..9]=ibex_uuid LE, [9]=session channel, [10..13]=0, [13..17]=session
-  // base, [17]=session prefix. Radio auto-appends CRC16 0x11021.
-  memset(rftx,0,sizeof rftx);
-  rftx[0]=0x12;                        // LENGTH = 18 (controller's buf[0]==0x12 check validates this)
-  rftx[1]=(uint8_t)((g_pid++&3)<<1);   // S1 = PID<<1 | noack0  (matches real puck 00/02/04/06)
-  rftx[2]=0xE1;                        // payload[0] marker
-  memcpy(rftx+3, rec+0, 4);            // payload[1..5] proteus_uuid (LE, as bonded)
-  memcpy(rftx+7, rec+4, 4);            // payload[5..9] ibex_uuid
-  rftx[11]=g_sessCh;                   // payload[9] session channel: tell the controller to run the session on
-                                       // the clean channel (it adopts buf[0xe]); discovery beacon still TXes on ch2
-  memcpy(rftx+15, g_sessBase, 4);      // payload[13..17] session base  (the per-device UNIQUE address)
-  rftx[19]=g_sessPrefix;               // payload[17] session prefix
-  // TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses our unique address
-  // (where the controller now listens). The advertised session params (above) are identical either way.
-  const uint8_t* txBase = discovery ? g_rfBase   : g_sessBase;
-  uint8_t        txPfx  = discovery ? g_rfPrefix : g_sessPrefix;
-  rfConfig(g_rfCh); rfSetAddr(txBase, txPfx);
-  NRF_RADIO->PACKETPTR=(uint32_t)rftx;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
-  NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
-  RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-  // Discovery/pairing beacons listen for the controller's response (matters for RE/pairing); the connected
-  // session keepalive expects NO response (the controller answers E3 polls, not the beacon), so don't burn
-  // 800us of dead air per frame -- that was the bulk of the idle poll-rate deficit (40 beacons/s x slots).
-  uint16_t bwin = discovery ? 800u : 150u;
-  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<bwin){}
-  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;   // ANY reception = controller answered our frame
-    g_rfRxCount++; bool crcok=NRF_RADIO->CRCSTATUS&1; uint8_t len=rfrx[0];
-    if (Serial.availableForWrite()>90){                  // non-blocking: don't stall the loop on CDC backpressure
-      Serial.printf("*** RESP#%lu ch%u crc%d rxmatch%lu len%u: ",(unsigned long)g_rfRxCount,
-                    g_rfCh,crcok,(unsigned long)NRF_RADIO->RXMATCH,len);
-      for(uint8_t i=0;i<(len<40?len+2:40);i++)Serial.printf("%02X ",rfrx[i]); Serial.println(); } }
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
+static void rfHostFrameOnce(int slot, bool discovery)
+{
+	if (slot < 0 || slot >= NSLOT || !g_slot[slot].used)
+		return;
+	uint8_t *rec =
+		g_slot[slot].rec; // [proteus_uuid 4][ibex_uuid 4][serial 16]
+	// CRC-VALIDATED frame (decoded from real puck): ESB-DPL RAM = [LENGTH][S1=PID][payload(18)]. payload:
+	// [0]=0xE1, [1..5]=proteus_uuid LE, [5..9]=ibex_uuid LE, [9]=session channel, [10..13]=0, [13..17]=session
+	// base, [17]=session prefix. Radio auto-appends CRC16 0x11021.
+	memset(rftx, 0, sizeof rftx);
+	rftx[0] =
+		0x12; // LENGTH = 18 (controller's buf[0]==0x12 check validates this)
+	rftx[1] =
+		(uint8_t)((g_pid++ & 3)
+			  << 1); // S1 = PID<<1 | noack0  (matches real puck 00/02/04/06)
+	rftx[2] = 0xE1; // payload[0] marker
+	memcpy(rftx + 3, rec + 0,
+	       4); // payload[1..5] proteus_uuid (LE, as bonded)
+	memcpy(rftx + 7, rec + 4, 4); // payload[5..9] ibex_uuid
+	// payload[9] session channel: tell the controller to run the session on
+	// the clean channel (it adopts buf[0xe]); discovery beacon still TXes on ch2
+	rftx[11] = g_sessCh;
+	memcpy(rftx + 15, g_sessBase,
+	       4); // payload[13..17] session base  (the per-device UNIQUE address)
+	rftx[19] = g_sessPrefix; // payload[17] session prefix
+	// TX address: discovery uses the shared "ibex" rendezvous; the session keepalive uses our unique address
+	// (where the controller now listens). The advertised session params (above) are identical either way.
+	const uint8_t *txBase = discovery ? g_rfBase : g_sessBase;
+	uint8_t txPfx = discovery ? g_rfPrefix : g_sessPrefix;
+	rfConfig(g_rfCh);
+	rfSetAddr(txBase, txPfx);
+	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	rfrx[0] = 0;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	// Discovery/pairing beacons listen for the controller's response (matters for RE/pairing); the connected
+	// session keepalive expects NO response (the controller answers E3 polls, not the beacon), so don't burn
+	// 800us of dead air per frame -- that was the bulk of the idle poll-rate deficit (40 beacons/s x slots).
+	uint16_t bwin = discovery ? 800u : 150u;
+	uint32_t t0 = micros();
+	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < bwin) {
+	}
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END =
+			0; // ANY reception = controller answered our frame
+		g_rfRxCount++;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t len = rfrx[0];
+		if (Serial.availableForWrite() >
+		    90) { // non-blocking: don't stall the loop on CDC backpressure
+			Serial.printf(
+				"*** RESP#%lu ch%u crc%d rxmatch%lu len%u: ",
+				(unsigned long)g_rfRxCount, g_rfCh, crcok,
+				(unsigned long)NRF_RADIO->RXMATCH, len);
+			for (uint8_t i = 0; i < (len < 40 ? len + 2 : 40); i++)
+				Serial.printf("%02X ", rfrx[i]);
+			Serial.println();
+		}
+	}
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
 }
 
-void rfHopTo(uint8_t newCh){
-  if(g_connSlot<0 || newCh==g_sessCh) return;
-  uint8_t cur=g_sessCh, savedRfCh=g_rfCh;
-  g_sessCh=newCh; g_rfCh=cur;                // host frame now advertises newCh but is TXed on cur (session addr)
-  for(int k=0;k<6;k++){ rfHostFrameOnce(g_connSlot, false); delayMicroseconds(700); }
-  g_rfCh=savedRfCh;                          // poll + session beacon now run on g_sessCh=newCh
+void rfHopTo(uint8_t newCh)
+{
+	if (g_connSlot < 0 || newCh == g_sessCh)
+		return;
+	uint8_t cur = g_sessCh, savedRfCh = g_rfCh;
+	g_sessCh = newCh;
+	g_rfCh =
+		cur; // host frame now advertises newCh but is TXed on cur (session addr)
+	for (int k = 0; k < 6; k++) {
+		rfHostFrameOnce(g_connSlot, false);
+		delayMicroseconds(700);
+	}
+	g_rfCh = savedRfCh; // poll + session beacon now run on g_sessCh=newCh
 }
 
-uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, uint16_t rxWinUs){
-  uint16_t win = rxWinUs ? rxWinUs : g_rxWin;   // relays pass a tiny window (no reply expected); polls use g_rxWin
-  memset(rftx,0,sizeof rftx);
-  rftx[0]=plen;                          // LENGTH = payload byte count
-  rftx[1]=s1;                            // S1 (type-specific)
-  memcpy(rftx+2, payload, plen);         // payload[0]=type byte, then data/TLVs
-  rfConfig(ch); rfSetAddr(g_sessBase,g_sessPrefix);   // connected poll runs on this puck's UNIQUE session addr
-  NRF_RADIO->PACKETPTR=(uint32_t)rftx;
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_END_DISABLE_Msk;
-  NRF_RADIO->EVENTS_DISABLED=0; NRF_RADIO->TASKS_TXEN=1;
-  RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  NRF_RADIO->PACKETPTR=(uint32_t)rfrx; rfrx[0]=0;
-  // RXEN->READY->START; catch the reply. ADDRESS->RSSISTART samples the reply's signal strength (read from
-  // RSSISAMPLE below, surfaced to Steam via report 0x7B); DISABLED->RSSISTOP closes the measurement.
-  NRF_RADIO->SHORTS=RADIO_SHORTS_READY_START_Msk|RADIO_SHORTS_ADDRESS_RSSISTART_Msk|RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
-  NRF_RADIO->EVENTS_END=0; NRF_RADIO->TASKS_RXEN=1;
-  g_stPoll++;
-  uint32_t t0=micros(); while(!NRF_RADIO->EVENTS_END && (micros()-t0)<win){}  // RX window (tunable 'r'; or relay override)
-  uint8_t rxlen=0;
-  if (NRF_RADIO->EVENTS_END){ NRF_RADIO->EVENTS_END=0;
-    bool crcok=NRF_RADIO->CRCSTATUS&1; rxlen=rfrx[0];
-    if(!crcok){ g_stCrc++; g_qosBad++; }                // reply arrived but CRC failed -> RF quality (channel/interference)
-    if (crcok && rxlen && rxlen<=96){                   // F1 input ~46B; 0x43-augmented ~66B -> allow up to MAXLEN(96)
-      uint8_t rtype=rfrx[2];                            // reply type byte (proven offset from captures)
-      // Only OUR controller's replies (F-type: 0xF1 input / 0xF2 disconnect / 0xF3 status) mark the link
-      // alive. Every OpenPuck shares the same RF address "ibex" + CRC config, and a puck transmits host-frame
-      // beacons (0xE1) + polls (0xE2/E3/E7) -- all E-type. Without this gate, puck A receives a SECOND puck's
-      // 0xE1 beacon (e.g. one just plugged into another computer), bumps g_connReplyMs, and the "new RF
-      // connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
-      if (rtype >= 0xF0){                                       // F-type reply (F1/F2/F3) -> our controller is alive
-        // A reply after a long gap (or the first ever) = a (re)connect. Arm the haptic block + re-init HERE,
-        // directly off the reply stream -- reliable even when hapticTask's 300ms link-up edge doesn't fire
-        // (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
-        if (g_connReplyMs==0 || (uint32_t)(millis()-g_connReplyMs) > 1500u) hapticOnReconnect();
-        g_connRx++; g_connReplyMs=millis();                     // link alive -> loop() suppresses the redundant E1 beacon
-        uint8_t rs=(uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);      // |dBm| of this reply (started by the ADDRESS short)
-        if(rs) g_linkRssi = g_linkRssi ? (uint8_t)((g_linkRssi*7u + rs + 4u)/8u) : rs;   // EWMA, ~8-sample horizon
-      }
-      if(rtype==0xF1) g_stF1++;
-      if(rtype==0xF2) g_connCooldown=millis();          // controller disconnecting/powering off -> back off 2.5s
-      if(rtype==0xF3){                                  // F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
-        g_stF3++; g_connF3v=rfrx[6];
-        if(g_connVerbose && Serial.availableForWrite()>40){ Serial.print("  F3 "); for(uint8_t i=0;i<(rxlen+2<32?rxlen+2:32);i++)Serial.printf("%02X",rfrx[i]); Serial.println(); }
-      }
-      bool isF1=(rtype==0xF1);
-      if(isF1){
-        g_connF1++;
-        int idx=3, end=rxlen+2;                         // walk ALL type6 TLVs (= HID report 0x45). idx is INT, not
-        const uint8_t* lastRep=nullptr; uint8_t lastTlen=0;  // uint8_t: a tlen of 0xFE would make idx+=tlen+2 wrap
-        while(idx+1<end){                               // mod-256 back to itself -> infinite loop -> USB hang/"crash".
-          uint8_t tlen=rfrx[idx], ttype=rfrx[idx+1];    // pace with the real puck -- taking only [0] halved our rate.
-          if(tlen==0) break;
-          // Only a FULL 0x45 report that fits entirely in rfrx: a short or late/garbled TLV must not let the
-          // decode read past the RF buffer (corrupts rftx/RAM -> eventual crash).
-          if(ttype==6 && tlen>=28 && (size_t)(idx+2)+tlen<=sizeof(rfrx) && rfrx[idx+2]==0x45){
-            const uint8_t* rep=&rfrx[idx+2];            // report 0x45: [0x45][seq][buttons u32]...
-            bool fresh=(rep[1]!=g_lastSeq); if(fresh){ g_stNew++; g_lastSeq=rep[1]; }  // genuine new report vs stale poll-repeat
-            uint32_t bb=btnsOf(rep);
-            // USB remote wakeup on Steam button short press (down + up within 1 s). A long press likely means
-            // the user is powering off the controller, so we ignore it.
-            { static bool steamWasDown=false; static unsigned long steamDownMs=0;
-              if(fresh){
-                bool steamNow=(bb & TB_STEAM)!=0;
-                if(steamNow && !steamWasDown) steamDownMs=millis();                                                            // rising edge: record press time
-                if(!steamNow && steamWasDown && millis()-steamDownMs<1000u && USBDevice.suspended()){                          // falling edge within 1 s -> short press -> wake
-                  USBDevice.remoteWakeup(); ledWakePulse();
-                  if(g_active) g_active->wakeEvent();   // post-resume nudge (host needs real input to actually wake)
-                }
-                steamWasDown=steamNow;
-              }
-            }
-            // Decode the report into the shared g_in (one source, read by every IController).
-            g_in.buttons=bb;
-            // Global power-off chord: Steam + Y held 2 s -> shut the controller down (any mode). Detect on the raw
-            // `bb` (pre-mask), time-based (poll rate varies), fires once per hold, re-arms only after release. While
-            // held, mask Steam+Y out of g_in.buttons so the press doesn't leak to the host -- in EVERY mode except
-            // regular Steam (mode 0 forwards the raw 0x45 to Steam, which owns the Steam button). Runs before
-            // onReport45 below so push modes (Xbox) see the mask too; stream modes read g_in in task() (also masked).
-            { static unsigned long offHoldMs=0; static bool offFired=false;
-              if((bb&(TB_STEAM|TB_Y))==(TB_STEAM|TB_Y)){
-                if(offHoldMs==0) offHoldMs=millis();
-                else if(!offFired && (unsigned long)(millis()-offHoldMs)>=2000u){ offFired=true; hapticSendShutdown(); }
-                if(g_usbMode!=MODE_STEAM){
-                  g_in.buttons &= ~(uint32_t)(TB_STEAM|TB_Y);          // stream modes read g_in
-                  ((uint8_t*)rep)[2] &= ~(uint8_t)TB_Y;               // push modes read btnsOf(rep): TB_Y in rep[2],
-                  ((uint8_t*)rep)[4] &= ~(uint8_t)(TB_STEAM>>16);     // TB_STEAM in rep[4]
-                }
-              } else { offHoldMs=0; offFired=false; }
-            }
-            g_in.lx=(int16_t)s16off(rep,8);  g_in.ly=(int16_t)s16off(rep,10);
-            g_in.rx=(int16_t)s16off(rep,12); g_in.ry=(int16_t)s16off(rep,14);
-            g_in.lt=trigU8(u16off(rep,4));   g_in.rt=trigU8(u16off(rep,6));   // for the Switch digital-trigger threshold
-            g_in.lpx=(int16_t)s16off(rep,16); g_in.lpy=(int16_t)s16off(rep,18);
-            g_in.rpx=(int16_t)s16off(rep,22); g_in.rpy=(int16_t)s16off(rep,24);
-            // IMU lives at report bytes 0x22..0x2D (rep[34..45]). Decode it ONLY when a FULL 46-byte report was
-            // actually received -- bounded by `end` (the received length), NOT sizeof(rfrx). The outer gate is
-            // tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a short 0x45 (button-only, or
-            // one whose IMU tail was lost) still passes it; without this guard imuFrom45 would read STALE bytes
-            // past the received data and clobber g_in's gyro/accel. On a short frame, hold the last good IMU.
-            if(tlen>=46 && (size_t)(idx+2)+46<=(size_t)end)
-              imuFrom45(rep, &g_in.ax,&g_in.ay,&g_in.az,&g_in.gx,&g_in.gy,&g_in.gz);
-            // Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in.buttons stays
-            // intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
-            if((bb&CHORD_BACK4)==CHORD_BACK4)
-              ((uint8_t*)rep)[2] &= ~(uint8_t)(TB_A|TB_B|TB_X|TB_Y);
-            // Hand the report to the active controller. STREAM modes ignore it (they emit from task() reading
-            // g_in); PUSH modes (Xbox, puck/lizard) build + send their host report here.
-            if(g_active) g_active->onReport45(rep, fresh, tlen);
-            lastRep=rep; lastTlen=tlen;
-          }
-          else if(ttype==6 && (size_t)(idx+2)+tlen<=sizeof(rfrx) && tlen>=2 && (rfrx[idx+2]==0x43 || rfrx[idx+2]==0x44)){
-            // Controller STATUS reports (0x43 = periodic power/battery, ~every 2s; 0x44 = status event). The real
-            // puck forwards these to the host verbatim -- that's how Steam reads battery -- but OpenPuck used to
-            // drop everything but 0x45. Forward them (onAuxReport), and snapshot the battery % for the WebUSB panel.
-            const uint8_t* rep=&rfrx[idx+2];                 // [rid][body...]
-            if(rep[0]==0x43 && tlen>=3) g_battery=rep[2];    // 0x43 body[1] (~0x5e=94) reads as battery % (sniff-derived)
-            if(g_active) g_active->onAuxReport(rep[0], rep+1, (uint8_t)(tlen-1));
-          }
-          idx+=tlen+2;
-        }
-        // mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.
-        { static uint8_t chWant=0xFF, chCnt=0;
-          uint8_t want=0xFF;
-          if((g_in.buttons&CHORD_BACK4)==CHORD_BACK4){
-            if(g_in.buttons&TB_A) want=MODE_STEAM;
-            else if(g_in.buttons&TB_B) want=g_chordBtn[0];
-            else if(g_in.buttons&TB_X) want=g_chordBtn[1];
-            else if(g_in.buttons&TB_Y) want=g_chordBtn[2];
-          }
-          if(want!=0xFF && want==chWant){ if(++chCnt>=12 && want!=g_usbMode && modeValid(want) && !USBDevice.suspended()){ saveMode(want); delay(40); NVIC_SystemReset(); } }
-          else { chWant=want; chCnt=(want!=0xFF)?1:0; }
-        }
-        // compact stream for rf_controller_ui.py -- NON-BLOCKING: skip if CDC TX is backed up (a blocking
-        // Serial.print stalls the RF+USB loop -> jaggy input). One line/frame using the last record.
-        if(lastRep && !g_connVerbose && Serial.availableForWrite()>110 && millis()-g_lastStream>=4){
-          g_lastStream=millis(); Serial.print("I45 ");
-          for(uint8_t i=0;i<lastTlen;i++)Serial.printf("%02X",lastRep[i]); Serial.println();
-        }
-      }
-      if(g_connVerbose){
-        Serial.printf("%s CRX#%lu txtype%02X ch%u len%u: ",isF1?"<<<F1":(rtype==0xF3?"  F3":"  rx"),
-                      (unsigned long)g_connRx,payload[0],ch,rxlen);
-        for(uint8_t i=0;i<(rxlen+2<=66?rxlen+2:66);i++)Serial.printf("%02X",rfrx[i]); Serial.println();
-      }
-    } else rxlen=0;
-  } else { g_stNoRx++; g_qosBad++; }                   // RX window expired with no packet at all
-  NRF_RADIO->TASKS_DISABLE=1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED=0;
-  return rxlen;
+uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
+		 uint16_t rxWinUs)
+{
+	uint16_t win =
+		rxWinUs ?
+			rxWinUs :
+			g_rxWin; // relays pass a tiny window (no reply expected); polls use g_rxWin
+	memset(rftx, 0, sizeof rftx);
+	rftx[0] = plen; // LENGTH = payload byte count
+	rftx[1] = s1; // S1 (type-specific)
+	memcpy(rftx + 2, payload, plen); // payload[0]=type byte, then data/TLVs
+	rfConfig(ch);
+	rfSetAddr(
+		g_sessBase,
+		g_sessPrefix); // connected poll runs on this puck's UNIQUE session addr
+	NRF_RADIO->PACKETPTR = (uint32_t)rftx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_DISABLE_Msk;
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->TASKS_TXEN = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	rfrx[0] = 0;
+	// RXEN->READY->START; catch the reply. ADDRESS->RSSISTART samples the reply's signal strength (read from
+	// RSSISAMPLE below, surfaced to Steam via report 0x7B); DISABLED->RSSISTOP closes the measurement.
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_ADDRESS_RSSISTART_Msk |
+			    RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
+	g_stPoll++;
+	uint32_t t0 = micros();
+	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < win) {
+	} // RX window (tunable 'r'; or relay override)
+	uint8_t rxlen = 0;
+	if (NRF_RADIO->EVENTS_END) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		rxlen = rfrx[0];
+		if (!crcok) {
+			g_stCrc++;
+			g_qosBad++;
+		} // reply arrived but CRC failed -> RF quality (channel/interference)
+		if (crcok && rxlen &&
+		    rxlen <=
+			    96) { // F1 input ~46B; 0x43-augmented ~66B -> allow up to MAXLEN(96)
+			uint8_t rtype =
+				rfrx[2]; // reply type byte (proven offset from captures)
+			// Only OUR controller's replies (F-type: 0xF1 input / 0xF2 disconnect / 0xF3 status) mark the link
+			// alive. Every OpenPuck shares the same RF address "ibex" + CRC config, and a puck transmits host-frame
+			// beacons (0xE1) + polls (0xE2/E3/E7) -- all E-type. Without this gate, puck A receives a SECOND puck's
+			// 0xE1 beacon (e.g. one just plugged into another computer), bumps g_connReplyMs, and the "new RF
+			// connection" wake in rfLinkTask() fires -> the second puck spuriously wakes this sleeping host.
+			if (rtype >=
+			    0xF0) { // F-type reply (F1/F2/F3) -> our controller is alive
+				// A reply after a long gap (or the first ever) = a (re)connect. Arm the haptic block + re-init HERE,
+				// directly off the reply stream -- reliable even when hapticTask's 300ms link-up edge doesn't fire
+				// (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
+				if (g_connReplyMs == 0 ||
+				    (uint32_t)(millis() - g_connReplyMs) >
+					    1500u)
+					hapticOnReconnect();
+				g_connRx++;
+				g_connReplyMs =
+					millis(); // link alive -> loop() suppresses the redundant E1 beacon
+				uint8_t rs =
+					(uint8_t)(NRF_RADIO->RSSISAMPLE &
+						  0x7F); // |dBm| of this reply (started by the ADDRESS short)
+				if (rs)
+					g_linkRssi =
+						g_linkRssi ?
+							(uint8_t)((g_linkRssi *
+									   7u +
+								   rs + 4u) /
+								  8u) :
+							rs; // EWMA, ~8-sample horizon
+			}
+			if (rtype == 0xF1)
+				g_stF1++;
+			if (rtype == 0xF2)
+				g_connCooldown =
+					millis(); // controller disconnecting/powering off -> back off 2.5s
+			if (rtype ==
+			    0xF3) { // F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
+				g_stF3++;
+				g_connF3v = rfrx[6];
+				if (g_connVerbose &&
+				    Serial.availableForWrite() > 40) {
+					Serial.print("  F3 ");
+					for (uint8_t i = 0;
+					     i <
+					     (rxlen + 2 < 32 ? rxlen + 2 : 32);
+					     i++)
+						Serial.printf("%02X", rfrx[i]);
+					Serial.println();
+				}
+			}
+			bool isF1 = (rtype == 0xF1);
+			if (isF1) {
+				g_connF1++;
+				int idx = 3,
+				    end = rxlen +
+					  2; // walk ALL type6 TLVs (= HID report 0x45). idx is INT, not
+				const uint8_t *lastRep = nullptr;
+				uint8_t lastTlen =
+					0; // uint8_t: a tlen of 0xFE would make idx+=tlen+2 wrap
+				while (idx + 1 <
+				       end) { // mod-256 back to itself -> infinite loop -> USB hang/"crash".
+					uint8_t tlen = rfrx[idx],
+						ttype = rfrx[idx +
+							     1]; // pace with the real puck -- taking only [0] halved our rate.
+					if (tlen == 0)
+						break;
+					// Only a FULL 0x45 report that fits entirely in rfrx: a short or late/garbled TLV must not let the
+					// decode read past the RF buffer (corrupts rftx/RAM -> eventual crash).
+					if (ttype == 6 && tlen >= 28 &&
+					    (size_t)(idx + 2) + tlen <=
+						    sizeof(rfrx) &&
+					    rfrx[idx + 2] == 0x45) {
+						const uint8_t *rep =
+							&rfrx[idx +
+							      2]; // report 0x45: [0x45][seq][buttons u32]...
+						bool fresh =
+							(rep[1] != g_lastSeq);
+						if (fresh) {
+							g_stNew++;
+							g_lastSeq = rep[1];
+						} // genuine new report vs stale poll-repeat
+						uint32_t bb = btnsOf(rep);
+						// USB remote wakeup on Steam button short press (down + up within 1 s). A long press likely means
+						// the user is powering off the controller, so we ignore it.
+						{
+							static bool steamWasDown =
+								false;
+							static unsigned long
+								steamDownMs = 0;
+							if (fresh) {
+								bool steamNow =
+									(bb &
+									 TB_STEAM) !=
+									0;
+								if (steamNow &&
+								    !steamWasDown)
+									steamDownMs =
+										millis(); // rising edge: record press time
+								if (!steamNow &&
+								    steamWasDown &&
+								    millis() - steamDownMs <
+									    1000u &&
+								    USBDevice
+									    .suspended()) { // falling edge within 1 s -> short press -> wake
+									USBDevice
+										.remoteWakeup();
+									ledWakePulse();
+									if (g_active)
+										g_active->wakeEvent(); // post-resume nudge (host needs real input to actually wake)
+								}
+								steamWasDown =
+									steamNow;
+							}
+						}
+						// Decode the report into the shared g_in (one source, read by every IController).
+						g_in.buttons = bb;
+						// Global power-off chord: Steam + Y held 2 s -> shut the controller down (any mode). Detect on the raw
+						// `bb` (pre-mask), time-based (poll rate varies), fires once per hold, re-arms only after release. While
+						// held, mask Steam+Y out of g_in.buttons so the press doesn't leak to the host -- in EVERY mode except
+						// regular Steam (mode 0 forwards the raw 0x45 to Steam, which owns the Steam button). Runs before
+						// onReport45 below so push modes (Xbox) see the mask too; stream modes read g_in in task() (also masked).
+						{
+							static unsigned long
+								offHoldMs = 0;
+							static bool offFired =
+								false;
+							if ((bb & (TB_STEAM |
+								   TB_Y)) ==
+							    (TB_STEAM | TB_Y)) {
+								if (offHoldMs ==
+								    0)
+									offHoldMs =
+										millis();
+								else if (
+									!offFired &&
+									(unsigned long)(millis() -
+											offHoldMs) >=
+										2000u) {
+									offFired =
+										true;
+									hapticSendShutdown();
+								}
+								if (g_usbMode !=
+								    MODE_STEAM) {
+									g_in.buttons &= ~(
+										uint32_t)(TB_STEAM |
+											  TB_Y); // stream modes read g_in
+									((uint8_t *)
+										 rep)
+										[2] &=
+										~(uint8_t)
+											TB_Y; // push modes read btnsOf(rep): TB_Y in rep[2],
+									((uint8_t *)
+										 rep)
+										[4] &=
+										~(uint8_t)(TB_STEAM >>
+											   16); // TB_STEAM in rep[4]
+								}
+							} else {
+								offHoldMs = 0;
+								offFired =
+									false;
+							}
+						}
+						g_in.lx =
+							(int16_t)s16off(rep, 8);
+						g_in.ly = (int16_t)s16off(rep,
+									  10);
+						g_in.rx = (int16_t)s16off(rep,
+									  12);
+						g_in.ry = (int16_t)s16off(rep,
+									  14);
+						g_in.lt =
+							trigU8(u16off(rep, 4));
+						g_in.rt = trigU8(u16off(
+							rep,
+							6)); // for the Switch digital-trigger threshold
+						g_in.lpx = (int16_t)s16off(rep,
+									   16);
+						g_in.lpy = (int16_t)s16off(rep,
+									   18);
+						g_in.rpx = (int16_t)s16off(rep,
+									   22);
+						g_in.rpy = (int16_t)s16off(rep,
+									   24);
+						// IMU lives at report bytes 0x22..0x2D (rep[34..45]). Decode it ONLY when a FULL 46-byte report was
+						// actually received -- bounded by `end` (the received length), NOT sizeof(rfrx). The outer gate is
+						// tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a short 0x45 (button-only, or
+						// one whose IMU tail was lost) still passes it; without this guard imuFrom45 would read STALE bytes
+						// past the received data and clobber g_in's gyro/accel. On a short frame, hold the last good IMU.
+						if (tlen >= 46 &&
+						    (size_t)(idx + 2) + 46 <=
+							    (size_t)end)
+							imuFrom45(rep, &g_in.ax,
+								  &g_in.ay,
+								  &g_in.az,
+								  &g_in.gx,
+								  &g_in.gy,
+								  &g_in.gz);
+						// Mode-switch chord (all 4 back + face): don't leak the face press to the host. g_in.buttons stays
+						// intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
+						if ((bb & CHORD_BACK4) ==
+						    CHORD_BACK4)
+							((uint8_t *)rep)[2] &= ~(
+								uint8_t)(TB_A |
+									 TB_B |
+									 TB_X |
+									 TB_Y);
+						// Hand the report to the active controller. STREAM modes ignore it (they emit from task() reading
+						// g_in); PUSH modes (Xbox, puck/lizard) build + send their host report here.
+						if (g_active)
+							g_active->onReport45(
+								rep, fresh,
+								tlen);
+						lastRep = rep;
+						lastTlen = tlen;
+					} else if (ttype == 6 &&
+						   (size_t)(idx + 2) + tlen <=
+							   sizeof(rfrx) &&
+						   tlen >= 2 &&
+						   (rfrx[idx + 2] == 0x43 ||
+						    rfrx[idx + 2] == 0x44)) {
+						// Controller STATUS reports (0x43 = periodic power/battery, ~every 2s; 0x44 = status event). The real
+						// puck forwards these to the host verbatim -- that's how Steam reads battery -- but OpenPuck used to
+						// drop everything but 0x45. Forward them (onAuxReport), and snapshot the battery % for the WebUSB panel.
+						const uint8_t *rep =
+							&rfrx[idx +
+							      2]; // [rid][body...]
+						if (rep[0] == 0x43 && tlen >= 3)
+							g_battery =
+								rep[2]; // 0x43 body[1] (~0x5e=94) reads as battery % (sniff-derived)
+						if (g_active)
+							g_active->onAuxReport(
+								rep[0], rep + 1,
+								(uint8_t)(tlen -
+									  1));
+					}
+					idx += tlen + 2;
+				}
+				// mode-switch chord (back4 + face): A=always Steam; B/X/Y=configurable (g_chordBtn[]). Debounced.
+				{
+					static uint8_t chWant = 0xFF, chCnt = 0;
+					uint8_t want = 0xFF;
+					if ((g_in.buttons & CHORD_BACK4) ==
+					    CHORD_BACK4) {
+						if (g_in.buttons & TB_A)
+							want = MODE_STEAM;
+						else if (g_in.buttons & TB_B)
+							want = g_chordBtn[0];
+						else if (g_in.buttons & TB_X)
+							want = g_chordBtn[1];
+						else if (g_in.buttons & TB_Y)
+							want = g_chordBtn[2];
+					}
+					if (want != 0xFF && want == chWant) {
+						if (++chCnt >= 12 &&
+						    want != g_usbMode &&
+						    modeValid(want) &&
+						    !USBDevice.suspended()) {
+							saveMode(want);
+							delay(40);
+							NVIC_SystemReset();
+						}
+					} else {
+						chWant = want;
+						chCnt = (want != 0xFF) ? 1 : 0;
+					}
+				}
+				// compact stream for rf_controller_ui.py -- NON-BLOCKING: skip if CDC TX is backed up (a blocking
+				// Serial.print stalls the RF+USB loop -> jaggy input). One line/frame using the last record.
+				if (lastRep && !g_connVerbose &&
+				    Serial.availableForWrite() > 110 &&
+				    millis() - g_lastStream >= 4) {
+					g_lastStream = millis();
+					Serial.print("I45 ");
+					for (uint8_t i = 0; i < lastTlen; i++)
+						Serial.printf("%02X",
+							      lastRep[i]);
+					Serial.println();
+				}
+			}
+			if (g_connVerbose) {
+				Serial.printf(
+					"%s CRX#%lu txtype%02X ch%u len%u: ",
+					isF1 ? "<<<F1" :
+					       (rtype == 0xF3 ? "  F3" :
+								"  rx"),
+					(unsigned long)g_connRx, payload[0], ch,
+					rxlen);
+				for (uint8_t i = 0;
+				     i < (rxlen + 2 <= 66 ? rxlen + 2 : 66);
+				     i++)
+					Serial.printf("%02X", rfrx[i]);
+				Serial.println();
+			}
+		} else
+			rxlen = 0;
+	} else {
+		g_stNoRx++;
+		g_qosBad++;
+	} // RX window expired with no packet at all
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	return rxlen;
 }
 
 // Drive the connected-mode sequence one step per call. Camps on g_sessCh (the host-frame channel the
 // controller connected on); the host-frame beacon runs in parallel as keepalive.
-static void rfConnStep(){
-  g_connSlot=-1; for(int s=0;s<NSLOT;s++) if(g_slot[s].used){g_connSlot=s;break;}
-  if(g_connSlot<0) return;               // need a bonded slot (session established by host frame)
-  uint8_t ch=g_sessCh;
-  if(g_connSt==0){                       // announce HOST AWAKE: E7 00 00, a few times
-    uint8_t p[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,p,3);
-    if(++g_connStep>=4){ g_connSt=1; g_connStep=0; Serial.println("# CONN: awake announced -> polling GET report 0x45"); }
-  } else {                               // poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
-    if((uint32_t)(micros()-g_lastPollUs) < g_pollUs) return;   // CONTROLLED CADENCE: poll ~every g_pollUs
-    // FIXED-RATE schedule: advance the deadline by exactly one interval. The old `g_lastPollUs = micros()`
-    // reset the reference to AFTER the gate passed, so each cycle's ~1ms of poll work + loop jitter was added
-    // to the next interval and never reclaimed -- the period drifted to ~5000us (200/s) instead of 4000us
-    // (250/s). Advancing by g_pollUs keeps the long-run average at exactly the target; the resync guard
-    // prevents a catch-up burst if we ever fall more than a full interval behind (a real stall).
-    { uint32_t now=micros();
-      static uint32_t lastFire=0;
-      if(lastFire){ g_pollDtSum += (uint32_t)(now-lastFire); g_pollDtCnt++; }   // MEASURED actual period
-      lastFire=now;
-      g_lastPollUs += g_pollUs;
-      if((uint32_t)(now - g_lastPollUs) >= g_pollUs) g_lastPollUs = now;        // fell >1 interval behind -> resync
-    }
-    if((g_connPoll & 0x1F)==0){ uint8_t pa[3]={0xE7,0x00,g_e7b}; rfConnTx(ch,0x01,pa,3); }   // re-assert awake/version
-    rfConnQueueHapticRelay();
-    // Relay (if any) gets its OWN cycled PID, then the GET poll gets the NEXT one -- so they're always distinct
-    // and the controller never dedups the GET as a retransmit of the relay (that was dropping ~half the replies
-    // during haptics). Advancing g_e3pid even when no relay is pending just skips a PID value (harmless).
-    { uint8_t rs1=(uint8_t)((((g_e3pid++)&3)<<1)|1); rfConnFlushRelay(ch, rs1); }
-    {
-      uint8_t p[5]={0xE3,0x02,0x01,0x45,g_getParam}; // E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
-      uint8_t s1 = (g_e3mode==1) ? (uint8_t)((((g_e3pid++)&3)<<1)|1)   // cycle PID (S1 1,3,5,7), NO_ACK=1
-                 : (g_e3mode==2) ? (uint8_t)(((g_e3pid++)&3)<<1)       // cycle PID (S1 0,2,4,6), NO_ACK=0
-                 : 0x07;                                               // fixed (matches captured puck poll)
-      uint8_t rx=rfConnTx(ch,s1,p,5);
-      if(rx) g_chF1[0]++;
-    }
-    g_connPoll++;
-  }
+static void rfConnStep()
+{
+	g_connSlot = -1;
+	for (int s = 0; s < NSLOT; s++)
+		if (g_slot[s].used) {
+			g_connSlot = s;
+			break;
+		}
+	if (g_connSlot < 0)
+		return; // need a bonded slot (session established by host frame)
+	uint8_t ch = g_sessCh;
+	if (g_connSt == 0) { // announce HOST AWAKE: E7 00 00, a few times
+		uint8_t p[3] = { 0xE7, 0x00, g_e7b };
+		rfConnTx(ch, 0x01, p, 3);
+		if (++g_connStep >= 4) {
+			g_connSt = 1;
+			g_connStep = 0;
+			Serial.println(
+				"# CONN: awake announced -> polling GET report 0x45");
+		}
+	} else { // poll loop: E3 + GET report 0x45 every poll; re-assert awake periodically
+		if ((uint32_t)(micros() - g_lastPollUs) < g_pollUs)
+			return; // CONTROLLED CADENCE: poll ~every g_pollUs
+		// FIXED-RATE schedule: advance the deadline by exactly one interval. The old `g_lastPollUs = micros()`
+		// reset the reference to AFTER the gate passed, so each cycle's ~1ms of poll work + loop jitter was added
+		// to the next interval and never reclaimed -- the period drifted to ~5000us (200/s) instead of 4000us
+		// (250/s). Advancing by g_pollUs keeps the long-run average at exactly the target; the resync guard
+		// prevents a catch-up burst if we ever fall more than a full interval behind (a real stall).
+		{
+			uint32_t now = micros();
+			static uint32_t lastFire = 0;
+			if (lastFire) {
+				g_pollDtSum += (uint32_t)(now - lastFire);
+				g_pollDtCnt++;
+			} // MEASURED actual period
+			lastFire = now;
+			g_lastPollUs += g_pollUs;
+			if ((uint32_t)(now - g_lastPollUs) >= g_pollUs)
+				g_lastPollUs =
+					now; // fell >1 interval behind -> resync
+		}
+		if ((g_connPoll & 0x1F) == 0) {
+			uint8_t pa[3] = { 0xE7, 0x00, g_e7b };
+			rfConnTx(ch, 0x01, pa, 3);
+		} // re-assert awake/version
+		rfConnQueueHapticRelay();
+		// Relay (if any) gets its OWN cycled PID, then the GET poll gets the NEXT one -- so they're always distinct
+		// and the controller never dedups the GET as a retransmit of the relay (that was dropping ~half the replies
+		// during haptics). Advancing g_e3pid even when no relay is pending just skips a PID value (harmless).
+		{
+			uint8_t rs1 = (uint8_t)((((g_e3pid++) & 3) << 1) | 1);
+			rfConnFlushRelay(ch, rs1);
+		}
+		{
+			uint8_t p[5] = {
+				0xE3, 0x02, 0x01, 0x45, g_getParam
+			}; // E3 + TLV [len=02][subtype=01 GET][id=0x45][param]
+			uint8_t s1 =
+				(g_e3mode == 1) ?
+					(uint8_t)((((g_e3pid++) & 3) << 1) |
+						  1) // cycle PID (S1 1,3,5,7), NO_ACK=1
+				:
+				(g_e3mode == 2) ?
+					(uint8_t)(((g_e3pid++) & 3)
+						  << 1) // cycle PID (S1 0,2,4,6), NO_ACK=0
+					:
+					0x07; // fixed (matches captured puck poll)
+			uint8_t rx = rfConnTx(ch, s1, p, 5);
+			if (rx)
+				g_chF1[0]++;
+		}
+		g_connPoll++;
+	}
 }
 
-void rfLinkTask(){
-  // Host-frame beacon: sent continuously, INCLUDING while connected. The controller uses the periodic E1 (the
-  // real puck's per-hop-cycle announce) to stay synced and keep answering polls at full rate; suppressing it
-  // drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect cooldown so a controller
-  // that's powering off isn't immediately re-woken/reconnected.
-  if (g_rfHost && millis()-g_connCooldown > 2500) {
-    bool connNow = (g_connSlot>=0 && millis()-g_connReplyMs < 300);
-    // session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
-    // (every-loop beaconing also hammers the session ch and steals reply slots from the poll)
-    if (millis()-g_lastSessBeacon >= (connNow ? 25u : 0u)) { g_lastSessBeacon=millis(); g_rfCh=g_sessCh; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s, false); }
-    // discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
-    if (millis()-g_lastDisc >= (connNow ? 200u : 0u)) { g_lastDisc=millis(); g_rfCh=2; for (int s=0;s<NSLOT;s++) rfHostFrameOnce(s, true); }
-  }
-  if (g_connOn && millis()-g_connCooldown > 2500) { rfConnStep(); }            // connected-mode: poll controller, read input
-  { static bool wasRfConn=false;                                               // remote wakeup on new RF controller connection
-    bool nowRfConn=(g_connSlot>=0 && millis()-g_connReplyMs<300);
-    if(nowRfConn && !wasRfConn && USBDevice.suspended()){
-      USBDevice.remoteWakeup(); ledWakePulse();
-      if(g_active) g_active->wakeEvent();   // post-resume nudge (host needs real input to actually wake)
-    }
-    wasRfConn=nowRfConn;
-  }
-  // QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
-  if (g_qos && g_connSlot>=0 && millis()-g_qosCheckMs>=600) {
-    uint16_t bad=g_qosBad; g_qosBad=0; g_qosCheckMs=millis();
-    if (bad>20 && millis()-g_qosLastHopMs>2000) {
-      for(int k=0;k<(int)sizeof g_hopCand;k++){ g_hopIdx=(g_hopIdx+1)%(sizeof g_hopCand); if(g_hopCand[g_hopIdx]!=g_sessCh) break; }
-      if(Serial.availableForWrite()>60) Serial.printf("# QoS: ch%u bad=%u -> hop ch%u\n",g_sessCh,bad,g_hopCand[g_hopIdx]);
-      rfHopTo(g_hopCand[g_hopIdx]); g_qosLastHopMs=millis();
-    }
-  }
-  if (g_connOn && millis()-g_stMs>=1000){ g_f1ps=g_stF1; g_newps=g_stNew; g_pollsps=(uint16_t)g_stPoll;
-    g_pollPeriodUs = g_pollDtCnt ? (uint16_t)(g_pollDtSum/g_pollDtCnt) : 0; g_pollDtSum=0; g_pollDtCnt=0; if(Serial.availableForWrite()>70) Serial.printf("# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",(unsigned long)g_stPoll,(unsigned long)g_stF1,(unsigned long)g_stNew,(unsigned long)g_stF3,(int8_t)g_connF3v,g_e7b,(unsigned long)g_stCrc,(unsigned long)g_stNoRx,g_connSlot); g_stPoll=0; g_stF1=0; g_stNew=0; g_stF3=0; g_stCrc=0; g_stNoRx=0; g_chF1[0]=g_chF1[1]=g_chF1[2]=0; g_stMs=millis(); }
+void rfLinkTask()
+{
+	// Host-frame beacon: sent continuously, INCLUDING while connected. The controller uses the periodic E1 (the
+	// real puck's per-hop-cycle announce) to stay synced and keep answering polls at full rate; suppressing it
+	// drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect cooldown so a controller
+	// that's powering off isn't immediately re-woken/reconnected.
+	if (g_rfHost && millis() - g_connCooldown > 2500) {
+		bool connNow =
+			(g_connSlot >= 0 && millis() - g_connReplyMs < 300);
+		// session keepalive on the clean channel: every loop while connecting (fast), every 25ms once connected
+		// (every-loop beaconing also hammers the session ch and steals reply slots from the poll)
+		if (millis() - g_lastSessBeacon >= (connNow ? 25u : 0u)) {
+			g_lastSessBeacon = millis();
+			g_rfCh = g_sessCh;
+			for (int s = 0; s < NSLOT; s++)
+				rfHostFrameOnce(s, false);
+		}
+		// discovery beacon on ch2 (where a searching controller looks): every loop when down, occasionally when up
+		if (millis() - g_lastDisc >= (connNow ? 200u : 0u)) {
+			g_lastDisc = millis();
+			g_rfCh = 2;
+			for (int s = 0; s < NSLOT; s++)
+				rfHostFrameOnce(s, true);
+		}
+	}
+	if (g_connOn && millis() - g_connCooldown > 2500) {
+		rfConnStep();
+	} // connected-mode: poll controller, read input
+	{
+		static bool wasRfConn =
+			false; // remote wakeup on new RF controller connection
+		bool nowRfConn =
+			(g_connSlot >= 0 && millis() - g_connReplyMs < 300);
+		if (nowRfConn && !wasRfConn && USBDevice.suspended()) {
+			USBDevice.remoteWakeup();
+			ledWakePulse();
+			if (g_active)
+				g_active->wakeEvent(); // post-resume nudge (host needs real input to actually wake)
+		}
+		wasRfConn = nowRfConn;
+	}
+	// QoS: if the current channel is degrading (crcfail+noRx), hop to the next clean candidate (conservative).
+	if (g_qos && g_connSlot >= 0 && millis() - g_qosCheckMs >= 600) {
+		uint16_t bad = g_qosBad;
+		g_qosBad = 0;
+		g_qosCheckMs = millis();
+		if (bad > 20 && millis() - g_qosLastHopMs > 2000) {
+			for (int k = 0; k < (int)sizeof g_hopCand; k++) {
+				g_hopIdx = (g_hopIdx + 1) % (sizeof g_hopCand);
+				if (g_hopCand[g_hopIdx] != g_sessCh)
+					break;
+			}
+			if (Serial.availableForWrite() > 60)
+				Serial.printf(
+					"# QoS: ch%u bad=%u -> hop ch%u\n",
+					g_sessCh, bad, g_hopCand[g_hopIdx]);
+			rfHopTo(g_hopCand[g_hopIdx]);
+			g_qosLastHopMs = millis();
+		}
+	}
+	if (g_connOn && millis() - g_stMs >= 1000) {
+		g_f1ps = g_stF1;
+		g_newps = g_stNew;
+		g_pollsps = (uint16_t)g_stPoll;
+		g_pollPeriodUs =
+			g_pollDtCnt ? (uint16_t)(g_pollDtSum / g_pollDtCnt) : 0;
+		g_pollDtSum = 0;
+		g_pollDtCnt = 0;
+		if (Serial.availableForWrite() > 70)
+			Serial.printf(
+				"# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",
+				(unsigned long)g_stPoll, (unsigned long)g_stF1,
+				(unsigned long)g_stNew, (unsigned long)g_stF3,
+				(int8_t)g_connF3v, g_e7b,
+				(unsigned long)g_stCrc, (unsigned long)g_stNoRx,
+				g_connSlot);
+		g_stPoll = 0;
+		g_stF1 = 0;
+		g_stNew = 0;
+		g_stF3 = 0;
+		g_stCrc = 0;
+		g_stNoRx = 0;
+		g_chF1[0] = g_chF1[1] = g_chF1[2] = 0;
+		g_stMs = millis();
+	}
 }

+ 29 - 20
OpenPuck/rf_link.h

@@ -14,34 +14,42 @@
 #include <stdint.h>
 
 // ---- operational toggles + tunables (set from the CDC console / WebUSB; read here + by webusb status) ----
-extern bool     g_rfHost;       // auto-start host beacon on boot (resumes puck role after a USB replug)
-extern bool     g_connOn;       // auto-start connected-mode poll on boot
-extern uint8_t  g_connType;     // start packet type (E7 handshake, then E3)
-extern uint8_t  g_e7b;          // E7 payload B-byte: 0=current(slow/awake), 1=test protocol-version-1
-extern uint8_t  g_connLen;
-extern uint8_t  g_getParam;     // GET report 0x45 param byte
-extern uint8_t  g_e3mode;       // E3-poll PID/S1 mode (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0)
-extern bool     g_connVerbose;  // full multi-line debug dump vs compact "I45 <hex>" stream
-extern uint32_t g_rxWin;        // poll RX-window (us): caps poll rate (~1e6/rxWin)
-extern unsigned long g_connCooldown; // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
+extern bool
+	g_rfHost; // auto-start host beacon on boot (resumes puck role after a USB replug)
+extern bool g_connOn; // auto-start connected-mode poll on boot
+extern uint8_t g_connType; // start packet type (E7 handshake, then E3)
+extern uint8_t
+	g_e7b; // E7 payload B-byte: 0=current(slow/awake), 1=test protocol-version-1
+extern uint8_t g_connLen;
+extern uint8_t g_getParam; // GET report 0x45 param byte
+extern uint8_t
+	g_e3mode; // E3-poll PID/S1 mode (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0)
+extern bool
+	g_connVerbose; // full multi-line debug dump vs compact "I45 <hex>" stream
+extern uint32_t g_rxWin; // poll RX-window (us): caps poll rate (~1e6/rxWin)
+extern unsigned long
+	g_connCooldown; // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep
 
 // connected-mode state (reset by the 'k' console toggle)
-extern uint8_t  g_connSt, g_connStep;
+extern uint8_t g_connSt, g_connStep;
 extern uint16_t g_connPoll;
-extern uint32_t g_connF1;       // count of 0xF1 input reports seen
-extern uint8_t  g_connF3v;      // last protocol version the controller reported in an F3 reply (0xFF=none)
+extern uint32_t g_connF1; // count of 0xF1 input reports seen
+extern uint8_t
+	g_connF3v; // last protocol version the controller reported in an F3 reply (0xFF=none)
 
 // QoS adaptive channel hopping
-extern uint8_t  g_qos;          // 0=off (static g_sessCh), 1=auto-hop on degradation
-extern uint8_t  g_hopIdx;
+extern uint8_t g_qos; // 0=off (static g_sessCh), 1=auto-hop on degradation
+extern uint8_t g_hopIdx;
 extern volatile uint16_t g_qosBad;
 extern unsigned long g_qosCheckMs, g_qosLastHopMs;
 
 // per-second rate readouts for the WebUSB status blob
-extern uint16_t g_f1ps;         // last completed second's F1 rate
-extern uint16_t g_newps;        // genuine new-report rate (report 0x45 seq byte changes)
-extern uint16_t g_pollsps;      // last second's poll TX count (GET+relay) -- vs F1 tells starvation from reply-loss
-extern uint16_t g_pollPeriodUs; // MEASURED avg us between GET-poll fires (compare to the intended g_pollUs=4000)
+extern uint16_t g_f1ps; // last completed second's F1 rate
+extern uint16_t g_newps; // genuine new-report rate (report 0x45 seq byte changes)
+extern uint16_t
+	g_pollsps; // last second's poll TX count (GET+relay) -- vs F1 tells starvation from reply-loss
+extern uint16_t
+	g_pollPeriodUs; // MEASURED avg us between GET-poll fires (compare to the intended g_pollUs=4000)
 
 // Smoothed controller->puck signal strength, sampled by the radio (RSSISAMPLE) on each CRC-good controller
 // reply during the poll. Stored as the dBm MAGNITUDE (35 = -35dBm); 0 = no sample yet. puck_hid reports it
@@ -57,7 +65,8 @@ extern volatile uint8_t g_battery;
 // TX one connected packet [LEN][S1][payload] on channel ch, then RX the reply into rfrx; decodes 0xF1.
 // rxWinUs overrides the reply-wait window (0 = use g_rxWin). Pass a tiny value for NO-ACK relays that expect
 // no reply, so they don't burn a full ~1.2ms window of dead air per haptic.
-uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t* payload, uint8_t plen, uint16_t rxWinUs=0);
+uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
+		 uint16_t rxWinUs = 0);
 // Mid-session channel hop: advertise newCh on the current channel a few times, then move the poll to newCh.
 void rfHopTo(uint8_t newCh);
 // Per-loop: host-frame beacons + connected-mode poll + remote-wakeup + QoS hop + per-second stats.

+ 368 - 74
OpenPuck/serial_console.cpp

@@ -13,78 +13,372 @@
 // CDC commands: l=listen, s=stop, cN=channel, p<hex>=prefix, a<8hex>=base addr, b=bonds, etc.
 // NOTE: command order matters -- this is an else-if chain, so the FIRST matching letter wins. A couple of
 // letters ('C', 'H') appear twice; the second occurrence is unreachable (kept verbatim from the original).
-void serialConsolePoll(){
-  static char line[24]; static uint8_t li=0;
-  while (Serial.available()){
-    char c=Serial.read();
-    if (c=='\n'||c=='\r'){ line[li]=0;
-      // FULL factory wipe -- requires the exact word "ERASE-ALL" (not a single letter) so it can't be fat-
-      // fingered. Reformats the internal FS (cfg.bin + bonds.bin gone), then reboots into clean defaults; the
-      // controller must be re-paired afterwards.
-      if (!strcmp(line,"ERASE-ALL")){ Serial.println("# ERASING ALL persistent storage (config + bonds)..."); factoryErase(); Serial.println("# done -- rebooting into clean defaults; re-pair the controller"); delay(40); NVIC_SystemReset(); }
-      else if (line[0]=='l') rfListenStart();
-      else if (line[0]=='B'){ g_rfBeacon=!g_rfBeacon; g_rfListen=false; Serial.printf("# beacon %s ch%u\n",g_rfBeacon?"ON":"off",g_rfCh); }
-      else if (line[0]=='L'){ g_plen=strtol(line+1,0,16); Serial.printf("# plen=%02X\n",g_plen); }
-      else if (line[0]=='1'){ g_s1incl=!g_s1incl; Serial.printf("# s1incl=%u\n",g_s1incl); }
-      else if (line[0]=='R'){ g_rfCh=atoi(line+1); g_rfRaw=true; g_rfListen=false; g_rfBeacon=false; rfRawStart(g_rfCh); Serial.printf("# raw cap ch%u\n",g_rfCh); }
-      else if (line[0]=='Z'){ g_rfRaw=true; g_rfSweep=true; g_rfListen=false; g_rfBeacon=false; g_rfCh=2; rfRawStart(g_rfCh); Serial.println("# raw SWEEP"); }
-      else if (line[0]=='H'){ g_rfHost=!g_rfHost; g_rfBeacon=false; g_rfListen=false; g_rfRaw=false; g_rfSweep=false; g_rfCh=2;
-        Serial.printf("# HOSTFRAME beacon %s base %02X%02X%02X%02X (slot0 uuids %02X%02X%02X%02X/%02X%02X%02X%02X)\n",
-          g_rfHost?"ON":"off",g_rfBase[0],g_rfBase[1],g_rfBase[2],g_rfBase[3],
-          g_slot[0].rec[0],g_slot[0].rec[1],g_slot[0].rec[2],g_slot[0].rec[3],
-          g_slot[0].rec[4],g_slot[0].rec[5],g_slot[0].rec[6],g_slot[0].rec[7]); }
-      else if (line[0]=='C'){ g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=g_rfHost=false; rfRespondStart(); }
-      else if (line[0]=='s'){ g_rfListen=false; g_rfBeacon=false; g_rfRaw=false; g_rfSweep=false; g_rfHost=false; g_rfRespond=false; NRF_RADIO->TASKS_DISABLE=1; Serial.println("# RF off"); }
-      else if (line[0]=='x'){ uint8_t m=strtoul(line+1,0,10); if(modeValid(m)){
-        if(USBDevice.suspended()){ Serial.println("# mode change blocked: host suspended"); }
-        else { Serial.printf("# switch mode %u (reboot)\n",m); delay(20); saveMode(m); delay(40); NVIC_SystemReset(); }
-      } }   // switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean
-      else if (line[0]=='c'){ g_rfCh=atoi(line+1); Serial.printf("# ch=%u\n",g_rfCh); if(g_rfListen) rfListenStart(); }
-      else if (line[0]=='p'){ g_rfPrefix=strtol(line+1,0,16); Serial.printf("# prefix=%02X\n",g_rfPrefix); if(g_rfListen) rfListenStart(); }
-      else if (line[0]=='i'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%06lX\n",(unsigned long)g_crcinit); }
-      else if (line[0]=='w'){ g_whiteiv=atoi(line+1); Serial.printf("# whiteiv=%u\n",g_whiteiv); }
-      else if (line[0]=='0'){ g_pcnf0=strtoul(line+1,0,16); Serial.printf("# pcnf0=%08lX\n",(unsigned long)g_pcnf0); }
-      else if (line[0]=='2'){ g_pcnf1=strtoul(line+1,0,16); Serial.printf("# pcnf1=%08lX\n",(unsigned long)g_pcnf1); }
-      else if (line[0]=='M'){ g_mode=strtoul(line+1,0,16); Serial.printf("# mode=%u (3=Ble1M,4=Ble2M,0/2=Nrf)\n",g_mode); }
-      else if (line[0]=='I'){ g_crcinit=strtoul(line+1,0,16); Serial.printf("# crcinit=%08lX\n",(unsigned long)g_crcinit); }
-      else if (line[0]=='P'){ g_crcpoly=strtoul(line+1,0,16); Serial.printf("# crcpoly=%lX\n",(unsigned long)g_crcpoly); }
-      else if (line[0]=='N'){ g_crccnf=strtoul(line+1,0,16); Serial.printf("# crccnf=%X\n",g_crccnf); }
-      else if (line[0]=='Y'){ g_rfCap=true; g_rfCapOne=true; g_rfReplay=g_rfHost=g_rfAuto=false; g_rfCh=2; rfCapStart(2); Serial.println("# capture ONE frame for replay..."); }
-      else if (line[0]=='y'){ g_rfReplay=!g_rfReplay; g_rfCap=g_rfHost=g_rfAuto=false; Serial.printf("# REPLAY %s (%uB)\n",g_rfReplay?"ON":"off",g_replayLen); }
-      else if (line[0]=='T'){ g_statlen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# statlen=%02X\n",g_statlen); }
-      else if (line[0]=='k'){ g_connOn=!g_connOn; if(g_connOn){ g_connSt=0; g_connStep=0; g_connPoll=0; g_connF1=0; } Serial.printf("# CONN mode %s: E7-awake[00 00] -> E3+GET-report-0x45 poll on ch%u (param=%02X). F1 seen=%lu\n",g_connOn?"ON":"off",g_rfCh,g_getParam,(unsigned long)g_connF1); }
-      else if (line[0]=='q'){ g_getParam = g_getParam ? 0x00 : 0x2D; Serial.printf("# GET-0x45 param=%02X\n",g_getParam); }
-      else if (line[0]=='v'){ g_connVerbose=!g_connVerbose; Serial.printf("# conn verbose %s (F1 seen=%lu)\n",g_connVerbose?"ON":"off",(unsigned long)g_connF1); }
-      else if (line[0]=='C'){ g_sessCh=strtoul(line+1,0,10); Serial.printf("# session channel=%u (re-pair/reconnect to apply)\n",g_sessCh); }
-      else if (line[0]=='E'){ g_mDiv=strtoul(line+1,0,10); if(g_mDiv<4)g_mDiv=4; saveCfg(); Serial.printf("# xbox-mouse sensitivity divisor=%d (lower=faster)\n",g_mDiv); }
-      else if (line[0]=='F'){ g_mFric=strtoul(line+1,0,10); if(g_mFric>99)g_mFric=99; saveCfg(); Serial.printf("# 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"); }
-      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; 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",i,(const char*[]){"L4","R4","L5","R5"}[i],code); } 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); }
-      else if (line[0]=='J'){ char* sp=0; uint8_t id=strtoul(line+1,&sp,0); uint16_t val=sp?strtoul(sp,0,0):0;  // inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
-        uint8_t pl[3]={id,(uint8_t)(val&0xFF),(uint8_t)(val>>8)}; relayEnqueue(0x87,pl,3);
-        Serial.printf("# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",id,val); }
-      else if (line[0]=='G'){ g_sniff=!g_sniff; g_sniffPh=0; g_rfHost=g_connOn=g_rfCap=g_rfAuto=g_rfReplay=false; if(g_sniff) rfSniffStart(); Serial.printf("# SESSION SNIFF %s (phase0: catch host frame on ibex/ch2; phase1 PARK ch=%s)\n",g_sniff?"ON":"off",g_sniffPark?"override":"primary"); }
-      else if (line[0]=='g'){ g_sniffPark=strtoul(line+1,0,10); if(g_sniffPh==1) rfSniffStart(); Serial.printf("# sniff park channel=%u (0=learned primary)\n",g_sniffPark); }
-      else if (line[0]=='V'){ g_e7b=strtoul(line+1,0,10); Serial.printf("# E7 protocol-version B-byte=%u (0=awake/slow, 1=try v1-fast; watch new=/s; revert if input stops)\n",g_e7b); }
-      else if (line[0]=='O'){ g_relayOp=strtoul(line+1,0,16); Serial.printf("# relay opcode=%02X\n",g_relayOp); }
-      else if (line[0]=='o'){ g_relaySub=strtoul(line+1,0,16); Serial.printf("# relay sub-type=%02X\n",g_relaySub); }
-      else if (line[0]=='h'){ uint8_t nc=strtoul(line+1,0,10); Serial.printf("# HOP %u->%u (advertise on current ch, then poll new). Watch F1=/s\n",g_sessCh,nc); rfHopTo(nc); }
-      else if (line[0]=='z'){ g_qos=!g_qos; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); Serial.printf("# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",g_qos?"ON":"off"); }
-      else if (line[0]=='r'){ g_rxWin=strtoul(line+1,0,10); if(g_rxWin<150)g_rxWin=150; Serial.printf("# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",(unsigned long)g_rxWin,(unsigned long)(1000000/g_rxWin)); }
-      else if (line[0]=='e'){ g_e3mode=strtoul(line+1,0,10); Serial.printf("# E3 poll PID mode=%u (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0) - watch new=/s\n",g_e3mode); }
-      else if (line[0]=='t'){ uint8_t n=line[1]?strtoul(line+1,0,10):40;     // inject n test haptics (output 0x82 [01 01 F7]) over the relay
-        g_testHaptic=n; Serial.printf("# test-haptic burst x%u queued (relay 0x82 01 01 F7 via op=%02X sub=%02X)\n",n,g_relayOp,g_relaySub); }
-      else if (line[0]=='H'){ hapticDumpLog(); }   // dump the captured OUTPUT-report history (oldest->newest)
-      else if (line[0]=='j'){ g_connType=strtoul(line+1,0,16); Serial.printf("# connType=%02X\n",g_connType); }
-      else if (line[0]=='Q'){ g_connLen=strtoul(line+1,0,16); Serial.printf("# connLen=%02X\n",g_connLen); }
-      else if (line[0]=='A'){ g_balen=strtoul(line+1,0,16); g_pcnf1=0; Serial.printf("# balen=%u\n",g_balen); }
-      else if (line[0]=='a'){ uint32_t v=strtoul(line+1,0,16); g_rfBase[0]=v>>24;g_rfBase[1]=v>>16;g_rfBase[2]=v>>8;g_rfBase[3]=v; Serial.printf("# base=%08lX\n",(unsigned long)v); if(g_rfListen) rfListenStart(); }
-      else if (line[0]=='b'){ for(int i=0;i<NSLOT;i++) if(g_slot[i].used){Serial.printf("slot%d ",i); for(int j=0;j<24;j++)Serial.printf("%02X",g_slot[i].rec[j]); Serial.println();} }
-      else if (line[0]=='D'){ armDebugCdcNextBoot(); Serial.println("# debug-CDC armed: reset to KEEP the serial console one more boot (auto-reverts after; the wake mouse is dropped that boot)"); }
-      else if (line[0]=='X'){ g_rfCap=!g_rfCap; g_rfHost=g_rfAuto=g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=g_rfRespond=false; g_rfCh=2; g_capV=0; if(g_rfCap) rfCapStart(g_rfCh); Serial.printf("# CAPTURE(listen) %s ch%u on \"ibex\", cycling 1M/2M x BALEN3/4 (use cN to set ch)\n", g_rfCap?"ON":"off",g_rfCh); }
-      else if (line[0]=='S'){ g_rfAuto=!g_rfAuto; g_rfHost=g_rfAuto; g_rfBeacon=g_rfListen=g_rfRaw=g_rfSweep=false; g_rfCh=2; g_cfgIdx=0; if(g_rfAuto) applyCfg(0); Serial.printf("# AUTOSWEEP %s\n", g_rfAuto?"ON":"off"); }
-      li=0;
-    } else if (li<sizeof line-1) line[li++]=c;
-  }
+void serialConsolePoll()
+{
+	static char line[24];
+	static uint8_t li = 0;
+	while (Serial.available()) {
+		char c = Serial.read();
+		if (c == '\n' || c == '\r') {
+			line[li] = 0;
+			// FULL factory wipe -- requires the exact word "ERASE-ALL" (not a single letter) so it can't be fat-
+			// fingered. Reformats the internal FS (cfg.bin + bonds.bin gone), then reboots into clean defaults; the
+			// controller must be re-paired afterwards.
+			if (!strcmp(line, "ERASE-ALL")) {
+				Serial.println(
+					"# ERASING ALL persistent storage (config + bonds)...");
+				factoryErase();
+				Serial.println(
+					"# done -- rebooting into clean defaults; re-pair the controller");
+				delay(40);
+				NVIC_SystemReset();
+			} else if (line[0] == 'l')
+				rfListenStart();
+			else if (line[0] == 'B') {
+				g_rfBeacon = !g_rfBeacon;
+				g_rfListen = false;
+				Serial.printf("# beacon %s ch%u\n",
+					      g_rfBeacon ? "ON" : "off",
+					      g_rfCh);
+			} else if (line[0] == 'L') {
+				g_plen = strtol(line + 1, 0, 16);
+				Serial.printf("# plen=%02X\n", g_plen);
+			} else if (line[0] == '1') {
+				g_s1incl = !g_s1incl;
+				Serial.printf("# s1incl=%u\n", g_s1incl);
+			} else if (line[0] == 'R') {
+				g_rfCh = atoi(line + 1);
+				g_rfRaw = true;
+				g_rfListen = false;
+				g_rfBeacon = false;
+				rfRawStart(g_rfCh);
+				Serial.printf("# raw cap ch%u\n", g_rfCh);
+			} else if (line[0] == 'Z') {
+				g_rfRaw = true;
+				g_rfSweep = true;
+				g_rfListen = false;
+				g_rfBeacon = false;
+				g_rfCh = 2;
+				rfRawStart(g_rfCh);
+				Serial.println("# raw SWEEP");
+			} else if (line[0] == 'H') {
+				g_rfHost = !g_rfHost;
+				g_rfBeacon = false;
+				g_rfListen = false;
+				g_rfRaw = false;
+				g_rfSweep = false;
+				g_rfCh = 2;
+				Serial.printf(
+					"# HOSTFRAME beacon %s base %02X%02X%02X%02X (slot0 uuids %02X%02X%02X%02X/%02X%02X%02X%02X)\n",
+					g_rfHost ? "ON" : "off", g_rfBase[0],
+					g_rfBase[1], g_rfBase[2], g_rfBase[3],
+					g_slot[0].rec[0], g_slot[0].rec[1],
+					g_slot[0].rec[2], g_slot[0].rec[3],
+					g_slot[0].rec[4], g_slot[0].rec[5],
+					g_slot[0].rec[6], g_slot[0].rec[7]);
+			} else if (line[0] == 'C') {
+				g_rfBeacon = g_rfListen = g_rfRaw = g_rfSweep =
+					g_rfHost = false;
+				rfRespondStart();
+			} else if (line[0] == 's') {
+				g_rfListen = false;
+				g_rfBeacon = false;
+				g_rfRaw = false;
+				g_rfSweep = false;
+				g_rfHost = false;
+				g_rfRespond = false;
+				NRF_RADIO->TASKS_DISABLE = 1;
+				Serial.println("# RF off");
+			} else if (line[0] == 'x') {
+				uint8_t m = strtoul(line + 1, 0, 10);
+				if (modeValid(m)) {
+					if (USBDevice.suspended()) {
+						Serial.println(
+							"# mode change blocked: host suspended");
+					} else {
+						Serial.printf(
+							"# switch mode %u (reboot)\n",
+							m);
+						delay(20);
+						saveMode(m);
+						delay(40);
+						NVIC_SystemReset();
+					}
+				}
+			} // switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean
+			else if (line[0] == 'c') {
+				g_rfCh = atoi(line + 1);
+				Serial.printf("# ch=%u\n", g_rfCh);
+				if (g_rfListen)
+					rfListenStart();
+			} else if (line[0] == 'p') {
+				g_rfPrefix = strtol(line + 1, 0, 16);
+				Serial.printf("# prefix=%02X\n", g_rfPrefix);
+				if (g_rfListen)
+					rfListenStart();
+			} else if (line[0] == 'i') {
+				g_crcinit = strtoul(line + 1, 0, 16);
+				Serial.printf("# crcinit=%06lX\n",
+					      (unsigned long)g_crcinit);
+			} else if (line[0] == 'w') {
+				g_whiteiv = atoi(line + 1);
+				Serial.printf("# whiteiv=%u\n", g_whiteiv);
+			} else if (line[0] == '0') {
+				g_pcnf0 = strtoul(line + 1, 0, 16);
+				Serial.printf("# pcnf0=%08lX\n",
+					      (unsigned long)g_pcnf0);
+			} else if (line[0] == '2') {
+				g_pcnf1 = strtoul(line + 1, 0, 16);
+				Serial.printf("# pcnf1=%08lX\n",
+					      (unsigned long)g_pcnf1);
+			} else if (line[0] == 'M') {
+				g_mode = strtoul(line + 1, 0, 16);
+				Serial.printf(
+					"# mode=%u (3=Ble1M,4=Ble2M,0/2=Nrf)\n",
+					g_mode);
+			} else if (line[0] == 'I') {
+				g_crcinit = strtoul(line + 1, 0, 16);
+				Serial.printf("# crcinit=%08lX\n",
+					      (unsigned long)g_crcinit);
+			} else if (line[0] == 'P') {
+				g_crcpoly = strtoul(line + 1, 0, 16);
+				Serial.printf("# crcpoly=%lX\n",
+					      (unsigned long)g_crcpoly);
+			} else if (line[0] == 'N') {
+				g_crccnf = strtoul(line + 1, 0, 16);
+				Serial.printf("# crccnf=%X\n", g_crccnf);
+			} else if (line[0] == 'Y') {
+				g_rfCap = true;
+				g_rfCapOne = true;
+				g_rfReplay = g_rfHost = g_rfAuto = false;
+				g_rfCh = 2;
+				rfCapStart(2);
+				Serial.println(
+					"# capture ONE frame for replay...");
+			} else if (line[0] == 'y') {
+				g_rfReplay = !g_rfReplay;
+				g_rfCap = g_rfHost = g_rfAuto = false;
+				Serial.printf("# REPLAY %s (%uB)\n",
+					      g_rfReplay ? "ON" : "off",
+					      g_replayLen);
+			} else if (line[0] == 'T') {
+				g_statlen = strtoul(line + 1, 0, 16);
+				g_pcnf1 = 0;
+				Serial.printf("# statlen=%02X\n", g_statlen);
+			} else if (line[0] == 'k') {
+				g_connOn = !g_connOn;
+				if (g_connOn) {
+					g_connSt = 0;
+					g_connStep = 0;
+					g_connPoll = 0;
+					g_connF1 = 0;
+				}
+				Serial.printf(
+					"# CONN mode %s: E7-awake[00 00] -> E3+GET-report-0x45 poll on ch%u (param=%02X). F1 seen=%lu\n",
+					g_connOn ? "ON" : "off", g_rfCh,
+					g_getParam, (unsigned long)g_connF1);
+			} else if (line[0] == 'q') {
+				g_getParam = g_getParam ? 0x00 : 0x2D;
+				Serial.printf("# GET-0x45 param=%02X\n",
+					      g_getParam);
+			} else if (line[0] == 'v') {
+				g_connVerbose = !g_connVerbose;
+				Serial.printf(
+					"# conn verbose %s (F1 seen=%lu)\n",
+					g_connVerbose ? "ON" : "off",
+					(unsigned long)g_connF1);
+			} else if (line[0] == 'C') {
+				g_sessCh = strtoul(line + 1, 0, 10);
+				Serial.printf(
+					"# session channel=%u (re-pair/reconnect to apply)\n",
+					g_sessCh);
+			} else if (line[0] == 'E') {
+				g_mDiv = strtoul(line + 1, 0, 10);
+				if (g_mDiv < 4)
+					g_mDiv = 4;
+				saveCfg();
+				Serial.printf(
+					"# xbox-mouse sensitivity divisor=%d (lower=faster)\n",
+					g_mDiv);
+			} else if (line[0] == 'F') {
+				g_mFric = strtoul(line + 1, 0, 10);
+				if (g_mFric > 99)
+					g_mFric = 99;
+				saveCfg();
+				Serial.printf(
+					"# 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");
+			} 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;
+					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",
+						i,
+						(const char *[]){ "L4", "R4",
+								  "L5",
+								  "R5" }[i],
+						code);
+				} 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);
+			} else if (line[0] == 'J') {
+				char *sp = 0;
+				uint8_t id = strtoul(line + 1, &sp, 0);
+				uint16_t val =
+					sp ? strtoul(sp, 0, 0) :
+					     0; // inject SET-SETTINGS to controller: report 0x87 [id][val u16 LE]
+				uint8_t pl[3] = { id, (uint8_t)(val & 0xFF),
+						  (uint8_t)(val >> 8) };
+				relayEnqueue(0x87, pl, 3);
+				Serial.printf(
+					"# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",
+					id, val);
+			} else if (line[0] == 'G') {
+				g_sniff = !g_sniff;
+				g_sniffPh = 0;
+				g_rfHost = g_connOn = g_rfCap = g_rfAuto =
+					g_rfReplay = false;
+				if (g_sniff)
+					rfSniffStart();
+				Serial.printf(
+					"# SESSION SNIFF %s (phase0: catch host frame on ibex/ch2; phase1 PARK ch=%s)\n",
+					g_sniff ? "ON" : "off",
+					g_sniffPark ? "override" : "primary");
+			} else if (line[0] == 'g') {
+				g_sniffPark = strtoul(line + 1, 0, 10);
+				if (g_sniffPh == 1)
+					rfSniffStart();
+				Serial.printf(
+					"# sniff park channel=%u (0=learned primary)\n",
+					g_sniffPark);
+			} else if (line[0] == 'V') {
+				g_e7b = strtoul(line + 1, 0, 10);
+				Serial.printf(
+					"# E7 protocol-version B-byte=%u (0=awake/slow, 1=try v1-fast; watch new=/s; revert if input stops)\n",
+					g_e7b);
+			} else if (line[0] == 'O') {
+				g_relayOp = strtoul(line + 1, 0, 16);
+				Serial.printf("# relay opcode=%02X\n",
+					      g_relayOp);
+			} else if (line[0] == 'o') {
+				g_relaySub = strtoul(line + 1, 0, 16);
+				Serial.printf("# relay sub-type=%02X\n",
+					      g_relaySub);
+			} else if (line[0] == 'h') {
+				uint8_t nc = strtoul(line + 1, 0, 10);
+				Serial.printf(
+					"# HOP %u->%u (advertise on current ch, then poll new). Watch F1=/s\n",
+					g_sessCh, nc);
+				rfHopTo(nc);
+			} else if (line[0] == 'z') {
+				g_qos = !g_qos;
+				g_hopIdx = 0;
+				g_qosBad = 0;
+				g_qosCheckMs = millis();
+				Serial.printf(
+					"# QoS adaptive channel hopping %s (candidates 18,46,76,22,68)\n",
+					g_qos ? "ON" : "off");
+			} else if (line[0] == 'r') {
+				g_rxWin = strtoul(line + 1, 0, 10);
+				if (g_rxWin < 150)
+					g_rxWin = 150;
+				Serial.printf(
+					"# poll RX-window=%lu us (poll rate caps ~%lu/s)\n",
+					(unsigned long)g_rxWin,
+					(unsigned long)(1000000 / g_rxWin));
+			} else if (line[0] == 'e') {
+				g_e3mode = strtoul(line + 1, 0, 10);
+				Serial.printf(
+					"# E3 poll PID mode=%u (0=fixed07, 1=cyclePID+noack1, 2=cyclePID+noack0) - watch new=/s\n",
+					g_e3mode);
+			} else if (line[0] == 't') {
+				uint8_t n =
+					line[1] ?
+						strtoul(line + 1, 0, 10) :
+						40; // inject n test haptics (output 0x82 [01 01 F7]) over the relay
+				g_testHaptic = n;
+				Serial.printf(
+					"# test-haptic burst x%u queued (relay 0x82 01 01 F7 via op=%02X sub=%02X)\n",
+					n, g_relayOp, g_relaySub);
+			} else if (line[0] == 'H') {
+				hapticDumpLog();
+			} // dump the captured OUTPUT-report history (oldest->newest)
+			else if (line[0] == 'j') {
+				g_connType = strtoul(line + 1, 0, 16);
+				Serial.printf("# connType=%02X\n", g_connType);
+			} else if (line[0] == 'Q') {
+				g_connLen = strtoul(line + 1, 0, 16);
+				Serial.printf("# connLen=%02X\n", g_connLen);
+			} else if (line[0] == 'A') {
+				g_balen = strtoul(line + 1, 0, 16);
+				g_pcnf1 = 0;
+				Serial.printf("# balen=%u\n", g_balen);
+			} else if (line[0] == 'a') {
+				uint32_t v = strtoul(line + 1, 0, 16);
+				g_rfBase[0] = v >> 24;
+				g_rfBase[1] = v >> 16;
+				g_rfBase[2] = v >> 8;
+				g_rfBase[3] = v;
+				Serial.printf("# base=%08lX\n",
+					      (unsigned long)v);
+				if (g_rfListen)
+					rfListenStart();
+			} else if (line[0] == 'b') {
+				for (int i = 0; i < NSLOT; i++)
+					if (g_slot[i].used) {
+						Serial.printf("slot%d ", i);
+						for (int j = 0; j < 24; j++)
+							Serial.printf(
+								"%02X",
+								g_slot[i].rec[j]);
+						Serial.println();
+					}
+			} else if (line[0] == 'D') {
+				armDebugCdcNextBoot();
+				Serial.println(
+					"# debug-CDC armed: reset to KEEP the serial console one more boot (auto-reverts after; the wake mouse is dropped that boot)");
+			} else if (line[0] == 'X') {
+				g_rfCap = !g_rfCap;
+				g_rfHost = g_rfAuto = g_rfBeacon = g_rfListen =
+					g_rfRaw = g_rfSweep = g_rfRespond =
+						false;
+				g_rfCh = 2;
+				g_capV = 0;
+				if (g_rfCap)
+					rfCapStart(g_rfCh);
+				Serial.printf(
+					"# CAPTURE(listen) %s ch%u on \"ibex\", cycling 1M/2M x BALEN3/4 (use cN to set ch)\n",
+					g_rfCap ? "ON" : "off", g_rfCh);
+			} else if (line[0] == 'S') {
+				g_rfAuto = !g_rfAuto;
+				g_rfHost = g_rfAuto;
+				g_rfBeacon = g_rfListen = g_rfRaw = g_rfSweep =
+					false;
+				g_rfCh = 2;
+				g_cfgIdx = 0;
+				if (g_rfAuto)
+					applyCfg(0);
+				Serial.printf("# AUTOSWEEP %s\n",
+					      g_rfAuto ? "ON" : "off");
+			}
+			li = 0;
+		} else if (li < sizeof line - 1)
+			line[li++] = c;
+	}
 }

+ 1 - 1
OpenPuck/serial_console.h

@@ -6,4 +6,4 @@
 // only in puck modes (clean controller modes drop CDC). See the parser in serial_console.cpp for the full map.
 #pragma once
 
-void serialConsolePoll();   // drain + dispatch CDC commands; call every loop()
+void serialConsolePoll(); // drain + dispatch CDC commands; call every loop()

+ 23 - 14
OpenPuck/status_led.cpp

@@ -1,28 +1,37 @@
 #include "status_led.h"
 #include <Arduino.h>
 
-#define WAKE_LED_OFF ((WAKE_LED_ON)==HIGH ? LOW : HIGH)
-#define PULSE_MS 500u   // wake flash duration
+#define WAKE_LED_OFF ((WAKE_LED_ON) == HIGH ? LOW : HIGH)
+#define PULSE_MS 500u // wake flash duration
 
 static unsigned long g_pulseMs = 0;
 static bool g_lit = false;
 
-static void ledWrite(int level){
-  digitalWrite(WAKE_LED_PIN_A, level);
-  digitalWrite(WAKE_LED_PIN_B, level);
+static void ledWrite(int level)
+{
+	digitalWrite(WAKE_LED_PIN_A, level);
+	digitalWrite(WAKE_LED_PIN_B, level);
 }
 
-void ledInit(){
-  pinMode(WAKE_LED_PIN_A, OUTPUT);
-  pinMode(WAKE_LED_PIN_B, OUTPUT);
-  ledWrite(WAKE_LED_OFF);
+void ledInit()
+{
+	pinMode(WAKE_LED_PIN_A, OUTPUT);
+	pinMode(WAKE_LED_PIN_B, OUTPUT);
+	ledWrite(WAKE_LED_OFF);
 }
 
-void ledWakePulse(){
-  g_pulseMs = millis(); g_lit = true;
-  ledWrite(WAKE_LED_ON);   // light immediately at the remoteWakeup() call site, not on the next loop
+void ledWakePulse()
+{
+	g_pulseMs = millis();
+	g_lit = true;
+	ledWrite(
+		WAKE_LED_ON); // light immediately at the remoteWakeup() call site, not on the next loop
 }
 
-void ledTask(){
-  if (g_lit && millis()-g_pulseMs >= PULSE_MS){ g_lit = false; ledWrite(WAKE_LED_OFF); }
+void ledTask()
+{
+	if (g_lit && millis() - g_pulseMs >= PULSE_MS) {
+		g_lit = false;
+		ledWrite(WAKE_LED_OFF);
+	}
 }

+ 8 - 6
OpenPuck/status_led.h

@@ -14,15 +14,17 @@
 #pragma once
 
 #ifndef WAKE_LED_PIN_A
-#define WAKE_LED_PIN_A LED_BUILTIN   // Feather: P1.15 user LED (harmless unconnected pad on SuperMini clones)
+#define WAKE_LED_PIN_A \
+	LED_BUILTIN // Feather: P1.15 user LED (harmless unconnected pad on SuperMini clones)
 #endif
 #ifndef WAKE_LED_PIN_B
-#define WAKE_LED_PIN_B 24            // SuperMini "Pro Micro" clone: P0.15 blue user LED (D24 in the Feather map)
+#define WAKE_LED_PIN_B \
+	24 // SuperMini "Pro Micro" clone: P0.15 blue user LED (D24 in the Feather map)
 #endif
 #ifndef WAKE_LED_ON
-#define WAKE_LED_ON HIGH             // set LOW if your board's LED is wired active-low
+#define WAKE_LED_ON HIGH // set LOW if your board's LED is wired active-low
 #endif
 
-void ledInit();        // call once from setup(): pins to output, LED off
-void ledWakePulse();   // call at each USBDevice.remoteWakeup() site: LED on now, off after 500ms
-void ledTask();        // call every loop(): times out the pulse
+void ledInit(); // call once from setup(): pins to output, LED off
+void ledWakePulse(); // call at each USBDevice.remoteWakeup() site: LED on now, off after 500ms
+void ledTask(); // call every loop(): times out the pulse

+ 9 - 3
OpenPuck/triton.cpp

@@ -2,7 +2,13 @@
 
 PuckInput g_in;
 
-void imuFrom45(const uint8_t* r, int16_t* ax, int16_t* ay, int16_t* az, int16_t* gx, int16_t* gy, int16_t* gz){
-  *ax=s16off(r,32); *ay=s16off(r,34); *az=s16off(r,36);
-  *gx=s16off(r,38); *gy=s16off(r,40); *gz=s16off(r,42);
+void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
+	       int16_t *gx, int16_t *gy, int16_t *gz)
+{
+	*ax = s16off(r, 32);
+	*ay = s16off(r, 34);
+	*az = s16off(r, 36);
+	*gx = s16off(r, 38);
+	*gy = s16off(r, 40);
+	*gz = s16off(r, 42);
 }

+ 58 - 39
OpenPuck/triton.h

@@ -8,57 +8,76 @@
 #include <stdint.h>
 
 // ---- Triton button masks (the 32-bit field at rep[2..5]) ----
-#define TB_A      0x1u
-#define TB_B      0x2u
-#define TB_X      0x4u
-#define TB_Y      0x8u
-#define TB_QAM    0x10u
-#define TB_R3     0x20u
-#define TB_VIEW   0x40u
-#define TB_R4     0x80u
-#define TB_R5     0x100u
-#define TB_RB     0x200u
-#define TB_DDN    0x400u
-#define TB_DRT    0x800u
-#define TB_DLF    0x1000u
-#define TB_DUP    0x2000u
-#define TB_MENU   0x4000u
-#define TB_L3     0x8000u
-#define TB_STEAM  0x10000u
-#define TB_L4     0x20000u
-#define TB_L5     0x40000u
-#define TB_LB     0x80000u
-#define TB_RPADT  0x200000u
-#define TB_RPADC  0x400000u
-#define TB_TOUCH  0x100000u      // virtual: PS Touch Click (back-paddle/QAM target, not a real Triton button)
-#define TB_MUTE   0x1000000u     // virtual: PS5 Mute button (back-paddle/QAM target, not a real Triton button)
-#define TB_LPADT  0x2000000u
-#define TB_LPADC  0x4000000u
+#define TB_A 0x1u
+#define TB_B 0x2u
+#define TB_X 0x4u
+#define TB_Y 0x8u
+#define TB_QAM 0x10u
+#define TB_R3 0x20u
+#define TB_VIEW 0x40u
+#define TB_R4 0x80u
+#define TB_R5 0x100u
+#define TB_RB 0x200u
+#define TB_DDN 0x400u
+#define TB_DRT 0x800u
+#define TB_DLF 0x1000u
+#define TB_DUP 0x2000u
+#define TB_MENU 0x4000u
+#define TB_L3 0x8000u
+#define TB_STEAM 0x10000u
+#define TB_L4 0x20000u
+#define TB_L5 0x40000u
+#define TB_LB 0x80000u
+#define TB_RPADT 0x200000u
+#define TB_RPADC 0x400000u
+#define TB_TOUCH \
+	0x100000u // virtual: PS Touch Click (back-paddle/QAM target, not a real Triton button)
+#define TB_MUTE \
+	0x1000000u // virtual: PS5 Mute button (back-paddle/QAM target, not a real Triton button)
+#define TB_LPADT 0x2000000u
+#define TB_LPADC 0x4000000u
 
 // all four back paddles held -> mode-switch chord guard
-#define CHORD_BACK4 (TB_R4|TB_L4|TB_R5|TB_L5)
-#define SW_TRIG_ON 40   // analog-trigger fraction (of 0xFF) at which digital ZL/ZR (Switch) etc. trip
+#define CHORD_BACK4 (TB_R4 | TB_L4 | TB_R5 | TB_L5)
+#define SW_TRIG_ON \
+	40 // analog-trigger fraction (of 0xFF) at which digital ZL/ZR (Switch) etc. trip
 
 // ---- report 0x45 field decoders (offsets relative to rep[2], the buttons low byte) ----
-static inline int s16off(const uint8_t* r,int off){ int v=r[2+off]|(r[2+off+1]<<8); return (v&0x8000)?v-0x10000:v; }
-static inline int u16off(const uint8_t* r,int off){ return r[2+off]|(r[2+off+1]<<8); }
+static inline int s16off(const uint8_t *r, int off)
+{
+	int v = r[2 + off] | (r[2 + off + 1] << 8);
+	return (v & 0x8000) ? v - 0x10000 : v;
+}
+static inline int u16off(const uint8_t *r, int off)
+{
+	return r[2 + off] | (r[2 + off + 1] << 8);
+}
 // Controller trigger analog: the u16 in report 0x45 tops out near half-scale (~0x8000) at a full pull, so a
 // straight >>8 reads only ~0x80 (host sees a half-pressed trigger). Scale x2 (>>7) and saturate so a full
 // pull maps to the full 0xFF.
-static inline uint8_t trigU8(int u16v){ int v=u16v>>7; return (uint8_t)(v>255?255:v); }
-static inline uint32_t btnsOf(const uint8_t* r){ return (uint32_t)r[2]|((uint32_t)r[3]<<8)|((uint32_t)r[4]<<16)|((uint32_t)r[5]<<24); }
+static inline uint8_t trigU8(int u16v)
+{
+	int v = u16v >> 7;
+	return (uint8_t)(v > 255 ? 255 : v);
+}
+static inline uint32_t btnsOf(const uint8_t *r)
+{
+	return (uint32_t)r[2] | ((uint32_t)r[3] << 8) | ((uint32_t)r[4] << 16) |
+	       ((uint32_t)r[5] << 24);
+}
 // report 0x45 IMU offsets (PROTOCOL.md §8): accel @0x22, gyro @0x28 from report start.
-void imuFrom45(const uint8_t* r, int16_t* ax, int16_t* ay, int16_t* az, int16_t* gx, int16_t* gy, int16_t* gz);
+void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
+	       int16_t *gx, int16_t *gy, int16_t *gz);
 
 // ---- shared decoded input (filled by rf_link.cpp once per fresh report 0x45, read by every mode) ----
 // This single struct replaces the per-mode duplicates the firmware used to carry (the IMU/stick/button state
 // was copied verbatim into g_swPro*, g_ps5*, and g_gyro*). One source, many consumers.
 struct PuckInput {
-  uint32_t buttons;            // raw Triton buttons (TB_*); per-mode builders apply their own chord masking
-  int16_t  lx, ly, rx, ry;     // sticks (int16, center 0)
-  uint8_t  lt, rt;             // triggers scaled 0..255 (trigU8)
-  int16_t  lpx, lpy, rpx, rpy; // left / right trackpad coords (int16)
-  int16_t  ax, ay, az;         // accelerometer
-  int16_t  gx, gy, gz;         // gyroscope
+	uint32_t buttons; // raw Triton buttons (TB_*); per-mode builders apply their own chord masking
+	int16_t lx, ly, rx, ry; // sticks (int16, center 0)
+	uint8_t lt, rt; // triggers scaled 0..255 (trigU8)
+	int16_t lpx, lpy, rpx, rpy; // left / right trackpad coords (int16)
+	int16_t ax, ay, az; // accelerometer
+	int16_t gx, gy, gz; // gyroscope
 };
 extern PuckInput g_in;

+ 8 - 6
OpenPuck/wake_hid.cpp

@@ -8,10 +8,12 @@
 static const uint8_t WAKE_HID_DESC[] = { TUD_HID_REPORT_DESC_MOUSE() };
 static Adafruit_USBD_HID g_wakeHid;
 
-void wakeHidBegin(){
-  g_wakeHid.setBootProtocol(HID_ITF_PROTOCOL_MOUSE);   // boot mouse = a wake device class honored by Windows + Linux
-  g_wakeHid.setStringDescriptor("OpenPuck Wake");
-  g_wakeHid.setReportDescriptor(WAKE_HID_DESC, sizeof WAKE_HID_DESC);
-  g_wakeHid.setPollInterval(10);
-  g_wakeHid.begin();
+void wakeHidBegin()
+{
+	g_wakeHid.setBootProtocol(
+		HID_ITF_PROTOCOL_MOUSE); // boot mouse = a wake device class honored by Windows + Linux
+	g_wakeHid.setStringDescriptor("OpenPuck Wake");
+	g_wakeHid.setReportDescriptor(WAKE_HID_DESC, sizeof WAKE_HID_DESC);
+	g_wakeHid.setPollInterval(10);
+	g_wakeHid.begin();
 }

+ 1 - 1
OpenPuck/wake_hid.h

@@ -16,4 +16,4 @@
 // (g_debugCdcThisBoot) and ARCHITECTURE.md.
 #pragma once
 
-void wakeHidBegin();   // register the boot-keyboard wake interface (call from setup() for clean modes)
+void wakeHidBegin(); // register the boot-keyboard wake interface (call from setup() for clean modes)

+ 255 - 104
OpenPuck/webusb_config.cpp

@@ -4,7 +4,7 @@
 #include "rf_link.h"
 #include "haptics.h"
 #include "puck_hid.h"
-#include "triton.h"   // g_in raw IMU (diagnostic readout)
+#include "triton.h" // g_in raw IMU (diagnostic readout)
 #include "build_info.h"
 #include <Arduino.h>
 #include <string.h>
@@ -17,121 +17,272 @@ Adafruit_USBD_WebUSB usb_web;
 //                [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]
-#define WB_PAYLEN 60   // keep the blob under 64 bytes total (readBlob reads one 64-byte packet)
-static void webusbSendBlob(){
-  if(!usb_web.connected()) return;
-  bool up = (g_connSlot>=0 && (millis()-g_connReplyMs) < 300);
-  uint8_t p[2+WB_PAYLEN];
-  p[0]=0xA5; p[1]=WB_PAYLEN;
-  p[2]=7;                          // protocol version (7 = +raw accel; 6 = +swPro120/gyroScale; 5 = +rumbleScale)
-  p[3]=g_usbMode; p[4]=(uint8_t)g_mDiv; p[5]=(uint8_t)g_mFric; p[6]=g_qamMap; p[7]=g_abSwap;
-  p[8]=g_back[0]; p[9]=g_back[1]; p[10]=g_back[2]; p[11]=g_back[3];
-  p[12]=(g_connSlot>=0)?(uint8_t)g_connSlot:0xFF;
-  p[13]=up?1:0;
-  p[14]=(uint8_t)g_f1ps; p[15]=(uint8_t)(g_f1ps>>8);
-  p[16]=(uint8_t)(g_pollUs/100);
-  p[17]=(uint8_t)g_newps; p[18]=(uint8_t)(g_newps>>8);
-  p[19]=g_e7b;
-  p[20]=g_relayOp; p[21]=g_relaySub; p[22]=g_fwdNewOnly; p[23]=g_qos; p[24]=g_persistMode?1:0;
-  p[25]=g_chordBtn[0]; p[26]=g_chordBtn[1]; p[27]=g_chordBtn[2];
-  p[28]=(uint8_t)g_pollsps; p[29]=(uint8_t)(g_pollsps>>8);   // poll TX rate (vs delivered/new -> starvation vs reply-loss)
-  p[30]=(uint8_t)g_loopPeriodUs; p[31]=(uint8_t)(g_loopPeriodUs>>8);   // loop diag: avg iteration time + slowest section
-  p[32]=g_loopWorst; p[33]=(uint8_t)g_loopWorstUs; p[34]=(uint8_t)(g_loopWorstUs>>8);
-  p[35]=(uint8_t)g_pollPeriodUs; p[36]=(uint8_t)(g_pollPeriodUs>>8);   // measured actual poll period (vs intended 4000)
-  p[37]=OPK_LOG;                                                       // logging build? panel shows/hides its log UI
-  p[38]=g_battery;                                                     // controller battery % (report 0x43); 0=unknown
-  p[39]=g_linkRssi;                                                    // RAW signal strength |dBm| (0=no sample)
-  // Build provenance: which git commit this firmware was built from, and whether the tree was dirty. Lets the
-  // panel confirm exactly what's flashed. Injected at build time (build_info.h / gen_version.sh); "unknown" if
-  // the version header wasn't generated.
-  p[40]=OPK_GIT_DIRTY ? 1 : 0;
-  memset(&p[41],0,12);                                                 // 12B ASCII git hash, NUL-padded
-  { const char* h=OPK_GIT_HASH; for(uint8_t i=0;i<12 && h[i];i++) p[41+i]=(uint8_t)h[i]; }
-  p[53]=g_rumbleScale;                                                 // rumble strength % (protocol v5)
-  p[54]=g_swProRate;                                                   // Switch Pro report rate 0=66/1=120/2=full (protocol v6)
-  p[55]=g_swGyroScale10;                                               // Switch Pro gyro sensitivity x10 (protocol v6)
-  { int16_t a[3]={g_in.ax,g_in.ay,g_in.az}; memcpy(&p[56], a, 6); }    // raw accelerometer for scale diagnostics (protocol v7)
-  usb_web.write(p,sizeof p); usb_web.flush();
+#define WB_PAYLEN \
+	60 // keep the blob under 64 bytes total (readBlob reads one 64-byte packet)
+static void webusbSendBlob()
+{
+	if (!usb_web.connected())
+		return;
+	bool up = (g_connSlot >= 0 && (millis() - g_connReplyMs) < 300);
+	uint8_t p[2 + WB_PAYLEN];
+	p[0] = 0xA5;
+	p[1] = WB_PAYLEN;
+	p[2] = 7; // protocol version (7 = +raw accel; 6 = +swPro120/gyroScale; 5 = +rumbleScale)
+	p[3] = g_usbMode;
+	p[4] = (uint8_t)g_mDiv;
+	p[5] = (uint8_t)g_mFric;
+	p[6] = g_qamMap;
+	p[7] = g_abSwap;
+	p[8] = g_back[0];
+	p[9] = g_back[1];
+	p[10] = g_back[2];
+	p[11] = g_back[3];
+	p[12] = (g_connSlot >= 0) ? (uint8_t)g_connSlot : 0xFF;
+	p[13] = up ? 1 : 0;
+	p[14] = (uint8_t)g_f1ps;
+	p[15] = (uint8_t)(g_f1ps >> 8);
+	p[16] = (uint8_t)(g_pollUs / 100);
+	p[17] = (uint8_t)g_newps;
+	p[18] = (uint8_t)(g_newps >> 8);
+	p[19] = g_e7b;
+	p[20] = g_relayOp;
+	p[21] = g_relaySub;
+	p[22] = g_fwdNewOnly;
+	p[23] = g_qos;
+	p[24] = g_persistMode ? 1 : 0;
+	p[25] = g_chordBtn[0];
+	p[26] = g_chordBtn[1];
+	p[27] = g_chordBtn[2];
+	p[28] = (uint8_t)g_pollsps;
+	p[29] = (uint8_t)(g_pollsps >>
+			  8); // poll TX rate (vs delivered/new -> starvation vs reply-loss)
+	p[30] = (uint8_t)g_loopPeriodUs;
+	p[31] = (uint8_t)(g_loopPeriodUs >>
+			  8); // loop diag: avg iteration time + slowest section
+	p[32] = g_loopWorst;
+	p[33] = (uint8_t)g_loopWorstUs;
+	p[34] = (uint8_t)(g_loopWorstUs >> 8);
+	p[35] = (uint8_t)g_pollPeriodUs;
+	p[36] = (uint8_t)(g_pollPeriodUs >>
+			  8); // measured actual poll period (vs intended 4000)
+	p[37] = OPK_LOG; // logging build? panel shows/hides its log UI
+	p[38] = g_battery; // controller battery % (report 0x43); 0=unknown
+	p[39] = g_linkRssi; // RAW signal strength |dBm| (0=no sample)
+	// Build provenance: which git commit this firmware was built from, and whether the tree was dirty. Lets the
+	// panel confirm exactly what's flashed. Injected at build time (build_info.h / gen_version.sh); "unknown" if
+	// the version header wasn't generated.
+	p[40] = OPK_GIT_DIRTY ? 1 : 0;
+	memset(&p[41], 0, 12); // 12B ASCII git hash, NUL-padded
+	{
+		const char *h = OPK_GIT_HASH;
+		for (uint8_t i = 0; i < 12 && h[i]; i++)
+			p[41 + i] = (uint8_t)h[i];
+	}
+	p[53] = g_rumbleScale; // rumble strength % (protocol v5)
+	p[54] = g_swProRate; // Switch Pro report rate 0=66/1=120/2=full (protocol v6)
+	p[55] = g_swGyroScale10; // Switch Pro gyro sensitivity x10 (protocol v6)
+	{
+		int16_t a[3] = { g_in.ax, g_in.ay, g_in.az };
+		memcpy(&p[56], a, 6);
+	} // raw accelerometer for scale diagnostics (protocol v7)
+	usb_web.write(p, sizeof p);
+	usb_web.flush();
 }
 #if OPK_LOG
 // Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames, so the browser can
 // pull it with no CDC. Frame formats:
 //   entry: [0xA6][L][T=1][age u32 LE][slot][rid][n][bytes n]   (L = 8 + n)
 //   end:   [0xA6][1][T=0]
-static void webusbCapFrame(uint32_t ms,uint8_t slot,uint8_t rid,uint8_t nb,const uint8_t* b){
-  if(nb>16) nb=16;
-  uint8_t f[2+9+16]; uint8_t L=(uint8_t)(8+nb);
-  f[0]=0xA6; f[1]=L; f[2]=1;
-  f[3]=(uint8_t)ms; f[4]=(uint8_t)(ms>>8); f[5]=(uint8_t)(ms>>16); f[6]=(uint8_t)(ms>>24);  // absolute log time
-  f[7]=slot; f[8]=rid; f[9]=nb; memcpy(f+10,b,nb);
-  usb_web.write(f,(uint16_t)(2+L)); usb_web.flush();
+static void webusbCapFrame(uint32_t ms, uint8_t slot, uint8_t rid, uint8_t nb,
+			   const uint8_t *b)
+{
+	if (nb > 16)
+		nb = 16;
+	uint8_t f[2 + 9 + 16];
+	uint8_t L = (uint8_t)(8 + nb);
+	f[0] = 0xA6;
+	f[1] = L;
+	f[2] = 1;
+	f[3] = (uint8_t)ms;
+	f[4] = (uint8_t)(ms >> 8);
+	f[5] = (uint8_t)(ms >> 16);
+	f[6] = (uint8_t)(ms >> 24); // absolute log time
+	f[7] = slot;
+	f[8] = rid;
+	f[9] = nb;
+	memcpy(f + 10, b, nb);
+	usb_web.write(f, (uint16_t)(2 + L));
+	usb_web.flush();
+}
+static void webusbCapEnd()
+{
+	uint8_t e[3] = { 0xA6, 1, 0 };
+	usb_web.write(e, 3);
+	usb_web.flush();
 }
-static void webusbCapEnd(){ uint8_t e[3]={0xA6,1,0}; usb_web.write(e,3); usb_web.flush(); }
 // 0x06: drain -- entries since the last drain (or since the 0x05 start/rewind). The panel polls this on a
 // timer and accumulates, so a dump-from-boot of the whole ring streams over many polls without blocking the
 // loop. A per-call budget bounds how long one poll spends here.
-static void webusbDrainCapture(){
-  if(!usb_web.connected()) return;
-  uint32_t ms=0; uint8_t slot=0,rid=0,nb=0,b[16]; uint16_t budget=128;
-  while(budget-- && hapLogPull(&ms,&slot,&rid,&nb,b)) webusbCapFrame(ms,slot,rid,nb,b);
-  webusbCapEnd();
+static void webusbDrainCapture()
+{
+	if (!usb_web.connected())
+		return;
+	uint32_t ms = 0;
+	uint8_t slot = 0, rid = 0, nb = 0, b[16];
+	uint16_t budget = 128;
+	while (budget-- && hapLogPull(&ms, &slot, &rid, &nb, b))
+		webusbCapFrame(ms, slot, rid, nb, b);
+	webusbCapEnd();
 }
 #endif // OPK_LOG
-void webusbPoll(){
-  static uint8_t buf[16]; static uint8_t n=0;
-  while(usb_web.available()){
-    int c=usb_web.read(); if(c<0) break;
-    if(n<sizeof buf) buf[n++]=(uint8_t)c;
-    // process complete commands from the front of buf
-    for(;;){
-      if(n==0) break;
-      uint8_t op=buf[0]; uint8_t need = (op==0x02)?3 : (op==0x03||op==0x05)?2 : (op==0x0A)?4 : 1;   // 0x05 carries a value byte; 0x0A carries a 3-byte magic
-      if(op<0x01 || op>0x0A){ // resync: drop one byte
-        memmove(buf,buf+1,--n); continue;
-      }
-      if(n<need) break;                      // wait for more bytes
-      if(op==0x01){ webusbSendBlob(); }
+void webusbPoll()
+{
+	static uint8_t buf[16];
+	static uint8_t n = 0;
+	while (usb_web.available()) {
+		int c = usb_web.read();
+		if (c < 0)
+			break;
+		if (n < sizeof buf)
+			buf[n++] = (uint8_t)c;
+		// process complete commands from the front of buf
+		for (;;) {
+			if (n == 0)
+				break;
+			uint8_t op = buf[0];
+			uint8_t need =
+				(op == 0x02) ?
+					3 :
+				(op == 0x03 || op == 0x05) ?
+					2 :
+				(op == 0x0A) ?
+					4 :
+					1; // 0x05 carries a value byte; 0x0A carries a 3-byte magic
+			if (op < 0x01 || op > 0x0A) { // resync: drop one byte
+				memmove(buf, buf + 1, --n);
+				continue;
+			}
+			if (n < need)
+				break; // wait for more bytes
+			if (op == 0x01) {
+				webusbSendBlob();
+			}
 #if OPK_LOG
-      else if(op==0x05){ hapLogResetDrain(buf[1]!=0); }   // rewind drain: buf[1]=1 from boot (whole ring), 0 from now
-      else if(op==0x06){ webusbDrainCapture(); }          // drain entries since the rewind (panel polls this)
+			else if (op == 0x05) {
+				hapLogResetDrain(buf[1] != 0);
+			} // rewind drain: buf[1]=1 from boot (whole ring), 0 from now
+			else if (op == 0x06) {
+				webusbDrainCapture();
+			} // drain entries since the rewind (panel polls this)
 #endif
-      else if(op==0x07){ hapticReinit(); }                // clear a stuck/latched haptic buzz on the controller (functional)
-      else if(op==0x08){ hapticSendShutdown(); }          // TEST: trigger the controller power-off (same path Steam 0x9F / host-suspend use)
-      else if(op==0x0A){                                  // FULL factory wipe: erase cfg.bin + bonds.bin, reboot to clean defaults.
-        // Guarded by a 3-byte magic ("ERS") so a stray/corrupt byte can never trigger it; the panel double-
-        // confirms before sending. Irreversible -- the controller must be re-paired afterwards.
-        if(buf[1]==0x45 && buf[2]==0x52 && buf[3]==0x53){ usb_web.flush(); factoryErase(); delay(40); NVIC_SystemReset(); }
-      }
-      else if(op==0x02){
-        uint8_t f=buf[1], v=buf[2];
-        bool persist=true;   // every settable field persists (poll rate is no longer settable)
-        switch(f){
-          case 1: g_mDiv = v<4?4:v; break;
-          case 2: g_mFric = v>99?99:v; break;
-          // case 3 (padSmooth) removed -- Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
-          case 4: g_abSwap = v?1:0; break;
-          case 5: case 6: case 7: case 8: g_back[f-5]=v; break;
-          // case 9 (pollU100) removed -- poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
-          case 10: g_e7b = v?1:0; break;                       // E7 protocol-version B-byte (experimental v1 fast)
-          case 11: g_relayOp = v; break;                       // haptic-relay opcode (buzz hunt)
-          case 12: g_relaySub = v; break;                      // haptic-relay sub-type (buzz hunt)
-          case 13: g_testHaptic = v?v:40; break;               // inject v test haptics (0->40)
-          case 14: g_fwdNewOnly = v?1:0; break;                // Steam: forward only fresh reports (dedupe)
-          case 15: g_qos = v?1:0; g_hopIdx=0; g_qosBad=0; g_qosCheckMs=millis(); break;  // QoS adaptive channel hopping
-          case 16: g_persistMode = v?true:false; break;   // persist last mode across reboots (else always boot Steam)
-          case 17: case 18: case 19: if(modeValid(v)) g_chordBtn[f-17]=v; break;   // back4+B/X/Y mode assignments
-          case 20: armDebugCdcNextBoot(); usb_web.flush(); delay(40); NVIC_SystemReset(); break;  // reboot once WITH the CDC serial console (puck mode), then auto-revert
-          case 21: g_qamMap = v; break;   // QAM physical button remap code (0=default/unmapped)
-          case 22: g_rumbleScale = v; break;   // rumble strength % (0=off, 100=1x, 200=double)
-          case 23: g_swProRate = (v<=2)?v:1; swProSaveCfg(); persist=false; break;   // Switch Pro report rate (0=66Hz,1=120Hz,2=full)
-          case 24: g_swGyroScale10 = (v>=5&&v<=30)?v:10; swProSaveCfg(); persist=false; break;   // Switch Pro gyro scale x10
-        }
-        if(persist) saveCfg();
-        webusbSendBlob();
-      } else if(op==0x03){
-        uint8_t m=buf[1]; if(modeValid(m) && !USBDevice.suspended()){ webusbSendBlob(); usb_web.flush(); saveMode(m); delay(40); NVIC_SystemReset(); }
-      }
-      memmove(buf,buf+need,n-need); n-=need;
-    }
-  }
+			else if (op == 0x07) {
+				hapticReinit();
+			} // clear a stuck/latched haptic buzz on the controller (functional)
+			else if (op == 0x08) {
+				hapticSendShutdown();
+			} // TEST: trigger the controller power-off (same path Steam 0x9F / host-suspend use)
+			else if (op ==
+				 0x0A) { // FULL factory wipe: erase cfg.bin + bonds.bin, reboot to clean defaults.
+				// Guarded by a 3-byte magic ("ERS") so a stray/corrupt byte can never trigger it; the panel double-
+				// confirms before sending. Irreversible -- the controller must be re-paired afterwards.
+				if (buf[1] == 0x45 && buf[2] == 0x52 &&
+				    buf[3] == 0x53) {
+					usb_web.flush();
+					factoryErase();
+					delay(40);
+					NVIC_SystemReset();
+				}
+			} else if (op == 0x02) {
+				uint8_t f = buf[1], v = buf[2];
+				bool persist =
+					true; // every settable field persists (poll rate is no longer settable)
+				switch (f) {
+				case 1:
+					g_mDiv = v < 4 ? 4 : v;
+					break;
+				case 2:
+					g_mFric = v > 99 ? 99 : v;
+					break;
+				// case 3 (padSmooth) removed -- Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
+				case 4:
+					g_abSwap = v ? 1 : 0;
+					break;
+				case 5:
+				case 6:
+				case 7:
+				case 8:
+					g_back[f - 5] = v;
+					break;
+				// case 9 (pollU100) removed -- poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
+				case 10:
+					g_e7b = v ? 1 : 0;
+					break; // E7 protocol-version B-byte (experimental v1 fast)
+				case 11:
+					g_relayOp = v;
+					break; // haptic-relay opcode (buzz hunt)
+				case 12:
+					g_relaySub = v;
+					break; // haptic-relay sub-type (buzz hunt)
+				case 13:
+					g_testHaptic = v ? v : 40;
+					break; // inject v test haptics (0->40)
+				case 14:
+					g_fwdNewOnly = v ? 1 : 0;
+					break; // Steam: forward only fresh reports (dedupe)
+				case 15:
+					g_qos = v ? 1 : 0;
+					g_hopIdx = 0;
+					g_qosBad = 0;
+					g_qosCheckMs = millis();
+					break; // QoS adaptive channel hopping
+				case 16:
+					g_persistMode = v ? true : false;
+					break; // persist last mode across reboots (else always boot Steam)
+				case 17:
+				case 18:
+				case 19:
+					if (modeValid(v))
+						g_chordBtn[f - 17] = v;
+					break; // back4+B/X/Y mode assignments
+				case 20:
+					armDebugCdcNextBoot();
+					usb_web.flush();
+					delay(40);
+					NVIC_SystemReset();
+					break; // reboot once WITH the CDC serial console (puck mode), then auto-revert
+				case 21:
+					g_qamMap = v;
+					break; // QAM physical button remap code (0=default/unmapped)
+				case 22:
+					g_rumbleScale = v;
+					break; // rumble strength % (0=off, 100=1x, 200=double)
+				case 23:
+					g_swProRate = (v <= 2) ? v : 1;
+					swProSaveCfg();
+					persist = false;
+					break; // Switch Pro report rate (0=66Hz,1=120Hz,2=full)
+				case 24:
+					g_swGyroScale10 =
+						(v >= 5 && v <= 30) ? v : 10;
+					swProSaveCfg();
+					persist = false;
+					break; // Switch Pro gyro scale x10
+				}
+				if (persist)
+					saveCfg();
+				webusbSendBlob();
+			} else if (op == 0x03) {
+				uint8_t m = buf[1];
+				if (modeValid(m) && !USBDevice.suspended()) {
+					webusbSendBlob();
+					usb_web.flush();
+					saveMode(m);
+					delay(40);
+					NVIC_SystemReset();
+				}
+			}
+			memmove(buf, buf + need, n - need);
+			n -= need;
+		}
+	}
 }

+ 1 - 1
OpenPuck/webusb_config.h

@@ -12,4 +12,4 @@
 
 extern Adafruit_USBD_WebUSB usb_web;
 
-void webusbPoll();   // drain host commands + reply; call every loop()
+void webusbPoll(); // drain host commands + reply; call every loop()

+ 1 - 0
README.md

@@ -69,6 +69,7 @@ I have tested this software fairly extensively but I have limited resources. I o
 - Alan for not scalping and selling me this controller for $120
 - https://github.com/knflrpn/2wiCC for the Switch Pro controller mode help
 - Massive thanks to [u/Careful_Tune4744](https://www.reddit.com/user/Careful_Tune4744/) for latency testing as well as testing and giving feedback on the Switch Pro mode
+- Thanks to Lawstorant from a mutual discord server for constructive criticism of the repo's state
 
 # * Another note on LLM use
 I want nothing more than for there to be a fully human coded alternative to OpenPuck that's on par with or better in every way. I would love to stop working on OpenPuck.

+ 905 - 582
pairtui/scmd.c

@@ -32,635 +32,958 @@
 
 #define VALVE_VID 0x28DE
 
-static void dump(const char *label, const uint8_t *p, int n) {
-    printf("%s [%d]:\n  ", label, n);
-    for (int i = 0; i < n; i++) { if (i && i % 32 == 0) printf("\n  "); printf("%02X ", p[i]); }
-    printf("\n  ascii: ");
-    for (int i = 0; i < n; i++) putchar((p[i] >= 32 && p[i] < 127) ? p[i] : '.');
-    printf("\n");
+static void dump(const char *label, const uint8_t *p, int n)
+{
+	printf("%s [%d]:\n  ", label, n);
+	for (int i = 0; i < n; i++) {
+		if (i && i % 32 == 0)
+			printf("\n  ");
+		printf("%02X ", p[i]);
+	}
+	printf("\n  ascii: ");
+	for (int i = 0; i < n; i++)
+		putchar((p[i] >= 32 && p[i] < 127) ? p[i] : '.');
+	printf("\n");
 }
 
-static void print_payload_inline(const uint8_t *p, int n) {
-    if (n <= 0) {
-        printf("-");
-        return;
-    }
-    for (int i = 0; i < n; i++) {
-        if (i) printf(" ");
-        printf("%02X", p[i]);
-    }
+static void print_payload_inline(const uint8_t *p, int n)
+{
+	if (n <= 0) {
+		printf("-");
+		return;
+	}
+	for (int i = 0; i < n; i++) {
+		if (i)
+			printf(" ");
+		printf("%02X", p[i]);
+	}
 }
 
-static const char *cmd_name(uint8_t cmd) {
-    switch (cmd) {
-        case 0x83: return "GET_ATTRIBUTES_VALUES";
-        case 0xA1: return "GET_DEVICE_INFO";
-        case 0xA2: return "TRITON_A2_OBSERVED_PAIRING_RECORD";
-        case 0xA3: return "TRITON_A3_BOND_EVENT_OR_STATUS";
-        case 0xAD: return "ENABLE_PAIRING";
-        case 0xAE: return "GET_STRING_ATTRIBUTE";
-        case 0xAF: return "SDL_SHARED_RADIO_ERASE_RECORDS";
-        case 0xB0: return "SDL_SHARED_RADIO_WRITE_RECORD";
-        case 0xB2: return "SDL_SHARED_DONGLE_DISCONNECT_DEVICE";
-        case 0xB3: return "SDL_SHARED_DONGLE_COMMIT_DEVICE_CANDIDATE";
-        case 0xB4: return "SDL_SHARED_DONGLE_GET_WIRELESS_STATE";
-        case 0xC4: return "SDL_SHARED_DONGLE_GET_CONNECTED_SLOTS";
-        case 0xEE: return "TRITON_KEYED_VALUE_WRITE";
-        case 0xEF: return "TRITON_KEYED_VALUE_COMMIT";
-        default: return NULL;
-    }
+static const char *cmd_name(uint8_t cmd)
+{
+	switch (cmd) {
+	case 0x83:
+		return "GET_ATTRIBUTES_VALUES";
+	case 0xA1:
+		return "GET_DEVICE_INFO";
+	case 0xA2:
+		return "TRITON_A2_OBSERVED_PAIRING_RECORD";
+	case 0xA3:
+		return "TRITON_A3_BOND_EVENT_OR_STATUS";
+	case 0xAD:
+		return "ENABLE_PAIRING";
+	case 0xAE:
+		return "GET_STRING_ATTRIBUTE";
+	case 0xAF:
+		return "SDL_SHARED_RADIO_ERASE_RECORDS";
+	case 0xB0:
+		return "SDL_SHARED_RADIO_WRITE_RECORD";
+	case 0xB2:
+		return "SDL_SHARED_DONGLE_DISCONNECT_DEVICE";
+	case 0xB3:
+		return "SDL_SHARED_DONGLE_COMMIT_DEVICE_CANDIDATE";
+	case 0xB4:
+		return "SDL_SHARED_DONGLE_GET_WIRELESS_STATE";
+	case 0xC4:
+		return "SDL_SHARED_DONGLE_GET_CONNECTED_SLOTS";
+	case 0xEE:
+		return "TRITON_KEYED_VALUE_WRITE";
+	case 0xEF:
+		return "TRITON_KEYED_VALUE_COMMIT";
+	default:
+		return NULL;
+	}
 }
 
-static int hid_long(IOHIDDeviceRef d, CFStringRef key, long *out) {
-    CFTypeRef v = IOHIDDeviceGetProperty(d, key);
-    if (!v || CFGetTypeID(v) != CFNumberGetTypeID()) return 0;
-    return CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, out);
+static int hid_long(IOHIDDeviceRef d, CFStringRef key, long *out)
+{
+	CFTypeRef v = IOHIDDeviceGetProperty(d, key);
+	if (!v || CFGetTypeID(v) != CFNumberGetTypeID())
+		return 0;
+	return CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, out);
 }
 
-static void hid_string(IOHIDDeviceRef d, CFStringRef key, char *out, size_t outn) {
-    if (!outn) return;
-    out[0] = 0;
-    CFTypeRef v = IOHIDDeviceGetProperty(d, key);
-    if (!v) return;
-    if (CFGetTypeID(v) == CFStringGetTypeID()) {
-        CFStringGetCString((CFStringRef)v, out, outn, kCFStringEncodingUTF8);
-    } else if (CFGetTypeID(v) == CFNumberGetTypeID()) {
-        long x = 0;
-        if (CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, &x)) snprintf(out, outn, "%ld", x);
-    }
+static void hid_string(IOHIDDeviceRef d, CFStringRef key, char *out,
+		       size_t outn)
+{
+	if (!outn)
+		return;
+	out[0] = 0;
+	CFTypeRef v = IOHIDDeviceGetProperty(d, key);
+	if (!v)
+		return;
+	if (CFGetTypeID(v) == CFStringGetTypeID()) {
+		CFStringGetCString((CFStringRef)v, out, outn,
+				   kCFStringEncodingUTF8);
+	} else if (CFGetTypeID(v) == CFNumberGetTypeID()) {
+		long x = 0;
+		if (CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, &x))
+			snprintf(out, outn, "%ld", x);
+	}
 }
 
-static int reg_long(io_object_t svc, const char *key, long *out) {
-    CFStringRef k = CFStringCreateWithCString(kCFAllocatorDefault, key, kCFStringEncodingUTF8);
-    if (!k) return 0;
-    CFTypeRef v = IORegistryEntryCreateCFProperty(svc, k, kCFAllocatorDefault, 0);
-    CFRelease(k);
-    if (!v) return 0;
-    int ok = 0;
-    if (CFGetTypeID(v) == CFNumberGetTypeID()) ok = CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, out);
-    CFRelease(v);
-    return ok;
+static int reg_long(io_object_t svc, const char *key, long *out)
+{
+	CFStringRef k = CFStringCreateWithCString(kCFAllocatorDefault, key,
+						  kCFStringEncodingUTF8);
+	if (!k)
+		return 0;
+	CFTypeRef v =
+		IORegistryEntryCreateCFProperty(svc, k, kCFAllocatorDefault, 0);
+	CFRelease(k);
+	if (!v)
+		return 0;
+	int ok = 0;
+	if (CFGetTypeID(v) == CFNumberGetTypeID())
+		ok = CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, out);
+	CFRelease(v);
+	return ok;
 }
 
-static void reg_string(io_object_t svc, const char *key, char *out, size_t outn) {
-    if (!outn) return;
-    out[0] = 0;
-    CFStringRef k = CFStringCreateWithCString(kCFAllocatorDefault, key, kCFStringEncodingUTF8);
-    if (!k) return;
-    CFTypeRef v = IORegistryEntryCreateCFProperty(svc, k, kCFAllocatorDefault, 0);
-    CFRelease(k);
-    if (!v) return;
-    if (CFGetTypeID(v) == CFStringGetTypeID()) {
-        CFStringGetCString((CFStringRef)v, out, outn, kCFStringEncodingUTF8);
-    } else if (CFGetTypeID(v) == CFNumberGetTypeID()) {
-        long x = 0;
-        if (CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, &x)) snprintf(out, outn, "%ld", x);
-    }
-    CFRelease(v);
+static void reg_string(io_object_t svc, const char *key, char *out, size_t outn)
+{
+	if (!outn)
+		return;
+	out[0] = 0;
+	CFStringRef k = CFStringCreateWithCString(kCFAllocatorDefault, key,
+						  kCFStringEncodingUTF8);
+	if (!k)
+		return;
+	CFTypeRef v =
+		IORegistryEntryCreateCFProperty(svc, k, kCFAllocatorDefault, 0);
+	CFRelease(k);
+	if (!v)
+		return;
+	if (CFGetTypeID(v) == CFStringGetTypeID()) {
+		CFStringGetCString((CFStringRef)v, out, outn,
+				   kCFStringEncodingUTF8);
+	} else if (CFGetTypeID(v) == CFNumberGetTypeID()) {
+		long x = 0;
+		if (CFNumberGetValue((CFNumberRef)v, kCFNumberLongType, &x))
+			snprintf(out, outn, "%ld", x);
+	}
+	CFRelease(v);
 }
 
-static void print_device_line(io_object_t svc, IOHIDDeviceRef d) {
-    long vid = -1, pid = -1, up = -1, usage = -1, loc = -1, maxF = -1;
-    char serial[128], product[128], manufacturer[128];
-    serial[0] = product[0] = manufacturer[0] = 0;
-    reg_long(svc, "VendorID", &vid); if (vid < 0) hid_long(d, CFSTR(kIOHIDVendorIDKey), &vid);
-    reg_long(svc, "ProductID", &pid); if (pid < 0) hid_long(d, CFSTR(kIOHIDProductIDKey), &pid);
-    reg_long(svc, "PrimaryUsagePage", &up); if (up < 0) hid_long(d, CFSTR(kIOHIDPrimaryUsagePageKey), &up);
-    reg_long(svc, "PrimaryUsage", &usage); if (usage < 0) hid_long(d, CFSTR(kIOHIDPrimaryUsageKey), &usage);
-    reg_long(svc, "LocationID", &loc); if (loc < 0) hid_long(d, CFSTR(kIOHIDLocationIDKey), &loc);
-    reg_long(svc, "MaxFeatureReportSize", &maxF);
-    reg_string(svc, "SerialNumber", serial, sizeof serial); if (!serial[0]) hid_string(d, CFSTR(kIOHIDSerialNumberKey), serial, sizeof serial);
-    reg_string(svc, "Product", product, sizeof product); if (!product[0]) hid_string(d, CFSTR(kIOHIDProductKey), product, sizeof product);
-    reg_string(svc, "Manufacturer", manufacturer, sizeof manufacturer); if (!manufacturer[0]) hid_string(d, CFSTR(kIOHIDManufacturerKey), manufacturer, sizeof manufacturer);
-    printf("Valve %04lX:%04lX usagePage=0x%lX usage=0x%lX maxFeature=%ld loc=0x%lX serial=%s product=%s manufacturer=%s\n",
-           vid, pid, up, usage, maxF, loc, serial[0] ? serial : "-", product[0] ? product : "-",
-           manufacturer[0] ? manufacturer : "-");
+static void print_device_line(io_object_t svc, IOHIDDeviceRef d)
+{
+	long vid = -1, pid = -1, up = -1, usage = -1, loc = -1, maxF = -1;
+	char serial[128], product[128], manufacturer[128];
+	serial[0] = product[0] = manufacturer[0] = 0;
+	reg_long(svc, "VendorID", &vid);
+	if (vid < 0)
+		hid_long(d, CFSTR(kIOHIDVendorIDKey), &vid);
+	reg_long(svc, "ProductID", &pid);
+	if (pid < 0)
+		hid_long(d, CFSTR(kIOHIDProductIDKey), &pid);
+	reg_long(svc, "PrimaryUsagePage", &up);
+	if (up < 0)
+		hid_long(d, CFSTR(kIOHIDPrimaryUsagePageKey), &up);
+	reg_long(svc, "PrimaryUsage", &usage);
+	if (usage < 0)
+		hid_long(d, CFSTR(kIOHIDPrimaryUsageKey), &usage);
+	reg_long(svc, "LocationID", &loc);
+	if (loc < 0)
+		hid_long(d, CFSTR(kIOHIDLocationIDKey), &loc);
+	reg_long(svc, "MaxFeatureReportSize", &maxF);
+	reg_string(svc, "SerialNumber", serial, sizeof serial);
+	if (!serial[0])
+		hid_string(d, CFSTR(kIOHIDSerialNumberKey), serial,
+			   sizeof serial);
+	reg_string(svc, "Product", product, sizeof product);
+	if (!product[0])
+		hid_string(d, CFSTR(kIOHIDProductKey), product, sizeof product);
+	reg_string(svc, "Manufacturer", manufacturer, sizeof manufacturer);
+	if (!manufacturer[0])
+		hid_string(d, CFSTR(kIOHIDManufacturerKey), manufacturer,
+			   sizeof manufacturer);
+	printf("Valve %04lX:%04lX usagePage=0x%lX usage=0x%lX maxFeature=%ld loc=0x%lX serial=%s product=%s manufacturer=%s\n",
+	       vid, pid, up, usage, maxF, loc, serial[0] ? serial : "-",
+	       product[0] ? product : "-",
+	       manufacturer[0] ? manufacturer : "-");
 }
 
-static int list_devices(long wantPid) {
-    CFMutableDictionaryRef m = IOServiceMatching("IOHIDDevice");
-    io_iterator_t it;
-    if (IOServiceGetMatchingServices(kIOMainPortDefault, m, &it) != KERN_SUCCESS) {
-        fprintf(stderr, "match fail\n");
-        return 1;
-    }
-    int n = 0;
-    io_object_t svc;
-    while ((svc = IOIteratorNext(it))) {
-        IOHIDDeviceRef d = IOHIDDeviceCreate(kCFAllocatorDefault, svc);
-        if (d) {
-            long vid = -1, pid = -1;
-            reg_long(svc, "VendorID", &vid); if (vid < 0) hid_long(d, CFSTR(kIOHIDVendorIDKey), &vid);
-            reg_long(svc, "ProductID", &pid); if (pid < 0) hid_long(d, CFSTR(kIOHIDProductIDKey), &pid);
-            if (vid == VALVE_VID && (wantPid < 0 || pid == wantPid)) {
-                print_device_line(svc, d);
-                n++;
-            }
-            CFRelease(d);
-        }
-        IOObjectRelease(svc);
-    }
-    IOObjectRelease(it);
-    if (!n) printf("no Valve HID devices found%s\n", wantPid < 0 ? "" : " for requested PID");
-    return n ? 0 : 1;
+static int list_devices(long wantPid)
+{
+	CFMutableDictionaryRef m = IOServiceMatching("IOHIDDevice");
+	io_iterator_t it;
+	if (IOServiceGetMatchingServices(kIOMainPortDefault, m, &it) !=
+	    KERN_SUCCESS) {
+		fprintf(stderr, "match fail\n");
+		return 1;
+	}
+	int n = 0;
+	io_object_t svc;
+	while ((svc = IOIteratorNext(it))) {
+		IOHIDDeviceRef d = IOHIDDeviceCreate(kCFAllocatorDefault, svc);
+		if (d) {
+			long vid = -1, pid = -1;
+			reg_long(svc, "VendorID", &vid);
+			if (vid < 0)
+				hid_long(d, CFSTR(kIOHIDVendorIDKey), &vid);
+			reg_long(svc, "ProductID", &pid);
+			if (pid < 0)
+				hid_long(d, CFSTR(kIOHIDProductIDKey), &pid);
+			if (vid == VALVE_VID &&
+			    (wantPid < 0 || pid == wantPid)) {
+				print_device_line(svc, d);
+				n++;
+			}
+			CFRelease(d);
+		}
+		IOObjectRelease(svc);
+	}
+	IOObjectRelease(it);
+	if (!n)
+		printf("no Valve HID devices found%s\n",
+		       wantPid < 0 ? "" : " for requested PID");
+	return n ? 0 : 1;
 }
 
 // Issue one command on report id `rid`: [rid][cmd][len][payload...].
-static void sc_cmd_ex(IOHIDDeviceRef d, int rid, uint8_t cmd, const uint8_t *pl, int pln, int do_get) {
-    uint8_t tx[64]; memset(tx, 0, sizeof tx);
-    tx[0] = (uint8_t)rid; tx[1] = cmd; tx[2] = (uint8_t)pln;
-    if (pln > 0) memcpy(tx + 3, pl, pln);
-    IOReturn rs = IOHIDDeviceSetReport(d, kIOHIDReportTypeFeature, rid, tx, 64);
-    const char *name = cmd_name(cmd);
-    if (rs != kIOReturnSuccess) {
-        printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd, name ? " " : "", name ? name : "", pln);
-        print_payload_inline(pl, pln);
-        printf(") on report id %d  [set=0x%08X get=skipped-set-failed] ===\n", rid, rs);
-        return;
-    }
-    if (!do_get) {
-        printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd, name ? " " : "", name ? name : "", pln);
-        print_payload_inline(pl, pln);
-        printf(") on report id %d  [set=0x%08X get=skipped] ===\n", rid, rs);
-        return;
-    }
-    usleep(20000);
-    uint8_t rx[64]; memset(rx, 0, sizeof rx); rx[0] = (uint8_t)rid;
-    CFIndex rl = 64;
-    IOReturn rg = IOHIDDeviceGetReport(d, kIOHIDReportTypeFeature, rid, rx, &rl);
-    printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd, name ? " " : "", name ? name : "", pln);
-    print_payload_inline(pl, pln);
-    printf(") on report id %d  [set=0x%08X get=0x%08X] ===\n", rid, rs, rg);
-    if (rg == kIOReturnSuccess) dump("reply", rx, (int)rl);
-    else printf("  (GET failed)\n");
+static void sc_cmd_ex(IOHIDDeviceRef d, int rid, uint8_t cmd, const uint8_t *pl,
+		      int pln, int do_get)
+{
+	uint8_t tx[64];
+	memset(tx, 0, sizeof tx);
+	tx[0] = (uint8_t)rid;
+	tx[1] = cmd;
+	tx[2] = (uint8_t)pln;
+	if (pln > 0)
+		memcpy(tx + 3, pl, pln);
+	IOReturn rs =
+		IOHIDDeviceSetReport(d, kIOHIDReportTypeFeature, rid, tx, 64);
+	const char *name = cmd_name(cmd);
+	if (rs != kIOReturnSuccess) {
+		printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd,
+		       name ? " " : "", name ? name : "", pln);
+		print_payload_inline(pl, pln);
+		printf(") on report id %d  [set=0x%08X get=skipped-set-failed] ===\n",
+		       rid, rs);
+		return;
+	}
+	if (!do_get) {
+		printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd,
+		       name ? " " : "", name ? name : "", pln);
+		print_payload_inline(pl, pln);
+		printf(") on report id %d  [set=0x%08X get=skipped] ===\n", rid,
+		       rs);
+		return;
+	}
+	usleep(20000);
+	uint8_t rx[64];
+	memset(rx, 0, sizeof rx);
+	rx[0] = (uint8_t)rid;
+	CFIndex rl = 64;
+	IOReturn rg =
+		IOHIDDeviceGetReport(d, kIOHIDReportTypeFeature, rid, rx, &rl);
+	printf("\n=== cmd 0x%02X%s%s (payload %d: ", cmd, name ? " " : "",
+	       name ? name : "", pln);
+	print_payload_inline(pl, pln);
+	printf(") on report id %d  [set=0x%08X get=0x%08X] ===\n", rid, rs, rg);
+	if (rg == kIOReturnSuccess)
+		dump("reply", rx, (int)rl);
+	else
+		printf("  (GET failed)\n");
 }
 
-static void sc_cmd(IOHIDDeviceRef d, int rid, uint8_t cmd, const uint8_t *pl, int pln) {
-    sc_cmd_ex(d, rid, cmd, pl, pln, 1);
+static void sc_cmd(IOHIDDeviceRef d, int rid, uint8_t cmd, const uint8_t *pl,
+		   int pln)
+{
+	sc_cmd_ex(d, rid, cmd, pl, pln, 1);
 }
 
-static int hex_value(char c) {
-    if (c >= '0' && c <= '9') return c - '0';
-    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
-    if (c >= 'A' && c <= 'F') return c - 'A' + 10;
-    return -1;
+static int hex_value(char c)
+{
+	if (c >= '0' && c <= '9')
+		return c - '0';
+	if (c >= 'a' && c <= 'f')
+		return c - 'a' + 10;
+	if (c >= 'A' && c <= 'F')
+		return c - 'A' + 10;
+	return -1;
 }
 
-static int parse_hex_bytes(const char *hex, uint8_t *out, int outn) {
-    int n = 0;
-    int hi = -1;
-    for (const char *p = hex; *p; p++) {
-        if (*p == ':' || *p == '-' || *p == ' ' || *p == '_') continue;
-        int v = hex_value(*p);
-        if (v < 0) return -1;
-        if (hi < 0) {
-            hi = v;
-        } else {
-            if (n >= outn) return -1;
-            out[n++] = (uint8_t)((hi << 4) | v);
-            hi = -1;
-        }
-    }
-    if (hi >= 0) return -1;
-    return n;
+static int parse_hex_bytes(const char *hex, uint8_t *out, int outn)
+{
+	int n = 0;
+	int hi = -1;
+	for (const char *p = hex; *p; p++) {
+		if (*p == ':' || *p == '-' || *p == ' ' || *p == '_')
+			continue;
+		int v = hex_value(*p);
+		if (v < 0)
+			return -1;
+		if (hi < 0) {
+			hi = v;
+		} else {
+			if (n >= outn)
+				return -1;
+			out[n++] = (uint8_t)((hi << 4) | v);
+			hi = -1;
+		}
+	}
+	if (hi >= 0)
+		return -1;
+	return n;
 }
 
-static int make_serial16(const char *serial, uint8_t out[16]) {
-    memset(out, 0, 16);
-    size_t n = strlen(serial);
-    if (n > 16) return 0;
-    memcpy(out, serial, n);
-    return 1;
+static int make_serial16(const char *serial, uint8_t out[16])
+{
+	memset(out, 0, 16);
+	size_t n = strlen(serial);
+	if (n > 16)
+		return 0;
+	memcpy(out, serial, n);
+	return 1;
 }
 
-static int triton_keyed_write(IOHIDDeviceRef dev, const char *key, const uint8_t *payload, int payload_len) {
-    size_t key_len = strlen(key) + 1;
-    if (key_len + (size_t)payload_len > 61 || key_len > 61) {
-        fprintf(stderr, "keyed payload too long for 64-byte feature report\n");
-        return 2;
-    }
-
-    uint8_t ee[61];
-    memcpy(ee, key, key_len);
-    if (payload_len > 0) memcpy(ee + key_len, payload, payload_len);
-
-    uint8_t ef[61];
-    memcpy(ef, key, key_len);
-
-    printf("Triton keyed write key=\"%s\" payload=", key);
-    print_payload_inline(payload, payload_len);
-    printf("\n");
-    sc_cmd_ex(dev, 2, 0xEE, ee, (int)key_len + payload_len, 0);
-    usleep(20000);
-    sc_cmd_ex(dev, 2, 0xEF, ef, (int)key_len, 0);
-    return 0;
+static int triton_keyed_write(IOHIDDeviceRef dev, const char *key,
+			      const uint8_t *payload, int payload_len)
+{
+	size_t key_len = strlen(key) + 1;
+	if (key_len + (size_t)payload_len > 61 || key_len > 61) {
+		fprintf(stderr,
+			"keyed payload too long for 64-byte feature report\n");
+		return 2;
+	}
+
+	uint8_t ee[61];
+	memcpy(ee, key, key_len);
+	if (payload_len > 0)
+		memcpy(ee + key_len, payload, payload_len);
+
+	uint8_t ef[61];
+	memcpy(ef, key, key_len);
+
+	printf("Triton keyed write key=\"%s\" payload=", key);
+	print_payload_inline(payload, payload_len);
+	printf("\n");
+	sc_cmd_ex(dev, 2, 0xEE, ee, (int)key_len + payload_len, 0);
+	usleep(20000);
+	sc_cmd_ex(dev, 2, 0xEF, ef, (int)key_len, 0);
+	return 0;
 }
 
-static int triton_bond_write(IOHIDDeviceRef dev, int slot, const char *key_hex, const char *serial) {
-    if (slot < 0 || slot > 1) {
-        fprintf(stderr, "triton-bond slot must be 0 or 1\n");
-        return 2;
-    }
-    uint8_t payload[24];
-    int keyn = parse_hex_bytes(key_hex, payload, 8);
-    if (keyn != 8) {
-        fprintf(stderr, "triton-bond key must be exactly 8 bytes / 16 hex digits\n");
-        return 2;
-    }
-    if (!make_serial16(serial, payload + 8)) {
-        fprintf(stderr, "controller serial must be at most 16 ASCII bytes\n");
-        return 2;
-    }
-    const char *key = slot == 0 ? "esb/bond" : "esb/bond_2";
-    printf("note: Steam uses this keyed path for controller-side bond storage; real pucks reject it.\n");
-    return triton_keyed_write(dev, key, payload, sizeof payload);
+static int triton_bond_write(IOHIDDeviceRef dev, int slot, const char *key_hex,
+			     const char *serial)
+{
+	if (slot < 0 || slot > 1) {
+		fprintf(stderr, "triton-bond slot must be 0 or 1\n");
+		return 2;
+	}
+	uint8_t payload[24];
+	int keyn = parse_hex_bytes(key_hex, payload, 8);
+	if (keyn != 8) {
+		fprintf(stderr,
+			"triton-bond key must be exactly 8 bytes / 16 hex digits\n");
+		return 2;
+	}
+	if (!make_serial16(serial, payload + 8)) {
+		fprintf(stderr,
+			"controller serial must be at most 16 ASCII bytes\n");
+		return 2;
+	}
+	const char *key = slot == 0 ? "esb/bond" : "esb/bond_2";
+	printf("note: Steam uses this keyed path for controller-side bond storage; real pucks reject it.\n");
+	return triton_keyed_write(dev, key, payload, sizeof payload);
 }
 
-static int parse_triton_transport(const char *arg, uint8_t *out) {
-    if (strcmp(arg, "slot0") == 0 || strcmp(arg, "0") == 0) { *out = 2; return 1; }
-    if (strcmp(arg, "slot1") == 0 || strcmp(arg, "1") == 0) { *out = 3; return 1; }
-    if (strcmp(arg, "slot2") == 0 || strcmp(arg, "2") == 0) { *out = 0; return 1; }
-    char *end = NULL;
-    long raw = strtol(arg, &end, 0);
-    if (end && *end == 0 && raw >= 0 && raw <= 255) { *out = (uint8_t)raw; return 1; }
-    return 0;
+static int parse_triton_transport(const char *arg, uint8_t *out)
+{
+	if (strcmp(arg, "slot0") == 0 || strcmp(arg, "0") == 0) {
+		*out = 2;
+		return 1;
+	}
+	if (strcmp(arg, "slot1") == 0 || strcmp(arg, "1") == 0) {
+		*out = 3;
+		return 1;
+	}
+	if (strcmp(arg, "slot2") == 0 || strcmp(arg, "2") == 0) {
+		*out = 0;
+		return 1;
+	}
+	char *end = NULL;
+	long raw = strtol(arg, &end, 0);
+	if (end && *end == 0 && raw >= 0 && raw <= 255) {
+		*out = (uint8_t)raw;
+		return 1;
+	}
+	return 0;
 }
 
-static int triton_transport_write(IOHIDDeviceRef dev, const char *arg) {
-    uint8_t value = 0;
-    if (!parse_triton_transport(arg, &value)) {
-        fprintf(stderr, "triton-transport expects slot0, slot1, slot2, or raw byte 0..255\n");
-        return 2;
-    }
-    return triton_keyed_write(dev, "user/wireless_transport", &value, 1);
+static int triton_transport_write(IOHIDDeviceRef dev, const char *arg)
+{
+	uint8_t value = 0;
+	if (!parse_triton_transport(arg, &value)) {
+		fprintf(stderr,
+			"triton-transport expects slot0, slot1, slot2, or raw byte 0..255\n");
+		return 2;
+	}
+	return triton_keyed_write(dev, "user/wireless_transport", &value, 1);
 }
 
-static void print_a3_decode(const uint8_t *rx, int rl) {
-    if (rl < 3 || rx[1] != 0xA3) {
-        printf("  not an A3 reply\n");
-        return;
-    }
-    int len = rx[2];
-    if (len != 0x18 || rl < 27) {
-        printf("  A3 len=0x%02X (no bond payload)\n", len);
-        return;
-    }
-    const uint8_t *key = rx + 3;
-    const uint8_t *serial = rx + 11;
-    uint32_t w0 = (uint32_t)key[0] | ((uint32_t)key[1] << 8) | ((uint32_t)key[2] << 16) | ((uint32_t)key[3] << 24);
-    uint32_t w1 = (uint32_t)key[4] | ((uint32_t)key[5] << 8) | ((uint32_t)key[6] << 16) | ((uint32_t)key[7] << 24);
-    printf("  A3 bond key=");
-    for (int i = 0; i < 8; i++) printf("%s%02X", i ? " " : "", key[i]);
-    printf(" steam_words=%X_%X serial=", w0, w1);
-    for (int i = 0; i < 16 && serial[i]; i++) putchar((serial[i] >= 32 && serial[i] < 127) ? serial[i] : '.');
-    printf("\n");
+static void print_a3_decode(const uint8_t *rx, int rl)
+{
+	if (rl < 3 || rx[1] != 0xA3) {
+		printf("  not an A3 reply\n");
+		return;
+	}
+	int len = rx[2];
+	if (len != 0x18 || rl < 27) {
+		printf("  A3 len=0x%02X (no bond payload)\n", len);
+		return;
+	}
+	const uint8_t *key = rx + 3;
+	const uint8_t *serial = rx + 11;
+	uint32_t w0 = (uint32_t)key[0] | ((uint32_t)key[1] << 8) |
+		      ((uint32_t)key[2] << 16) | ((uint32_t)key[3] << 24);
+	uint32_t w1 = (uint32_t)key[4] | ((uint32_t)key[5] << 8) |
+		      ((uint32_t)key[6] << 16) | ((uint32_t)key[7] << 24);
+	printf("  A3 bond key=");
+	for (int i = 0; i < 8; i++)
+		printf("%s%02X", i ? " " : "", key[i]);
+	printf(" steam_words=%X_%X serial=", w0, w1);
+	for (int i = 0; i < 16 && serial[i]; i++)
+		putchar((serial[i] >= 32 && serial[i] < 127) ? serial[i] : '.');
+	printf("\n");
 }
 
-static void print_a3_decode_any(const uint8_t *rx, int rl) {
-    if (rl >= 2 && rx[0] == 0xA3) {
-        uint8_t shifted[64];
-        int n = rl + 1;
-        if (n > (int)sizeof shifted) n = (int)sizeof shifted;
-        memset(shifted, 0, sizeof shifted);
-        shifted[0] = 2;
-        memcpy(shifted + 1, rx, n - 1);
-        print_a3_decode(shifted, n);
-        return;
-    }
-    print_a3_decode(rx, rl);
+static void print_a3_decode_any(const uint8_t *rx, int rl)
+{
+	if (rl >= 2 && rx[0] == 0xA3) {
+		uint8_t shifted[64];
+		int n = rl + 1;
+		if (n > (int)sizeof shifted)
+			n = (int)sizeof shifted;
+		memset(shifted, 0, sizeof shifted);
+		shifted[0] = 2;
+		memcpy(shifted + 1, rx, n - 1);
+		print_a3_decode(shifted, n);
+		return;
+	}
+	print_a3_decode(rx, rl);
 }
 
-static int a3_watch(IOHIDDeviceRef dev, int count, int delay_ms) {
-    if (count <= 0) count = 20;
-    if (delay_ms < 0) delay_ms = 100;
-    for (int i = 0; i < count; i++) {
-        uint8_t tx[64]; memset(tx, 0, sizeof tx);
-        tx[0] = 2; tx[1] = 0xA3; tx[2] = 0;
-        IOReturn rs = IOHIDDeviceSetReport(dev, kIOHIDReportTypeFeature, 2, tx, 64);
-        usleep(20000);
-        uint8_t rx[64]; memset(rx, 0, sizeof rx); rx[0] = 2;
-        CFIndex rl = 64;
-        IOReturn rg = kIOReturnError;
-        if (rs == kIOReturnSuccess) rg = IOHIDDeviceGetReport(dev, kIOHIDReportTypeFeature, 2, rx, &rl);
-        printf("[%02d] A3 set=0x%08X get=0x%08X", i + 1, rs, rg);
-        if (rg == kIOReturnSuccess && rl >= 3) printf(" reply=%02X %02X %02X", rx[0], rx[1], rx[2]);
-        printf("\n");
-        if (rg == kIOReturnSuccess) print_a3_decode(rx, (int)rl);
-        if (delay_ms > 0 && i + 1 < count) usleep((useconds_t)delay_ms * 1000);
-    }
-    return 0;
+static int a3_watch(IOHIDDeviceRef dev, int count, int delay_ms)
+{
+	if (count <= 0)
+		count = 20;
+	if (delay_ms < 0)
+		delay_ms = 100;
+	for (int i = 0; i < count; i++) {
+		uint8_t tx[64];
+		memset(tx, 0, sizeof tx);
+		tx[0] = 2;
+		tx[1] = 0xA3;
+		tx[2] = 0;
+		IOReturn rs = IOHIDDeviceSetReport(dev, kIOHIDReportTypeFeature,
+						   2, tx, 64);
+		usleep(20000);
+		uint8_t rx[64];
+		memset(rx, 0, sizeof rx);
+		rx[0] = 2;
+		CFIndex rl = 64;
+		IOReturn rg = kIOReturnError;
+		if (rs == kIOReturnSuccess)
+			rg = IOHIDDeviceGetReport(dev, kIOHIDReportTypeFeature,
+						  2, rx, &rl);
+		printf("[%02d] A3 set=0x%08X get=0x%08X", i + 1, rs, rg);
+		if (rg == kIOReturnSuccess && rl >= 3)
+			printf(" reply=%02X %02X %02X", rx[0], rx[1], rx[2]);
+		printf("\n");
+		if (rg == kIOReturnSuccess)
+			print_a3_decode(rx, (int)rl);
+		if (delay_ms > 0 && i + 1 < count)
+			usleep((useconds_t)delay_ms * 1000);
+	}
+	return 0;
 }
 
-static void a3_variant(IOHIDDeviceRef dev, const char *label, int include_rid, int set_len, int get_len, int seed_rid) {
-    uint8_t tx[64]; memset(tx, 0, sizeof tx);
-    if (include_rid) {
-        tx[0] = 2;
-        tx[1] = 0xA3;
-        tx[2] = 0;
-    } else {
-        tx[0] = 0xA3;
-        tx[1] = 0;
-    }
-    IOReturn rs = IOHIDDeviceSetReport(dev, kIOHIDReportTypeFeature, 2, tx, set_len);
-    usleep(20000);
-    uint8_t rx[64]; memset(rx, 0, sizeof rx);
-    if (seed_rid) rx[0] = 2;
-    CFIndex rl = get_len;
-    IOReturn rg = kIOReturnError;
-    if (rs == kIOReturnSuccess) rg = IOHIDDeviceGetReport(dev, kIOHIDReportTypeFeature, 2, rx, &rl);
-    printf("\n=== A3 variant %s setLen=%d getLen=%d includeRid=%d seedRid=%d set=0x%08X get=0x%08X rl=%ld ===\n",
-           label, set_len, get_len, include_rid, seed_rid, rs, rg, (long)rl);
-    if (rg == kIOReturnSuccess) {
-        dump("reply", rx, (int)rl);
-        print_a3_decode_any(rx, (int)rl);
-    }
+static void a3_variant(IOHIDDeviceRef dev, const char *label, int include_rid,
+		       int set_len, int get_len, int seed_rid)
+{
+	uint8_t tx[64];
+	memset(tx, 0, sizeof tx);
+	if (include_rid) {
+		tx[0] = 2;
+		tx[1] = 0xA3;
+		tx[2] = 0;
+	} else {
+		tx[0] = 0xA3;
+		tx[1] = 0;
+	}
+	IOReturn rs = IOHIDDeviceSetReport(dev, kIOHIDReportTypeFeature, 2, tx,
+					   set_len);
+	usleep(20000);
+	uint8_t rx[64];
+	memset(rx, 0, sizeof rx);
+	if (seed_rid)
+		rx[0] = 2;
+	CFIndex rl = get_len;
+	IOReturn rg = kIOReturnError;
+	if (rs == kIOReturnSuccess)
+		rg = IOHIDDeviceGetReport(dev, kIOHIDReportTypeFeature, 2, rx,
+					  &rl);
+	printf("\n=== A3 variant %s setLen=%d getLen=%d includeRid=%d seedRid=%d set=0x%08X get=0x%08X rl=%ld ===\n",
+	       label, set_len, get_len, include_rid, seed_rid, rs, rg,
+	       (long)rl);
+	if (rg == kIOReturnSuccess) {
+		dump("reply", rx, (int)rl);
+		print_a3_decode_any(rx, (int)rl);
+	}
 }
 
-static int a3_variants(IOHIDDeviceRef dev) {
-    a3_variant(dev, "steam-short", 0, 2, 0x1a, 0);
-    a3_variant(dev, "steam-short-seeded", 0, 2, 0x1a, 1);
-    a3_variant(dev, "rid-short", 1, 3, 0x1b, 1);
-    a3_variant(dev, "rid-64", 1, 64, 64, 1);
-    return 0;
+static int a3_variants(IOHIDDeviceRef dev)
+{
+	a3_variant(dev, "steam-short", 0, 2, 0x1a, 0);
+	a3_variant(dev, "steam-short-seeded", 0, 2, 0x1a, 1);
+	a3_variant(dev, "rid-short", 1, 3, 0x1b, 1);
+	a3_variant(dev, "rid-64", 1, 64, 64, 1);
+	return 0;
 }
 
-static int run_seq(IOHIDDeviceRef dev, int argc, char **argv, int start, int do_get) {
-    int i = start;
-    while (i < argc) {
-        if (strcmp(argv[i], "--") == 0) { i++; continue; }
-        if (strncmp(argv[i], "rid", 3) != 0) {
-            fprintf(stderr, "seq expects ridN before each command, got: %s\n", argv[i]);
-            return 2;
-        }
-        int rid = atoi(argv[i] + 3);
-        if (rid < 1 || rid > 255) { fprintf(stderr, "bad report id: %s\n", argv[i]); return 2; }
-        i++;
-        if (i >= argc) { fprintf(stderr, "seq missing cmd after rid%d\n", rid); return 2; }
-        uint8_t cmd = (uint8_t)strtol(argv[i++], NULL, 16);
-        uint8_t pl[62]; int pln = 0;
-        while (i < argc && strcmp(argv[i], "--") != 0) {
-            if (strncmp(argv[i], "rid", 3) == 0) break;
-            if (pln >= 62) { fprintf(stderr, "payload too long for cmd 0x%02X\n", cmd); return 2; }
-            pl[pln++] = (uint8_t)strtol(argv[i++], NULL, 16);
-        }
-        sc_cmd_ex(dev, rid, cmd, pl, pln, do_get);
-        usleep(20000);
-    }
-    return 0;
+static int run_seq(IOHIDDeviceRef dev, int argc, char **argv, int start,
+		   int do_get)
+{
+	int i = start;
+	while (i < argc) {
+		if (strcmp(argv[i], "--") == 0) {
+			i++;
+			continue;
+		}
+		if (strncmp(argv[i], "rid", 3) != 0) {
+			fprintf(stderr,
+				"seq expects ridN before each command, got: %s\n",
+				argv[i]);
+			return 2;
+		}
+		int rid = atoi(argv[i] + 3);
+		if (rid < 1 || rid > 255) {
+			fprintf(stderr, "bad report id: %s\n", argv[i]);
+			return 2;
+		}
+		i++;
+		if (i >= argc) {
+			fprintf(stderr, "seq missing cmd after rid%d\n", rid);
+			return 2;
+		}
+		uint8_t cmd = (uint8_t)strtol(argv[i++], NULL, 16);
+		uint8_t pl[62];
+		int pln = 0;
+		while (i < argc && strcmp(argv[i], "--") != 0) {
+			if (strncmp(argv[i], "rid", 3) == 0)
+				break;
+			if (pln >= 62) {
+				fprintf(stderr,
+					"payload too long for cmd 0x%02X\n",
+					cmd);
+				return 2;
+			}
+			pl[pln++] = (uint8_t)strtol(argv[i++], NULL, 16);
+		}
+		sc_cmd_ex(dev, rid, cmd, pl, pln, do_get);
+		usleep(20000);
+	}
+	return 0;
 }
 
-int main(int argc, char **argv) {
-    if (argc < 2) { fprintf(stderr, "usage: %s list [pidHex] | <pidHex> [--serial SERIAL] [cmdHex payloadBytes...]\n", argv[0]); return 2; }
-    if (strcmp(argv[1], "list") == 0) {
-        long wantPid = argc >= 3 ? strtol(argv[2], NULL, 16) : -1;
-        return list_devices(wantPid);
-    }
-    long wantPid = strtol(argv[1], NULL, 16);
-    const char *wantSerial = NULL;
-    long wantLoc = -1;
-    long wantUp = -1;
-    int wantIdx = -1;
-    int matchIdx = 0;
-    int arg = 2;
-    while (arg < argc) {
-        if (strcmp(argv[arg], "--serial") == 0) {
-            if (arg + 1 >= argc) { fprintf(stderr, "--serial needs a value\n"); return 2; }
-            wantSerial = argv[arg + 1];
-            arg += 2;
-        } else if (strncmp(argv[arg], "--serial=", 9) == 0) {
-            wantSerial = argv[arg] + 9;
-            arg++;
-        } else if (strcmp(argv[arg], "--loc") == 0) {
-            if (arg + 1 >= argc) { fprintf(stderr, "--loc needs a value\n"); return 2; }
-            wantLoc = strtol(argv[arg + 1], NULL, 0);
-            arg += 2;
-        } else if (strncmp(argv[arg], "--loc=", 6) == 0) {
-            wantLoc = strtol(argv[arg] + 6, NULL, 0);
-            arg++;
-        } else if (strcmp(argv[arg], "--up") == 0) {
-            if (arg + 1 >= argc) { fprintf(stderr, "--up needs a value\n"); return 2; }
-            wantUp = strtol(argv[arg + 1], NULL, 0);
-            arg += 2;
-        } else if (strcmp(argv[arg], "--idx") == 0) {
-            if (arg + 1 >= argc) { fprintf(stderr, "--idx needs a value\n"); return 2; }
-            wantIdx = strtol(argv[arg + 1], NULL, 0);
-            arg += 2;
-        } else {
-            break;
-        }
-    }
-
-    CFMutableDictionaryRef m = IOServiceMatching("IOHIDDevice");
-    io_iterator_t it;
-    if (IOServiceGetMatchingServices(kIOMainPortDefault, m, &it) != KERN_SUCCESS) { fprintf(stderr, "match fail\n"); return 1; }
-
-    IOHIDDeviceRef dev = NULL; io_object_t svc;
-    while ((svc = IOIteratorNext(it))) {
-        CFTypeRef vid = IORegistryEntryCreateCFProperty(svc, CFSTR("VendorID"), kCFAllocatorDefault, 0);
-        CFTypeRef pid = IORegistryEntryCreateCFProperty(svc, CFSTR("ProductID"), kCFAllocatorDefault, 0);
-        long v = -1, p = -1;
-        if (vid) { CFNumberGetValue((CFNumberRef)vid, kCFNumberLongType, &v); CFRelease(vid); }
-        if (pid) { CFNumberGetValue((CFNumberRef)pid, kCFNumberLongType, &p); CFRelease(pid); }
-        if (v == VALVE_VID && p == wantPid) {
-            IOHIDDeviceRef d = IOHIDDeviceCreate(kCFAllocatorDefault, svc);
-            long up = -1, maxF = -1, loc = -1;
-            if (d) {
-                reg_long(svc, "PrimaryUsagePage", &up);
-                if (up < 0) {
-                    CFTypeRef u = IOHIDDeviceGetProperty(d, CFSTR(kIOHIDPrimaryUsagePageKey));
-                    if (u) CFNumberGetValue((CFNumberRef)u, kCFNumberLongType, &up);
-                }
-                reg_long(svc, "MaxFeatureReportSize", &maxF);
-                reg_long(svc, "LocationID", &loc);
-            }
-            char serial[128] = "";
-            char product[128] = "";
-            if (d) {
-                reg_string(svc, "SerialNumber", serial, sizeof serial);
-                if (!serial[0]) hid_string(d, CFSTR(kIOHIDSerialNumberKey), serial, sizeof serial);
-                reg_string(svc, "Product", product, sizeof product);
-                if (!product[0]) hid_string(d, CFSTR(kIOHIDProductKey), product, sizeof product);
-            }
-            if (wantSerial && strcmp(wantSerial, serial) != 0) {
-                if (d) CFRelease(d);
-                IOObjectRelease(svc);
-                continue;
-            }
-            if (wantLoc >= 0 && loc != wantLoc) {
-                if (d) CFRelease(d);
-                IOObjectRelease(svc);
-                continue;
-            }
-            if (wantUp >= 0 && up != wantUp) {
-                if (d) CFRelease(d);
-                IOObjectRelease(svc);
-                continue;
-            }
-            if (wantIdx >= 0 && matchIdx++ != wantIdx) {
-                if (d) CFRelease(d);
-                IOObjectRelease(svc);
-                continue;
-            }
-            int command_capable = (up == 0xFF00) || (maxF >= 64);
-            IOReturn ro = d && command_capable ? IOHIDDeviceOpen(d, kIOHIDOptionsTypeNone) : kIOReturnNotFound;
-            if (d && command_capable && ro == kIOReturnSuccess) {
-                printf("opened Valve %04lX:%04lX node (usagePage=0x%lX maxFeature=%ld serial=%s product=%s)\n",
-                       (long)VALVE_VID, wantPid, up, maxF, serial[0] ? serial : "-", product[0] ? product : "-");
-                dev = d; IOObjectRelease(svc); break;
-            }
-            if (d && command_capable && (wantSerial || wantLoc >= 0)) {
-                fprintf(stderr, "matched Valve %04lX:%04lX usagePage=0x%lX maxFeature=%ld loc=0x%lX serial=%s but open failed: 0x%08X\n",
-                        (long)VALVE_VID, wantPid, up, maxF, loc, serial[0] ? serial : "-", ro);
-            }
-            if (d) CFRelease(d);
-        }
-        IOObjectRelease(svc);
-    }
-    IOObjectRelease(it);
-    if (!dev) {
-        if (wantSerial || wantLoc >= 0) fprintf(stderr, "no openable Valve %04lX command-capable interface matching serial=%s loc=0x%lX (quit Steam? wrong device?)\n", wantPid, wantSerial ? wantSerial : "-", wantLoc);
-        else fprintf(stderr, "no openable Valve %04lX command-capable interface (quit Steam? add --serial/--loc?)\n", wantPid);
-        return 1;
-    }
-
-    if (arg < argc && strcmp(argv[arg], "slotwrite") == 0) {
-        // Single-session dongle slot write: 0xAD 01 (enter pairing mode) -> 0xA2 <24-byte record>
-        // -> 0xAD 00, all on the SAME open handle (Steam keeps one session; pairing mode resets on
-        // close). record = [8-byte uuids][16-byte serial]; zeros-serial clears the matching slot.
-        // first arg = report id (the ALT channel is report id 3); rest = 24-byte record
-        int rid = (arg + 1 < argc) ? atoi(argv[arg + 1]) : 3;
-        uint8_t rec[24]; memset(rec, 0, sizeof rec);
-        int n = 0;
-        for (int i = arg + 2; i < argc && n < 24; i++) rec[n++] = (uint8_t)strtol(argv[i], NULL, 16);
-        uint8_t on[2] = {0x01, 0x00}, off[1] = {0x00};
-        printf("# slotwrite rid%d: AD 01 -> A2 <24> -> AD 00 (one session)\n", rid);
-        sc_cmd(dev, rid, 0xAD, on, 2);
-        sc_cmd(dev, rid, 0xA2, rec, 24);
-        sc_cmd(dev, rid, 0xAD, off, 1);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone); CFRelease(dev); return 0;
-    } else if (arg < argc && strcmp(argv[arg], "pair") == 0) {
-        // Steamless pairing (firmware-derived): 0xAD [mode=1][channel] enters RF pairing mode;
-        // the puck then runs the pairing handshake autonomously. Channel 0 => firmware default
-        // (0x3c). Put the controller in pairing via its key-chord, then we poll 0xA3 for the bond.
-        uint8_t ch = (arg + 1 < argc) ? (uint8_t)strtol(argv[arg + 1], NULL, 16) : 0x00;
-        uint8_t start[2] = { 0x01, ch };
-        printf("# pair: entering pairing mode (0xAD 01 %02X).\n", ch);
-        printf("# >>> Now put the controller into pairing via its key-chord. Polling 0xA3...\n");
-        sc_cmd(dev, 2, 0xAD, start, 2);
-        int rc = a3_watch(dev, 120, 200);          // ~24s: watch status for the controller to bond
-        uint8_t stop[1] = { 0x00 };
-        printf("# pair: exiting pairing mode (0xAD 00).\n");
-        sc_cmd(dev, 2, 0xAD, stop, 1);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone); CFRelease(dev); return rc;
-    } else if (arg < argc && strcmp(argv[arg], "stop") == 0) {
-        uint8_t stop[1] = { 0x00 };                // exit pairing mode
-        sc_cmd(dev, 2, 0xAD, stop, 1);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone); CFRelease(dev); return 0;
-    } else if (arg < argc && strcmp(argv[arg], "info") == 0) {
-        // Read identity + bond status without changing anything.
-        printf("\n##### 0x83 attributes #####\n");        sc_cmd(dev, 2, 0x83, NULL, 0);
-        printf("\n##### 0xAE strings (idx 0..7) #####\n"); for (int a = 0; a < 8; a++) { uint8_t aa = (uint8_t)a; sc_cmd(dev, 2, 0xAE, &aa, 1); }
-        printf("\n##### 0xB4 wireless state #####\n");     sc_cmd(dev, 2, 0xB4, NULL, 0);
-        printf("\n##### 0xA3 status / bond #####\n");      sc_cmd(dev, 2, 0xA3, NULL, 0);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone); CFRelease(dev); return 0;
-    } else if (arg < argc && strcmp(argv[arg], "a3watch") == 0) {
-        int count = (arg + 1 < argc) ? atoi(argv[arg + 1]) : 20;
-        int delay_ms = (arg + 2 < argc) ? atoi(argv[arg + 2]) : 100;
-        int rc = a3_watch(dev, count, delay_ms);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-        CFRelease(dev);
-        return rc;
-    } else if (arg < argc && strcmp(argv[arg], "a3variants") == 0) {
-        int rc = a3_variants(dev);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-        CFRelease(dev);
-        return rc;
-    } else if (arg < argc && strcmp(argv[arg], "triton-bond") == 0) {
-        if (arg + 3 >= argc) {
-            fprintf(stderr, "usage: %s <pidHex> [--serial S] triton-bond <0|1> <keyHex16> <controllerSerial>\n", argv[0]);
-            IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-            CFRelease(dev);
-            return 2;
-        }
-        int slot = atoi(argv[arg + 1]);
-        int rc = triton_bond_write(dev, slot, argv[arg + 2], argv[arg + 3]);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-        CFRelease(dev);
-        return rc;
-    } else if (arg < argc && strcmp(argv[arg], "triton-transport") == 0) {
-        if (arg + 1 >= argc) {
-            fprintf(stderr, "usage: %s <pidHex> [--serial S] triton-transport <slot0|slot1|slot2|rawByte>\n", argv[0]);
-            IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-            CFRelease(dev);
-            return 2;
-        }
-        int rc = triton_transport_write(dev, argv[arg + 1]);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-        CFRelease(dev);
-        return rc;
-    } else if (arg < argc && strcmp(argv[arg], "find") == 0) {     // probe all non-destructive opcodes for bond/serial data
-        // BLOCKLIST = known state-changing/destructive opcodes; never sent here.
-        static const int block[] = {0x80,0x81,0x85,0x86,0x87,0x88,0x8D,0x8E,0x8F,0x9F,
-                                     0xAD,0xAF,0xB0,0xB2,0xB3,0xEB,-1};
-        int rid = 2;
-        for (int cmd = 0x82; cmd <= 0xFE; cmd++) {
-            int blocked = 0; for (const int *b = block; *b >= 0; b++) if (*b == cmd) blocked = 1;
-            if (blocked) continue;
-            // send with empty payload; report only if SET succeeded (supported opcode)
-            uint8_t tx[64]; memset(tx, 0, sizeof tx); tx[0] = rid; tx[1] = (uint8_t)cmd; tx[2] = 0;
-            IOReturn rs = IOHIDDeviceSetReport(dev, kIOHIDReportTypeFeature, rid, tx, 64);
-            if (rs != kIOReturnSuccess) continue;             // unsupported -> stall, skip silently
-            usleep(15000);
-            uint8_t rx[64]; memset(rx, 0, sizeof rx); rx[0] = rid; CFIndex rl = 64;
-            IOReturn rg = IOHIDDeviceGetReport(dev, kIOHIDReportTypeFeature, rid, rx, &rl);
-            if (rg == kIOReturnSuccess && rx[1] == cmd) {      // echoed -> real reply
-                char lbl[24]; snprintf(lbl, sizeof lbl, "SUPPORTED cmd 0x%02X", cmd);
-                dump(lbl, rx, (int)rl);
-            }
-        }
-    } else if (arg < argc && strcmp(argv[arg], "labels") == 0) {   // SAFE read-only label/settings sweep
-        int rid = 2;
-        printf("\n##### 0x84 GET_ATTRIBUTE_LABEL idx 0..15 #####\n");
-        for (int i = 0; i < 16; i++) { uint8_t p[2] = {(uint8_t)i, 0}; sc_cmd(dev, rid, 0x84, p, 2); }
-        printf("\n##### 0x8A GET_SETTING_LABEL idx 0..47 #####\n");
-        for (int i = 0; i < 48; i++) { uint8_t p[2] = {(uint8_t)i, 0}; sc_cmd(dev, rid, 0x8A, p, 2); }
-        printf("\n##### 0x89 GET_SETTINGS_VALUES idx 0..47 #####\n");
-        for (int i = 0; i < 48; i++) { uint8_t p[2] = {(uint8_t)i, 0}; sc_cmd(dev, rid, 0x89, p, 2); }
-    } else if (arg < argc && strcmp(argv[arg], "seq") == 0) {
-        int rc = run_seq(dev, argc, argv, arg + 1, 1);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-        CFRelease(dev);
-        return rc;
-    } else if (arg < argc && strcmp(argv[arg], "seq-noget") == 0) {
-        int rc = run_seq(dev, argc, argv, arg + 1, 0);
-        IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-        CFRelease(dev);
-        return rc;
-    } else if (arg + 1 < argc && strncmp(argv[arg], "rid", 3) == 0) { // custom command on one report id
-        int rid = atoi(argv[arg] + 3);
-        if (rid < 1 || rid > 255) { fprintf(stderr, "bad report id: %s\n", argv[arg]); return 2; }
-        uint8_t cmd = (uint8_t)strtol(argv[arg + 1], NULL, 16);
-        uint8_t pl[62]; int pln = 0;
-        for (int i = arg + 2; i < argc && pln < 62; i++) pl[pln++] = (uint8_t)strtol(argv[i], NULL, 16);
-        sc_cmd(dev, rid, cmd, pl, pln);
-    } else if (arg < argc) {                   // custom single command on both command report ids
-        uint8_t cmd = (uint8_t)strtol(argv[arg], NULL, 16);
-        uint8_t pl[62]; int pln = 0;
-        for (int i = arg + 1; i < argc && pln < 62; i++) pl[pln++] = (uint8_t)strtol(argv[i], NULL, 16);
-        for (int rid = 1; rid <= 2; rid++) sc_cmd(dev, rid, cmd, pl, pln);
-    } else {                                  // safe read battery on the working channel (report id 2)
-        int rid = 2;
-        printf("\n########## string attributes 0xAE (idx 0..15) ##########\n");
-        for (int a = 0; a < 16; a++) { uint8_t aa = (uint8_t)a; sc_cmd(dev, rid, 0xAE, &aa, 1); }
-        printf("\n########## 0x83 GET_ATTRIBUTES_VALUES ##########\n");
-        sc_cmd(dev, rid, 0x83, NULL, 0);
-        printf("\n########## 0xB4 DONGLE_GET_WIRELESS_STATE (no idx, then idx 0..3) ##########\n");
-        sc_cmd(dev, rid, 0xB4, NULL, 0);
-        for (int i = 0; i < 4; i++) { uint8_t ii = (uint8_t)i; sc_cmd(dev, rid, 0xB4, &ii, 1); }
-        printf("\n########## 0x82 GET_DIGITAL_MAPPINGS (read) ##########\n");
-        sc_cmd(dev, rid, 0x82, NULL, 0);
-    }
-    IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
-    CFRelease(dev);
-    return 0;
+int main(int argc, char **argv)
+{
+	if (argc < 2) {
+		fprintf(stderr,
+			"usage: %s list [pidHex] | <pidHex> [--serial SERIAL] [cmdHex payloadBytes...]\n",
+			argv[0]);
+		return 2;
+	}
+	if (strcmp(argv[1], "list") == 0) {
+		long wantPid = argc >= 3 ? strtol(argv[2], NULL, 16) : -1;
+		return list_devices(wantPid);
+	}
+	long wantPid = strtol(argv[1], NULL, 16);
+	const char *wantSerial = NULL;
+	long wantLoc = -1;
+	long wantUp = -1;
+	int wantIdx = -1;
+	int matchIdx = 0;
+	int arg = 2;
+	while (arg < argc) {
+		if (strcmp(argv[arg], "--serial") == 0) {
+			if (arg + 1 >= argc) {
+				fprintf(stderr, "--serial needs a value\n");
+				return 2;
+			}
+			wantSerial = argv[arg + 1];
+			arg += 2;
+		} else if (strncmp(argv[arg], "--serial=", 9) == 0) {
+			wantSerial = argv[arg] + 9;
+			arg++;
+		} else if (strcmp(argv[arg], "--loc") == 0) {
+			if (arg + 1 >= argc) {
+				fprintf(stderr, "--loc needs a value\n");
+				return 2;
+			}
+			wantLoc = strtol(argv[arg + 1], NULL, 0);
+			arg += 2;
+		} else if (strncmp(argv[arg], "--loc=", 6) == 0) {
+			wantLoc = strtol(argv[arg] + 6, NULL, 0);
+			arg++;
+		} else if (strcmp(argv[arg], "--up") == 0) {
+			if (arg + 1 >= argc) {
+				fprintf(stderr, "--up needs a value\n");
+				return 2;
+			}
+			wantUp = strtol(argv[arg + 1], NULL, 0);
+			arg += 2;
+		} else if (strcmp(argv[arg], "--idx") == 0) {
+			if (arg + 1 >= argc) {
+				fprintf(stderr, "--idx needs a value\n");
+				return 2;
+			}
+			wantIdx = strtol(argv[arg + 1], NULL, 0);
+			arg += 2;
+		} else {
+			break;
+		}
+	}
+
+	CFMutableDictionaryRef m = IOServiceMatching("IOHIDDevice");
+	io_iterator_t it;
+	if (IOServiceGetMatchingServices(kIOMainPortDefault, m, &it) !=
+	    KERN_SUCCESS) {
+		fprintf(stderr, "match fail\n");
+		return 1;
+	}
+
+	IOHIDDeviceRef dev = NULL;
+	io_object_t svc;
+	while ((svc = IOIteratorNext(it))) {
+		CFTypeRef vid = IORegistryEntryCreateCFProperty(
+			svc, CFSTR("VendorID"), kCFAllocatorDefault, 0);
+		CFTypeRef pid = IORegistryEntryCreateCFProperty(
+			svc, CFSTR("ProductID"), kCFAllocatorDefault, 0);
+		long v = -1, p = -1;
+		if (vid) {
+			CFNumberGetValue((CFNumberRef)vid, kCFNumberLongType,
+					 &v);
+			CFRelease(vid);
+		}
+		if (pid) {
+			CFNumberGetValue((CFNumberRef)pid, kCFNumberLongType,
+					 &p);
+			CFRelease(pid);
+		}
+		if (v == VALVE_VID && p == wantPid) {
+			IOHIDDeviceRef d =
+				IOHIDDeviceCreate(kCFAllocatorDefault, svc);
+			long up = -1, maxF = -1, loc = -1;
+			if (d) {
+				reg_long(svc, "PrimaryUsagePage", &up);
+				if (up < 0) {
+					CFTypeRef u = IOHIDDeviceGetProperty(
+						d,
+						CFSTR(kIOHIDPrimaryUsagePageKey));
+					if (u)
+						CFNumberGetValue(
+							(CFNumberRef)u,
+							kCFNumberLongType, &up);
+				}
+				reg_long(svc, "MaxFeatureReportSize", &maxF);
+				reg_long(svc, "LocationID", &loc);
+			}
+			char serial[128] = "";
+			char product[128] = "";
+			if (d) {
+				reg_string(svc, "SerialNumber", serial,
+					   sizeof serial);
+				if (!serial[0])
+					hid_string(d,
+						   CFSTR(kIOHIDSerialNumberKey),
+						   serial, sizeof serial);
+				reg_string(svc, "Product", product,
+					   sizeof product);
+				if (!product[0])
+					hid_string(d, CFSTR(kIOHIDProductKey),
+						   product, sizeof product);
+			}
+			if (wantSerial && strcmp(wantSerial, serial) != 0) {
+				if (d)
+					CFRelease(d);
+				IOObjectRelease(svc);
+				continue;
+			}
+			if (wantLoc >= 0 && loc != wantLoc) {
+				if (d)
+					CFRelease(d);
+				IOObjectRelease(svc);
+				continue;
+			}
+			if (wantUp >= 0 && up != wantUp) {
+				if (d)
+					CFRelease(d);
+				IOObjectRelease(svc);
+				continue;
+			}
+			if (wantIdx >= 0 && matchIdx++ != wantIdx) {
+				if (d)
+					CFRelease(d);
+				IOObjectRelease(svc);
+				continue;
+			}
+			int command_capable = (up == 0xFF00) || (maxF >= 64);
+			IOReturn ro =
+				d && command_capable ?
+					IOHIDDeviceOpen(d,
+							kIOHIDOptionsTypeNone) :
+					kIOReturnNotFound;
+			if (d && command_capable && ro == kIOReturnSuccess) {
+				printf("opened Valve %04lX:%04lX node (usagePage=0x%lX maxFeature=%ld serial=%s product=%s)\n",
+				       (long)VALVE_VID, wantPid, up, maxF,
+				       serial[0] ? serial : "-",
+				       product[0] ? product : "-");
+				dev = d;
+				IOObjectRelease(svc);
+				break;
+			}
+			if (d && command_capable &&
+			    (wantSerial || wantLoc >= 0)) {
+				fprintf(stderr,
+					"matched Valve %04lX:%04lX usagePage=0x%lX maxFeature=%ld loc=0x%lX serial=%s but open failed: 0x%08X\n",
+					(long)VALVE_VID, wantPid, up, maxF, loc,
+					serial[0] ? serial : "-", ro);
+			}
+			if (d)
+				CFRelease(d);
+		}
+		IOObjectRelease(svc);
+	}
+	IOObjectRelease(it);
+	if (!dev) {
+		if (wantSerial || wantLoc >= 0)
+			fprintf(stderr,
+				"no openable Valve %04lX command-capable interface matching serial=%s loc=0x%lX (quit Steam? wrong device?)\n",
+				wantPid, wantSerial ? wantSerial : "-",
+				wantLoc);
+		else
+			fprintf(stderr,
+				"no openable Valve %04lX command-capable interface (quit Steam? add --serial/--loc?)\n",
+				wantPid);
+		return 1;
+	}
+
+	if (arg < argc && strcmp(argv[arg], "slotwrite") == 0) {
+		// Single-session dongle slot write: 0xAD 01 (enter pairing mode) -> 0xA2 <24-byte record>
+		// -> 0xAD 00, all on the SAME open handle (Steam keeps one session; pairing mode resets on
+		// close). record = [8-byte uuids][16-byte serial]; zeros-serial clears the matching slot.
+		// first arg = report id (the ALT channel is report id 3); rest = 24-byte record
+		int rid = (arg + 1 < argc) ? atoi(argv[arg + 1]) : 3;
+		uint8_t rec[24];
+		memset(rec, 0, sizeof rec);
+		int n = 0;
+		for (int i = arg + 2; i < argc && n < 24; i++)
+			rec[n++] = (uint8_t)strtol(argv[i], NULL, 16);
+		uint8_t on[2] = { 0x01, 0x00 }, off[1] = { 0x00 };
+		printf("# slotwrite rid%d: AD 01 -> A2 <24> -> AD 00 (one session)\n",
+		       rid);
+		sc_cmd(dev, rid, 0xAD, on, 2);
+		sc_cmd(dev, rid, 0xA2, rec, 24);
+		sc_cmd(dev, rid, 0xAD, off, 1);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return 0;
+	} else if (arg < argc && strcmp(argv[arg], "pair") == 0) {
+		// Steamless pairing (firmware-derived): 0xAD [mode=1][channel] enters RF pairing mode;
+		// the puck then runs the pairing handshake autonomously. Channel 0 => firmware default
+		// (0x3c). Put the controller in pairing via its key-chord, then we poll 0xA3 for the bond.
+		uint8_t ch = (arg + 1 < argc) ?
+				     (uint8_t)strtol(argv[arg + 1], NULL, 16) :
+				     0x00;
+		uint8_t start[2] = { 0x01, ch };
+		printf("# pair: entering pairing mode (0xAD 01 %02X).\n", ch);
+		printf("# >>> Now put the controller into pairing via its key-chord. Polling 0xA3...\n");
+		sc_cmd(dev, 2, 0xAD, start, 2);
+		int rc = a3_watch(
+			dev, 120,
+			200); // ~24s: watch status for the controller to bond
+		uint8_t stop[1] = { 0x00 };
+		printf("# pair: exiting pairing mode (0xAD 00).\n");
+		sc_cmd(dev, 2, 0xAD, stop, 1);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return rc;
+	} else if (arg < argc && strcmp(argv[arg], "stop") == 0) {
+		uint8_t stop[1] = { 0x00 }; // exit pairing mode
+		sc_cmd(dev, 2, 0xAD, stop, 1);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return 0;
+	} else if (arg < argc && strcmp(argv[arg], "info") == 0) {
+		// Read identity + bond status without changing anything.
+		printf("\n##### 0x83 attributes #####\n");
+		sc_cmd(dev, 2, 0x83, NULL, 0);
+		printf("\n##### 0xAE strings (idx 0..7) #####\n");
+		for (int a = 0; a < 8; a++) {
+			uint8_t aa = (uint8_t)a;
+			sc_cmd(dev, 2, 0xAE, &aa, 1);
+		}
+		printf("\n##### 0xB4 wireless state #####\n");
+		sc_cmd(dev, 2, 0xB4, NULL, 0);
+		printf("\n##### 0xA3 status / bond #####\n");
+		sc_cmd(dev, 2, 0xA3, NULL, 0);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return 0;
+	} else if (arg < argc && strcmp(argv[arg], "a3watch") == 0) {
+		int count = (arg + 1 < argc) ? atoi(argv[arg + 1]) : 20;
+		int delay_ms = (arg + 2 < argc) ? atoi(argv[arg + 2]) : 100;
+		int rc = a3_watch(dev, count, delay_ms);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return rc;
+	} else if (arg < argc && strcmp(argv[arg], "a3variants") == 0) {
+		int rc = a3_variants(dev);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return rc;
+	} else if (arg < argc && strcmp(argv[arg], "triton-bond") == 0) {
+		if (arg + 3 >= argc) {
+			fprintf(stderr,
+				"usage: %s <pidHex> [--serial S] triton-bond <0|1> <keyHex16> <controllerSerial>\n",
+				argv[0]);
+			IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+			CFRelease(dev);
+			return 2;
+		}
+		int slot = atoi(argv[arg + 1]);
+		int rc = triton_bond_write(dev, slot, argv[arg + 2],
+					   argv[arg + 3]);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return rc;
+	} else if (arg < argc && strcmp(argv[arg], "triton-transport") == 0) {
+		if (arg + 1 >= argc) {
+			fprintf(stderr,
+				"usage: %s <pidHex> [--serial S] triton-transport <slot0|slot1|slot2|rawByte>\n",
+				argv[0]);
+			IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+			CFRelease(dev);
+			return 2;
+		}
+		int rc = triton_transport_write(dev, argv[arg + 1]);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return rc;
+	} else if (arg < argc &&
+		   strcmp(argv[arg], "find") ==
+			   0) { // probe all non-destructive opcodes for bond/serial data
+		// BLOCKLIST = known state-changing/destructive opcodes; never sent here.
+		static const int block[] = { 0x80, 0x81, 0x85, 0x86, 0x87, 0x88,
+					     0x8D, 0x8E, 0x8F, 0x9F, 0xAD, 0xAF,
+					     0xB0, 0xB2, 0xB3, 0xEB, -1 };
+		int rid = 2;
+		for (int cmd = 0x82; cmd <= 0xFE; cmd++) {
+			int blocked = 0;
+			for (const int *b = block; *b >= 0; b++)
+				if (*b == cmd)
+					blocked = 1;
+			if (blocked)
+				continue;
+			// send with empty payload; report only if SET succeeded (supported opcode)
+			uint8_t tx[64];
+			memset(tx, 0, sizeof tx);
+			tx[0] = rid;
+			tx[1] = (uint8_t)cmd;
+			tx[2] = 0;
+			IOReturn rs = IOHIDDeviceSetReport(
+				dev, kIOHIDReportTypeFeature, rid, tx, 64);
+			if (rs != kIOReturnSuccess)
+				continue; // unsupported -> stall, skip silently
+			usleep(15000);
+			uint8_t rx[64];
+			memset(rx, 0, sizeof rx);
+			rx[0] = rid;
+			CFIndex rl = 64;
+			IOReturn rg = IOHIDDeviceGetReport(
+				dev, kIOHIDReportTypeFeature, rid, rx, &rl);
+			if (rg == kIOReturnSuccess &&
+			    rx[1] == cmd) { // echoed -> real reply
+				char lbl[24];
+				snprintf(lbl, sizeof lbl,
+					 "SUPPORTED cmd 0x%02X", cmd);
+				dump(lbl, rx, (int)rl);
+			}
+		}
+	} else if (arg < argc &&
+		   strcmp(argv[arg], "labels") ==
+			   0) { // SAFE read-only label/settings sweep
+		int rid = 2;
+		printf("\n##### 0x84 GET_ATTRIBUTE_LABEL idx 0..15 #####\n");
+		for (int i = 0; i < 16; i++) {
+			uint8_t p[2] = { (uint8_t)i, 0 };
+			sc_cmd(dev, rid, 0x84, p, 2);
+		}
+		printf("\n##### 0x8A GET_SETTING_LABEL idx 0..47 #####\n");
+		for (int i = 0; i < 48; i++) {
+			uint8_t p[2] = { (uint8_t)i, 0 };
+			sc_cmd(dev, rid, 0x8A, p, 2);
+		}
+		printf("\n##### 0x89 GET_SETTINGS_VALUES idx 0..47 #####\n");
+		for (int i = 0; i < 48; i++) {
+			uint8_t p[2] = { (uint8_t)i, 0 };
+			sc_cmd(dev, rid, 0x89, p, 2);
+		}
+	} else if (arg < argc && strcmp(argv[arg], "seq") == 0) {
+		int rc = run_seq(dev, argc, argv, arg + 1, 1);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return rc;
+	} else if (arg < argc && strcmp(argv[arg], "seq-noget") == 0) {
+		int rc = run_seq(dev, argc, argv, arg + 1, 0);
+		IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+		CFRelease(dev);
+		return rc;
+	} else if (arg + 1 < argc &&
+		   strncmp(argv[arg], "rid", 3) ==
+			   0) { // custom command on one report id
+		int rid = atoi(argv[arg] + 3);
+		if (rid < 1 || rid > 255) {
+			fprintf(stderr, "bad report id: %s\n", argv[arg]);
+			return 2;
+		}
+		uint8_t cmd = (uint8_t)strtol(argv[arg + 1], NULL, 16);
+		uint8_t pl[62];
+		int pln = 0;
+		for (int i = arg + 2; i < argc && pln < 62; i++)
+			pl[pln++] = (uint8_t)strtol(argv[i], NULL, 16);
+		sc_cmd(dev, rid, cmd, pl, pln);
+	} else if (arg <
+		   argc) { // custom single command on both command report ids
+		uint8_t cmd = (uint8_t)strtol(argv[arg], NULL, 16);
+		uint8_t pl[62];
+		int pln = 0;
+		for (int i = arg + 1; i < argc && pln < 62; i++)
+			pl[pln++] = (uint8_t)strtol(argv[i], NULL, 16);
+		for (int rid = 1; rid <= 2; rid++)
+			sc_cmd(dev, rid, cmd, pl, pln);
+	} else { // safe read battery on the working channel (report id 2)
+		int rid = 2;
+		printf("\n########## string attributes 0xAE (idx 0..15) ##########\n");
+		for (int a = 0; a < 16; a++) {
+			uint8_t aa = (uint8_t)a;
+			sc_cmd(dev, rid, 0xAE, &aa, 1);
+		}
+		printf("\n########## 0x83 GET_ATTRIBUTES_VALUES ##########\n");
+		sc_cmd(dev, rid, 0x83, NULL, 0);
+		printf("\n########## 0xB4 DONGLE_GET_WIRELESS_STATE (no idx, then idx 0..3) ##########\n");
+		sc_cmd(dev, rid, 0xB4, NULL, 0);
+		for (int i = 0; i < 4; i++) {
+			uint8_t ii = (uint8_t)i;
+			sc_cmd(dev, rid, 0xB4, &ii, 1);
+		}
+		printf("\n########## 0x82 GET_DIGITAL_MAPPINGS (read) ##########\n");
+		sc_cmd(dev, rid, 0x82, NULL, 0);
+	}
+	IOHIDDeviceClose(dev, kIOHIDOptionsTypeNone);
+	CFRelease(dev);
+	return 0;
 }

+ 297 - 150
puck_sniffer/puck_sniffer.ino

@@ -33,203 +33,350 @@
 Adafruit_USBD_WebUSB usb_web;
 
 // ---- capture ring (single producer + consumer, both loop context -> no locking) ----
-struct Cap { uint32_t t; uint8_t ch, flags, rssi, match, n; uint8_t b[96]; };
+struct Cap {
+	uint32_t t;
+	uint8_t ch, flags, rssi, match, n;
+	uint8_t b[96];
+};
 #define RINGN 512
-static Cap      g_ring[RINGN];
+static Cap g_ring[RINGN];
 static volatile uint16_t g_head = 0, g_tail = 0;
-static inline uint16_t rnext(uint16_t i){ return (uint16_t)((i + 1) % RINGN); }
+static inline uint16_t rnext(uint16_t i)
+{
+	return (uint16_t)((i + 1) % RINGN);
+}
 
 static void rxArmInit();
 static void tuneDiscovery();
 static void tuneSession();
 
-static bool     g_streaming = false;      // app sent START -> emit packet frames
-static uint8_t  g_state = 0;              // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
-static uint8_t  g_curCh = 2;
-static uint8_t  g_sBase[4] = {0x69,0x62,0x65,0x78};
-static uint8_t  g_sPrefix = 0x10;
-static uint8_t  g_forceCh = 0;            // !=0 -> user pinned a channel (skip auto-acquire retune)
-static bool     g_pinSession = false;     // user pinned a FULL session (cmd 05): never auto-retune addr/ch
-static bool     g_survey = false;         // cmd 06: stay on ibex/ch2 and stream EVERY E1 beacon, never auto-lock
-                                          // (so you can see how many pucks/slots are advertising + their bases)
+static bool g_streaming = false; // app sent START -> emit packet frames
+static uint8_t g_state = 0; // 0=ACQUIRE (ibex/ch2), 1=CAPTURE (session)
+static uint8_t g_curCh = 2;
+static uint8_t g_sBase[4] = { 0x69, 0x62, 0x65, 0x78 };
+static uint8_t g_sPrefix = 0x10;
+static uint8_t g_forceCh =
+	0; // !=0 -> user pinned a channel (skip auto-acquire retune)
+static bool g_pinSession =
+	false; // user pinned a FULL session (cmd 05): never auto-retune addr/ch
+// cmd 06: stay on ibex/ch2 and stream EVERY E1 beacon, never auto-lock
+// (so you can see how many pucks/slots are advertising + their bases)
+static bool g_survey = false;
 // cmd 07: lock ONLY the E1 whose ibex_uuid (E1 payload bytes, = rfrx[7..10], LE) matches -- the puck beacons one
 // E1 per bonded slot and we must pick the CONNECTED controller's. Read its uuid from pairtui (e.g. 0xEF7171B4).
-static bool     g_haveTarget = false;
-static uint8_t  g_targetIbex[4] = {0,0,0,0};
-static unsigned long g_lastRx = 0;        // for stall -> sweep/re-acquire
-static uint8_t  g_lastMatch = 0;          // RXMATCH pipe of the most recent frame (diagnostic)
+static bool g_haveTarget = false;
+static uint8_t g_targetIbex[4] = { 0, 0, 0, 0 };
+static unsigned long g_lastRx = 0; // for stall -> sweep/re-acquire
+static uint8_t g_lastMatch =
+	0; // RXMATCH pipe of the most recent frame (diagnostic)
 
 // What the last E1 host frame ADVERTISED (parsed by the assumed offsets). Reported in status EVEN IF we don't
 // lock, so the panel can show whether those offsets are sane (channel in range, address non-degenerate) -- the
 // fastest way to tell if auto-acquire is mis-reading the real puck's E1.
-static uint8_t  g_advCh = 0, g_advBase[4] = {0,0,0,0}, g_advPfx = 0;
+static uint8_t g_advCh = 0, g_advBase[4] = { 0, 0, 0, 0 }, g_advPfx = 0;
 
 // CAPTURE-mode stall sweep: instead of jumping straight back to discovery (which abandons the session and
 // strands us on ch2 seeing only E1/E2), step through clean candidate channels KEEPING the learned address --
 // 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, g_sweepDry = 0;
+static const uint8_t SWEEP_CH[] = { 18, 46, 76, 22, 68, 2, 80, 52, 55, 56, 57 };
+static uint8_t g_sweepIdx = 0, g_sweepDry = 0;
 
 // 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 +
 // 0xF* controller reply) rides a ~3-channel set {primary,2,80} derived from the per-session address. So HUNT on
 // "no 0xF* reply" instead: dwell briefly on each of {primary,2,80,+clean candidates} until the controller's
 // replies appear, then PARK there. g_lastFrx = last controller (0xF*) frame; g_primaryCh = E1-advertised channel.
-static const uint8_t HUNT_CH[] = {2,80,18,46,76,22,68,52};
-static uint8_t  g_primaryCh = 78, g_huntIdx = 0;
+static const uint8_t HUNT_CH[] = { 2, 80, 18, 46, 76, 22, 68, 52 };
+static uint8_t g_primaryCh = 78, g_huntIdx = 0;
 static unsigned long g_lastFrx = 0, g_huntMs = 0;
 #define HUNT_DWELL_MS 350u
 #define HUNT_NOFRX_MS 1000u
 
-static void tuneDiscovery(){ g_state=0; g_curCh=2; rfConfig(2); rfSetAddr(PAIR_BASE, 0x10); rxArmInit(); }
+static void tuneDiscovery()
+{
+	g_state = 0;
+	g_curCh = 2;
+	rfConfig(2);
+	rfSetAddr(PAIR_BASE, 0x10);
+	rxArmInit();
+}
 // Session RX listens across all 8 pipes on the learned base (rfSetAddrMulti) so a same-base/other-prefix
 // controller reply is still caught; RXMATCH tells us which pipe each frame used.
-static void tuneSession(){ g_state=1; rfConfig(g_curCh); rfSetAddrMulti(g_sBase, g_sPrefix); rxArmInit(); }
+static void tuneSession()
+{
+	g_state = 1;
+	rfConfig(g_curCh);
+	rfSetAddrMulti(g_sBase, g_sPrefix);
+	rxArmInit();
+}
 
 // Continuous (gapless) RX: READY->START and END->START keep the radio receiving; ADDRESS->RSSISTART samples
 // signal strength for each packet. We poll EVENTS_END and copy rfrx before the next packet can overwrite it.
-static void rxArmInit(){
-  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
-  NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk | RADIO_SHORTS_END_START_Msk
-                    | RADIO_SHORTS_ADDRESS_RSSISTART_Msk | RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
-  NRF_RADIO->EVENTS_END = 0; NRF_RADIO->EVENTS_ADDRESS = 0;
-  NRF_RADIO->TASKS_RXEN = 1;
+static void rxArmInit()
+{
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk |
+			    RADIO_SHORTS_END_START_Msk |
+			    RADIO_SHORTS_ADDRESS_RSSISTART_Msk |
+			    RADIO_SHORTS_DISABLED_RSSISTOP_Msk;
+	NRF_RADIO->EVENTS_END = 0;
+	NRF_RADIO->EVENTS_ADDRESS = 0;
+	NRF_RADIO->TASKS_RXEN = 1;
 }
 
-static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi, uint8_t match, const uint8_t* b, uint8_t n){
-  uint16_t h = g_head, nx = rnext(h);
-  if (nx == g_tail) g_tail = rnext(g_tail);   // full -> drop oldest
-  Cap &c = g_ring[h];
-  c.t = t; c.ch = ch; c.flags = crcok ? 1 : 0; c.rssi = rssi; c.match = match; c.n = n;
-  memcpy(c.b, b, n);
-  g_head = nx;
+static void ringPush(uint32_t t, uint8_t ch, bool crcok, uint8_t rssi,
+		     uint8_t match, const uint8_t *b, uint8_t n)
+{
+	uint16_t h = g_head, nx = rnext(h);
+	if (nx == g_tail)
+		g_tail = rnext(g_tail); // full -> drop oldest
+	Cap &c = g_ring[h];
+	c.t = t;
+	c.ch = ch;
+	c.flags = crcok ? 1 : 0;
+	c.rssi = rssi;
+	c.match = match;
+	c.n = n;
+	memcpy(c.b, b, n);
+	g_head = nx;
 }
 
 // Drain the radio: copy every completed RX frame into the ring. Called very often from loop() so the tight
 // poll->reply exchange is caught. RSSISAMPLE/CRCSTATUS belong to the frame whose END we just saw.
-static void rxPump(){
-  for (int guard = 0; guard < 8 && NRF_RADIO->EVENTS_END; guard++){
-    NRF_RADIO->EVENTS_END = 0;
-    bool crcok = NRF_RADIO->CRCSTATUS & 1;
-    uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
-    uint8_t match = (uint8_t)(NRF_RADIO->RXMATCH & 0x7);   // which logical address (pipe) the frame hit
-    g_lastMatch = match;
-    uint8_t len = rfrx[0];
-    uint16_t n = (uint16_t)(2 + len); if (n > 96) n = 96;   // LENGTH + S1 + payload, clamped
-    ringPush(micros(), g_curCh, crcok, rssi, match, rfrx, (uint8_t)n);
-    g_lastRx = millis();
-    if (g_state == 1) g_sweepDry = 0;                        // any frame on the session resets the dry-sweep count
-    if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0) g_lastFrx = millis();   // controller reply = ACTIVE session here -> stop hunting, park
-    // Parse the session params advertised in an E1 host-frame (payload[0]=0xE1) by the ASSUMED offsets, and
-    // always record them for the status report -- so the panel shows what auto-acquire would lock onto even
-    // when we choose not to (lets us eyeball whether the real puck's offsets match ours).
-    if (crcok && len >= 18 && rfrx[2] == 0xE1){
-      g_advCh = rfrx[11];
-      g_advBase[0]=rfrx[15]; g_advBase[1]=rfrx[16]; g_advBase[2]=rfrx[17]; g_advBase[3]=rfrx[18];
-      g_advPfx = rfrx[19];
-      if (g_pinSession || g_survey) continue;                // pinned / survey -> never auto-retune (just record adv)
-      // Target a specific bonded slot by its ibex_uuid (E1 bytes rfrx[7..10]): the puck beacons one E1 per slot,
-      // so skip any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
-      if (g_haveTarget && memcmp(rfrx+7, g_targetIbex, 4) != 0) continue;
-      // ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
-      if (g_advCh >= 1 && g_advCh <= 80){
-        if (g_state == 0){
-          memcpy(g_sBase, g_advBase, 4); g_sPrefix = g_advPfx; g_primaryCh = g_advCh;
-          g_curCh = g_forceCh ? g_forceCh : g_advCh; g_huntIdx = 0; g_lastFrx = millis();
-          tuneSession(); return;
-        } else if (!g_forceCh && g_advCh != g_curCh){
-          g_curCh = g_advCh; tuneSession(); return;
-        }
-      }
-    }
-  }
+static void rxPump()
+{
+	for (int guard = 0; guard < 8 && NRF_RADIO->EVENTS_END; guard++) {
+		NRF_RADIO->EVENTS_END = 0;
+		bool crcok = NRF_RADIO->CRCSTATUS & 1;
+		uint8_t rssi = (uint8_t)(NRF_RADIO->RSSISAMPLE & 0x7F);
+		uint8_t match =
+			(uint8_t)(NRF_RADIO->RXMATCH &
+				  0x7); // which logical address (pipe) the frame hit
+		g_lastMatch = match;
+		uint8_t len = rfrx[0];
+		uint16_t n = (uint16_t)(2 + len);
+		if (n > 96)
+			n = 96; // LENGTH + S1 + payload, clamped
+		ringPush(micros(), g_curCh, crcok, rssi, match, rfrx,
+			 (uint8_t)n);
+		g_lastRx = millis();
+		if (g_state == 1)
+			g_sweepDry =
+				0; // any frame on the session resets the dry-sweep count
+		if (crcok && len >= 1 && (rfrx[2] & 0xF0) == 0xF0)
+			g_lastFrx =
+				millis(); // controller reply = ACTIVE session here -> stop hunting, park
+		// Parse the session params advertised in an E1 host-frame (payload[0]=0xE1) by the ASSUMED offsets, and
+		// always record them for the status report -- so the panel shows what auto-acquire would lock onto even
+		// when we choose not to (lets us eyeball whether the real puck's offsets match ours).
+		if (crcok && len >= 18 && rfrx[2] == 0xE1) {
+			g_advCh = rfrx[11];
+			g_advBase[0] = rfrx[15];
+			g_advBase[1] = rfrx[16];
+			g_advBase[2] = rfrx[17];
+			g_advBase[3] = rfrx[18];
+			g_advPfx = rfrx[19];
+			if (g_pinSession || g_survey)
+				continue; // pinned / survey -> never auto-retune (just record adv)
+			// Target a specific bonded slot by its ibex_uuid (E1 bytes rfrx[7..10]): the puck beacons one E1 per slot,
+			// so skip any E1 that isn't the CONNECTED controller's -- otherwise we lock an idle slot's keepalive.
+			if (g_haveTarget &&
+			    memcmp(rfrx + 7, g_targetIbex, 4) != 0)
+				continue;
+			// ACQUIRE: lock onto the advertised session. CAPTURE: a different advertised channel = QoS hop -> follow.
+			if (g_advCh >= 1 && g_advCh <= 80) {
+				if (g_state == 0) {
+					memcpy(g_sBase, g_advBase, 4);
+					g_sPrefix = g_advPfx;
+					g_primaryCh = g_advCh;
+					g_curCh = g_forceCh ? g_forceCh :
+							      g_advCh;
+					g_huntIdx = 0;
+					g_lastFrx = millis();
+					tuneSession();
+					return;
+				} else if (!g_forceCh && g_advCh != g_curCh) {
+					g_curCh = g_advCh;
+					tuneSession();
+					return;
+				}
+			}
+		}
+	}
 }
 
 // ---- WebUSB stream out ----
-static void emitPacket(const Cap &c){
-  uint8_t f[11 + 96];
-  f[0]=0xC0; f[1]=0xDE; f[2]=c.n;
-  f[3]=(uint8_t)c.t; f[4]=(uint8_t)(c.t>>8); f[5]=(uint8_t)(c.t>>16); f[6]=(uint8_t)(c.t>>24);
-  f[7]=c.ch; f[8]=c.flags; f[9]=c.rssi; f[10]=c.match;
-  memcpy(f+11, c.b, c.n);
-  usb_web.write(f, (uint32_t)(11 + c.n));
+static void emitPacket(const Cap &c)
+{
+	uint8_t f[11 + 96];
+	f[0] = 0xC0;
+	f[1] = 0xDE;
+	f[2] = c.n;
+	f[3] = (uint8_t)c.t;
+	f[4] = (uint8_t)(c.t >> 8);
+	f[5] = (uint8_t)(c.t >> 16);
+	f[6] = (uint8_t)(c.t >> 24);
+	f[7] = c.ch;
+	f[8] = c.flags;
+	f[9] = c.rssi;
+	f[10] = c.match;
+	memcpy(f + 11, c.b, c.n);
+	usb_web.write(f, (uint32_t)(11 + c.n));
 }
-static void emitStatus(){
-  static unsigned long last=0; if (millis()-last < 250) return; last=millis();
-  static uint16_t rx=0; rx++;
-  uint8_t f[19]={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};
-  usb_web.write(f, sizeof f); usb_web.flush();
+static void emitStatus()
+{
+	static unsigned long last = 0;
+	if (millis() - last < 250)
+		return;
+	last = millis();
+	static uint16_t rx = 0;
+	rx++;
+	uint8_t f[19] = { 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 };
+	usb_web.write(f, sizeof f);
+	usb_web.flush();
 }
-static void drainToHost(){
-  if (!usb_web.connected()) return;
-  uint16_t budget = 64;   // bound per-call so RX stays responsive; the ring covers bursts
-  while (budget-- && g_tail != g_head){
-    if (g_streaming) emitPacket(g_ring[g_tail]);
-    g_tail = rnext(g_tail);
-  }
-  usb_web.flush();
-  emitStatus();
+static void drainToHost()
+{
+	if (!usb_web.connected())
+		return;
+	uint16_t budget =
+		64; // bound per-call so RX stays responsive; the ring covers bursts
+	while (budget-- && g_tail != g_head) {
+		if (g_streaming)
+			emitPacket(g_ring[g_tail]);
+		g_tail = rnext(g_tail);
+	}
+	usb_web.flush();
+	emitStatus();
 }
-static void handleCmd(){
-  while (usb_web.available()){
-    int c = usb_web.read(); if (c < 0) break;
-    if (c == 0x01){ g_streaming = true; g_head = g_tail = 0; }   // START (flush stale ring)
-    else if (c == 0x02){ g_streaming = false; }                 // STOP
-    else if (c == 0x03){ g_forceCh = 0; g_pinSession = false; g_survey = false; tuneDiscovery(); }   // re-acquire from ibex/ch2
-    else if (c == 0x06){ g_survey = !g_survey; g_forceCh = 0; g_pinSession = false; tuneDiscovery(); }   // survey: list all E1 beacons on ch2, no lock
-    else if (c == 0x07){                                       // target ibex_uuid: lock only the matching slot's E1
-      int u0=usb_web.read(),u1=usb_web.read(),u2=usb_web.read(),u3=usb_web.read();
-      if (u0>=0&&u1>=0&&u2>=0&&u3>=0){
-        g_targetIbex[0]=u0; g_targetIbex[1]=u1; g_targetIbex[2]=u2; g_targetIbex[3]=u3;
-        g_haveTarget=true; g_survey=false; g_forceCh=0; g_pinSession=false; tuneDiscovery();
-      }
-    }
-    else if (c == 0x04){ int ch = usb_web.read(); if (ch>=1 && ch<=80){ g_forceCh=(uint8_t)ch; g_curCh=(uint8_t)ch; tuneSession(); } }   // pin channel, keep addr
-    else if (c == 0x05){                                        // pin a FULL session: b0 b1 b2 b3 pfx ch
-      int b0=usb_web.read(),b1=usb_web.read(),b2=usb_web.read(),b3=usb_web.read(),pf=usb_web.read(),ch=usb_web.read();
-      if (b0>=0&&b1>=0&&b2>=0&&b3>=0&&pf>=0&&ch>=1&&ch<=80){
-        g_sBase[0]=b0; g_sBase[1]=b1; g_sBase[2]=b2; g_sBase[3]=b3; g_sPrefix=(uint8_t)pf;
-        g_curCh=(uint8_t)ch; g_forceCh=(uint8_t)ch; g_pinSession=true; tuneSession();
-      }
-    }
-  }
+static void handleCmd()
+{
+	while (usb_web.available()) {
+		int c = usb_web.read();
+		if (c < 0)
+			break;
+		if (c == 0x01) {
+			g_streaming = true;
+			g_head = g_tail = 0;
+		} // START (flush stale ring)
+		else if (c == 0x02) {
+			g_streaming = false;
+		} // STOP
+		else if (c == 0x03) {
+			g_forceCh = 0;
+			g_pinSession = false;
+			g_survey = false;
+			tuneDiscovery();
+		} // re-acquire from ibex/ch2
+		else if (c == 0x06) {
+			g_survey = !g_survey;
+			g_forceCh = 0;
+			g_pinSession = false;
+			tuneDiscovery();
+		} // survey: list all E1 beacons on ch2, no lock
+		else if (c ==
+			 0x07) { // target ibex_uuid: lock only the matching slot's E1
+			int u0 = usb_web.read(), u1 = usb_web.read(),
+			    u2 = usb_web.read(), u3 = usb_web.read();
+			if (u0 >= 0 && u1 >= 0 && u2 >= 0 && u3 >= 0) {
+				g_targetIbex[0] = u0;
+				g_targetIbex[1] = u1;
+				g_targetIbex[2] = u2;
+				g_targetIbex[3] = u3;
+				g_haveTarget = true;
+				g_survey = false;
+				g_forceCh = 0;
+				g_pinSession = false;
+				tuneDiscovery();
+			}
+		} else if (c == 0x04) {
+			int ch = usb_web.read();
+			if (ch >= 1 && ch <= 80) {
+				g_forceCh = (uint8_t)ch;
+				g_curCh = (uint8_t)ch;
+				tuneSession();
+			}
+		} // pin channel, keep addr
+		else if (c == 0x05) { // pin a FULL session: b0 b1 b2 b3 pfx ch
+			int b0 = usb_web.read(), b1 = usb_web.read(),
+			    b2 = usb_web.read(), b3 = usb_web.read(),
+			    pf = usb_web.read(), ch = usb_web.read();
+			if (b0 >= 0 && b1 >= 0 && b2 >= 0 && b3 >= 0 &&
+			    pf >= 0 && ch >= 1 && ch <= 80) {
+				g_sBase[0] = b0;
+				g_sBase[1] = b1;
+				g_sBase[2] = b2;
+				g_sBase[3] = b3;
+				g_sPrefix = (uint8_t)pf;
+				g_curCh = (uint8_t)ch;
+				g_forceCh = (uint8_t)ch;
+				g_pinSession = true;
+				tuneSession();
+			}
+		}
+	}
 }
 
-void setup(){
-  // Standard Adafruit WebUSB setup (keep the default CDC composite; just add the WebUSB vendor interface). No
-  // landing page -> no Chrome "open <url>" notification. No detach/clearConfiguration: that's OpenPuck-specific
-  // endpoint juggling and can leave the WebUSB stream un-enabled.
-  USBDevice.setID(0x28DE, 0x534E);                 // 'SN' -- distinct PID so the host doesn't confuse it with a puck
-  USBDevice.setManufacturerDescriptor("Valve Software");
-  USBDevice.setProductDescriptor("OpenPuck Sniffer");
-  usb_web.begin();
-  Serial.begin(115200);
-  for (int i=0;i<200 && !USBDevice.mounted(); i++) delay(10);
+void setup()
+{
+	// Standard Adafruit WebUSB setup (keep the default CDC composite; just add the WebUSB vendor interface). No
+	// landing page -> no Chrome "open <url>" notification. No detach/clearConfiguration: that's OpenPuck-specific
+	// endpoint juggling and can leave the WebUSB stream un-enabled.
+	USBDevice.setID(
+		0x28DE,
+		0x534E); // 'SN' -- distinct PID so the host doesn't confuse it with a puck
+	USBDevice.setManufacturerDescriptor("Valve Software");
+	USBDevice.setProductDescriptor("OpenPuck Sniffer");
+	usb_web.begin();
+	Serial.begin(115200);
+	for (int i = 0; i < 200 && !USBDevice.mounted(); i++)
+		delay(10);
 
-  rfGenSessionAddr();        // harmless (we never TX); leaves the radio tunables at their validated defaults
-  tuneDiscovery();
-  g_lastRx = millis();
+	rfGenSessionAddr(); // harmless (we never TX); leaves the radio tunables at their validated defaults
+	tuneDiscovery();
+	g_lastRx = millis();
 }
 
-void loop(){
-  rxPump();
-  handleCmd();
-  rxPump();                  // pump again around the (fast, buffered) USB work to catch tight poll->reply pairs
-  drainToHost();
-  // Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise: while we're NOT seeing the
-  // controller's 0xF* replies (the ACTIVE session), the keepalive trickles in on the primary but the real E3/F1
-  // exchange is on another channel of the per-session set -> hunt {primary,2,80,+candidates}, dwelling briefly on
-  // each, KEEPING the learned address. The instant a 0xF* arrives (g_lastFrx) we stop and park there. After two
-  // full dry passes (no replies anywhere -> session address likely changed on a reconnect) fall back to discovery
-  // to re-read an E1. Hunting only runs when there are no replies, so an active capture is never disturbed.
-  if (g_state == 1 && !g_forceCh && !g_pinSession
-      && millis() - g_lastFrx > HUNT_NOFRX_MS && millis() - g_huntMs > HUNT_DWELL_MS){
-    g_huntMs = millis();
-    uint8_t ch = (g_huntIdx == 0) ? g_primaryCh : HUNT_CH[(g_huntIdx - 1) % (sizeof HUNT_CH)];
-    g_huntIdx++;
-    if (g_huntIdx > 1 + 2 * (uint8_t)sizeof HUNT_CH){ g_huntIdx = 0; tuneDiscovery(); g_lastFrx = millis(); g_huntMs = millis(); return; }
-    g_curCh = ch; rfConfig(g_curCh); rfSetAddrMulti(g_sBase, g_sPrefix); rxArmInit();
-  }
+void loop()
+{
+	rxPump();
+	handleCmd();
+	rxPump(); // pump again around the (fast, buffered) USB work to catch tight poll->reply pairs
+	drainToHost();
+	// Session HUNT (Gazell). A pinned session (cmd 04/05) is never disturbed. Otherwise: while we're NOT seeing the
+	// controller's 0xF* replies (the ACTIVE session), the keepalive trickles in on the primary but the real E3/F1
+	// exchange is on another channel of the per-session set -> hunt {primary,2,80,+candidates}, dwelling briefly on
+	// each, KEEPING the learned address. The instant a 0xF* arrives (g_lastFrx) we stop and park there. After two
+	// full dry passes (no replies anywhere -> session address likely changed on a reconnect) fall back to discovery
+	// to re-read an E1. Hunting only runs when there are no replies, so an active capture is never disturbed.
+	if (g_state == 1 && !g_forceCh && !g_pinSession &&
+	    millis() - g_lastFrx > HUNT_NOFRX_MS &&
+	    millis() - g_huntMs > HUNT_DWELL_MS) {
+		g_huntMs = millis();
+		uint8_t ch =
+			(g_huntIdx == 0) ?
+				g_primaryCh :
+				HUNT_CH[(g_huntIdx - 1) % (sizeof HUNT_CH)];
+		g_huntIdx++;
+		if (g_huntIdx > 1 + 2 * (uint8_t)sizeof HUNT_CH) {
+			g_huntIdx = 0;
+			tuneDiscovery();
+			g_lastFrx = millis();
+			g_huntMs = millis();
+			return;
+		}
+		g_curCh = ch;
+		rfConfig(g_curCh);
+		rfSetAddrMulti(g_sBase, g_sPrefix);
+		rxArmInit();
+	}
 }

+ 116 - 61
puck_sniffer/radio.cpp

@@ -2,55 +2,83 @@
 
 // Pairing rendezvous bytes from IBEX rodata. The 0x91A2A793 key that precedes the "ibex" string is a
 // DIFFERENT key, NOT the discovery address (confirmed via PTR_s_ibexesb_common=0x56f99 -> "ibex...").
-const uint8_t PAIR_BASE[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
-uint8_t  g_rfPrefix = 0x10;
-uint8_t  g_rfCh = 2;
-uint8_t  g_rfBase[4] = {0x69, 0x62, 0x65, 0x78};  // "ibex"
+const uint8_t PAIR_BASE[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
+uint8_t g_rfPrefix = 0x10;
+uint8_t g_rfCh = 2;
+uint8_t g_rfBase[4] = { 0x69, 0x62, 0x65, 0x78 }; // "ibex"
 // ch18 measured clean (RSSI 95/90, PER 0) on the REAL PUCK's own channel scan and is in its active hop set
 // {18,2,80}; trackpad data bursts collide on bad channels -> reply-rate crash. Tunable 'C'.
-uint8_t  g_sessCh = 18;
+uint8_t g_sessCh = 18;
 
 // pre-init to "ibex"/0x10 as a safe default; rfGenSessionAddr() overwrites with the per-device value at boot.
-uint8_t  g_sessBase[4] = {0x69, 0x62, 0x65, 0x78};
-uint8_t  g_sessPrefix = 0x10;
+uint8_t g_sessBase[4] = { 0x69, 0x62, 0x65, 0x78 };
+uint8_t g_sessPrefix = 0x10;
 
-void rfGenSessionAddr(){
-  // Mix the 64-bit FICR DEVICEID into a 4-byte base + a prefix. Deterministic per chip (stable across reboots,
-  // debuggable) and unique across chips (DEVICEID is unique) -> two OpenPucks never collide on the connected
-  // session. Avoid degenerate address bytes (0x00/0xFF) for clean preamble correlation, and never reproduce
-  // the shared discovery base "ibex" (else the isolation is lost).
-  uint32_t h  = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^ NRF_FICR->DEVICEID[1];
-  uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^ NRF_FICR->DEVICEID[0];
-  for(int i=0;i<4;i++){ uint8_t b=(uint8_t)(h>>(i*8)); if(b==0x00||b==0xFF) b^=0x5A; g_sessBase[i]=b; }
-  uint8_t p=(uint8_t)(h2>>16); if(p==0x00||p==0xFF) p=0x5C; g_sessPrefix=p;
-  if(g_sessBase[0]==g_rfBase[0] && g_sessBase[1]==g_rfBase[1] &&
-     g_sessBase[2]==g_rfBase[2] && g_sessBase[3]==g_rfBase[3]) g_sessBase[0]^=0x80;   // collided with "ibex"
+void rfGenSessionAddr()
+{
+	// Mix the 64-bit FICR DEVICEID into a 4-byte base + a prefix. Deterministic per chip (stable across reboots,
+	// debuggable) and unique across chips (DEVICEID is unique) -> two OpenPucks never collide on the connected
+	// session. Avoid degenerate address bytes (0x00/0xFF) for clean preamble correlation, and never reproduce
+	// the shared discovery base "ibex" (else the isolation is lost).
+	uint32_t h = NRF_FICR->DEVICEID[0] * 0x9E3779B1u ^
+		     NRF_FICR->DEVICEID[1];
+	uint32_t h2 = NRF_FICR->DEVICEID[1] * 0x85EBCA6Bu ^
+		      NRF_FICR->DEVICEID[0];
+	for (int i = 0; i < 4; i++) {
+		uint8_t b = (uint8_t)(h >> (i * 8));
+		if (b == 0x00 || b == 0xFF)
+			b ^= 0x5A;
+		g_sessBase[i] = b;
+	}
+	uint8_t p = (uint8_t)(h2 >> 16);
+	if (p == 0x00 || p == 0xFF)
+		p = 0x5C;
+	g_sessPrefix = p;
+	if (g_sessBase[0] == g_rfBase[0] && g_sessBase[1] == g_rfBase[1] &&
+	    g_sessBase[2] == g_rfBase[2] && g_sessBase[3] == g_rfBase[3])
+		g_sessBase[0] ^= 0x80; // collided with "ibex"
 }
 
-uint8_t  rfrx[100], rftx[100];
+uint8_t rfrx[100], rftx[100];
 uint32_t g_rfRxCount = 0;
 
 // HYBRID puck-link config (firmware-derived, definitive). DECODED from real-puck capture: puck TX = Ble_2Mbit,
 // ENDIAN=Big (MSB-first), NO whitening, addr "ibex", CRC16 0x11021/0xFFFF; frame = FUN_00027e9a (static length).
 uint32_t g_crcinit = 0xFFFF;
-uint8_t  g_whiteiv = 37;
-uint8_t  g_mode    = RADIO_MODE_MODE_Ble_2Mbit;   // <-- was the bug (1Mbit). Real puck = 2Mbit.
-uint32_t g_pcnf0   = 0x00030008UL;                // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
-uint8_t  g_statlen = 0x20;
-uint32_t g_pcnf1   = 0x01040060;                  // ENDIAN=Big, BALEN4, MAXLEN=96 (was 64; longer F1 frames e.g. 0x43-augmented)
+uint8_t g_whiteiv = 37;
+uint8_t g_mode =
+	RADIO_MODE_MODE_Ble_2Mbit; // <-- was the bug (1Mbit). Real puck = 2Mbit.
+uint32_t g_pcnf0 =
+	0x00030008UL; // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) - CRC-VALIDATED
+uint8_t g_statlen = 0x20;
+uint32_t g_pcnf1 =
+	0x01040060; // ENDIAN=Big, BALEN4, MAXLEN=96 (was 64; longer F1 frames e.g. 0x43-augmented)
 uint32_t g_crcpoly = 0x11021UL;
-uint16_t g_crccnf  = 0x2;                          // CRC16, address included
-uint8_t  g_pid     = 0;
-uint8_t  g_balen   = 4;                            // ESB 5-byte addr
-bool     g_prefixRaw = false;
+uint16_t g_crccnf = 0x2; // CRC16, address included
+uint8_t g_pid = 0;
+uint8_t g_balen = 4; // ESB 5-byte addr
+bool g_prefixRaw = false;
 
-uint8_t rfBitrev8(uint8_t x){ uint8_t y=0; for(int i=0;i<8;i++){y=(y<<1)|(x&1);x>>=1;} return y; }
+uint8_t rfBitrev8(uint8_t x)
+{
+	uint8_t y = 0;
+	for (int i = 0; i < 8; i++) {
+		y = (y << 1) | (x & 1);
+		x >>= 1;
+	}
+	return y;
+}
 
-void rfSetAddr(const uint8_t b4[4], uint8_t prefix){
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
-  NRF_RADIO->BASE0   = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
-  NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 1u<<0;
+void rfSetAddr(const uint8_t b4[4], uint8_t prefix)
+{
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(b4[i]);
+	NRF_RADIO->BASE0 = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			   ((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->PREFIX0 = g_prefixRaw ? prefix : rfBitrev8(prefix);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 1u << 0;
 }
 
 // Listen on ONE base across ALL 8 ESB logical addresses (pipes): the real prefix on pipe 0, plus a spread of
@@ -59,35 +87,62 @@ void rfSetAddr(const uint8_t b4[4], uint8_t prefix){
 // us having to know that prefix in advance. RXMATCH (reported per packet) then tells us which pipe it used.
 // Extra pipes only ADD matches; they never drop the pipe-0 (puck) frames. base/prefix are stored (pre-transform)
 // bytes; the on-air values get the same bitrev8 transform as rfSetAddr (g_prefixRaw=false).
-void rfSetAddrMulti(const uint8_t b4[4], uint8_t prefix){
-  uint8_t b[4]; for(int i=0;i<4;i++) b[i]=rfBitrev8(b4[i]);
-  uint32_t base = ((uint32_t)b[0]<<24)|((uint32_t)b[1]<<16)|((uint32_t)b[2]<<8)|b[3];
-  NRF_RADIO->BASE0 = base; NRF_RADIO->BASE1 = base;          // pipe 0 uses BASE0; pipes 1..7 use BASE1
-  // 8 candidate prefixes: the learned one first, then a spread. De-dup isn't needed (a repeat just matches twice).
-  uint8_t cand[8] = { prefix, (uint8_t)(prefix^0x01), (uint8_t)(prefix+1), (uint8_t)(prefix-1),
-                      0x10, (uint8_t)(prefix^0x80), (uint8_t)(prefix+0x10), (uint8_t)(prefix-0x10) };
-  uint8_t ap[8]; for(int i=0;i<8;i++) ap[i] = g_prefixRaw ? cand[i] : rfBitrev8(cand[i]);
-  NRF_RADIO->PREFIX0 = (uint32_t)ap[0] | ((uint32_t)ap[1]<<8) | ((uint32_t)ap[2]<<16) | ((uint32_t)ap[3]<<24);
-  NRF_RADIO->PREFIX1 = (uint32_t)ap[4] | ((uint32_t)ap[5]<<8) | ((uint32_t)ap[6]<<16) | ((uint32_t)ap[7]<<24);
-  NRF_RADIO->TXADDRESS = 0; NRF_RADIO->RXADDRESSES = 0xFF;   // enable all 8 pipes
+void rfSetAddrMulti(const uint8_t b4[4], uint8_t prefix)
+{
+	uint8_t b[4];
+	for (int i = 0; i < 4; i++)
+		b[i] = rfBitrev8(b4[i]);
+	uint32_t base = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) |
+			((uint32_t)b[2] << 8) | b[3];
+	NRF_RADIO->BASE0 = base;
+	NRF_RADIO->BASE1 = base; // pipe 0 uses BASE0; pipes 1..7 use BASE1
+	// 8 candidate prefixes: the learned one first, then a spread. De-dup isn't needed (a repeat just matches twice).
+	uint8_t cand[8] = { prefix,
+			    (uint8_t)(prefix ^ 0x01),
+			    (uint8_t)(prefix + 1),
+			    (uint8_t)(prefix - 1),
+			    0x10,
+			    (uint8_t)(prefix ^ 0x80),
+			    (uint8_t)(prefix + 0x10),
+			    (uint8_t)(prefix - 0x10) };
+	uint8_t ap[8];
+	for (int i = 0; i < 8; i++)
+		ap[i] = g_prefixRaw ? cand[i] : rfBitrev8(cand[i]);
+	NRF_RADIO->PREFIX0 = (uint32_t)ap[0] | ((uint32_t)ap[1] << 8) |
+			     ((uint32_t)ap[2] << 16) | ((uint32_t)ap[3] << 24);
+	NRF_RADIO->PREFIX1 = (uint32_t)ap[4] | ((uint32_t)ap[5] << 8) |
+			     ((uint32_t)ap[6] << 16) | ((uint32_t)ap[7] << 24);
+	NRF_RADIO->TXADDRESS = 0;
+	NRF_RADIO->RXADDRESSES = 0xFF; // enable all 8 pipes
 }
 
-void rfConfig(uint8_t ch){
-  NRF_RADIO->TASKS_DISABLE = 1; RWAIT_DISABLED(); NRF_RADIO->EVENTS_DISABLED = 0;
-  NRF_RADIO->MODE = ((uint32_t)g_mode << RADIO_MODE_MODE_Pos);   // Ble_2Mbit (tunable 'M')
-  NRF_RADIO->FREQUENCY = ch;
-  NRF_RADIO->TXPOWER   = (RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
+void rfConfig(uint8_t ch)
+{
+	NRF_RADIO->TASKS_DISABLE = 1;
+	RWAIT_DISABLED();
+	NRF_RADIO->EVENTS_DISABLED = 0;
+	NRF_RADIO->MODE = ((uint32_t)g_mode
+			   << RADIO_MODE_MODE_Pos); // Ble_2Mbit (tunable 'M')
+	NRF_RADIO->FREQUENCY = ch;
+	NRF_RADIO->TXPOWER =
+		(RADIO_TXPOWER_TXPOWER_0dBm << RADIO_TXPOWER_TXPOWER_Pos);
 #if defined(RADIO_MODECNF0_RU_Fast)
-  NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast<<RADIO_MODECNF0_RU_Pos);
+	NRF_RADIO->MODECNF0 = (RADIO_MODECNF0_RU_Fast << RADIO_MODECNF0_RU_Pos);
 #endif
-  NRF_RADIO->PCNF0 = g_pcnf0;       // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
-  NRF_RADIO->PCNF1 = g_pcnf1 ? g_pcnf1 :
-        ((1u<<RADIO_PCNF1_ENDIAN_Pos)              // ENDIAN=Big (puck transmits MSB-first)
-        |((uint32_t)g_balen<<RADIO_PCNF1_BALEN_Pos)
-        |((uint32_t)g_statlen<<RADIO_PCNF1_STATLEN_Pos)
-        |((uint32_t)g_statlen<<RADIO_PCNF1_MAXLEN_Pos));   // WHITEEN=0
-  NRF_RADIO->CRCCNF = g_crccnf;     // CRC16, address included
-  NRF_RADIO->CRCPOLY = g_crcpoly; NRF_RADIO->CRCINIT = g_crcinit;
-  NRF_RADIO->DATAWHITEIV = g_whiteiv;
-  NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
+	NRF_RADIO->PCNF0 =
+		g_pcnf0; // static length (tunable '0'); g_pcnf1=0 -> build from g_statlen/g_balen
+	NRF_RADIO->PCNF1 =
+		g_pcnf1 ?
+			g_pcnf1 :
+			((1u
+			  << RADIO_PCNF1_ENDIAN_Pos) // ENDIAN=Big (puck transmits MSB-first)
+			 | ((uint32_t)g_balen << RADIO_PCNF1_BALEN_Pos) |
+			 ((uint32_t)g_statlen << RADIO_PCNF1_STATLEN_Pos) |
+			 ((uint32_t)g_statlen
+			  << RADIO_PCNF1_MAXLEN_Pos)); // WHITEEN=0
+	NRF_RADIO->CRCCNF = g_crccnf; // CRC16, address included
+	NRF_RADIO->CRCPOLY = g_crcpoly;
+	NRF_RADIO->CRCINIT = g_crcinit;
+	NRF_RADIO->DATAWHITEIV = g_whiteiv;
+	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
 }

+ 26 - 18
puck_sniffer/radio.h

@@ -14,39 +14,47 @@
 // Bounded radio-disable wait: NEVER spin forever. A wedged RADIO peripheral would otherwise hang the whole
 // main loop (and USB stops being serviced -> device "dies" until replug). On timeout we bail and continue;
 // the next rfConfig re-inits the radio.
-#define RWAIT_DISABLED() do{ uint32_t _w=micros(); while(!NRF_RADIO->EVENTS_DISABLED && (uint32_t)(micros()-_w)<3000){} }while(0)
+#define RWAIT_DISABLED()                                   \
+	do {                                               \
+		uint32_t _w = micros();                    \
+		while (!NRF_RADIO->EVENTS_DISABLED &&      \
+		       (uint32_t)(micros() - _w) < 3000) { \
+		}                                          \
+	} while (0)
 
 // Discovery/reconnect address (IBEX FUN_00019000, firmware-derived, definitive):
 //   base = "ibex" (69 62 65 78), prefix = 0x10, channel = 2.
-extern const uint8_t PAIR_BASE[4];   // "ibex"
-extern uint8_t  g_rfPrefix;          // discovery prefix (rodata byte @0x56f98)
-extern uint8_t  g_rfCh;              // current TX/RX channel (hopped during beacon/poll)
-extern uint8_t  g_rfBase[4];         // "ibex"
-extern uint8_t  g_sessCh;            // connected-session channel: a CLEAN data channel off the congested adv ch2
+extern const uint8_t PAIR_BASE[4]; // "ibex"
+extern uint8_t g_rfPrefix; // discovery prefix (rodata byte @0x56f98)
+extern uint8_t g_rfCh; // current TX/RX channel (hopped during beacon/poll)
+extern uint8_t g_rfBase[4]; // "ibex"
+extern uint8_t
+	g_sessCh; // connected-session channel: a CLEAN data channel off the congested adv ch2
 
 // Per-device SESSION address (base+prefix). Discovery/rendezvous stays on the SHARED "ibex" address (g_rfBase)
 // so any controller can find us; the host frame then advertises THIS unique address and the controller adopts
 // it for the connected session. Two OpenPucks therefore never share an on-air session address -> no crosstalk,
 // no spurious cross-wake. Derived from the FICR DEVICEID (stable per chip, unique across chips).
-extern uint8_t  g_sessBase[4];
-extern uint8_t  g_sessPrefix;
+extern uint8_t g_sessBase[4];
+extern uint8_t g_sessPrefix;
 void rfGenSessionAddr();
 
-extern uint8_t  rfrx[100], rftx[100];  // RADIO DMA buffers
+extern uint8_t rfrx[100], rftx[100]; // RADIO DMA buffers
 extern uint32_t g_rfRxCount;
 
 // ---- tunable radio parameters (CDC console M/0/2/i/w/I/P/N/T/A) ----
 extern uint32_t g_crcinit;
-extern uint8_t  g_whiteiv;
-extern uint8_t  g_mode;     // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
-extern uint32_t g_pcnf0;    // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
-extern uint8_t  g_statlen;  // static-mode payload length
-extern uint32_t g_pcnf1;    // ENDIAN=Big, BALEN4, MAXLEN64 -- CRC-VALIDATED
+extern uint8_t g_whiteiv;
+extern uint8_t g_mode; // RADIO_MODE_MODE_* (real puck = Ble_2Mbit)
+extern uint32_t g_pcnf0; // S0LEN0, LFLEN8, S1LEN3 (ESB DPL) -- CRC-VALIDATED
+extern uint8_t g_statlen; // static-mode payload length
+extern uint32_t g_pcnf1; // ENDIAN=Big, BALEN4, MAXLEN64 -- CRC-VALIDATED
 extern uint32_t g_crcpoly;
-extern uint16_t g_crccnf;   // CRC16, address included
-extern uint8_t  g_pid;
-extern uint8_t  g_balen;    // ESB 5-byte addr
-extern bool     g_prefixRaw;// false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
+extern uint16_t g_crccnf; // CRC16, address included
+extern uint8_t g_pid;
+extern uint8_t g_balen; // ESB 5-byte addr
+extern bool
+	g_prefixRaw; // false = bitrev8 prefix (ESB addr_conv); true = raw prefix byte
 
 uint8_t rfBitrev8(uint8_t x);
 // Access address (IBEX FUN_00037530): BASE0 = bitrev8 each base byte packed big-endian; PREFIX0 = raw or