Sfoglia il codice sorgente

Support latest beta, fix buzzing, fix stability problems (#102)

* Respond to 0x42 reports correctly

* multiple fixes including less active haptics on init

* The buzzing is finally gone

* remove hardcoded serial

* more stability fixes

* can't seem to crash the Teyleten board

* clean webusb

* fmt
Jari 3 mesi fa
parent
commit
668bda67e1

+ 2 - 0
.github/workflows/build.yml

@@ -4,6 +4,8 @@ on:
   push:
     branches:
       - main
+  # PRs get the same gate -- without this the checks only ran after merge.
+  pull_request:
 
 jobs:
   compile:

+ 2 - 0
.github/workflows/format.yml

@@ -4,6 +4,8 @@ on:
   push:
     branches:
       - main
+  # PRs get the same gate -- without this the checks only ran after merge.
+  pull_request:
 
 jobs:
   clang-format:

+ 10 - 0
OpenPuck/OpenPuck.ino

@@ -29,6 +29,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "controllers.h"
 #include "haptics.h"
 #include "rf_link.h"
+#include "puck_hid.h" // puckCmdLogDrain()
 #include "rf_diag.h"
 #include "webusb_config.h"
 #include "serial_console.h"
@@ -272,6 +273,9 @@ void setup()
 	NRF_WDT->TASKS_START = 1;
 	// Capture the stuck PC on the WDT's pre-reset interrupt (software stand-in for SWD on the clone hangs).
 	faultDiagArmHangCapture();
+	// Flash black box: at ~6s of loop stall (2s before the WDT reset) dump both tasks' stacked PCs + vitals
+	// to a reserved flash page -- the post-mortem channel that survives even the boards that wipe .noinit.
+	faultDiagBlackBoxArm();
 }
 
 // loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only
@@ -290,11 +294,15 @@ void loop()
 	NRF_WDT->RR[0] = WDT_RR_RR_Reload;
 	faultDiagBeat(); // loop heartbeat -- the SOF blob reports ms-since-beat so a wedge is visible live
 	faultDiagStackTick(); // per-task stack headroom (self-gated ~1Hz) -- usbd-overflow hypothesis check
+	faultDiagFlightTick(); // flight recorder: refresh vitals + heartbeat crumb + live CDC vitals line
 	hapticStabTask(); // stability-test keepalive buzz (no-op unless armed via WebUSB)
 	// cross-check HFCLK(micros) vs LFCLK(millis) once a second -- cheap, both builds (clone clock diagnostic)
 	clockDiagTick();
 	if (g_dirty) {
 		g_dirty = false;
+		// A bond flash write erases+programs a page with interrupts effectively stalled for ms -- a known
+		// watchdog-hang class on slow-flash clones, so leave a crumb bracketing it in the recorder.
+		faultDiagTrace(FR_SAVE, 0);
 		saveBonds();
 	}
 #if OPK_LOG
@@ -336,6 +344,7 @@ void loop()
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
+	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
 	loops++;
 	if (millis() - secMs >= 1000) {
 		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
@@ -375,5 +384,6 @@ void loop()
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
+	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
 #endif
 }

+ 16 - 10
OpenPuck/config.cpp

@@ -70,14 +70,15 @@ uint8_t g_rumbleScale = 200;
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-// Struct layout/semantics changed (RF tunables); bump so old flash format is discarded -> clean defaults once.
-#define CFG_MAGIC 0xCD
+// Struct layout/semantics changed (haptic-block bytes repurposed); bump so old flash format is discarded ->
+// clean defaults once.
+#define CFG_MAGIC 0xCE
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;
-	// Connection tunables: the connected-poll RX window in 10us units (g_rxWin/10), the post-connect haptic
-	// block enable, and its duration in seconds. 0 in rxWin10 => use the boot default.
-	uint8_t rxWin10, hapticBlockOn, hapticBlockS;
+	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the id9=0 hold enable (see
+	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
+	uint8_t rxWin10, lizKeep, landAll87;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
@@ -94,8 +95,8 @@ void saveCfg()
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
 		  g_rumbleScale,
 		  (uint8_t)(g_rxWin / 10),
-		  g_hapticBlockOn,
-		  (uint8_t)(g_hapticBlockMs / 1000),
+		  g_lizKeep,
+		  g_landAll87,
 		  {} };
 	for (int i = 0; i < ET_COUNT; i++)
 		c.type[i] = g_type[i];
@@ -150,9 +151,14 @@ void loadCfg()
 
 			// 0 is a valid setting (rumble off)
 			g_rumbleScale = c.rumbleScale;
-			// Connection tunables: the poll RX window is now FIXED (g_rxWin is const) -- any persisted
-			// rxWin10 is ignored. The post-connect haptic block is permanently disabled and no longer
-			// configurable, so any persisted hapticBlockOn/hapticBlockS is ignored too.
+			// lizard-suppression keepalive enable (0/1; anything else = a pre-0xCE cfg leaked
+			// through -> keep the on default)
+			if (c.lizKeep <= 1)
+				g_lizKeep = c.lizKeep;
+			// verbatim-0x87-relay experiment toggle (0/1; default off)
+			if (c.landAll87 <= 1)
+				g_landAll87 = c.landAll87;
+			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();
 	}

+ 543 - 8
OpenPuck/fault_diag.cpp

@@ -3,6 +3,8 @@
 #include <FreeRTOS.h>
 #include <task.h> // uxTaskGetSystemState -> per-task stack high-water (overflow hypothesis check)
 #include <string.h>
+#include "rf_link.h" // g_pollsps/g_relayps/g_crcps/g_norxps/g_rfStallRecover -- flight-recorder vitals
+#include "haptics.h" // g_ringFault, relayPending() -- relay-load vitals
 
 // GPREGRET2 markers. GPREGRET (id 0) is reserved by the Adafruit bootloader for the DFU magic; GPREGRET2 is
 // free for the application and is retained across soft/watchdog/pin reset, cleared only on power-on/brownout.
@@ -51,7 +53,9 @@ __attribute__((naked)) void WDT_IRQHandler(void)
 void faultDiagArmHangCapture()
 {
 	NRF_WDT->INTENSET = WDT_INTENSET_TIMEOUT_Msk;
-	NVIC_SetPriority(WDT_IRQn, 0); // above FreeRTOS configMAX_SYSCALL priority -> fires through BASEPRI guards
+	NVIC_SetPriority(
+		WDT_IRQn,
+		0); // above FreeRTOS configMAX_SYSCALL priority -> fires through BASEPRI guards
 	NVIC_ClearPendingIRQ(WDT_IRQn);
 	NVIC_EnableIRQ(WDT_IRQn);
 }
@@ -137,6 +141,305 @@ const char *faultDiagHangStageStr()
 	return g_hangStage < STAGE_COUNT ? STAGE_STR[g_hangStage] : "n/a";
 }
 
+// ---- flight recorder (data) --------------------------------------------------------------------------------
+// The live ring lives in .noinit so it survives a watchdog reset (like g_hangFrame). At boot we copy the
+// pre-reset trail into g_flightSaved (ordinary RAM -- no need to survive twice) so the live session can start a
+// fresh ring while the console can still re-dump the old trail on demand. Declared here (above faultDiagBoot)
+// because the boot classifier snapshots + dumps it; the push/tick/dump functions are implemented further down.
+struct FRRec {
+	uint32_t ms; // millis() at the push
+	uint16_t arg; // event-specific
+	uint8_t evt; // FR_*
+	uint8_t stage; // loop stage current at the push (g_curStage)
+};
+#define FR_RING 96u
+#define FR_MAGIC 0x464C4954u // "FLIT"
+struct FlightRec {
+	uint32_t magic;
+	uint16_t head; // next write slot
+	uint16_t count; // total pushed this session (saturating) -> "showing last N of M"
+	FRRec ring[FR_RING];
+	// live vitals, refreshed ~4x/s -- the last values captured before a wedge
+	uint32_t vMs; // millis() at last refresh
+	uint32_t loopPerSec; // loop() iterations in the last full second
+	uint32_t heapUsed; // mallinfo().uordblks (bytes) -- trend up = leak/fragmentation
+	uint16_t usbdStackFree, loopStackFree; // words (0 = overflowed)
+	uint16_t pollsps, relayps, crcps, norxps;
+	uint16_t rfHeal, ringFault;
+	uint8_t curStage;
+	uint8_t stallMs; // ms since last loop beat, capped 255
+};
+__attribute__((section(".noinit"))) static volatile struct FlightRec g_flight;
+static struct FlightRec
+	g_flightSaved; // boot-time copy of the pre-reset trail (BSS)
+static bool g_haveSaved = false;
+bool g_vitals = true; // live per-second CDC vitals line (console "VIT" toggles)
+
+static const char *const FR_STR[] = { "none",  "beat",	  "SET",    "GET",
+				      "relay", "rf-up",	  "rf-DN",  "HEAL!",
+				      "mount", "SUSPEND", "resume", "OFF",
+				      "RINGF", "save" };
+#define FR_STR_COUNT ((uint8_t)(sizeof FR_STR / sizeof FR_STR[0]))
+static const char *frEvtStr(uint8_t e)
+{
+	return e < FR_STR_COUNT ? FR_STR[e] : "?";
+}
+
+// ---- flash black box (pre-watchdog dump) -------------------------------------------------------------------
+// On boards whose bootloader wipes .noinit + GPREGRET2 across a watchdog reset (Teyleten-class), EVERY
+// post-mortem channel above is destroyed -- hangs on those boards were unforensicable. Flash survives any
+// reset, so: a 4 Hz TIMER4 interrupt (priority 1, above FreeRTOS BASEPRI masking, below the WDT capture's 0)
+// watches the loop heartbeat; when loop() has been frozen ~6 s (the WDT resets at 8 s), it walks the loop and
+// usbd tasks' TCBs for their stacked PCs (both tasks BLOCKED = the suspected mutual-deadlock class) plus the
+// PC it interrupted (covers the one-task-spinning class), and writes one record to a reserved raw flash page.
+// Next boot reports it on CDC and feeds the existing hangPC/hangStage panel channels. Caveats: fires only if
+// priority-1 interrupts still run (PRIMASK-off hangs leave no record -- same caveat as the WDT capture, and
+// itself a signal); a task that is RUNNING (not blocked) has a stale TCB pxTopOfStack, so cross-check its PC
+// against irqPC.
+#define BB_ADDR \
+	0xE8000UL // raw page: app image (~170 KB, ends < 0x60000) < here < InternalFS (0xED000)
+#define BB_MAGIC 0x62627831u // "bbx1"
+#define BB_WORDS 12
+static TaskHandle_t g_hLoop,
+	g_hUsbd; // captured by faultDiagStackTick's 1 Hz sweep
+// non-static (like g_hangFrame): the naked ISR's `ldr =symbol` needs external linkage to resolve
+extern "C" {
+volatile uint32_t g_bbIrqPC,
+	g_bbIrqLR; // stacked frame of whatever TIMER4 preempted
+}
+static void bbBootReport(
+	bool isHang); // defined below faultDiagBlackBoxArm; called from faultDiagBoot
+static void bbErase(void); // dump-time lazy erase (see bbTimerBody)
+
+static void bbFlashWord(uint32_t addr, uint32_t v)
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+	*(volatile uint32_t *)addr = v;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+}
+// Stacked PC/LR of a BLOCKED FreeRTOS task. pxTopOfStack is the first TCB member (portable-layer invariant);
+// the CM4F PendSV push order is [r4-r11, EXC_RETURN] then (s16-s31 iff the FPU frame was live) then the
+// hardware frame [r0-r3, r12, lr, pc, xpsr]. EXC_RETURN bit4 = 0 means the FPU frame is present.
+static void bbTaskPc(TaskHandle_t h, uint32_t *pc, uint32_t *lr)
+{
+	*pc = *lr = 0;
+	if (!h)
+		return;
+	uint32_t *tos = *(uint32_t **)h;
+	uint32_t a = (uint32_t)tos;
+	if (a < 0x20000000u || a >= 0x20040000u - (26 + 8) * 4)
+		return; // corrupt TCB/stack -> don't fault inside the black box
+	uint32_t excret = tos[8];
+	uint32_t hw = 9 + ((excret & 0x10) ? 0 : 16);
+	*pc = tos[hw + 6];
+	*lr = tos[hw + 5];
+}
+extern "C" void bbTimerBody(void)
+{
+	NRF_TIMER4->EVENTS_COMPARE[0] = 0;
+	(void)NRF_TIMER4->EVENTS_COMPARE[0]; // readback: event write is posted
+	static uint32_t lastBeat;
+	static uint16_t stuck;
+	static bool dumped;
+	uint32_t b = g_loopBeatMs;
+	if (b != lastBeat) {
+		lastBeat = b;
+		stuck = 0;
+		return;
+	}
+	if (dumped)
+		return;
+	if (++stuck <
+	    24) { // 24 ticks @ 4 Hz = 6 s frozen; WDT reset lands at 8 s
+		// USBD self-heal kick before giving up. The black-boxed wedge (2026-07-03, loopPC=start_dma,
+		// usbdPC=idle xQueueReceive) is a USB EasyDMA whose END event/interrupt got swallowed (clone
+		// USBD silicon under concurrent RADIO EasyDMA): TinyUSB's dma_running never clears, every next
+		// transfer re-defers on the TASK_PRIO_HIGH usbd task (livelock), and TASK_PRIO_LOW loop starves.
+		// If the END EVENT is latched but its IRQ was lost, re-pending the USBD IRQ makes the ISR run
+		// edpt_dma_end -> the defer chain unwinds -> FULL recovery, no reset (the panel trail shows a
+		// recovered stall episode). Harmless when nothing is pending: a spurious USBD ISR just returns.
+		// >=500ms of frozen loop heartbeat is never legitimate (bond flash saves are ms-class).
+		if (stuck >= 2) {
+			NVIC_SetPendingIRQ(USBD_IRQn);
+			faultDiagTrace(
+				FR_HEAL,
+				0x05Bu); // arg 0x5B = USBD kick (radio heal passes its own count)
+		}
+		return;
+	}
+	dumped = true;
+	// Lazy erase: the page still holds the PREVIOUS hang's (already-reported) record -- boot no longer
+	// erases it, so its CDC banner stays re-printable on any later debug boot. An in-ISR page erase is
+	// ~85ms of CPU stall, irrelevant here: the system is already dead and the WDT reset is ~2s away.
+	bbErase();
+	// Record v2, written in TWO PHASES: phase 1 (PCs + vitals) commits BEFORE any USBD register is
+	// touched -- if the USBD peripheral has wedged the AHB matrix, reading its registers could bus-stall
+	// this ISR forever (the WDT still resets: its reset path is hardware, not CPU). A record with phase 1
+	// present and phase 2 erased (0xFFFFFFFF) is therefore itself the proof of an AHB/USBD bus wedge.
+	uint32_t lpc, llr, upc, ulr;
+	bbTaskPc(g_hLoop, &lpc, &llr);
+	bbTaskPc(g_hUsbd, &upc, &ulr);
+	uint32_t w1[8] = {
+		BB_MAGIC,
+		(2u << 24) | ((uint32_t)g_curStage << 16), // ver 2
+		lpc, // w2: loop task stacked PC (where it starved/blocked)
+		g_bbIrqPC, // w3: what TIMER4 preempted = the code RUNNING at dump time
+		upc, // w4: usbd task stacked PC
+		((uint32_t)g_flight.usbdStackFree << 16) |
+			g_flight.loopStackFree, // w5
+		((uint32_t)g_flight.relayps << 16) | g_flight.pollsps, // w6
+		g_flight.vMs, // w7: millis at last healthy vitals ~= wedge time
+	};
+	(void)llr;
+	(void)ulr;
+	for (int i = 0; i < 8; i++)
+		bbFlashWord(BB_ADDR + 4u * i, w1[i]);
+	// phase 2: USBD peripheral state -- did the DMA END event ever latch? are its interrupts enabled?
+	uint32_t endmask = 0;
+	for (int i = 0; i < 8; i++) {
+		if (NRF_USBD->EVENTS_ENDEPIN[i])
+			endmask |= (1u << i);
+		if (NRF_USBD->EVENTS_ENDEPOUT[i])
+			endmask |= (1u << (8 + i));
+	}
+	if (NRF_USBD->EVENTS_EPDATA)
+		endmask |= (1u << 16);
+	if (NRF_USBD->EVENTS_USBEVENT)
+		endmask |= (1u << 17);
+	if (NRF_USBD->EVENTS_EP0DATADONE)
+		endmask |= (1u << 18);
+	if (NRF_USBD->EVENTS_SOF)
+		endmask |= (1u << 19);
+	if (NVIC_GetPendingIRQ(USBD_IRQn))
+		endmask |= (1u << 20); // IRQ pending but not being serviced
+	if (NVIC_GetEnableIRQ(USBD_IRQn))
+		endmask |= (1u << 21);
+	uint32_t w2[4] = {
+		endmask, // w8: latched-event bitmap (ENDEPIN 0-7, ENDEPOUT 8-15, EPDATA/USBEVENT/EP0DD/SOF, NVIC)
+		NRF_USBD->INTEN, // w9: which USBD interrupts are enabled
+		NRF_USBD->EPDATASTATUS, // w10: per-endpoint data-ready flags
+		((uint32_t)NRF_USBD->EPINEN << 16) |
+			(NRF_USBD->EPOUTEN & 0xFFFF), // w11
+	};
+	for (int i = 0; i < 4; i++)
+		bbFlashWord(BB_ADDR + 4u * (8 + i), w2[i]);
+}
+// Naked shim: grab the interrupted context's stacked PC/LR (like the WDT capture), then tail-branch to the C
+// body -- LR still holds EXC_RETURN, so the body's return IS the exception return.
+extern "C" __attribute__((naked)) void TIMER4_IRQHandler(void)
+{
+	__asm volatile("tst lr, #4            \n"
+		       "ite eq                \n"
+		       "mrseq r0, msp         \n"
+		       "mrsne r0, psp         \n"
+		       "ldr r1, [r0, #24]     \n"
+		       "ldr r2, =g_bbIrqPC    \n"
+		       "str r1, [r2]          \n"
+		       "ldr r1, [r0, #20]     \n"
+		       "ldr r2, =g_bbIrqLR    \n"
+		       "str r1, [r2]          \n"
+		       "b bbTimerBody         \n"
+		       ".ltorg                \n");
+}
+void faultDiagBlackBoxArm()
+{
+	NRF_TIMER4->TASKS_STOP = 1;
+	NRF_TIMER4->MODE = TIMER_MODE_MODE_Timer << TIMER_MODE_MODE_Pos;
+	NRF_TIMER4->BITMODE = TIMER_BITMODE_BITMODE_32Bit
+			      << TIMER_BITMODE_BITMODE_Pos;
+	NRF_TIMER4->PRESCALER = 9; // 16 MHz / 512 = 31250 Hz
+	NRF_TIMER4->CC[0] = 31250u / 4u; // 4 Hz
+	NRF_TIMER4->SHORTS = TIMER_SHORTS_COMPARE0_CLEAR_Msk;
+	NRF_TIMER4->EVENTS_COMPARE[0] = 0;
+	NRF_TIMER4->INTENSET = TIMER_INTENSET_COMPARE0_Msk;
+	NVIC_SetPriority(TIMER4_IRQn,
+			 1); // above BASEPRI masking, below the WDT capture
+	NVIC_ClearPendingIRQ(TIMER4_IRQn);
+	NVIC_EnableIRQ(TIMER4_IRQn);
+	NRF_TIMER4->TASKS_START = 1;
+}
+static void bbErase()
+{
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Een << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+	NRF_NVMC->ERASEPAGE = BB_ADDR;
+	while (!NRF_NVMC->READY) {
+	}
+	NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren << NVMC_CONFIG_WEN_Pos;
+	__DMB();
+}
+// Boot-side: report the record. Runs from faultDiagBoot AFTER the .noinit hangPC/hangStage recovery, so on
+// boards where those survive the black box only fills the gaps; on RAM-wiping boards it IS the evidence.
+// Lifecycle: the record is NOT erased here -- a normal boot often has no CDC attached, so erasing on first
+// boot would destroy the register words unread. Instead it persists (the banner re-prints on ANY later boot,
+// e.g. a debug-CDC reboot) and the NEXT dump erases the page itself. A "seen" marker at word 12 keeps the
+// panel adoption one-shot so a stale record can't masquerade as a later, unrelated hang's evidence.
+static void bbBootReport(bool isHang)
+{
+	const volatile uint32_t *w = (const volatile uint32_t *)BB_ADDR;
+	bool fresh = (w[12] == 0xFFFFFFFFu);
+	if (w[0] != BB_MAGIC) {
+		// garbage/partial page (interrupted dump or pre-feature content): clean it so the next dump
+		// writes onto erased flash. All-blank needs no erase.
+		bool blank = true;
+		for (int i = 0; i < BB_WORDS; i++)
+			if (w[i] != 0xFFFFFFFFu)
+				blank = false;
+		// keep a half-written phase-1 record (magic present handled below); anything else non-blank
+		// without magic is garbage
+		if (!blank)
+			bbErase();
+		return;
+	}
+	{
+		uint8_t stage = (uint8_t)(w[1] >> 16);
+		Serial.printf(
+			"# BLACK BOX v%lu (flash dump written ~6s into the last hang):\n",
+			(unsigned long)(w[1] >> 24));
+		Serial.printf(
+			"#   stage=%s  loopPC=%08lX  irqPC=%08lX (running at dump)  usbdPC=%08lX\n",
+			faultDiagStageStr(stage), (unsigned long)w[2],
+			(unsigned long)w[3], (unsigned long)w[4]);
+		Serial.printf(
+			"#   stkFree usbd=%luw loop=%luw  polls/s=%lu relay/s=%lu  tWedge~%lums\n",
+			(unsigned long)(w[5] >> 16),
+			(unsigned long)(w[5] & 0xFFFF),
+			(unsigned long)(w[6] & 0xFFFF),
+			(unsigned long)(w[6] >> 16), (unsigned long)w[7]);
+		// phase 2 = USBD peripheral state; all-FF here with phase 1 present = reading USBD registers
+		// bus-stalled the dump ISR = the peripheral wedged the AHB matrix (decisive by itself).
+		if (w[8] == 0xFFFFFFFFu && w[9] == 0xFFFFFFFFu)
+			Serial.printf(
+				"#   USBD regs: UNREADABLE (dump died reading them -> AHB/USBD bus wedge)\n");
+		else
+			Serial.printf(
+				"#   USBD: events=%08lX (ENDEPIN0-7,ENDEPOUT8-15,EPDATA16,USBEVT17,EP0DD18,SOF19,NVICpend20,NVICen21)"
+				" INTEN=%08lX EPDATASTATUS=%08lX EPINEN=%04lX EPOUTEN=%04lX\n",
+				(unsigned long)w[8], (unsigned long)w[9],
+				(unsigned long)w[10],
+				(unsigned long)(w[11] >> 16),
+				(unsigned long)(w[11] & 0xFFFF));
+		// feed the existing panel channels (hangLog / Last-reset tile) when the .noinit path came back
+		// blank -- i.e. exactly the RAM-wiping boards this exists for. loopPC is the primary suspect
+		// location; irqPC (the code actually RUNNING while loop starved) rides the LR slot. One-shot:
+		// only a FRESH record (seen-marker still erased) may claim this boot's hang.
+		if (isHang && fresh) {
+			if (!g_reportHangPC && w[2]) {
+				g_reportHangPC = w[2];
+				g_reportHangLR = w[3];
+			}
+			if (g_hangStage == 0xFF && stage < STAGE_COUNT)
+				g_hangStage = stage;
+		}
+		if (fresh)
+			bbFlashWord(BB_ADDR + 4u * 12, 1u); // stamp "seen"
+	}
+}
+
 void faultDiagBoot()
 {
 	uint32_t rr =
@@ -191,12 +494,237 @@ void faultDiagBoot()
 		"# reset cause: %s (RESETREAS=0x%08lX gpregret2=0x%02X)\n",
 		REASON_STR[reason], (unsigned long)rr, g2);
 	if (g_hangStage != 0xFF)
-		Serial.printf("# hang stage: %s (%u)\n", faultDiagHangStageStr(),
-			      g_hangStage);
+		Serial.printf("# hang stage: %s (%u)\n",
+			      faultDiagHangStageStr(), g_hangStage);
 	if (g_reportHangPC)
 		Serial.printf("# hang PC=0x%08lX LR=0x%08lX\n",
 			      (unsigned long)g_reportHangPC,
 			      (unsigned long)g_reportHangLR);
+
+	// Flash black box: report + consume last hang's pre-watchdog dump (fills hangPC/stage on boards whose
+	// bootloader wiped the .noinit/GPREGRET2 evidence above).
+	bbBootReport(isHang);
+
+	// Flight recorder: if the pre-reset trail survived (.noinit), snapshot it into ordinary RAM so the live
+	// session can start a fresh ring while the console can still re-dump the old trail ("FR"). On a hang, dump
+	// it now -- this is the post-mortem of what the firmware was doing in the seconds before it wedged.
+	if (g_flight.magic == FR_MAGIC) {
+		memcpy(&g_flightSaved, (const void *)&g_flight,
+		       sizeof g_flightSaved);
+		g_haveSaved = true;
+	}
+	g_flight.magic = FR_MAGIC; // start this session's trail fresh
+	g_flight.head = 0;
+	g_flight.count = 0;
+	if (isHang)
+		faultDiagDumpFlight();
+}
+
+// ---- flight recorder (implementation; data declared above faultDiagBoot) ----------------------------------
+void faultDiagTrace(uint8_t evt, uint16_t arg)
+{
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	if (g_flight.magic !=
+	    FR_MAGIC) { // lazy (re)init: power-on garbage or first push this session
+		g_flight.magic = FR_MAGIC;
+		g_flight.head = 0;
+		g_flight.count = 0;
+	}
+	uint16_t h = g_flight.head;
+	g_flight.ring[h].ms = millis();
+	g_flight.ring[h].arg = arg;
+	g_flight.ring[h].evt = evt;
+	g_flight.ring[h].stage = g_curStage;
+	g_flight.head = (uint16_t)((h + 1u) % FR_RING);
+	if (g_flight.count < 0xFFFFu)
+		g_flight.count++;
+	if (!pm)
+		__enable_irq();
+}
+
+void faultDiagFlightTick(void)
+{
+	static unsigned long beatMs = 0, secMs = 0;
+	static uint32_t loops = 0;
+	loops++;
+	unsigned long now = millis();
+
+	// ~4x/s: refresh the persistent vitals snapshot + drop a heartbeat crumb (its cadence in the trail shows
+	// exactly when loop() stopped advancing).
+	if ((uint32_t)(now - beatMs) >= 250u) {
+		beatMs = now;
+		uint32_t stall = faultDiagStallMs();
+		g_flight.vMs = now;
+		g_flight.usbdStackFree = faultDiagUsbdStackFree();
+		g_flight.loopStackFree = faultDiagLoopStackFree();
+		// NOTE: mallinfo() removed here -- it suspended the FreeRTOS scheduler + walked the heap free list 4x/s,
+		// which is new loop latency and a new wedge surface on a slow clone, for zero payoff (heap was flat). If
+		// a heap-usage trend is ever needed, sample it lazily from loop, not on this hot path.
+		g_flight.heapUsed = 0;
+		g_flight.pollsps = g_pollsps;
+		g_flight.relayps = g_relayps;
+		g_flight.crcps = g_crcps;
+		g_flight.norxps = g_norxps;
+		g_flight.rfHeal = g_rfStallRecover;
+		g_flight.ringFault = g_ringFault;
+		g_flight.curStage = g_curStage;
+		g_flight.stallMs = (uint8_t)(stall > 255 ? 255 : stall);
+		faultDiagTrace(FR_BEAT, (uint16_t)g_flight.stallMs);
+	}
+
+	// once per second: latch loop rate + emit the live CDC vitals line (loop context -> Serial is safe here,
+	// unlike the usbd task). Reads as a trend on a flaky board: usbd stack falling toward 0 = the overflow;
+	// heapUsed climbing = a leak; poll==0 = the RF loop stalled; relay high = Steam flooding the link.
+	if ((uint32_t)(now - secMs) >= 1000u) {
+		g_flight.loopPerSec = loops;
+		loops = 0;
+		secMs = now;
+		// Guard on FIFO space: a CDC write to a host that has the port open but isn't draining blocks the loop --
+		// itself a watchdog-hang source (same failure the WebUSB blob send hit). If there's no room this second,
+		// skip the line rather than stall; the persistent trail still captured the vitals via the beat above.
+		if (g_vitals && Serial && Serial.availableForWrite() >= 200) {
+			// Clock fingerprint on every line: the radio waits time out on micros() (HFCLK-derived). If a clone's
+			// HFCLK drops to the internal RC (or micros() freezes), usPerMs drifts off 1000 and a "bounded" RX/
+			// disable wait can spin forever -- the prime suspect for a healthy-then-sudden hard lock. lf/hf: R=RC,
+			// X=xtal, S=synth, -=stopped.
+			const char clkc[] = { '-', 'R', 'X', 'S' };
+			uint8_t lf = clockLfSrc(), hf = clockHfSrc();
+			Serial.printf(
+				"# vit up=%lus loop=%lu/s stall=%ums stage=%s usbdStk=%u loopStk=%u heapUsed=%lu "
+				"poll=%u relay=%u crc=%u norx=%u heal=%u ringF=%u clk=%c/%c usPerMs=%u\n",
+				(unsigned long)(now / 1000),
+				(unsigned long)g_flight.loopPerSec,
+				(unsigned)g_flight.stallMs,
+				faultDiagStageStr(g_flight.curStage),
+				(unsigned)g_flight.usbdStackFree,
+				(unsigned)g_flight.loopStackFree,
+				(unsigned long)g_flight.heapUsed,
+				(unsigned)g_flight.pollsps,
+				(unsigned)g_flight.relayps,
+				(unsigned)g_flight.crcps,
+				(unsigned)g_flight.norxps,
+				(unsigned)g_flight.rfHeal,
+				(unsigned)g_flight.ringFault,
+				lf < 4 ? clkc[lf] : '?',
+				hf < 4 ? clkc[hf] : '?',
+				(unsigned)clockUsPerMs());
+		}
+	}
+}
+
+// Wait (bounded) for room in the CDC TX FIFO so a multi-line dump to a slow host neither drops lines nor
+// blocks forever. Caps each line's wait at ~20ms -> the whole ~100-line dump can't approach the ~8s watchdog.
+static void frWaitTx(uint16_t need)
+{
+	for (uint8_t w = 0;
+	     w < 20 && Serial && Serial.availableForWrite() < need; w++)
+		delay(1);
+}
+
+void faultDiagDumpFlight(void)
+{
+	if (!Serial) // no host attached (e.g. the automatic boot dump before the panel connects) -- use "FR" later
+		return;
+	if (!g_haveSaved || g_flightSaved.magic != FR_MAGIC) {
+		Serial.println(
+			"# flight: no pre-reset trail (recorder lost across this reset, or last boot wasn't a hang)");
+		return;
+	}
+	const struct FlightRec *f = &g_flightSaved;
+	Serial.printf(
+		"# ---- FLIGHT RECORDER (trail up to the last hang) ----\n"
+		"# vitals@wedge: loop=%lu/s stall=%ums stage=%s usbdStkFree=%u loopStkFree=%u heapUsed=%lu\n"
+		"#               poll=%u relay=%u crc=%u norx=%u heal=%u ringFault=%u  (age %lums before reset)\n",
+		(unsigned long)f->loopPerSec, (unsigned)f->stallMs,
+		faultDiagStageStr(f->curStage), (unsigned)f->usbdStackFree,
+		(unsigned)f->loopStackFree, (unsigned long)f->heapUsed,
+		(unsigned)f->pollsps, (unsigned)f->relayps, (unsigned)f->crcps,
+		(unsigned)f->norxps, (unsigned)f->rfHeal,
+		(unsigned)f->ringFault, (unsigned long)0);
+
+	uint16_t total = f->count;
+	uint16_t cnt = total < FR_RING ? total : (uint16_t)FR_RING;
+	if (!cnt) {
+		Serial.println("# flight: trail empty");
+		return;
+	}
+	uint16_t start = (uint16_t)((f->head + FR_RING - cnt) % FR_RING);
+	uint16_t lastIdx = (uint16_t)((f->head + FR_RING - 1u) % FR_RING);
+	uint32_t last = f->ring[lastIdx].ms;
+	Serial.printf(
+		"# %u events (of %u total); t = ms BEFORE the last recorded crumb:\n",
+		cnt, total);
+	for (uint16_t i = 0; i < cnt; i++) {
+		uint16_t idx = (uint16_t)((start + i) % FR_RING);
+		const FRRec *r = &f->ring[idx];
+		frWaitTx(64);
+		Serial.printf("#  -%6lu  %-8s stage=%-9s arg=0x%04X\n",
+			      (unsigned long)(last - r->ms), frEvtStr(r->evt),
+			      faultDiagStageStr(r->stage), r->arg);
+	}
+	Serial.println("# ---- end flight recorder ----");
+}
+
+// ---- WebUSB panel access to the saved trail ----------------------------------------------------------------
+static uint16_t g_frDrain =
+	0; // pull cursor into g_flightSaved, 0 = oldest saved event
+
+bool faultDiagHaveFlight(void)
+{
+	return g_haveSaved && g_flightSaved.magic == FR_MAGIC;
+}
+uint16_t faultDiagFlightCount(void)
+{
+	if (!faultDiagHaveFlight())
+		return 0;
+	uint16_t t = g_flightSaved.count;
+	return t < FR_RING ? t : (uint16_t)FR_RING;
+}
+uint16_t faultDiagFlightTotal(void)
+{
+	return faultDiagHaveFlight() ? g_flightSaved.count : 0;
+}
+void faultDiagFlightDrainReset(void)
+{
+	g_frDrain = 0;
+}
+bool faultDiagFlightPull(uint32_t *dtMs, uint8_t *evt, uint8_t *stage,
+			 uint16_t *arg)
+{
+	uint16_t cnt = faultDiagFlightCount();
+	if (g_frDrain >= cnt)
+		return false;
+	const struct FlightRec *f = &g_flightSaved;
+	uint16_t start = (uint16_t)((f->head + FR_RING - cnt) % FR_RING);
+	uint16_t idx = (uint16_t)((start + g_frDrain) % FR_RING);
+	uint16_t lastIdx = (uint16_t)((f->head + FR_RING - 1u) % FR_RING);
+	*dtMs = (uint32_t)(f->ring[lastIdx].ms - f->ring[idx].ms);
+	*evt = f->ring[idx].evt;
+	*stage = f->ring[idx].stage;
+	*arg = f->ring[idx].arg;
+	g_frDrain++;
+	return true;
+}
+bool faultDiagFlightVitals(struct FaultFlightVitals *out)
+{
+	if (!faultDiagHaveFlight())
+		return false;
+	const struct FlightRec *f = &g_flightSaved;
+	out->loopPerSec =
+		(uint16_t)(f->loopPerSec > 0xFFFFu ? 0xFFFFu : f->loopPerSec);
+	out->usbdStkFree = f->usbdStackFree;
+	out->loopStkFree = f->loopStackFree;
+	out->heapUsed = f->heapUsed;
+	out->pollsps = f->pollsps;
+	out->relayps = f->relayps;
+	out->crcps = f->crcps;
+	out->norxps = f->norxps;
+	out->rfHeal = f->rfHeal;
+	out->ringFault = f->ringFault;
+	out->curStage = f->curStage;
+	out->stallMs = f->stallMs;
+	return true;
 }
 
 // ---- clock fingerprint -------------------------------------------------------------------------------------
@@ -211,7 +739,8 @@ void clockDiagBoot()
 	uint32_t hf = NRF_CLOCK->HFCLKSTAT;
 	bool lfrun = lf & CLOCK_LFCLKSTAT_STATE_Msk;
 	uint8_t lfsrc = (uint8_t)(lf & CLOCK_LFCLKSTAT_SRC_Msk);
-	g_clkLf = lfrun ? (uint8_t)(lfsrc + 1) : 0; // 1=RC,2=Xtal,3=Synth, 0=stopped
+	g_clkLf = lfrun ? (uint8_t)(lfsrc + 1) :
+			  0; // 1=RC,2=Xtal,3=Synth, 0=stopped
 	bool hfxtal = hf & CLOCK_HFCLKSTAT_SRC_Msk;
 	g_clkHf = hfxtal ? 2 : 0; // 2=crystal, 0=RC
 	Serial.printf("# clock: LFCLK=%s HFCLK=%s\n",
@@ -276,10 +805,16 @@ void faultDiagStackTick()
 	UBaseType_t n = uxTaskGetSystemState(st, 12, NULL);
 	for (UBaseType_t i = 0; i < n; i++) {
 		uint16_t hw = (uint16_t)st[i].usStackHighWaterMark;
-		if (!strcmp(st[i].pcTaskName, "usbd") && hw < g_usbdStackMin)
-			g_usbdStackMin = hw;
-		else if (!strcmp(st[i].pcTaskName, "loop") && hw < g_loopStackMin)
-			g_loopStackMin = hw;
+		if (!strcmp(st[i].pcTaskName, "usbd")) {
+			g_hUsbd =
+				st[i].xHandle; // black box: TCB to walk at dump time
+			if (hw < g_usbdStackMin)
+				g_usbdStackMin = hw;
+		} else if (!strcmp(st[i].pcTaskName, "loop")) {
+			g_hLoop = st[i].xHandle;
+			if (hw < g_loopStackMin)
+				g_loopStackMin = hw;
+		}
 	}
 }
 uint16_t faultDiagUsbdStackFree()

+ 67 - 1
OpenPuck/fault_diag.h

@@ -62,6 +62,10 @@ const char *faultDiagStageStr(uint8_t s);
 // watchdog hang, faultDiagHangPC()/LR() return the PC/LR of the stuck code (0 if the hang hard-masked
 // interrupts so the capture ISR couldn't run). Map the PC with addr2line on the build .elf.
 void faultDiagArmHangCapture();
+// Flash black box: 4 Hz TIMER4 watchdog-of-the-watchdog; at ~6s of loop stall it dumps both tasks' stacked
+// PCs + vitals to a reserved flash page (survives ANY reset, incl. boards that wipe .noinit/GPREGRET2).
+// Reported + consumed by faultDiagBoot on the next boot. Arm once from setup(), after the WDT starts.
+void faultDiagBlackBoxArm();
 uint32_t faultDiagHangPC();
 uint32_t faultDiagHangLR();
 
@@ -75,6 +79,67 @@ uint8_t faultDiagReason();
 uint32_t faultDiagResetReas();
 const char *faultDiagReasonStr();
 
+// ---- flight recorder (catch a watchdog hang post-mortem) -------------------------------------------------
+// A .noinit ring of recent high-level events + a live vitals snapshot, BOTH surviving a watchdog reset (same
+// mechanism as the hang PC frame: .noinit RAM isn't zeroed by the C runtime, so it rides through the reset even
+// on clone boards whose bootloader wipes GPREGRET2). A hang drops USB the instant it resets, so live CDC
+// logging can never catch the moment itself -- instead we record the trail continuously and DUMP IT ON THE NEXT
+// BOOT when the last reset was a hang. Every push is a few stores under PRIMASK: safe to call from the fragile
+// 800B usbd task and from ISRs (NO printf, NO allocation). This is the "what was going on when it wedged" the
+// cheap boards (Teyleten hangs ~every 4 min) need. `arg` is event-specific, usually packed (high<<8)|low.
+enum {
+	FR_NONE = 0,
+	FR_BEAT, // periodic loop heartbeat (arg = stall ms) -- the gap between beats localizes the wedge in time
+	FR_SET, // usbd handleSet feature write     (arg = (rid<<8)|cmd)
+	FR_GET, // usbd handleGet feature read      (arg = (rid<<8)|cmd)
+	FR_RELAY, // host->controller relay enqueued (arg = (slot<<8)|rid)
+	FR_RFUP, // RF (re)connect                   (arg = slot)
+	FR_RFDN, // RF link down                     (arg = slot)
+	FR_HEAL, // RF-stall radio self-heal (power-cycle)
+	FR_MOUNT, // USB HID mount/unmount changed   (arg = mounted-slot count)
+	FR_SUSP, // USB bus suspend edge
+	FR_RESUME, // USB bus resume edge
+	FR_OFF, // controller power-off (0x9F) relayed (arg = slot)
+	FR_RINGF, // relay-ring fault caught          (arg = running count)
+	FR_SAVE, // bond flash write (saveBonds)
+};
+
+// Push one event into the flight recorder. Cheap + ISR/usbd-safe (a few stores under PRIMASK, no printf).
+void faultDiagTrace(uint8_t evt, uint16_t arg);
+// Per-loop upkeep: refresh the persistent vitals snapshot + emit a heartbeat (~4x/s), and stream the live
+// per-second vitals line over CDC when g_vitals is on. Call once per loop() iteration.
+void faultDiagFlightTick(void);
+// Print the pre-reset flight trail + vitals-at-wedge over CDC (from the boot-time copy of the surviving ring).
+// Called automatically from faultDiagBoot() after a hang; also re-dumpable on demand from the console ("FR").
+void faultDiagDumpFlight(void);
+
+// ---- WebUSB panel access to the saved (pre-reset) trail ---------------------------------------------------
+// The same boot-time copy the console "FR" dumps, exposed for the panel to stream as 0xA8 frames. Mirrors the
+// hapLog capture drain: reset the cursor to the oldest saved event, then pull entries until exhausted, so a
+// full 96-event trail streams across several panel polls without a single blocking write.
+bool faultDiagHaveFlight(
+	void); // true if a pre-reset trail was captured at this boot
+uint16_t faultDiagFlightCount(void); // saved events available to stream (<= 96)
+uint16_t faultDiagFlightTotal(
+	void); // total events pushed before the reset (for "showing N of M")
+void faultDiagFlightDrainReset(
+	void); // rewind the pull cursor to the oldest saved event
+// Pull the next saved event; false when exhausted. *dtMs = ms the event fired BEFORE the last recorded crumb.
+bool faultDiagFlightPull(uint32_t *dtMs, uint8_t *evt, uint8_t *stage,
+			 uint16_t *arg);
+// Vitals as of the last ~4Hz refresh before the reset (the "@wedge" snapshot). false if no trail was captured.
+struct FaultFlightVitals {
+	uint16_t loopPerSec;
+	uint16_t usbdStkFree, loopStkFree;
+	uint32_t heapUsed;
+	uint16_t pollsps, relayps, crcps, norxps, rfHeal, ringFault;
+	uint8_t curStage, stallMs;
+};
+bool faultDiagFlightVitals(struct FaultFlightVitals *out);
+// 1 = stream the live per-second vitals line over CDC (console "VIT" toggles). Watch usbd stack / heap / relay
+// rate trend on a flaky board in the seconds before it wedges.
+extern bool g_vitals;
+
 // ---- clock fingerprint (clone-board stability diagnostic) -------------------------------------------------
 // nice!nano clones vary in their crystals. The bare-metal radio needs HFXO (32 MHz xtal); millis()/RTC and the
 // watchdog run on LFCLK (32.768 kHz xtal or RC). If a clone runs HFCLK on the internal RC, or its LFCLK is
@@ -86,4 +151,5 @@ void clockDiagBoot(); // read+log the clock sources once at boot
 void clockDiagTick(); // call from loop(); recomputes usPerMs about once a second
 uint8_t clockLfSrc();
 uint8_t clockHfSrc();
-uint16_t clockUsPerMs(); // measured micros() advanced per millis() tick (ideal 1000; 0 until first sample)
+uint16_t
+clockUsPerMs(); // measured micros() advanced per millis() tick (ideal 1000; 0 until first sample)

+ 142 - 53
OpenPuck/haptics.cpp

@@ -2,7 +2,10 @@
 #include "bonds.h"
 #include "config.h"
 #include "rf_link.h"
+#include "usb_tx.h" // usbTxBoost/Unboost -- flood-rate CDC prints share the dcd DMA claim window
+#include "puck_hid.h" // puckLizardActive() -- gate the lizard-suppression keepalive
 
+#include "fault_diag.h" // faultDiagTrace() -- flight recorder
 // USBDevice.suspended() -> autonomous controller power-off on host sleep
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
@@ -18,6 +21,23 @@ volatile uint8_t g_hapticStop = 0;
 // Off by default -- opt in from the WebUSB panel if a controller's haptics come up degraded after connect.
 uint8_t g_hapticBlockOn = 0;
 uint16_t g_hapticBlockMs = HAPTIC_BLOCK_MS_DEFAULT;
+// id9=0 hold in MODE_STEAM (see hapticTask): ON by default -- keeps the controller's autonomous haptic engine
+// off so it can't latch into the deep-inside buzz. Scoped to MODE_STEAM (pure MODE_LIZARD keeps its ticks).
+// Persisted; console 'u' toggles for A/B.
+uint8_t g_lizKeep = 1;
+// EXPERIMENT (console "L87" toggles, persisted): land ALL relayed 0x87 SET_SETTINGS via type-01 framing so
+// Steam's haptic/amp/IMU config actually reaches the controller (verbatim relay = real-puck behavior),
+// instead of the whitelist discarding everything but LED(0x2D)/id9(0x09). Hypothesis: the buzz is the
+// controller's haptic engine left MISCONFIGURED because Steam's 0x87 config is discarded; landing it should
+// configure the engine correctly and stop the buzz. Default OFF (keeps the safe whitelist) because landing
+// reg 0x30 was once blamed for freezing the gyro and 0x34/0x35 for the buzz -- but those were OpenPuck's own
+// bad hapticReinit values, so Steam's real values may be fine. Flip it on hardware to test; flip back if the
+// gyro freezes.
+uint8_t g_landAll87 = 0;
+// Land Steam's amp/haptic-config 0x87 blocks (regs 0x18/0x2E/0x34/0x35, NOT gyro 0x30) so the controller's
+// amplifier is configured and haptics play as clean ticks instead of a default-amp buzz. On by default;
+// console "AMP" toggles for A/B. See the land01 whitelist in rfConnFlushRelay.
+uint8_t g_landAmp = 1;
 // Per-slot reconnect block. 0 = idle; non-zero = drop haptics aimed at this slot until millis() catches up.
 unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 
@@ -26,14 +46,16 @@ unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 // forwards host feature reports verbatim, so the controller acts on report 0x9F 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.
-void hapticSendShutdown()
+void hapticSendShutdown(uint8_t slot)
 {
 	static const uint8_t OFF[4] = { 0x6f, 0x66, 0x66, 0x21 }; // "off!"
-	// broadcast: every connected controller should power off, not just the slot the shutdown was triggered
-	// from (the trigger can come from a Steam interface, the test button, or host-suspend -- any of which
-	// "logically" means "all controllers off").
+	// slot-targeted: Steam's 0x9F arrives on ONE controller's interface and must power off only that
+	// controller (broadcasting it killed every connected controller when the user turned off one). The
+	// broadcast default (0xFF) remains for the triggers that logically mean "all off": host suspend and
+	// the panel/test power-off button.
+	faultDiagTrace(FR_OFF, slot);
 	for (uint8_t i = 0; i < HAPTIC_SHUTDOWN_SHOTS; i++)
-		relayEnqueue(0x9F, OFF, sizeof OFF, 0xFF);
+		relayEnqueue(0x9F, OFF, sizeof OFF, slot);
 }
 
 // millis of last 0x82 haptic OUTPUT relayed (Steam mode)
@@ -49,16 +71,6 @@ static unsigned long g_rumble80Ms[NSLOT] = { 0 };
 // stuck-rumble watchdog is independent
 static bool g_rumble80On[NSLOT] = { false, false, false, false };
 
-// when to fire the next post-reconnect haptic re-init (0 = none scheduled)
-static unsigned long g_reinitAt = 0;
-
-// how many re-init shots remain in this connect window
-static uint8_t g_reinitLeft = 0;
-
-// 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 rings: one per bond slot. Multi-producer (USB ISR + loop-context console/xinput), one consumer
 // per slot (rfConnFlushRelay on that slot's poll turn). Producers serialize under PRIMASK.
 struct RelayMsg {
@@ -112,12 +124,11 @@ bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 			memcpy(g_rq[s][h].data, payload, plen);
 		g_rqHead[s] = nx;
 	}
-	// Any haptic relay arms the idle-clear (so the during-use latch gets cleared in every mode).
-	if (rid == 0x82 || rid == 0x80) {
+	// Track last-haptic time for the Steam-mode quiet timeout (marks the 0x82 stream inactive after silence).
+	if (rid == 0x82 || rid == 0x80)
 		g_haptic82Ms = millis();
-		g_hapClearArmed = true;
-	}
 	__set_PRIMASK(pm);
+	faultDiagTrace(FR_RELAY, (uint16_t)((slot << 8) | rid));
 	return true;
 }
 
@@ -237,17 +248,22 @@ static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
 			return true;
 	return false;
 }
-static void hapticCancelPendingOn()
+static void hapticCancelPendingOn(int slot)
 {
-	// void queued ON entries across all slot queues (stale haptics / rumble across a reconnect)
+	// void queued ON entries (stale haptics / rumble across a reconnect). Per-slot: only the reconnected
+	// slot's queue is scrubbed -- another controller's pending haptics are its own business. slot=-1 keeps
+	// the old scrub-everything behavior (boot).
+	int c0 = (slot >= 0 && slot < NSLOT) ? slot : 0;
+	int c1 = (slot >= 0 && slot < NSLOT) ? slot + 1 : NSLOT;
 	uint32_t pm = __get_PRIMASK();
 	__disable_irq();
-	for (int s = 0; s < NSLOT; s++) {
+	for (int s = c0; s < c1; s++) {
 		uint8_t guard = RELAY_QLEN + 1;
 		for (uint8_t i = g_rqTail[s]; i != g_rqHead[s]; i = rqNext(i)) {
 			if (!guard--) { // desynced/corrupt ring -> recover, don't spin IRQs-off
 				g_rqHead[s] = g_rqTail[s] = 0;
 				g_ringFault++;
+				faultDiagTrace(FR_RINGF, g_ringFault);
 				break;
 			}
 			RelayMsg &m = g_rq[s][i];
@@ -280,9 +296,6 @@ void haptic82HostReport(const uint8_t *p, uint16_t n)
 	if (n < 3)
 		return;
 	g_haptic82Ms = millis();
-
-	// any haptic activity arms a clear when it next goes idle (kills a latch from this use)
-	g_hapClearArmed = true;
 	// Track on/off only. Do NOT synthesize a stop burst: Steam's own stop is forwarded verbatim, and each extra
 	// 0x82 is a discrete pad click -- the spurious end-of-movement "click"/buzz the real puck never produces.
 	g_haptic82On = haptic82PayloadOn(p, n);
@@ -345,13 +358,22 @@ void hapticStabTask()
 	static const uint8_t off[3] = { 0x01, 0x01, 0x00 };
 	static unsigned long lastOn = 0, offAt = 0;
 	unsigned long now = millis();
+	// Buzz only slots whose controller is actually answering polls (same 300ms liveness the panel/0x79 use).
+	// The old 0xFF broadcast kept stuffing a powered-off controller's relay ring -- pure eviction churn plus
+	// wasted TX at a radio that can't hear it, right inside the power-off hang window (issue #72 repro).
+	auto enqLive = [&](const uint8_t *p) {
+		for (uint8_t s = 0; s < NSLOT; s++)
+			if (g_slot[s].used && g_connReplyMs[s] &&
+			    (unsigned long)(now - g_connReplyMs[s]) < 300u)
+				relayEnqueue(0x82, p, 3, s);
+	};
 	if (lastOn == 0 || (uint32_t)(now - lastOn) >= 10000u) {
 		lastOn = now;
-		relayEnqueue(0x82, on, 3, 0xFF); // broadcast buzz-on to all slots
+		enqLive(on);
 		offAt = now + 150;
 	}
 	if (offAt && (int32_t)(now - offAt) >= 0) {
-		relayEnqueue(0x82, off, 3, 0xFF);
+		enqLive(off);
 		offAt = 0;
 	}
 }
@@ -390,6 +412,7 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 		if (!guard--) { // desynced/corrupt ring -> recover, don't spin IRQs-off (watchdog-hang class)
 			g_rqHead[cur] = g_rqTail[cur] = 0;
 			g_ringFault++;
+			faultDiagTrace(FR_RINGF, g_ringFault);
 			break;
 		}
 		RelayMsg &m = g_rq[cur][g_rqTail[cur]];
@@ -412,12 +435,34 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 			// 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] makes the controller DISCARD any 0x87+ command.
 			//
-			// Whitelist type-01 to only two commands: LED brightness (0x87 reg 0x2D) and power-off
-			// (0x9F). Other 0x87 writes (e.g. reg 0x30 IMU enable, 0x34/0x35 haptic amplitude) must
-			// stay on legacy form -- landing 0x30 freezes the gyro; landing 0x34/0x35 causes the buzz.
+			// Whitelist type-01 to exactly three commands: LED brightness (0x87 reg 0x2D), power-off
+			// (0x9F), and the STANDALONE lizard-suppression keepalive (0x87 [09 00 00] -- rl==3 so a
+			// multi-register block that merely STARTS with 0x09 can't smuggle other registers into
+			// landing). Setting index 9 = digital-mappings/lizard-active (ibex FUN_0001f554 decomp);
+			// the real puck lands id9=0 every 3s -- without it the controller's revert timer fires,
+			// re-enables autonomous mode and resets ALL settings to defaults (audible amp pop +
+			// autonomous touchpad ticks = the spurious mid-session buzz). Other 0x87 writes (e.g. reg
+			// 0x30 IMU enable, 0x34/0x35 haptic amplitude) must stay on legacy form -- landing 0x30
+			// freezes the gyro; landing 0x34/0x35 causes the connect buzz.
+			// Amp/haptic-config registers (0x18/0x2E/0x34/0x35): LAND these so the controller's amplifier is
+			// configured the way Steam intends -> haptics (0x82 ticks, connect cue) play as clean ticks
+			// instead of the default-amp BUZZ we get when the config is discarded. Steam sends these grouped
+			// in 0x87 blocks that START with one of those regs and do NOT contain 0x30 (the gyro reg, which
+			// must NOT land -- it freezes the gyro), so gating on data[0] lands the amp block without 0x30.
+			// (g_landAmp, console "AMP"; on by default. The old "landing 0x34/0x35 buzzes" note was really the
+			// 0x81 storm, now removed.)
+			bool ampReg = (m.data[0] == 0x18 || m.data[0] == 0x2E ||
+				       m.data[0] == 0x34 || m.data[0] == 0x35);
 			bool land01 =
 				(m.rid == 0x9F) ||
-				(m.rid == 0x87 && rl >= 1 && m.data[0] == 0x2D);
+				(m.rid == 0x87 && rl >= 1 &&
+				 m.data[0] == 0x2D) ||
+				(m.rid == 0x87 && rl == 3 &&
+				 m.data[0] == 0x09) ||
+				(g_landAmp && m.rid == 0x87 && rl >= 1 &&
+				 ampReg) ||
+				// experiment: land EVERY 0x87 verbatim (real-puck relay) when enabled
+				(g_landAll87 && m.rid == 0x87);
 			uint8_t p[5 + RELAY_MAXP], plen;
 			if (land01) {
 				p[0] = g_relayOp;
@@ -436,6 +481,22 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				plen = (uint8_t)(4 + rl);
 			}
 			hapLogAdd(0xFE, m.rid, m.data, rl);
+			// Lifecycle log (CDC debug boot): EXACTLY what we TX to the controller, so a capture shows
+			// whether OpenPuck (or Steam in the background) pokes the controller right before a buzz
+			// latches -- the piece the I45 stream (controller->us) can't show. Low rate outside a haptic
+			// burst; guarded so it never blocks the loop. cur = target slot.
+			if (Serial.availableForWrite() > 60) {
+				// boosted: this print runs at relay-flood rate and CDC flush enters the same
+				// TinyUSB DMA claim window as HID sends (the issue-72 livelock; see usb_tx.cpp)
+				usbTxBoost();
+				Serial.printf("# TX t=%lu slot%d %s rid=%02X:",
+					      (unsigned long)millis(), cur,
+					      land01 ? "L01" : "l05", m.rid);
+				for (uint8_t i = 0; i < rl && i < 8; i++)
+					Serial.printf(" %02X", m.data[i]);
+				Serial.println();
+				usbTxUnboost();
+			}
 			// slot was already consumed under the critical section above.
 			// s1 carries a PID distinct from the GET poll (caller cycles it) so the controller's ESB
 			// dedup never treats the GET as a retransmit of this relay. 80us RX: relay is NO-ACK.
@@ -516,9 +577,9 @@ void hapticInit()
 		g_hapticBlockUntil[s] = 0;
 	}
 }
-// Arm the post-(re)connect haptic block + schedule the clearing re-init. Called on the reliable first-reply
-// signal from rf_link and again from hapticTask's link-up edge detector. Per-slot: only the reconnected
-// slot is blocked; the broadcast re-init covers all slots.
+// Schedule the clearing re-init for ONE slot. Called on the reliable first-reply signal from rf_link and
+// again from hapticTask's link-up edge detector. Strictly per-slot: only the reconnected controller gets
+// re-inits / its pending haptics scrubbed -- the other controllers hear nothing about it.
 void hapticOnReconnect(int slot)
 {
 	if (slot < 0 || slot >= NSLOT)
@@ -528,16 +589,43 @@ void hapticOnReconnect(int slot)
 	g_haptic82On = false;
 	g_rumble80On[slot] = false;
 	g_rumble80Ms[slot] = 0;
-	hapticCancelPendingOn();
-	// Re-init repeatedly across the settle window: the connect buzz engages early (during the block,
-	// controller-internal), so a single late shot misses it.
-	g_reinitAt = millis() + 200u;
-	g_reinitLeft = HAPTIC_REINIT_SHOTS;
+	// Scrub haptics queued before the link came up (stale across the reconnect) -- this slot only.
+	hapticCancelPendingOn(slot);
+	// NO automatic haptic re-init. hapticReinit lands 0x81 (CLEAR DIGITAL MAPPINGS -- rid < 0x87 so it
+	// EXECUTES even on legacy framing). 0x81 is NON-IDEMPOTENT (ibex FUN_0001f554): EVERY call re-runs the
+	// lizard-disable event + func_0x0001bbf0 (a hardware peripheral re-arm unique to the 0x81 path) -- so
+	// firing 8 across the connect window = 8 audible clicks = the "repeated non-periodic clicks at connect"
+	// the real puck (which clears ONCE then holds) never produces. The clearing re-init stays available on
+	// demand (WebUSB "Clear stuck buzz" -> hapticReinit), just not sprayed at connect.
 	uint8_t mk = 2;
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 }
 void hapticTask()
 {
+	// id9=0 hold in MODE_STEAM: land SET_SETTINGS index 9 = 0 on each connected controller every LIZKEEP_MS,
+	// the way the real puck does (sniff1.json: id9=0 landed ~every few s). This holds the controller in
+	// gamepad mode with its AUTONOMOUS mapping/haptic engine OFF -- which is what stops the controller's own
+	// touchpad-haptic engine from latching into the "deep-inside" buzz seen after repeated reconnects (that
+	// buzz is controller-internal: capture-for-haptics.txt showed the controller in autonomous mode, rid=40/
+	// 41, with OpenPuck relaying no haptics at all -- so only holding id9=0 quiets it). Framing note: 0x87
+	// (SET_SETTINGS) is idempotent -- a repeated same-value id9=0 is change-guarded in the controller, so it
+	// does NOT re-fire the click-causing 0x81 side effects; holding via 0x87 is silent where a looped 0x81
+	// was not. It DOES disable the controller's autonomous touchpad ticks (id9 gates the whole pad layer),
+	// which is fine in Steam mode (Steam owns haptics) -- hence scoped to MODE_STEAM ONLY. Pure MODE_LIZARD
+	// deliberately leaves id9 alone so its autonomous ticks keep working (and it's the mode with no buzz).
+	if (g_lizKeep && g_usbMode == MODE_STEAM) {
+		static unsigned long lastKeep[NSLOT] = { 0 };
+		static const uint8_t KA[3] = { 0x09, 0x00, 0x00 };
+		for (int s = 0; s < NSLOT; s++) {
+			if (!g_slot[s].used || !hapticLinkUp(s))
+				continue;
+			if (lastKeep[s] &&
+			    (uint32_t)(millis() - lastKeep[s]) < LIZKEEP_MS)
+				continue;
+			lastKeep[s] = millis();
+			relayEnqueue(0x87, KA, sizeof KA, (uint8_t)s);
+		}
+	}
 	// Per-slot link-edge detect (backup for hapticOnReconnect in rf_link).
 	static bool wasHapticLinkUp[NSLOT] = { 0 };
 	for (int s = 0; s < NSLOT; s++) {
@@ -552,16 +640,17 @@ void hapticTask()
 		if (!up && wasHapticLinkUp[s]) {
 			uint8_t mk = 0;
 			hapLogAdd(0xFD, 0xEE, &mk, 1);
+			// Lifecycle log (CDC debug boot): the 300ms link-up watchdog saw this slot go silent. Pairs
+			// with the CONNECT/RECONNECT lines from rf_link so a session of cycles shows the full
+			// down->up cadence -- how often the link actually drops (churn) vs stays up.
+			if (Serial.availableForWrite() > 50)
+				Serial.printf("# LC t=%lu slot%d link DOWN\n",
+					      (unsigned long)millis(), s);
 		}
 		wasHapticLinkUp[s] = up;
 	}
-	if (g_reinitAt && anySlotLinkUp() &&
-	    (int32_t)(millis() - g_reinitAt) >= 0) {
-		hapticReinit();
-		g_reinitAt = (g_reinitLeft && --g_reinitLeft) ?
-				     (millis() + HAPTIC_REINIT_GAP_MS) :
-				     0;
-	}
+	// (No automatic re-init firing -- see hapticOnReconnect: the connect-time 0x81 storm was the click train
+	// the real puck never makes. hapticReinit is on-demand only, via the WebUSB "Clear stuck buzz" button.)
 	// Power-off on host sleep: only when VBUS is present (genuine sleep, not a cable unplug which also
 	// trips the suspend edge briefly) AND the suspend has PERSISTED >= SUSPEND_OFF_MS. A brief USB
 	// selective-suspend (host idle power-management) resumes in <1s; firing the power-off on its edge
@@ -576,9 +665,13 @@ void hapticTask()
 	if (susp && !wasSusp) {
 		suspSinceMs = millis();
 		suspArmed = true;
+		faultDiagTrace(FR_SUSP, 0);
 	}
-	if (!susp)
+	if (!susp) {
+		if (wasSusp)
+			faultDiagTrace(FR_RESUME, 0);
 		suspArmed = false;
+	}
 	if (suspArmed && vbus && (millis() - suspSinceMs) >= SUSPEND_OFF_MS) {
 		hapticSendShutdown();
 		suspArmed = false; // fire once per suspend
@@ -593,10 +686,6 @@ void hapticTask()
 		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)
 			hapticSteamRumble(0, 0, (uint8_t)s);
 	}
-	// Idle-clear: after haptic activity goes quiet, re-init once to clear any latch left behind.
-	if (g_hapClearArmed &&
-	    (millis() - g_haptic82Ms) > HAPTIC_CLEAR_IDLE_MS) {
-		g_hapClearArmed = false;
-		hapticReinit();
-	}
+	// (No automatic idle-clear re-init either: same 0x81-click reason. A genuinely stuck buzz is cleared
+	// on demand from the panel. Verbatim relay -- like the real puck -- is the steady-state behavior.)
 }

+ 19 - 2
OpenPuck/haptics.h

@@ -47,6 +47,22 @@
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot = 0xFF);
 
+// id9=0 hold (MODE_STEAM only): land the controller's SET_SETTINGS index 9 (digital-mappings/lizard-active)
+// at 0, once per LIZKEEP_MS per connected slot, like the real puck. This holds the controller's autonomous
+// mapping/haptic engine OFF so it can't latch into the deep-inside buzz seen after repeated reconnects
+// (capture-for-haptics.txt: the buzz is controller-internal; OpenPuck relays no haptics in that state). It
+// also disables the controller's autonomous touchpad ticks (id9 gates the whole pad layer) -- fine in Steam
+// mode (Steam owns haptics), so it is scoped to MODE_STEAM; pure MODE_LIZARD is left alone to keep its ticks.
+#define LIZKEEP_MS 2000u
+extern uint8_t
+	g_lizKeep; // 1 = hold on (default, persisted); console 'u' toggles for A/B
+// Experiment: land ALL relayed 0x87 SET_SETTINGS verbatim (real-puck relay) instead of the discard-whitelist.
+// Default 0 (whitelist). Console "L87" toggles; persisted. See haptics.cpp for the buzz hypothesis it tests.
+extern uint8_t g_landAll87;
+// Land Steam's amp/haptic-config 0x87 (regs 0x18/0x2E/0x34/0x35, not gyro 0x30) so haptics play as clean
+// ticks not a default-amp buzz. On by default; console "AMP" toggles.
+extern uint8_t g_landAmp;
+
 // Post-connect haptic block (persisted, panel-controlled): when g_hapticBlockOn, Steam haptics are dropped for
 // g_hapticBlockMs after a (re)connect so the controller's haptic engine settles before the first real haptic.
 extern uint8_t
@@ -64,8 +80,9 @@ extern volatile uint8_t g_hapticStop;
 // Per-slot block: arm after a (re)connect, drop haptics aimed at the slot for g_hapticBlockMs (when g_hapticBlockOn).
 extern unsigned long g_hapticBlockUntil[NSLOT];
 
-// relay the controller power-off (0x9F "off!"), burst x3 (Steam 0x9F / host-suspend / test button)
-void hapticSendShutdown();
+// relay the controller power-off (0x9F "off!"), burst x3. Steam's per-interface 0x9F passes that slot so
+// only that controller powers off; host-suspend / the panel test button keep the broadcast default (all off).
+void hapticSendShutdown(uint8_t slot = 0xFF);
 
 // ---- 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. ----

+ 6 - 0
OpenPuck/mode_xinput.cpp

@@ -508,6 +508,12 @@ void XboxController::onReport45(int slot, const uint8_t *rep, bool fresh,
 	(void)bodyTlen;
 	if (slot < 0 || slot >= NSLOT)
 		return;
+	// rfXboxGamepad/rfXboxMouse read report 0x45 field offsets. The new-firmware report 0x42 is VERIFIED
+	// byte-identical over [0..45] (buttons/triggers/sticks/pads/IMU at the same offsets; it only appends 8
+	// trailing bytes and sets always-on status bits 28/29 that no TB_ mask reads -- see the rf_link decode
+	// note), so both ids decode here. Anything else must not be decoded as gamepad input.
+	if (rep[0] != 0x45 && rep[0] != 0x42)
+		return;
 	rfXboxGamepad((uint8_t)slot, rep);
 	// Shared desktop mouse: slot 0's right-pad only. Other slots' right-pad is still part of the
 	// per-slot XInput (TB_RPADT in the gamepad report is unused today but kept for forward-compat).

+ 257 - 18
OpenPuck/puck_hid.cpp

@@ -3,6 +3,7 @@
 #include "config.h"
 #include "identity.h"
 #include "haptics.h"
+#include "fault_diag.h"
 #include "rf_link.h"
 #include "triton.h"
 #include "mode_lizard.h"
@@ -92,6 +93,73 @@ static const uint8_t PUCK_LIZARD_HID_DESC[] = {
 
 static Adafruit_USBD_HID hid[NSLOT];
 
+// Drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS in Steam mode (console "S81" toggles; on by default). It's
+// the confirmed amp-clicker in Steam's per-connect config and OpenPuck doesn't need it (own input translation
+// + id9=0 keepalive). Reversible from the console if it regresses.
+bool g_drop81 = true;
+
+// Per-slot shadow of the controller's SET_SETTINGS_VALUES array (id-indexed u16, ids 0..0x52). Steam writes
+// it with 0x87 and READS IT BACK with 0x89 to verify its config "took"; on the real puck the controller
+// answers 0x89 from this array. OpenPuck can't relay-and-return over the NO-ACK RF link, so it maintains the
+// shadow here and answers 0x89 from it -- WITHOUT this, Steam's verify never matches and it re-clears (0x81)
+// + re-writes (0x87) forever = the connect config storm that buzzes the amp and floods the usbd task.
+static uint16_t g_setShadow[NSLOT][0x53];
+
+// ---- feature-command capture (diagnostic) ---------------------------------------------------------------
+// Log the USB feature command channel (Steam's SET/GET) to serial to see the connect handshake -- WITHOUT a
+// Serial.printf on the fragile 800B usbd task (that path can blow the stack -> LOCKUP; it's why production
+// avoids printf here). handleSet/handleGet (usbd task) push a compact record under PRIMASK; puckCmdLogDrain()
+// prints them from loop() context. When g_cmdCapture is on, the high-rate I45 input stream is also suppressed
+// (rf_link) so the command sequence is readable. Toggle from the console with "FC".
+bool g_cmdCapture = true;
+struct FCmdRec {
+	uint8_t dir; // 0 = SET (host->puck write), 1 = GET (host read)
+	uint8_t iface; // HID interface index = bond slot the command hit
+	uint8_t rid, cmd, len, b[10];
+};
+static FCmdRec g_fc[32];
+static volatile uint8_t g_fcHead, g_fcTail;
+static void fcPush(uint8_t dir, int iface, uint8_t rid, uint8_t cmd,
+		   uint8_t len, const uint8_t *b, uint8_t n)
+{
+	if (!g_cmdCapture)
+		return;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	uint8_t h = g_fcHead, nx = (uint8_t)((h + 1) & 31);
+	if (nx !=
+	    g_fcTail) { // drop-newest when full (loop drains fast); never block the usbd task
+		g_fc[h].dir = dir;
+		g_fc[h].iface = (uint8_t)iface;
+		g_fc[h].rid = rid;
+		g_fc[h].cmd = cmd;
+		g_fc[h].len = len;
+		for (uint8_t i = 0; i < 10; i++)
+			g_fc[h].b[i] = (i < n) ? b[i] : 0;
+		g_fcHead = nx;
+	}
+	__set_PRIMASK(pm);
+}
+void puckCmdLogDrain(void)
+{
+	// boosted: runs at Steam's feature-storm rate and CDC flush enters the same TinyUSB DMA claim window
+	// as HID sends (the issue-72 livelock; see usb_tx.cpp)
+	usbTxBoost();
+	while (g_fcHead != g_fcTail) {
+		if (Serial.availableForWrite() < 70)
+			break; // don't stall loop on CDC backpressure; resume next iteration
+		FCmdRec r = g_fc[g_fcTail];
+		g_fcTail = (uint8_t)((g_fcTail + 1) & 31);
+		Serial.printf("# FC %s if=%u rid=%u cmd=%02X len=%u:",
+			      r.dir ? "GET" : "SET", r.iface, r.rid, r.cmd,
+			      r.len);
+		for (uint8_t i = 0; i < 10; i++)
+			Serial.printf(" %02X", r.b[i]);
+		Serial.println();
+	}
+	usbTxUnboost();
+}
+
 // ===================== seamless LIZARD decision =====================
 // 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
@@ -118,6 +186,11 @@ static inline bool lizardActive()
 	       (g_usbMode == MODE_LIZARD ||
 		(g_autoLizard && !steamDrivingGamepad()));
 }
+// Public accessor for the haptic layer (haptics.cpp gates the lizard-suppression keepalive on this).
+bool puckLizardActive()
+{
+	return lizardActive();
+}
 static inline void hostStampAlive()
 {
 	g_steamAliveMs = millis();
@@ -153,7 +226,12 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		// exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop).
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
-		if (rid >= 0x80 && rid <= 0x86 && n >= 1 &&
+		// Drop the OUTPUT-report form of 0x81 too (Steam's haptic-reset action, a 7-byte report id 0x81 with
+		// payloads 00../01..). Same amp-clicker as the feature-0x01 0x81; g_drop81 only covered the feature
+		// path, so this OUTPUT form was still leaking through and clicking. OpenPuck doesn't need it.
+		bool dropOut81 =
+			(g_drop81 && g_usbMode == MODE_STEAM && rid == 0x81);
+		if (rid >= 0x80 && rid <= 0x86 && !dropOut81 && n >= 1 &&
 		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
 			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
@@ -195,15 +273,21 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 	// Capture EVERY feature SET (the whole cmd channel: 0x83 attr, 0xAE strings, 0xB4 conn, 0xA2/A3 bond, the
 	// feature-1 relay, AND any host battery query) to the WebUSB ring so the panel's capture view shows it.
 	hapLogAdd((uint8_t)slot, cmd, b, n);
+	// ...and to the serial feature-command capture (diagnostic; drained in loop, see puckCmdLogDrain).
+	fcPush(0, slot, rid, cmd, len, pl, pln > 10 ? 10 : (uint8_t)pln);
+	// ...and to the flight recorder (survives a watchdog reset). This runs ON the fragile usbd task, so the
+	// command flood that (theory) overflows it lands in the post-mortem trail rather than being lost with USB.
+	faultDiagTrace(FR_SET, (uint16_t)((rid << 8) | cmd));
 
 	// settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
 	if (cmd >= 0x80 && cmd <= 0x89)
 		hostStampAlive();
 	// 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).
+	// confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown
+	// bursts it for NO-ACK reliability. Slot-targeted: the command arrived on THIS controller's interface,
+	// so only this controller powers off (broadcasting killed all connected controllers at once).
 	if (rid == 1 && cmd == 0x9F)
-		hapticSendShutdown();
+		hapticSendShutdown((uint8_t)slot);
 
 	// report 0x01 = raw passthrough -> queue for RF relay to the controller
 	if (rid == 1 && n >= 2) {
@@ -214,8 +298,26 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 
+		// Steam-mode: DROP the relayed 0x81 CLEAR_DIGITAL_MAPPINGS (g_drop81, console "S81"). It EXECUTES on
+		// the controller (rid<0x87, legacy framing) and each one re-arms the haptic amp (non-idempotent, ibex
+		// func_0x0001bbf0) = the connect click/buzz; Steam sends ~13 in its per-connect config. OpenPuck does
+		// its OWN input translation and holds mappings cleared via the id9=0 keepalive, so it does NOT need the
+		// controller's mapping engine -> Steam's 0x81 is pure downside here. The manual "Clear stuck buzz"
+		// (hapticReinit) sends its own 0x81 via relayEnqueue directly, so that cure path is unaffected.
+		bool drop =
+			(g_drop81 && g_usbMode == MODE_STEAM && cmd == 0x81);
+		// Do NOT relay commands OpenPuck answers LOCALLY (identity/bond/settings READS). Relaying them to the
+		// controller is pointless (their reply can't come back over the NO-ACK RF link -- we answer from the
+		// handleSet/handleGet switch), and 0x83 GET_ATTRIBUTES in particular is < 0x87 so it EXECUTES on the
+		// controller -- captured as the remaining periodic CLICK, fired every time Steam re-polls identity
+		// (0x83/0xAE on all 4 interfaces). Only actuator/config commands (0x80-0x82/0x84-0x88 haptics+config,
+		// 0x9F power) should reach the controller.
+		bool localAnswer = (cmd == 0x83 || cmd == 0x89 || cmd == 0xAE ||
+				    cmd == 0xA2 || cmd == 0xA3 || cmd == 0xAD ||
+				    cmd == 0xB4 || cmd == 0xED || cmd == 0xA4);
 		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
-		bool relayOk = hapticRelaySlotOk(slot) &&
+		bool relayOk = hapticRelaySlotOk(slot) && !drop &&
+			       !localAnswer &&
 			       !(haptic82 && (lizardActive() || muted));
 		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
 			// Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's
@@ -253,18 +355,48 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		S.resp[0] = 0x83;
 		S.resp[1] = sizeof ATTR83;
 		memcpy(S.resp + 2, ATTR83, sizeof ATTR83);
+		// Report-id 1 = an attributes query RELAYED to the bonded CONTROLLER; it must report the CONTROLLER's
+		// product id (0x1302), NOT the puck/dongle's (0x1304) -- else Steam sees "a controller with a dongle's
+		// id" (HANDOFF.md) and drops to the LEGACY 0x81 CLEAR_DIGITAL_MAPPINGS init path, whose rapid re-arm
+		// storm is the connect buzz; with the correct id Steam uses the modern quiet 0x87 id9=0 path. Report-id
+		// 2 stays the puck (0x1304). ATTR83 stores product as u32 LE at offset 1, so only the low byte flips
+		// (0x04 -> 0x02); everything else in the blob is identical.
+		if (rid == 1)
+			S.resp[2 + 1] =
+				0x02; // product 0x1304 -> 0x1302 (controller)
 		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";
+		// Report-id 1 = string attributes of the bonded CONTROLLER, not the puck. Steam matches the connected
+		// controller to its bond by SERIAL: it reads the controller's serial here (rid 1) and compares to the
+		// 16-byte serial in the bond record it reads via 0xA3. OpenPuck was returning the PUCK's serial (g_unit,
+		// "FXB..."), which never matches any bond's controller serial ("FXA...") -> Steam can't associate the
+		// controller with a puck -> "paired to" list is EMPTY -> Steam treats it as unconfigured and re-runs the
+		// 0x81/0x87 config storm every connect (the buzz). So on rid 1, answer with the CONTROLLER's serial from
+		// this interface's bond record (rec[8..24], the same 16-byte serial 0xA3 returns). Captured: Steam
+		// hammered this read (AE x39 on rid1) exactly because the identity never matched. rid 2 = puck (unchanged).
+		// idx 0/1/4 = board/unit/alt serial (real controller returns the SAME serial for 0 and 4; the clone
+		// was returning "NA" for idx 4, which failed Steam's read -> retry).
 		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));
+		if (rid == 1 && slot >= 0 && slot < NSLOT &&
+		    g_slot[slot].used && (idx == 0 || idx == 1 || idx == 4)) {
+			// THIS slot's paired-controller serial, straight from its bond record (rec[8..24], the same
+			// 16-byte serial 0xA3 returns) -- per device, nothing hardcoded. Copied without a stack temp
+			// (this runs on the fragile 800B usbd task; every byte off the stack helps under a Steam
+			// re-enumeration burst).
+			memcpy(S.resp + 3, g_slot[slot].rec + 8, 16);
+		} else {
+			// rid 2 = the puck's own board/unit serials (device-derived). Any other idx -> "NA".
+			const char *s = (rid == 1)	       ? "NA" :
+					(idx == 0 || idx == 4) ? g_board :
+					(idx == 1)	       ? g_unit :
+								 "NA";
+			memcpy(S.resp + 3, s, strlen(s));
+		}
 		S.resp_len = 63;
 		break;
 	}
@@ -318,6 +450,86 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			memcpy(S.resp + 2, S.rec, 24);
 		S.resp_len = 63;
 		break;
+	case 0x87:
+		// SET_SETTINGS_VALUES: payload = N x [id][val16 LE]. Shadow each into g_setShadow[slot] so the 0x89
+		// read-back below matches what Steam wrote -- this is THE fix that stops Steam's endless config-verify
+		// retry (the 0x81/0x87 storm that buzzes the amp). (Still relayed to the controller by the feature-1
+		// path above; this just maintains the dongle-side shadow the real controller would return on 0x89.)
+		// ACK with [0x87][0], not the default payload echo (a clean success reply, like the real dongle).
+		if (slot >= 0 && slot < NSLOT)
+			for (uint16_t i = 0; i + 2 < pln; i += 3) {
+				uint8_t id = pl[i];
+				if (id < 0x53)
+					g_setShadow[slot][id] =
+						(uint16_t)(pl[i + 1] |
+							   (pl[i + 2] << 8));
+			}
+		S.resp[0] = 0x87;
+		S.resp[1] = 0;
+		S.resp_len = 63;
+		break;
+	case 0x89: {
+		// GET_SETTINGS_VALUES: Steam reads back setting `id` (payload[0]) from the id-indexed array. Answer
+		// from the shadow 0x87 populated: [0x89][3][id][val16 LE]. (Steam didn't use 0x89 in the captured
+		// session -- it provisions via 0xED below -- but this is the correct real-dongle behavior; harmless.)
+		uint8_t id = (pln > 0) ? pl[0] : 0;
+		uint16_t v = (slot >= 0 && slot < NSLOT && id < 0x53) ?
+				     g_setShadow[slot][id] :
+				     0;
+		S.resp[0] = 0x89;
+		S.resp[1] = 3;
+		S.resp[2] = id;
+		S.resp[3] = (uint8_t)v;
+		S.resp[4] = (uint8_t)(v >> 8);
+		S.resp_len = 63;
+		break;
+	}
+	case 0xED: {
+		// GET-SETTING-BY-PATH (keyed). Steam SETs the path string, then GETs the value; the REAL controller
+		// returns the setting's value, but OpenPuck was ECHOING the path -> Steam sees the controller as
+		// un-provisioned/un-bonded and re-runs its full LEGACY config every connect (0x81 CLEAR_DIGITAL_MAPPINGS
+		// = the amp clicks/buzz; captured: 0xED reads of "esb/bond"/"user/wireless_transport" that got echoed).
+		// Answer the paths Steam checks at connect so it treats the controller as provisioned + bonded:
+		//   esb/bond          -> this slot's 24-byte bond record (the controller's record of ITS puck)
+		//   esb/bond_2        -> a 2nd-puck bond; OpenPuck bonds one puck per slot -> absent (empty)
+		//   user/wireless_transport -> 1 byte = the ACTIVE (connected) slot's transport code (see below)
+		// Anything else -> empty ([0xED][0]) rather than a garbage path echo.
+		const char *p = (const char *)pl;
+		uint16_t pl_n = pln;
+		auto pathIs = [&](const char *k) -> bool {
+			uint16_t kl = (uint16_t)strlen(k);
+			return pl_n >= kl && memcmp(p, k, kl) == 0 &&
+			       (pl_n == kl || p[kl] == 0);
+		};
+		S.resp[0] = 0xED;
+		if (slot >= 0 && slot < NSLOT && g_slot[slot].used &&
+		    pathIs("esb/bond")) {
+			S.resp[1] = 0x18;
+			memcpy(S.resp + 2, g_slot[slot].rec, 24);
+		} else if (pathIs("user/wireless_transport")) {
+			// Steam reads this (a CONTROLLER setting, relayed rid1) to mark which bond slot is ACTIVE in the
+			// pairing list, mapping active_slot = value XOR 2 (HANDOFF: slot0->0x02, slot1->0x03, slot2->0x00,
+			// slot3->0x01). It's a single DONGLE-WIDE value = the currently-connected slot, NOT this
+			// interface's own index (returning the raw index made Steam mark slot^2 active -> the live puck
+			// showed "Inactive"). Report the connected slot's code so Steam marks the live puck active.
+			int act = 0;
+			unsigned long best = 0, now = millis();
+			for (int s2 = 0; s2 < NSLOT; s2++)
+				if (g_slot[s2].used && g_connReplyMs[s2] &&
+				    (now - g_connReplyMs[s2]) < 1200u &&
+				    (best == 0 || g_connReplyMs[s2] > best)) {
+					best = g_connReplyMs[s2];
+					act = s2;
+				}
+			S.resp[1] = 1;
+			S.resp[2] = (uint8_t)(act ^ 2);
+		} else {
+			S.resp[1] =
+				0; // absent/empty -- valid "no value" reply, not a path echo
+		}
+		S.resp_len = 63;
+		break;
+	}
 	default:
 		S.resp[0] = cmd;
 		S.resp[1] = len;
@@ -334,27 +546,43 @@ static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
 	if (type != HID_REPORT_TYPE_FEATURE)
 		return 0;
 
-	// host reading feature data => gamepad consumer active (Steam/SDL)
-	if (g_usbMode == MODE_STEAM)
-		hostStampAlive();
+	// Do NOT treat a feature GET as "Steam is driving." A bare read is weak evidence: on Linux the kernel
+	// hid-steam driver (and any hidapi enumerator) issues GET_FEATURE probes even with Steam's window closed,
+	// which pinned steamDrivingGamepad() true forever and blocked the Steam-closed lizard fallback (keyboard/
+	// mouse dead, and the lizard-off writes those readers pair with the probe also killed the autonomous
+	// touchpad haptics). Steam actually taking over is detected by its OUTPUT/SET WRITES (the 0x87 lizard-off
+	// heartbeat ~every 3s, and 0x82 haptics), which stamp g_steamAliveMs on the handleSet paths. So drop the
+	// GET-based stamp entirely; a read alone no longer suppresses lizard.
 	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);
+	// Flight recorder (every GET, un-deduped): a read STORM -- e.g. the "AE x39" identity hammering seen before
+	// the identity fix -- is itself a wedge signal, so we want it filling the post-mortem trail if it happens.
+	faultDiagTrace(FR_GET, (uint16_t)((rid << 8) | S.resp[0]));
 	// Battery diagnostic: in gamepad (Steam) mode battery is read host-side via the feature channel, NOT the
 	// forwarded 0x43 (that path is verbatim-identical to lizard mode, where battery works). Capture what Steam
 	// GETs so a WebUSB-panel (or CDC) capture in Steam mode shows the report id it polls for battery (then we
 	// answer it with g_battery in handleSet). De-duped by report id so the high-rate polling doesn't flood the
 	// ring/console -- a freshly-requested id is logged once, then again only after 1s.
 	{
-		static uint8_t lastRid = 0xFF;
+		// Dedup by (rid, interface, response-cmd) so each interface's GET is captured (not collapsed to one
+		// line across all 4 slots) -- the per-interface bond/identity responses are exactly what we need to
+		// see -- while still not flooding on high-rate same-report polling.
+		static uint8_t lastRid = 0xFF, lastSlot = 0xFF, lastCmd = 0xFF;
 		static unsigned long lastMs = 0;
-		if (rid != lastRid || millis() - lastMs > 1000) {
+		if (rid != lastRid || (uint8_t)slot != lastSlot ||
+		    S.resp[0] != lastCmd || millis() - lastMs > 1000) {
 			lastRid = rid;
+			lastSlot = (uint8_t)slot;
+			lastCmd = S.resp[0];
 			lastMs = millis();
 			// ring marker 0xFC = "host feature GET" (panel renders it as "GET rid=.."); payload = what we returned
 			hapLogAdd(0xFC, rid, S.resp, n);
+			// serial feature-command capture: what Steam READ on this interface + our answer
+			fcPush(1, slot, rid, S.resp[0], (uint8_t)n, S.resp + 2,
+			       10);
 #if OPK_LOG
 			// Diagnostic build only -- no Serial.printf on the 800B usbd-task stack in production (LOCKUP
 			// mitigation). The GET is captured to the OPK_LOG ring above for the WebUSB panel.
@@ -449,22 +677,33 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 		return;
 	if (!g_slot[slot].used)
 		return;
+	// Report id: 0x45 = legacy main input; 0x42 = new-firmware (SC2 beta ~2026-07) main input (53B). Forward
+	// whichever the controller actually sent, verbatim, under its own id -- both are declared in PUCK_HID_DESC
+	// and this is exactly what the real puck does. rep[0] is the id byte (rep points at it in the F1 TLV).
+	const uint8_t rid = rep[0];
 	if (lizardActive()) {
 		// Every connected controller drives keyboard+mouse on ITS OWN slot interface; the OS merges the
 		// multiple HID mice/keyboards onto the one desktop. (Was hardcoded to slot 0, which went silent
 		// whenever the live controller bonded to a non-zero slot -- e.g. slot 0 holding a stale/phantom
 		// bond from a cloned backup.) rfLizard keeps its glide/edge state per-slot so they don't clobber.
+		// rfLizard reads 0x45 field offsets; report 0x42 is VERIFIED byte-identical over [0..45] (see the
+		// rf_link decode note), so both ids drive lizard -- covers MODE_LIZARD and Steam-mode-with-Steam-
+		// closed on new-firmware controllers.
 		rfLizard(slot, rep, &hid[slot], &hid[slot], 0x40, 0x41);
 	} else {
+		// body length after the id byte, clamped to the descriptor's declared size for this report id
+		// (0x42 = 53B vendor input, 0x45 = 45B input).
+		uint8_t maxb = (rid == 0x42) ? 53 : 45;
 		uint8_t blen = bodyTlen - 1;
-		if (blen > 45)
-			blen = 45; // body after the 0x45 id byte
+		if (blen > maxb)
+			blen = maxb;
 		// forward the puck's raw pad coords untouched (Steam does its own interpolation/smoothing). Forward
 		// only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
 		// stair-step. g_fwdNewOnly toggles for A/B.
 		if ((fresh || !g_fwdNewOnly) && hid[slot].ready())
-			usbTxHid(&hid[slot], 0x45, rep + 1,
-				 blen); // Steam/SDL Triton: input report 0x45
+			usbTxHid(
+				&hid[slot], rid, rep + 1,
+				blen); // Steam/SDL Triton: input report 0x45 (old) / 0x42 (new fw)
 	}
 }
 

+ 16 - 0
OpenPuck/puck_hid.h

@@ -18,6 +18,22 @@
 // repeats makes Steam's trackpad smoothing stair-step. (WebUSB field 14)
 extern uint8_t g_fwdNewOnly;
 
+// True while we are presenting the desktop keyboard/mouse instead of forwarding the gamepad to Steam
+// (MODE_LIZARD always, or MODE_STEAM once Steam's heartbeat has stopped). Exposed so the haptic layer can
+// tell "lizard is active" without duplicating the heartbeat logic -- the lizard-suppression keepalive must
+// NOT run while lizard is active, or it disables the controller's autonomous touchpad haptic ticks.
+bool puckLizardActive();
+
+// Feature-command capture (diagnostic): when g_cmdCapture is on, Steam's USB feature SET/GET commands are
+// logged to serial (# FC lines) from loop context and the high-rate I45 input stream is suppressed, so the
+// connect handshake is readable. puckCmdLogDrain() must be called once per loop(). Console "FC" toggles it.
+extern bool g_cmdCapture;
+void puckCmdLogDrain(void);
+
+// Drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS in Steam mode (the amp-clicker in Steam's per-connect
+// config; OpenPuck doesn't need it). On by default; console "S81" toggles for A/B.
+extern bool g_drop81;
+
 class SteamPuckController : public IController {
     public:
 	void begin() override;

+ 187 - 44
OpenPuck/rf_link.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "triton.h"
 #include "haptics.h"
+#include "puck_hid.h" // g_cmdCapture (suppress I45 during feature-command capture)
 #include "controllers.h"
 #include "status_led.h"
 #include "fault_diag.h"
@@ -76,6 +77,14 @@ uint16_t g_rfStallRecover = 0;
 // not merely blipping. RF_RECOVER_MS rate-limits the recovery so it can't thrash while a stalled link re-syncs.
 #define RF_STALL_MS 2500u
 #define RF_RECOVER_MS 2000u
+// A genuinely WEDGED radio recovers within a power-cycle or two; a controller that is simply OFF / out of
+// range never comes back no matter how many times we power-cycle. So after RF_STALL_GIVEUP consecutive
+// recoveries that restored NO link, treat it as "controller absent" and stop hammering: back the power-cycle
+// off to RF_STALL_BACKOFF_MS (a slow safety-net kick). Discovery beacons keep running throughout, so a
+// returning controller still reconnects normally without the power-cycle -- and the aggressive 2s cadence was
+// both wasteful and able to disrupt a controller mid-reconnect. The counter resets the moment any slot replies.
+#define RF_STALL_GIVEUP 3u
+#define RF_STALL_BACKOFF_MS 30000u
 // measured avg us between GET-poll fires (vs intended g_pollUs)
 uint16_t g_pollPeriodUs = 0;
 static uint32_t g_pollDtSum = 0;
@@ -100,18 +109,29 @@ int g_curSlot = -1;
 // instead of ~400. Each slot's counter increments once per poll-of-that-slot so it cycles 0,1,2,3 cleanly.
 static uint8_t g_pollPid[NSLOT] = {};
 static uint8_t g_relayPid[NSLOT] = {};
-static uint32_t g_stPoll = 0, g_stF1 = 0, g_stF3 = 0;
+// All link statistics are PER SLOT: each controller's polls/replies/errors are counted (and reported --
+// serial stat line, WebUSB blob v13) against that controller only. The old scalar counters merged every
+// slot into one number, so the panel couldn't tell "controller B is drowning" from "everything is slow".
+// The legacy aggregate snapshots (g_pollsps & co) are now sums over slots, kept for the serial line and the
+// blob's pre-v13 fields.
+static uint32_t g_stPoll[NSLOT] = {}, g_stF1[NSLOT] = {};
+static uint32_t g_stF3 = 0;
 // g_stPoll counts true poll CYCLES (one E3 GET per warm slot per cycle). g_stRelay counts relay frames TX'd
 // (host/haptic output reports). They were conflated before (every rfConnTx bumped one counter), which made
 // "Polls/s" read ~540 (250 polls + ~290 relays) and hid that each relay steals a reply window from its poll.
-static uint32_t g_stRelay = 0;
+static uint32_t g_stRelay[NSLOT] = {};
 uint16_t g_relayps = 0;
+// per-slot per-second snapshots (WebUSB blob v13 / per-controller panel stats)
+uint16_t g_slotPollsps[NSLOT] = {}, g_slotF1ps[NSLOT] = {},
+	 g_slotNewps[NSLOT] = {};
+uint8_t g_slotCrcps[NSLOT] = {}, g_slotNoRxps[NSLOT] = {},
+	g_slotRelayps[NSLOT] = {};
 static unsigned long g_stMs = 0;
 // Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
 // slots into a single sequence makes one controller "swallow" the other's frame).
 static uint8_t g_lastSeq[NSLOT] = { 0 };
-static uint32_t g_stNew = 0;
-static uint32_t g_stCrc = 0, g_stNoRx = 0;
+static uint32_t g_stNew[NSLOT] = {};
+static uint32_t g_stCrc[NSLOT] = {}, g_stNoRx[NSLOT] = {};
 static uint32_t g_chF1[3] = { 0, 0, 0 };
 // Cycle gate: fires once per g_pollUs; each fire polls every warm slot so all run at ~250 Hz.
 static uint32_t g_lastPollUs = 0;
@@ -194,8 +214,8 @@ static void rfHostFrameOnce(int slot, bool discovery)
 		g_rfRxCount++;
 		bool crcok = NRF_RADIO->CRCSTATUS & 1;
 		uint8_t len = rfrx[0];
-		// non-blocking: don't stall the loop on CDC backpressure
-		if (Serial.availableForWrite() > 90) {
+		// non-blocking: don't stall the loop on CDC backpressure (whole line ~165B; CDC write() has no timeout)
+		if (Serial.availableForWrite() > 180) {
 			Serial.printf(
 				"*** RESP#%lu ch%u crc%d rxmatch%lu len%u: ",
 				(unsigned long)g_rfRxCount, g_rfCh, crcok,
@@ -273,7 +293,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 		rxlen = rfrx[0];
 		// reply arrived but CRC failed -> RF quality (channel/interference)
 		if (!crcok) {
-			g_stCrc++;
+			g_stCrc[slot]++;
 			g_qosBad++;
 		}
 		// F1 input ~46B; 0x43-augmented ~66B -> allow up to MAXLEN(96)
@@ -294,8 +314,32 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				// (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
 				if (g_connReplyMs[s] == 0 ||
 				    (uint32_t)(millis() - g_connReplyMs[s]) >
-					    1500u)
+					    1500u) {
+					// Lifecycle log (CDC debug boot): distinguish a first-ever connect from a reconnect and
+					// print the silent gap -- lets a long session of connect/disconnect cycles be diffed to
+					// see whether each cycle re-establishes (churn / boot-haptic click) vs stays linked.
+					if (Serial.availableForWrite() > 130) {
+						uint32_t gap =
+							g_connReplyMs[s] ?
+								(uint32_t)(millis() -
+									   g_connReplyMs
+										   [s]) :
+								0;
+						Serial.printf(
+							"# LC t=%lu slot%d %s gap=%lums rtype=%02X cd=%lums\n",
+							(unsigned long)millis(),
+							s,
+							g_connReplyMs[s] ?
+								"RECONNECT" :
+								"CONNECT",
+							(unsigned long)gap,
+							rtype,
+							(unsigned long)(millis() -
+									g_connCooldown));
+					}
 					hapticOnReconnect(s);
+					faultDiagTrace(FR_RFUP, s);
+				}
 				g_connRx++;
 				// link alive -> loop() suppresses the redundant E1 beacon
 				g_connReplyMs[s] = millis();
@@ -313,7 +357,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							rs;
 			}
 			if (rtype == 0xF1)
-				g_stF1++;
+				g_stF1[slot]++;
 			// controller disconnecting/powering off -> back off 2.5s so we don't immediately re-wake it.
 			// BUT only when no OTHER slot is still live: g_connCooldown is global and gates ALL polling +
 			// beacons, so backing off because ONE controller powered off would drop every other connected
@@ -325,8 +369,22 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 					if (i != g_curSlot && g_slot[i].used &&
 					    millis() - g_connReplyMs[i] < 300)
 						others++;
+				faultDiagTrace(FR_RFDN,
+					       (uint16_t)((g_curSlot << 8) |
+							  (others & 0xFF)));
 				if (others == 0)
 					g_connCooldown = millis();
+				// Lifecycle log: the controller sent F2 (disconnect/power-off). Note whether it armed the
+				// 2.5s cooldown (which pauses ALL beacon+poll -> the controller can lose the session and
+				// reboot on the next reconnect = a boot-haptic click). Prime suspect for the connect buzz.
+				if (Serial.availableForWrite() > 130)
+					Serial.printf(
+						"# LC t=%lu slot%d F2 DISCONNECT others=%d%s\n",
+						(unsigned long)millis(),
+						g_curSlot, others,
+						others == 0 ?
+							" -> cooldown 2.5s (beacon+poll paused)" :
+							"");
 			}
 			// F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
 			if (rtype == 0xF3) {
@@ -363,13 +421,20 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						ttype = rfrx[idx + 1];
 					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).
+					// Only a FULL input 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).
+					// Main input report id: 0x45 (legacy, 45B body) OR 0x42 (SC2 beta update ~2026-07, 53B body).
+					// VERIFIED from live captures of both: the 0x42 body [0..45] is byte-for-byte the SAME layout as
+					// 0x45 (buttons/triggers/sticks/pads/IMU at identical offsets) -- it just adds 8 trailing bytes
+					// ([46..47]=0x7FFF const, [48..53]=0) and sets two extra always-on status bits (28/29) that no
+					// mode reads. So both decode through this ONE path unchanged; rep[0] carries the id downstream
+					// (Steam forwards it verbatim under the right id/length in onReport45).
 					if (ttype == 6 && tlen >= 28 &&
 					    (size_t)(idx + 2) + tlen <=
 						    sizeof(rfrx) &&
-					    rfrx[idx + 2] == 0x45) {
-						// report 0x45: [0x45][seq][buttons u32]...
+					    (rfrx[idx + 2] == 0x45 ||
+					     rfrx[idx + 2] == 0x42)) {
+						// report 0x45/0x42: [id][seq][buttons u32]...
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 						bool fresh =
@@ -377,7 +442,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 							 g_lastSeq[g_curSlot]);
 						// genuine new report vs stale poll-repeat
 						if (fresh) {
-							g_stNew++;
+							g_stNew[g_curSlot]++;
 							g_lastSeq[g_curSlot] =
 								rep[1];
 						}
@@ -582,6 +647,33 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 								rep[0], rep + 1,
 								(uint8_t)(tlen -
 									  1));
+					} else if (ttype == 6 &&
+						   (size_t)(idx + 2) + tlen <=
+							   sizeof(rfrx) &&
+						   tlen >= 1) {
+						// DIAGNOSTIC (beta-update RE): a type-6 HID-report TLV whose report id we
+						// DON'T decode (not 0x45 input, not 0x43/0x44 status). If the new controller
+						// firmware moved input off 0x45, its record shows up here. Log rid + full body,
+						// rate-limited + non-blocking so it can't stall the loop. Remove once pinned.
+						static unsigned long lastUnk =
+							0;
+						if (Serial.availableForWrite() >
+							    150 &&
+						    millis() - lastUnk >= 200) {
+							lastUnk = millis();
+							Serial.printf(
+								"UNK rid=%02X tlen=%u: ",
+								rfrx[idx + 2],
+								tlen);
+							for (uint8_t i = 0;
+							     i < tlen; i++)
+								Serial.printf(
+									"%02X",
+									rfrx[idx +
+									     2 +
+									     i]);
+							Serial.println();
+						}
 					}
 					idx += tlen + 2;
 				}
@@ -633,7 +725,8 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				// 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 &&
+				    !g_cmdCapture &&
+				    Serial.availableForWrite() > 150 &&
 				    millis() - g_lastStream >= 4) {
 					g_lastStream = millis();
 					Serial.print("I45 ");
@@ -643,7 +736,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 					Serial.println();
 				}
 			}
-			if (g_connVerbose) {
+			if (g_connVerbose && Serial.availableForWrite() > 180) {
 				Serial.printf(
 					"%s CRX#%lu txtype%02X ch%u len%u: ",
 					isF1 ? "<<<F1" :
@@ -661,7 +754,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 			rxlen = 0;
 		// RX window expired with no packet at all
 	} else {
-		g_stNoRx++;
+		g_stNoRx[slot]++;
 		g_qosBad++;
 	}
 	NRF_RADIO->TASKS_DISABLE = 1;
@@ -674,6 +767,14 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 // instead of every cycle. This keeps the online controllers at full 250 Hz while barely touching offline ones.
 #define SLOT_COLD_MS 5000u
 #define SLOT_COLD_RETRY_MS 2000u
+// Quiet tier: a slot that WAS replying but has been silent past SLOT_QUIET_MS (controller powering off, out
+// of range) backs off to SLOT_QUIET_RETRY_MS retries instead of full-rate polling until SLOT_COLD_MS demotes
+// it. Without this, a power-off left the slot at 250 Hz for 5 s with EVERY poll burning the whole g_rxWin
+// (~2 ms) waiting for a reply that never comes -- with two controllers powered off together that is ~100%
+// radio duty, the churn window the power-off watchdog hang (issue #72 repro) lives in. Recovery stays snappy:
+// the first reply re-warms the slot to full rate, so a controller returning from a fade waits <= one retry.
+#define SLOT_QUIET_MS 300u
+#define SLOT_QUIET_RETRY_MS 50u
 static unsigned long g_slotLastAttemptMs[NSLOT] = {};
 
 // Drive the connected-mode sequence. The cycle gate fires once per g_pollUs (250 Hz); each fire
@@ -753,7 +854,7 @@ static void rfConnStep()
 			uint8_t rs1 =
 				(uint8_t)((((g_relayPid[k]++) & 3) << 1) | 1);
 			if (rfConnFlushRelay(ch, rs1))
-				g_stRelay++;
+				g_stRelay[k]++;
 		}
 		// per-slot PID cycle so each bonded controller's polls stay distinct
 		uint8_t pidv = g_pollPid[k]++;
@@ -773,7 +874,7 @@ static void rfConnStep()
 		}
 		if (rx)
 			g_chF1[0]++;
-		g_stPoll++; // one true poll cycle for this slot
+		g_stPoll[k]++; // one true poll cycle for this slot
 		g_connPoll++;
 	};
 
@@ -790,10 +891,15 @@ static void rfConnStep()
 		if (!g_slot[k].used)
 			continue;
 		bool everReplied = g_connReplyMs[k] != 0;
-		bool cold = everReplied && (nowMs - g_connReplyMs[k] > SLOT_COLD_MS);
+		unsigned long silentMs =
+			everReplied ? (nowMs - g_connReplyMs[k]) : 0;
+		bool cold = everReplied && silentMs > SLOT_COLD_MS;
+		bool quiet = everReplied && !cold && silentMs > SLOT_QUIET_MS;
 		bool phantom = !everReplied && linkUp;
-		if ((cold || phantom) &&
-		    nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
+		unsigned long retry = (cold || phantom) ? SLOT_COLD_RETRY_MS :
+				      quiet		? SLOT_QUIET_RETRY_MS :
+							  0;
+		if (retry && nowMs - g_slotLastAttemptMs[k] < retry)
 			continue;
 		doPoll(k);
 	}
@@ -851,6 +957,7 @@ void rfLinkTask()
 	// cannot thrash. g_rfStallRecover is surfaced to the panel so the wedge -- and its recovery -- is observable.
 	if (g_connOn && g_curSlot >= 0 && millis() - g_connCooldown > 2500) {
 		static unsigned long lastRecoverMs = 0;
+		static uint8_t consecStall = 0;
 		unsigned long nowMs2 = millis();
 		bool anyEverConnected = false, allStalled = true;
 		for (int s = 0; s < NSLOT; s++) {
@@ -860,19 +967,33 @@ void rfLinkTask()
 			if ((uint32_t)(nowMs2 - g_connReplyMs[s]) < RF_STALL_MS)
 				allStalled = false;
 		}
+		// Any slot alive -> a recovery worked (or we were never stalled): reset the give-up counter.
+		if (!allStalled)
+			consecStall = 0;
+		// Back off once we've power-cycled RF_STALL_GIVEUP times with nothing coming back (controller absent,
+		// not a wedged radio): slow the retry to RF_STALL_BACKOFF_MS instead of hammering every RF_RECOVER_MS.
+		uint32_t interval = (consecStall < RF_STALL_GIVEUP) ?
+					    RF_RECOVER_MS :
+					    RF_STALL_BACKOFF_MS;
 		if (anyEverConnected && allStalled &&
-		    (uint32_t)(nowMs2 - lastRecoverMs) > RF_RECOVER_MS) {
+		    (uint32_t)(nowMs2 - lastRecoverMs) > interval) {
 			lastRecoverMs = nowMs2;
 			g_rfStallRecover++;
+			faultDiagTrace(FR_HEAL, g_rfStallRecover);
+			if (consecStall < 255)
+				consecStall++;
 			NRF_RADIO->POWER = 0;
 			NRF_RADIO->POWER = 1;
 			g_connCooldown = 0;
 			g_connSt = 0;
 			g_connStep = 0;
-			if (Serial.availableForWrite() > 60)
+			if (Serial.availableForWrite() > 110)
 				Serial.printf(
-					"# RF STALL (#%u) -> radio power-cycle + reconnect\n",
-					g_rfStallRecover);
+					"# RF STALL (#%u consec=%u%s) -> radio power-cycle + reconnect\n",
+					g_rfStallRecover, consecStall,
+					consecStall >= RF_STALL_GIVEUP ?
+						" BACKOFF" :
+						"");
 		}
 	}
 
@@ -909,31 +1030,53 @@ void rfLinkTask()
 		}
 	}
 	if (g_connOn && millis() - g_stMs >= 1000) {
-		g_f1ps = g_stF1;
-		g_newps = g_stNew;
-		g_pollsps = (uint16_t)g_stPoll;
-		g_relayps = (uint16_t)g_stRelay;
-		g_crcps = (uint16_t)g_stCrc;
-		g_norxps = (uint16_t)g_stNoRx;
+		// Per-slot snapshots first (blob v13 / per-controller panel stats), then the legacy aggregates as
+		// their sums (serial stat line + pre-v13 blob fields).
+		uint32_t tPoll = 0, tF1 = 0, tNew = 0, tCrc = 0, tNoRx = 0,
+			 tRelay = 0;
+		for (int s = 0; s < NSLOT; s++) {
+			g_slotPollsps[s] = (uint16_t)g_stPoll[s];
+			g_slotF1ps[s] = (uint16_t)g_stF1[s];
+			g_slotNewps[s] = (uint16_t)g_stNew[s];
+			g_slotCrcps[s] =
+				(uint8_t)(g_stCrc[s] > 255 ? 255 : g_stCrc[s]);
+			g_slotNoRxps[s] = (uint8_t)(g_stNoRx[s] > 255 ?
+							    255 :
+							    g_stNoRx[s]);
+			g_slotRelayps[s] = (uint8_t)(g_stRelay[s] > 255 ?
+							     255 :
+							     g_stRelay[s]);
+			tPoll += g_stPoll[s];
+			tF1 += g_stF1[s];
+			tNew += g_stNew[s];
+			tCrc += g_stCrc[s];
+			tNoRx += g_stNoRx[s];
+			tRelay += g_stRelay[s];
+			g_stPoll[s] = g_stF1[s] = g_stNew[s] = 0;
+			g_stCrc[s] = g_stNoRx[s] = g_stRelay[s] = 0;
+		}
+		g_f1ps = (uint16_t)tF1;
+		g_newps = (uint16_t)tNew;
+		g_pollsps = (uint16_t)tPoll;
+		g_relayps = (uint16_t)tRelay;
+		g_crcps = (uint16_t)tCrc;
+		g_norxps = (uint16_t)tNoRx;
 		g_pollPeriodUs =
 			g_pollDtCnt ? (uint16_t)(g_pollDtSum / g_pollDtCnt) : 0;
 		g_pollDtSum = 0;
 		g_pollDtCnt = 0;
-		if (Serial.availableForWrite() > 70)
+		// Require room for the WHOLE line (~85B): CDC write() has NO timeout -- it spins yield() until the host
+		// drains, so a guard smaller than the line lets write() start, fill the FIFO mid-line, then spin loop()
+		// forever if the serial host stalls -> watchdog hang. (This exact line, guarded at >70 for an ~85B line,
+		// was the confirmed diagnostic-induced hang: the capture ended truncated mid-"# stat".)
+		if (Serial.availableForWrite() > 130)
 			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_curSlot);
-		g_stPoll = 0;
-		g_stRelay = 0;
-		g_stF1 = 0;
-		g_stNew = 0;
+				(unsigned long)tPoll, (unsigned long)tF1,
+				(unsigned long)tNew, (unsigned long)g_stF3,
+				(int8_t)g_connF3v, g_e7b, (unsigned long)tCrc,
+				(unsigned long)tNoRx, g_curSlot);
 		g_stF3 = 0;
-		g_stCrc = 0;
-		g_stNoRx = 0;
 		g_chF1[0] = g_chF1[1] = g_chF1[2] = 0;
 		g_stMs = millis();
 	}

+ 4 - 0
OpenPuck/rf_link.h

@@ -87,6 +87,10 @@ extern uint16_t g_relayps;
 // last second's CRC-fail and no-reply counts: diagnose a wedge -- polls>0 + noRx high = RX dead / wrong
 // channel / controller silent; crc high = corrupt replies (interference); polls==0 = poll loop stopped.
 extern uint16_t g_crcps, g_norxps;
+// PER-SLOT per-second snapshots (WebUSB blob v13): the aggregates above are sums of these. Each controller's
+// panel tab shows its OWN link health, so one slot drowning no longer hides behind another's healthy rate.
+extern uint16_t g_slotPollsps[NSLOT], g_slotF1ps[NSLOT], g_slotNewps[NSLOT];
+extern uint8_t g_slotCrcps[NSLOT], g_slotNoRxps[NSLOT], g_slotRelayps[NSLOT];
 // Count of RF-stall self-heals (radio power-cycle + reconnect) performed since boot. Non-zero means the
 // firmware caught and recovered from a full-link wedge that previously needed a manual puck replug.
 extern uint16_t g_rfStallRecover;

+ 45 - 0
OpenPuck/serial_console.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "config.h"
+#include "puck_hid.h" // g_cmdCapture (feature-command capture toggle)
 #include "fault_diag.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
@@ -33,6 +34,42 @@ void serialConsolePoll()
 				delay(40);
 				faultDiagArmIntentionalReset();
 				NVIC_SystemReset();
+			} else if (!strcmp(line, "AMP")) {
+				// A/B: land Steam's amp/haptic 0x87 config (0x18/0x2E/0x34/0x35) so haptics play as ticks
+				g_landAmp = !g_landAmp;
+				Serial.printf(
+					"# land amp/haptic 0x87 config %s\n",
+					g_landAmp ? "ON" : "off");
+			} else if (!strcmp(line, "S81")) {
+				// A/B: drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS (the connect amp-clicker)
+				g_drop81 = !g_drop81;
+				Serial.printf(
+					"# drop relayed 0x81 (Steam mode) %s\n",
+					g_drop81 ? "ON" : "off");
+			} else if (!strcmp(line, "FC")) {
+				// feature-command capture: log Steam's USB SET/GET commands to serial + suppress I45
+				g_cmdCapture = !g_cmdCapture;
+				Serial.printf(
+					"# feature-cmd capture %s (I45 %s)\n",
+					g_cmdCapture ? "ON" : "off",
+					g_cmdCapture ? "suppressed" : "on");
+			} else if (!strcmp(line, "L87")) {
+				// EXPERIMENT: land all relayed 0x87 config verbatim (real-puck relay) vs the discard-whitelist.
+				// Exact-match string (single letters are all taken; 'l' alone = rfListenStart).
+				g_landAll87 = !g_landAll87;
+				saveCfg();
+				Serial.printf(
+					"# land-all-0x87 (verbatim 0x87 relay) %s\n",
+					g_landAll87 ? "ON" : "off");
+			} else if (!strcmp(line, "FR")) {
+				// re-dump the flight recorder trail captured before the last watchdog hang (also printed
+				// automatically at boot, but CDC may not be attached yet then -- this reprints on demand).
+				faultDiagDumpFlight();
+			} else if (!strcmp(line, "VIT")) {
+				// toggle the live per-second CDC vitals line (loop rate / usbd stack / heap / relay trend)
+				g_vitals = !g_vitals;
+				Serial.printf("# live vitals line %s\n",
+					      g_vitals ? "ON" : "off");
 			} else if (line[0] == 'l')
 				rfListenStart();
 			else if (line[0] == 'B') {
@@ -90,6 +127,14 @@ void serialConsolePoll()
 				g_rfRespond = false;
 				NRF_RADIO->TASKS_DISABLE = 1;
 				Serial.println("# RF off");
+			} else if (line[0] == 'u') {
+				// id9=0 hold (MODE_STEAM only) A/B toggle (persisted; see haptics.h LIZKEEP_MS)
+				g_lizKeep = !g_lizKeep;
+				saveCfg();
+				Serial.printf(
+					"# id9=0 hold (Steam mode, /%ums) %s\n",
+					(unsigned)LIZKEEP_MS,
+					g_lizKeep ? "ON" : "off");
 			}
 
 			// 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 9=ps3(dualshock3)

+ 2 - 0
OpenPuck/usb_mount.cpp

@@ -1,5 +1,6 @@
 #include "usb_mount.h"
 #include "bonds.h"
+#include "fault_diag.h" // faultDiagTrace() -- flight recorder
 #include <Arduino.h>
 
 uint8_t g_usbMountCount = 0;
@@ -147,5 +148,6 @@ void usbMountTask()
 	if (maskOfList(tmp, n) == mountedMask())
 		return; // same set already mounted (order is preserved across joins/leaves)
 	commitOrder(tmp, n);
+	faultDiagTrace(FR_MOUNT, g_usbMountCount);
 	usbReenumerate(g_usbMountCount);
 }

+ 28 - 0
OpenPuck/usb_tx.cpp

@@ -114,6 +114,30 @@ void usbTxRegisterDrain(usbTxDrainFn fn)
 		g_drainFns[g_nDrain++] = fn;
 }
 
+// ---- priority-inversion guard (BLACK BOX capture 3, 2026-07-03) --------------------------------------------
+// TinyUSB's nrf5x dcd claims the chip's ONE EasyDMA slot (dma_running test_and_set) and only a few
+// instructions LATER writes TASKS_STARTEPIN -- and loop-context sends reach that window at TASK_PRIO_LOW.
+// Preempted right there during a Steam OUT flood (0x82 haptic storm), the flag is held by a task that never
+// runs again: the TASK_PRIO_HIGH usbd task livelocks re-deferring xact_out_dma around the never-started DMA
+// (caught live: loopPC=start_dma:139, irqPC=xact_out_dma re-defer) -> USB dead -> watchdog. The fix needs no
+// library patch: run loop's USB-TX windows at the usbd task's own priority. Equal priority = no preemption on
+// interrupt exit = the claim+start pair completes atomically w.r.t. the usbd task. Depth-counted so nested
+// boosted sections (pump -> drain callbacks) stay balanced; only ever used from the loop task.
+static UBaseType_t g_boostSaved;
+static uint8_t g_boostDepth;
+void usbTxBoost(void)
+{
+	if (!g_boostDepth++) {
+		g_boostSaved = uxTaskPriorityGet(NULL);
+		vTaskPrioritySet(NULL, TASK_PRIO_HIGH);
+	}
+}
+void usbTxUnboost(void)
+{
+	if (g_boostDepth && !--g_boostDepth)
+		vTaskPrioritySet(NULL, g_boostSaved);
+}
+
 // Drain everything from loop() -- NOT from a SOF callback, a dedicated task, or report_complete. All three of
 // those were tried and all fight the RF poll, which is a microsecond-precise busy-wait running in loop(): SOF
 // got silently disabled by bus resets; the high-priority usbd task (report_complete) preempted the RF RX
@@ -125,9 +149,13 @@ void usbTxRegisterDrain(usbTxDrainFn fn)
 // Call once per loop() iteration.
 void usbTxPump(void)
 {
+	// Whole pump (HID sends + vendor/WebUSB drains) runs boosted -- every dcd claim window in here is
+	// then atomic against the usbd task (see usbTxBoost above).
+	usbTxBoost();
 	usbTxDrain();
 	for (uint8_t i = 0; i < g_nDrain; i++)
 		g_drainFns[i]();
+	usbTxUnboost();
 }
 void usbTxBegin(void)
 {

+ 7 - 0
OpenPuck/usb_tx.h

@@ -36,6 +36,13 @@ void usbTxPump(void);
 // Drain pending HID reports (one ready report per destination per call). Internal -- called by usbTxPump().
 void usbTxDrain(void);
 
+// Priority-inversion guard: run a loop-context USB TX window (HID/vendor/CDC writes) at the usbd task's
+// priority so TinyUSB's dcd DMA claim+start can't be preempted mid-claim (the issue-72 livelock; see
+// usb_tx.cpp). Depth-counted; loop task only, always in a balanced pair. usbTxPump() boosts itself; wrap
+// direct Serial/usb_web writes that can run at flood rate (e.g. per-relay logging).
+void usbTxBoost(void);
+void usbTxUnboost(void);
+
 // Register a callback to run every USB frame ON THE usbd TASK (from tud_sof_cb, right after usbTxDrain()).
 // This is how senders that DON'T go through Adafruit_USBD_HID get their transmits off the loop task too:
 //   - mode_xinput flushes its raw custom-class IN endpoints;

+ 203 - 17
OpenPuck/webusb_config.cpp

@@ -36,7 +36,10 @@ static volatile bool g_bondExportRequest = false;
 // browser reads with transferIn(128) to span the two USB-FS packets.
 //                [diag: crc/s][noRx/s][rfStallRecover count]
 //                [v12: relayps(lo,hi)][clkLfSrc][clkHfSrc][usPerMs(lo,hi)][hangStage][curStage][stallMs/40][ringFault(lo,hi)][hangPC u32][hangLR u32][usbdStackFree(lo,hi)][loopStackFree(lo,hi)]
-#define WB_PAYLEN 139
+//                [v13: per-slot link stats, 4x9B from p[141]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
+//                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
+//                [v14: p[177] landAll87 (verbatim-0x87-relay experiment toggle)]
+#define WB_PAYLEN 176
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -53,12 +56,16 @@ static void webusbSendBlob()
 	// every ~250ms in normal use.
 	int cs = (g_curSlot >= 0 && g_curSlot < NSLOT) ? g_curSlot : 0;
 	bool up = (g_curSlot >= 0 && (millis() - g_connReplyMs[cs]) < 300);
-	uint8_t p[2 + WB_PAYLEN];
+	// STATIC, not stack: this runs on the usbd task, whose 800B stack (hardcoded USBD_STACK_SZ in the
+	// Adafruit core, not growable from the sketch) is the prime overflow suspect behind the issue-72
+	// watchdog hangs (hang boots report usbd stack free = 0). A 178B frame on that stack was the single
+	// biggest consumer. Single-writer (SOF drain only), so a static buffer is race-free.
+	static uint8_t p[2 + WB_PAYLEN];
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 12;
+	// protocol version (14 = +landAll87 toggle; 13 = +per-slot link stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 14;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -200,11 +207,28 @@ static void webusbSendBlob()
 	p[136] = (uint8_t)(hlr >> 24);
 	// least-ever free stack (words) on the usbd task (800B total) and loop task -- usbd trending to 0 confirms
 	// the overflow. words, not bytes.
-	uint16_t usbdFree = faultDiagUsbdStackFree(), loopFree = faultDiagLoopStackFree();
+	uint16_t usbdFree = faultDiagUsbdStackFree(),
+		 loopFree = faultDiagLoopStackFree();
 	p[137] = (uint8_t)usbdFree;
 	p[138] = (uint8_t)(usbdFree >> 8);
 	p[139] = (uint8_t)loopFree;
 	p[140] = (uint8_t)(loopFree >> 8);
+	// v13: per-slot link stats -- each controller's own poll/reply/error rates, so the panel's per-slot tabs
+	// no longer conflate every controller into the aggregate numbers above.
+	for (int s = 0; s < NSLOT; s++) {
+		uint8_t *q = &p[141 + s * 9];
+		q[0] = (uint8_t)g_slotPollsps[s];
+		q[1] = (uint8_t)(g_slotPollsps[s] >> 8);
+		q[2] = (uint8_t)g_slotF1ps[s];
+		q[3] = (uint8_t)(g_slotF1ps[s] >> 8);
+		q[4] = (uint8_t)g_slotNewps[s];
+		q[5] = (uint8_t)(g_slotNewps[s] >> 8);
+		q[6] = g_slotCrcps[s];
+		q[7] = g_slotNoRxps[s];
+		q[8] = g_slotRelayps[s];
+	}
+	// v14: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
+	p[177] = g_landAll87;
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -231,7 +255,8 @@ static void webusbSendBondExport()
 {
 	if (!usb_web.connected())
 		return;
-	uint8_t p[2 + WB_BOND_PAYLEN];
+	// static for the same reason as the status blob: keep the 100B frame off the 800B usbd-task stack.
+	static uint8_t p[2 + WB_BOND_PAYLEN];
 	p[0] = 0xA7;
 	p[1] = WB_BOND_PAYLEN;
 	p[2] = 1; // format version
@@ -249,6 +274,113 @@ static void webusbSendBondExport()
 	}
 }
 
+// Stream the saved (pre-reset) flight-recorder trail to the panel as 0xA8 frames. Always compiled -- the
+// recorder is not OPK_LOG-gated (field hangs happen on release builds too). Frame layout mirrors the 0xA6
+// capture drain so the same drop-on-full discipline applies (a write only issues when the FIFO can take the
+// whole frame -> usb_web.write() never spins -> loop() never stalls here):
+//   header: [0xA8][29][T=2][count u16][total u16][loopPerSec u16][stallMs][curStage][usbdStk u16][loopStk u16]
+//                        [heapUsed u32][pollsps u16][relayps u16][crc u8][norx u8][heal u16][ringF u16]
+//   entry:  [0xA8][9][T=1][dtMs u32][evt][stage][arg u16]     (dtMs = ms before the last recorded crumb)
+//   end:    [0xA8][1][T=0]
+// The panel sends 0x10 with buf[1]=1 to (re)start (emits the header + rewinds the cursor), then 0x10 buf[1]=0
+// repeatedly; each call streams a budgeted chunk and the final call appends the end frame.
+#define WB_FLIGHT_HDR 29
+// RAII boost: these drains run from loop() and push many usb_web.write()s per call -- each enters TinyUSB's
+// dcd DMA claim window (the issue-72 livelock; see usb_tx.cpp), so the whole burst runs boosted.
+struct TxBoostScope {
+	TxBoostScope()
+	{
+		usbTxBoost();
+	}
+	~TxBoostScope()
+	{
+		usbTxUnboost();
+	}
+};
+static void webusbDrainFlight(bool restart)
+{
+	if (!usb_web.connected())
+		return;
+	TxBoostScope boost;
+	if (restart) {
+		faultDiagFlightDrainReset();
+		if (tud_vendor_write_available() >= 2 + WB_FLIGHT_HDR) {
+			struct FaultFlightVitals v;
+			bool have = faultDiagFlightVitals(&v);
+			if (!have)
+				memset(&v, 0, sizeof v);
+			uint16_t cnt = faultDiagFlightCount();
+			uint16_t tot = faultDiagFlightTotal();
+			uint8_t f[2 + WB_FLIGHT_HDR];
+			f[0] = 0xA8;
+			f[1] = WB_FLIGHT_HDR;
+			f[2] = 2; // header type
+			f[3] = (uint8_t)cnt;
+			f[4] = (uint8_t)(cnt >> 8);
+			f[5] = (uint8_t)tot;
+			f[6] = (uint8_t)(tot >> 8);
+			f[7] = (uint8_t)v.loopPerSec;
+			f[8] = (uint8_t)(v.loopPerSec >> 8);
+			f[9] = v.stallMs;
+			f[10] = v.curStage;
+			f[11] = (uint8_t)v.usbdStkFree;
+			f[12] = (uint8_t)(v.usbdStkFree >> 8);
+			f[13] = (uint8_t)v.loopStkFree;
+			f[14] = (uint8_t)(v.loopStkFree >> 8);
+			f[15] = (uint8_t)v.heapUsed;
+			f[16] = (uint8_t)(v.heapUsed >> 8);
+			f[17] = (uint8_t)(v.heapUsed >> 16);
+			f[18] = (uint8_t)(v.heapUsed >> 24);
+			f[19] = (uint8_t)v.pollsps;
+			f[20] = (uint8_t)(v.pollsps >> 8);
+			f[21] = (uint8_t)v.relayps;
+			f[22] = (uint8_t)(v.relayps >> 8);
+			f[23] = (uint8_t)(v.crcps > 255 ? 255 : v.crcps);
+			f[24] = (uint8_t)(v.norxps > 255 ? 255 : v.norxps);
+			f[25] = (uint8_t)v.rfHeal;
+			f[26] = (uint8_t)(v.rfHeal >> 8);
+			f[27] = (uint8_t)v.ringFault;
+			f[28] = (uint8_t)(v.ringFault >> 8);
+			f[29] = (uint8_t)0; // reserved / pad to header len
+			f[30] = (uint8_t)0;
+			usb_web.write(f, sizeof f);
+			usb_web.flush();
+		}
+	}
+	uint32_t dt = 0;
+	uint8_t evt = 0, stage = 0;
+	uint16_t arg = 0;
+	uint16_t budget = 64;
+	bool done = false;
+	while (budget--) {
+		if (tud_vendor_write_available() < 2 + 9)
+			break; // FIFO full -> resume on the panel's next 0x10 poll
+		if (!faultDiagFlightPull(&dt, &evt, &stage, &arg)) {
+			done = true;
+			break;
+		}
+		uint8_t f[2 + 9];
+		f[0] = 0xA8;
+		f[1] = 9;
+		f[2] = 1; // entry type
+		f[3] = (uint8_t)dt;
+		f[4] = (uint8_t)(dt >> 8);
+		f[5] = (uint8_t)(dt >> 16);
+		f[6] = (uint8_t)(dt >> 24);
+		f[7] = evt;
+		f[8] = stage;
+		f[9] = (uint8_t)arg;
+		f[10] = (uint8_t)(arg >> 8);
+		usb_web.write(f, sizeof f);
+		usb_web.flush();
+	}
+	if (done && tud_vendor_write_available() >= 3) {
+		uint8_t e[3] = { 0xA8, 1, 0 };
+		usb_web.write(e, 3);
+		usb_web.flush();
+	}
+}
+
 // Runs on the usbd task (registered via usbTxRegisterDrain -> tud_sof_cb). Sends the blob if loop() asked for
 // one. Keeps every usb_web write/flush off the loop task so it can't block on the device event queue.
 static void webusbSofDrain(void)
@@ -267,11 +399,49 @@ static void webusbSofDrain(void)
 		webusbSendBondExport();
 	}
 }
+// ---- live wedge reporter (0xA9) --------------------------------------------------------------------------
+// THE one channel that survives a loop() wedge on boards that wipe .noinit/GPREGRET across the watchdog reset
+// (Teyleten): tud_sof_cb runs on the usbd task, which is HIGHER priority than the loop() task, so the USB
+// stack keeps dispatching it even while loop() is wedged (spinning or blocked) -- as long as interrupts aren't
+// hard-masked (PRIMASK). When loop() has been stuck a while, emit a tiny frame carrying the stuck loop stage so
+// the panel can log WHERE it hung, live, during the ~8s before the watchdog fires. Zero traffic when healthy
+// (returns immediately unless stalled); rate-limited; drop-on-full so it never spins the usbd task.
+//   frame: [0xA9][3][stuck stage][stallMs lo][stallMs hi]
+extern "C" void tud_sof_cb(uint32_t frame_count)
+{
+	(void)frame_count;
+	uint32_t stall = faultDiagStallMs();
+	if (stall <
+	    1000) // loop() healthy -> nothing to report (the common case: near-zero overhead)
+		return;
+	static uint32_t lastMs = 0;
+	uint32_t now = millis();
+	if ((uint32_t)(now - lastMs) < 200) // ~5 Hz cap
+		return;
+	lastMs = now;
+	if (!usb_web.connected())
+		return;
+	uint8_t f[2 + 3];
+	f[0] = 0xA9;
+	f[1] = 3;
+	f[2] = faultDiagCurStage();
+	uint16_t s = (stall > 0xFFFFu) ? 0xFFFFu : (uint16_t)stall;
+	f[3] = (uint8_t)s;
+	f[4] = (uint8_t)(s >> 8);
+	if (tud_vendor_write_available() >= sizeof f) {
+		usb_web.write(f, sizeof f);
+		usb_web.flush();
+	}
+}
+
 // Register the SOF drain. Call once from setup() (harmless even if WebUSB isn't enumerated -- webusbSendBlob
 // no-ops while disconnected, and g_blobRequest is only ever set by panel traffic, which needs a connection).
 void webusbInit(void)
 {
 	usbTxRegisterDrain(webusbSofDrain);
+	// Enable SOF-callback dispatch so tud_sof_cb() above fires -- our only live view into a loop() wedge on
+	// boards whose bootloader wipes retained RAM. Cheap: the callback no-ops unless loop() is already stalled.
+	tud_sof_cb_enable(true);
 }
 #if OPK_LOG
 // Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames. Frame formats:
@@ -311,6 +481,8 @@ static void webusbDrainCapture()
 {
 	if (!usb_web.connected())
 		return;
+	TxBoostScope
+		boost; // capture streams at flood rate through the same dcd DMA window
 	uint32_t ms = 0;
 	uint8_t slot = 0, rid = 0, nb = 0, b[16];
 	uint16_t budget = 128;
@@ -341,18 +513,20 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if (op < 0x01 || op > 0x0F) { // resync: drop one byte
+			if (op < 0x01 || op > 0x10) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
-			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x05/0x0E/0x0F carry
+			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x05/0x0E/0x0F/0x10 carry
 			// one value byte; 0x02 a field+value; 0x0A a 3-byte magic.
-			uint8_t need = (op == 0x0D)		      ? 27 :
-				       (op == 0x02)		      ? 3 :
+			uint8_t need = (op == 0x0D) ? 27 :
+				       (op == 0x02) ? 3 :
 				       (op == 0x03 || op == 0x05 ||
-					op == 0x0E || op == 0x0F)     ? 2 :
-				       (op == 0x0A)		      ? 4 :
-								        1;
+					op == 0x0E || op == 0x0F ||
+					op == 0x10) ?
+						      2 :
+				       (op == 0x0A) ? 4 :
+						      1;
 			if (n < need)
 				break; // wait for more bytes
 			if (op == 0x01) {
@@ -373,6 +547,12 @@ void webusbPoll()
 				g_e7b = 0; // byte2=0 => host-awake
 				g_e7announce = g_stabTest;
 			}
+			// 0x10: stream the saved flight-recorder trail (0xA8 frames). buf[1]=1 restarts (header + rewind),
+			// buf[1]=0 continues the drain. Drained straight from loop context like the 0x06 capture drain --
+			// webusbDrainFlight is drop-on-full so it never blocks. Always available (recorder isn't OPK_LOG).
+			else if (op == 0x10) {
+				webusbDrainFlight(buf[1] != 0);
+			}
 #if OPK_LOG
 
 			// rewind drain: buf[1]=1 from boot (whole ring), 0 from now
@@ -429,8 +609,8 @@ void webusbPoll()
 				uint8_t slot = buf[1], used = buf[2];
 				if (slot < NSLOT) {
 					if (used && !recEmpty(buf + 3)) {
-						memcpy(g_slot[slot].rec, buf + 3,
-						       24);
+						memcpy(g_slot[slot].rec,
+						       buf + 3, 24);
 						g_slot[slot].used = true;
 					} else {
 						memset(g_slot[slot].rec, 0, 24);
@@ -590,8 +770,14 @@ void webusbPoll()
 					persist = false;
 					break; // Switch Pro gyro scale x10
 
-				// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
-				// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
+					// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
+					// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
+
+				// EXPERIMENT: land ALL relayed 0x87 config verbatim (real-puck relay)
+				// instead of the discard-whitelist. Persisted; blob p[177] reflects state.
+				case 29:
+					g_landAll87 = v ? 1 : 0;
+					break;
 				}
 				if (persist)
 					saveCfg();

+ 254 - 18
docs/index.html

@@ -65,6 +65,7 @@
   <span id="connState" class="pill dn">disconnected</span>
   <button id="connectBtn" class="primary">Connect</button>
   <span class="spacer"></span>
+  <button id="debugCdc" title="Reboot ONCE with the CDC serial console enabled (WebUSB goes away for that boot); auto-reverts on the next boot">Debug CDC</button>
   <button id="dfuSerial" title="Reboot into serial DFU (adafruit-nrfutil), then replug">Serial DFU</button>
   <button id="dfuUf2" title="Reboot into the UF2 mass-storage bootloader; drop a .uf2 on the drive">UF2 DFU</button>
   <button id="factoryErase" class="danger" title="Wipe ALL bonds + settings and reboot to defaults. You must re-pair. Cannot be undone.">⚠ Factory erase</button>
@@ -107,19 +108,22 @@
       <!-- Per-controller tabs: visible only when 2+ controllers are bonded -->
       <div class="slot-tabs" id="slotTabs" style="display:none"></div>
 
-      <!-- Per-slot: RF link / Battery / Signal -->
+      <!-- Per-slot: RF link / Battery / Signal / this controller's own link rates (blob v13) -->
       <div class="statgrid per-slot-grid">
         <div class="stat"><div class="k">RF link</div><div class="v" id="stLink">—</div></div>
         <div class="stat"><div class="k">Battery</div><div class="v" id="stBatt">—</div></div>
         <div class="stat"><div class="k">Signal</div><div class="v" id="stRssi">—</div></div>
+        <div class="stat"><div class="k">Polls/s</div><div class="v" id="stSlotPolls">—</div></div>
+        <div class="stat"><div class="k">Delivered</div><div class="v" id="stSlotF1">—</div></div>
+        <div class="stat"><div class="k">Fails/s (crc·noRx·relay)</div><div class="v" id="stSlotFail">—</div></div>
       </div>
 
-      <!-- Global stats -->
+      <!-- Global stats (sums over ALL controllers; the per-slot grid above shows the selected controller's own rates) -->
       <div class="statgrid">
-        <div class="stat"><div class="k">Delivered</div><div class="v" id="stRate">—</div></div>
-        <div class="stat"><div class="k">New reports</div><div class="v" id="stNew">—</div></div>
-        <div class="stat"><div class="k">Polls/s</div><div class="v" id="stPolls">—</div></div>
-        <div class="stat"><div class="k">Relay/s</div><div class="v" id="stRelay">—</div></div>
+        <div class="stat"><div class="k">Delivered (all)</div><div class="v" id="stRate">—</div></div>
+        <div class="stat"><div class="k">New reports (all)</div><div class="v" id="stNew">—</div></div>
+        <div class="stat"><div class="k">Polls/s (all)</div><div class="v" id="stPolls">—</div></div>
+        <div class="stat"><div class="k">Relay/s (all)</div><div class="v" id="stRelay">—</div></div>
         <div class="stat"><div class="k">RF fails/s (crc·noRx·heal)</div><div class="v" id="stRfFail">—</div></div>
         <div class="stat"><div class="k">Clock LF / HF</div><div class="v" id="stClock">—</div></div>
         <div class="stat"><div class="k">µs per ms (ideal 1000)</div><div class="v" id="stUsPerMs">—</div></div>
@@ -136,13 +140,16 @@
       <p class="note" style="margin-top:8px"><strong>IMU (raw):</strong> <span id="stImu" style="font-family:ui-monospace,Menlo,monospace">—</span><br>
         Raw SC2 accel (before scaling). This controller is a ±2 g sensor (~16384 = 1 g); the Switch report divides it by 4 to present the genuine ±8 g (4096 = 1 g) scale so the console's gravity-correction engages and the gyro stops drifting.</p>
       <p class="note logonly">Polls/s ≈ 250 expected; it's capped by Loop period (≈4000 µs needed). "Slowest stage" is the loop section eating the most time per iteration.</p>
-      <div class="row" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="hapClear">Clear stuck buzz</button>
+      <div class="row debugonly" style="margin:14px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="hapClear">Clear stuck buzz</button>
         <span class="note" style="flex:1">If the controller's haptics get stuck/buzzing, this re-inits them (same as pressing Steam).</span>
       </div>
       <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
         <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path Steam's "turn off controller" and host-suspend use. Watch the link status to see it disconnect.</span>
       </div>
-      <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
+      <div class="row debugonly" style="margin:10px 0 0"><button id="landAll87">off</button>
+        <span class="note" style="flex:1"><strong>Land all 0x87 (experiment).</strong> Relay Steam's full haptic/settings config to the controller verbatim (like the real puck) instead of discarding it. Testing whether the connect buzz is caused by the controller's haptic engine being left misconfigured. If the <em>gyro freezes</em> in Steam, turn this back off.</span>
+      </div>
+      <div class="row debugonly" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
         <span class="v" id="stabStatus" style="flex:0 0 auto;min-width:120px">—</span>
         <span class="note" style="flex:1">Buzzes the controllers every 10 s (keeps them awake) and times how long the puck stays up. On a reset it records the uptime and shows it by <em>Last reset</em>; it auto-resumes after the puck reconnects. The count is kept in this tab (a page refresh clears it).</span>
       </div>
@@ -186,7 +193,7 @@
       <p class="note">Import <strong>overwrites</strong> all pairings and settings on the connected puck, then reboots it (~2 s — reconnect after). Tip: pair every controller to one puck, export once, then import onto each spare.</p>
     </div>
 
-    <div class="card wide">
+    <div class="card wide debugonly">
       <h2>Hang log</h2>
       <p class="note" style="margin-top:0">One row per reset: uptime before it, reason, the stuck loop stage and captured PC (when the watchdog ISR could run), and usbd stack free. Accumulates across resets in this tab — a page refresh clears it. Pair with <em>Test stability</em> above for unattended runs.</p>
       <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
@@ -197,6 +204,26 @@
       <pre id="hangOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin:0;max-height:260px;overflow:auto;white-space:pre"></pre>
     </div>
 
+    <div class="card wide debugonly">
+      <h2>Loop-state trail</h2>
+      <p class="note" style="margin-top:0">Every change of the <em>Loop</em> pill, timestamped — stall episodes, live wedge reports, heartbeat loss (hard wedge, USB silent), recoveries, disconnects and the reset cause on reconnect — so an unattended hang is recorded even if you weren't watching. Saved in this browser (localStorage): it <strong>survives page refreshes</strong> and device reboots, and clears only with the button.</p>
+      <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
+        <span class="v" id="trailSummary" style="flex:1">no events yet</span>
+        <button id="trailClear">Clear</button>
+      </div>
+      <pre id="trailOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin:0;max-height:260px;overflow:auto;white-space:pre-wrap">—</pre>
+    </div>
+
+    <div class="card wide debugonly">
+      <h2>Flight recorder</h2>
+      <p class="note" style="margin-top:0">The trail of events the firmware recorded in the seconds before its most recent <b>watchdog hang</b> — the ring survives the reset in <code>.noinit</code> RAM (like the hang PC), so it's the post-mortem of what wedged the board. Loads automatically after a hang reconnects; click to reload. Same data the console <code>FR</code> command prints. <span id="flightWedge" style="font-family:ui-monospace,Menlo,monospace"></span></p>
+      <div class="row" style="margin:0 0 8px;flex-wrap:wrap">
+        <span class="v" id="flightSummary" style="flex:1">no trail loaded</span>
+        <button id="flightLoad">Load last-hang trail</button>
+      </div>
+      <pre id="flightOut" style="font:12px ui-monospace,Menlo,monospace;color:var(--fg);background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:10px;margin:0;max-height:320px;overflow:auto;white-space:pre">—</pre>
+    </div>
+
     <div class="card wide logonly">
       <h2>Capture — host → controller commands</h2>
       <p class="note" style="margin-top:0">The firmware logs everything from boot into a big RAM ring: Steam's writes (<code>ifN</code>), the frames we transmit to the controller (<code>TX→ctlr</code>), and RF <code>LINK UP/DOWN</code> edges. For the reconnect buzz: trigger it (it appears moments after the puck boots / the controller reconnects), then connect here and click <b>Dump from boot</b> — the trigger is still in the ring. (Only present in a logging build, <code>-DOPK_LOG=1</code>.)</p>
@@ -249,8 +276,36 @@ function updateStabUI(){
 }
 setInterval(updateStabUI, 500);
 const $ = s=>document.querySelector(s);
+// Debug UI gate: developer/diagnostic controls (stability test, clear-stuck-buzz, land-all-0x87) and the
+// forensic cards (Hang log, Loop-state trail, Flight recorder) only show with ?debug=true in the URL.
+// Everything still RUNS regardless (the trail keeps recording to localStorage, stability auto-resume works)
+// -- only the UI is hidden, so adding ?debug=true after an unattended incident still shows the history.
+const DEBUG_UI = new URLSearchParams(location.search).get("debug")==="true";
+if(!DEBUG_UI) for(const el of document.querySelectorAll(".debugonly")) el.style.display="none";
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
+// ---- Loop-state trail (persistent) ----
+// Every transition of the Loop pill lands here with a wall-clock timestamp, persisted in localStorage --
+// unlike the pill (live-only) and the in-page logs (a refresh wipes them), this survives page refreshes and
+// device reboots, so a hang that happened while nobody was watching is still on record. Newest first, capped;
+// cleared only by the Clear button.
+const TRAIL_KEY="opk_loop_trail", TRAIL_MAX=400;
+let loopTrail=[]; try{ loopTrail=JSON.parse(localStorage.getItem(TRAIL_KEY))||[]; }catch(e){ loopTrail=[]; }
+function trailAdd(m){
+  loopTrail.unshift({t:Date.now(), m});
+  if(loopTrail.length>TRAIL_MAX) loopTrail.length=TRAIL_MAX;
+  try{ localStorage.setItem(TRAIL_KEY, JSON.stringify(loopTrail)); }catch(e){}
+  renderTrail();
+}
+function renderTrail(){
+  const out=$("#trailOut"), sum=$("#trailSummary"); if(!out) return;
+  sum.textContent = loopTrail.length ? (loopTrail.length+" event(s) — newest first") : "no events yet";
+  out.textContent = loopTrail.length
+    ? loopTrail.map(e=>new Date(e.t).toLocaleString()+"  "+e.m).join("\n") : "—";
+}
+$("#trailClear").onclick=()=>{ loopTrail=[]; try{ localStorage.removeItem(TRAIL_KEY); }catch(e){} renderTrail(); log("loop-state trail cleared"); };
+renderTrail();
+
 // Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
 // only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*8 + k
 // (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics, 7 LED brightness). Blob v10 reads them back at p[73 + et*8 + ...].
@@ -350,10 +405,19 @@ let g_slotData = []; // [{up, battery, rssi}] indexed by slot 0..3, only set for
 
 function updateSlotDisplay(slotIdx) {
   const d = g_slotData[slotIdx];
-  if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—"; return; }
+  if (!d) { $("#stLink").innerHTML="—"; $("#stBatt").textContent="—"; $("#stRssi").textContent="—";
+            $("#stSlotPolls").textContent="—"; $("#stSlotF1").textContent="—"; $("#stSlotFail").textContent="—"; return; }
   $("#stLink").innerHTML = d.up ? '<span class="pill up">up</span>' : '<span class="pill dn">down</span>';
   $("#stBatt").textContent = (d.up && d.battery) ? d.battery+"%" : "—";
   $("#stRssi").textContent = (d.up && d.rssi) ? ("-"+d.rssi+" dBm") : "—";
+  // blob v13: this controller's OWN rates (the global grid's numbers are the sum over all controllers)
+  if (d.stats) {
+    $("#stSlotPolls").textContent = d.stats.polls+" /s";
+    $("#stSlotF1").textContent = d.up ? (d.stats.f1+" /s ("+d.stats.newps+" new)") : "—";
+    $("#stSlotFail").textContent = d.stats.crc+" · "+d.stats.norx+" · "+d.stats.relay;
+  } else {
+    $("#stSlotPolls").textContent="—"; $("#stSlotF1").textContent="—"; $("#stSlotFail").textContent="—";
+  }
 }
 
 function renderSlotTabs(bondedCount) {
@@ -428,8 +492,20 @@ function onGone(){
   // (kept in-page so it survives the reset + reconnect, shown next to Last reset).
   let up=null;
   if(stabArmed && stabStart){ up=(Date.now()-stabStart)/1000; stabLastRun={secs:up}; log("⏱ stability: stayed up "+fmtDur(up)+", then reset"); stabStart=0; }
+  // if the PREVIOUS reset was never classified (no full blob landed between two resets), flush it as
+  // "unclassified" instead of silently overwriting it -- every reset must leave a row.
+  if(pendingHang){
+    hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
+      reason:"unclassified", stage:"", pc:"", lr:"", usbd:null });
+    renderHangLog();
+    trailAdd("note: the previous reset was never classified — no full status blob arrived before this disconnect");
+  }
   // queue a hang-log entry; the reason/PC/stack are filled from the first blob after reconnect
   pendingHang = { t: Date.now(), uptimeSecs: up };
+  trailAdd("device disconnected"+(up!=null?(" after "+fmtDur(up)+" up"):"")+" — reset or replug; reason logged on reconnect");
+  window._flightAuto=false; // re-arm the one-shot flight-trail auto-load for the next reconnect
+  window._wedgeEp=false; window._wedgePeak=0; // re-arm the live wedge logger for the next wedge episode
+  window._lastBlobTs=0; window._hbLostEp=false; // re-arm the heartbeat watchdog (don't fire on a stale pre-reset timestamp)
   polling=false; dev=null; tabInited=false; // re-default the config tab to the mode on next connect
   $("#connState").textContent="reconnecting…"; $("#connState").className="pill dn";
   $("#panel").classList.add("hide");
@@ -455,15 +531,26 @@ async function readFrame(marker, minLen){
     const r = await dev.transferIn(epIn, 192);
     if(r.status!=="ok"||r.data.byteLength<2) return null;
     const d=new Uint8Array(r.data.buffer);
+    // Live wedge reporter (0xA9): emitted from the firmware's SOF callback (usbd task) while loop() is stalled --
+    // the ONLY signal that survives a loop wedge on boards that wipe retained RAM across the reset. Scan for it
+    // in every read so it surfaces regardless of which frame we were after. Payload: [stage][stallMs u16].
+    for(let w=0; w+4<d.length; w++){
+      if(d[w]===0xA9 && d[w+1]===3){ onWedge(d[w+2], d[w+3]|(d[w+4]<<8)); break; }
+    }
     let i=0; while(i<d.length && d[i]!==marker) i++;
     if(i+2>d.length) return null;
-    const len=d[i+1]; const p=d.slice(i+2, i+2+len);
+    const len=d[i+1];
+    // truncated read (transfer ended mid-frame): applying a half blob silently skips every late field --
+    // reset cause, pendingHang classification, stack stats -- so drop the whole frame and retry next poll.
+    if(i+2+len>d.length) return null;
+    const p=d.slice(i+2, i+2+len);
     return p.length>=(minLen||0) ? p : null;
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 async function readBlob(){ return readFrame(0xA5, 12); }
 function applyBlob(p){
   lastP=p; // remember for Export (carries every config field the panel shows)
+  window._lastBlobTs=Date.now(); // feeds the host-side heartbeat watchdog (hard-wedge detection)
   const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
   const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
@@ -526,6 +613,16 @@ function applyBlob(p){
     for (let s=0; s<4; s++) { if (!bondedSet.has(s)) g_slotData[s] = null; }
     // clamp active slot to a valid bonded slot
     if (!g_slotData[g_activeSlot]) g_activeSlot = bondedSlots[0] || 0;
+    // v13: per-slot link stats (fw p[141..176] -> js index 139..174): each controller's own
+    // polls/delivered/new/crc/noRx/relay, so the tabs no longer show every controller merged into one number
+    if (p.length >= 175) {
+      for (let s=0; s<4; s++) {
+        if (!g_slotData[s]) continue;
+        const b = 139 + s*9;
+        g_slotData[s].stats = { polls: p[b]|(p[b+1]<<8), f1: p[b+2]|(p[b+3]<<8),
+                                newps: p[b+4]|(p[b+5]<<8), crc: p[b+6], norx: p[b+7], relay: p[b+8] };
+      }
+    }
     renderSlotTabs(bondedN);
   } else {
     // old firmware: single-slot display
@@ -566,6 +663,7 @@ function applyBlob(p){
     const cur=p[123], stallMs=p[124]*40, name=(STAGE[cur]||("stage "+cur));
     if(stallMs>=200){
       $("#stLoopState").innerHTML='<span class="pill dn">STALLED @ '+name+' '+stallMs+'ms</span>';
+      if(!window._stallEpisode) trailAdd("STALLED @ "+name+" ("+stallMs+"ms, soft wedge — usbd still alive)");
       // Latch+log the stall so it survives the imminent watchdog reset + panel reconnect (host-side log
       // persists). Log once per episode + keep escalating if it climbs toward the ~8s watchdog.
       if(!window._stallEpisode || stallMs>window._stallPeak+800){
@@ -573,6 +671,7 @@ function applyBlob(p){
         log("⚠ LOOP STALL @ "+name+" ("+stallMs+"ms) — watchdog reset imminent");
       }
     } else {
+      if(window._stallEpisode) trailAdd("running again — stall recovered without a reset (peaked "+window._stallPeak+"ms)");
       window._stallEpisode=false; window._stallPeak=0;
       $("#stLoopState").innerHTML='<span class="pill up">running</span>';
     }
@@ -618,8 +717,26 @@ function applyBlob(p){
       hangLog.unshift({ time:new Date(pendingHang.t).toLocaleTimeString(), uptime:pendingHang.uptimeSecs,
         reason:name, stage:(hangStage!==0xFF)?(STAGE_NAMES[hangStage]||("stage"+hangStage)):"",
         pc:hpc?hx(hpc):"", lr:hlr?hx(hlr):"", usbd:usbdW2 });
+      trailAdd("reconnected — last reset: "+name
+        +(hangStage!==0xFF?(" @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage))):"")
+        +(hpc?(" PC="+hx(hpc)):"")
+        +(pendingHang.uptimeSecs!=null?("  (up "+fmtDur(pendingHang.uptimeSecs)+" before it)"):""));
+      try{ localStorage.setItem("opk_trail_rrfp", code+"/"+raw.toString(16)+"/"+hangStage+"/"+hpc.toString(16)); }catch(e){}
       pendingHang=null; renderHangLog();
     }
+    // Page wasn't open when it reset (refresh / closed browser): a fault-class boot still lands in the trail,
+    // once per distinct reset -- fingerprinted + persisted so every later page load doesn't re-log the same one.
+    else if (RR_FAULT.has(code)){
+      const fp=code+"/"+raw.toString(16)+"/"+hangStage+"/"+hpc.toString(16);
+      if(localStorage.getItem("opk_trail_rrfp")!==fp){
+        try{ localStorage.setItem("opk_trail_rrfp", fp); }catch(e){}
+        trailAdd("connected — last boot was a "+label+(hpc?(" PC="+hx(hpc)):"")+" (page wasn't open to catch it live)");
+      }
+    }
+    // Auto-load the flight-recorder trail once per connection when the last boot was a real crash (watchdog/
+    // lockup/hardfault) -- the trail is the post-mortem of what wedged the board. Deferred to the poll loop so
+    // it doesn't nest transferIn inside this refresh.
+    if (RR_FAULT.has(code) && !window._flightAuto){ window._flightAuto=true; window._autoFlight=true; }
   }
 
   const gitDirty=(p.length>38?p[38]:0); let gitHash="";
@@ -629,6 +746,8 @@ function applyBlob(p){
     : "—";
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
+  // land-all-0x87 experiment toggle (blob v14, firmware p[177] -> JS p[175])
+  { const la=(p.length>175?p[175]:0); $("#landAll87").textContent = la?"on":"off"; $("#landAll87").classList.toggle("active",!!la); }
   // per-type button config (blob v10): 4 types x 8 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
   // the firmware's buffer index 75 lands at JS index 73 here.
   if(p.length>=73+TYPE_DEFS.length*8){
@@ -661,9 +780,12 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight||capturing||backupBusy) return; inflight=true;
-  await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
-  inflight=false;
+  if(!dev||inflight||capturing||backupBusy||flightBusy) return; inflight=true;
+  // try/finally: an exception anywhere in here (applyBlob included) must never leave inflight latched true --
+  // that would silently kill all future polls and read as a fake "heartbeat lost".
+  try{ await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
+  catch(e){ log("refresh err: "+e.message); }
+  finally{ inflight=false; }
 }
 
 // ---- Backup / clone ----
@@ -811,6 +933,109 @@ async function drainTick(){
   draining=false;
   if(liveTimer){ const el=$("#capOut"); const atBottom=el.scrollTop+el.clientHeight>=el.scrollHeight-4; renderCap(); if(atBottom) el.scrollTop=el.scrollHeight; }
 }
+// ---- Live wedge reporter (0xA9) ----
+// The firmware's SOF callback streams this while loop() is stuck. On boards that wipe retained RAM across the
+// watchdog reset (so the flight recorder / hang stage come back blank), this is the only way to see WHERE it
+// hung -- captured live, over the panel, in the ~8s before the watchdog fires. Log once per wedge episode.
+const WEDGE_STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
+function onWedge(stage, stallMs){
+  const name=WEDGE_STAGE[stage]||("stage "+stage);
+  if(!window._wedgeEp) trailAdd("WEDGED @ "+name+" ("+stallMs+"ms — live 0xA9 report; watchdog reset imminent)");
+  if(!window._wedgeEp || stallMs > (window._wedgePeak||0)+500){
+    window._wedgeEp=true; window._wedgePeak=stallMs;
+    log("🛑 LOOP WEDGED @ "+name+" (stuck "+stallMs+"ms) — this is where it hangs; watchdog reset imminent");
+    const el=$("#flightWedge"); if(el) el.innerHTML=' &nbsp;<span class="pill dn">last wedge: '+name+' ('+stallMs+'ms)</span>';
+  }
+}
+// ---- Host-side heartbeat watchdog (hard-wedge detection) ----
+// The STALLED pill and the 0xA9 reporter both rely on the puck still SENDING while loop() is wedged -- which
+// only holds for soft wedges where the usbd task + SOF IRQ stay alive. The LOCKUP/hardfault-class hangs (#72)
+// kill the whole MCU: USB goes silent, transferIn just pends, and the panel would freeze forever showing the
+// last state it rendered ("running"). Only the host can see that class of hang, so detect it here: connected
+// but no status blob for >2.5s (poll cadence is 600ms) => flip the pill and log the episode. The capture /
+// backup / flight paths pause the blob poll legitimately, so they're excluded.
+setInterval(()=>{
+  if(!dev || !window._lastBlobTs || capturing || backupBusy || flightBusy) return;
+  const age=Date.now()-window._lastBlobTs;
+  if(age>2500){
+    const secs=Math.round(age/1000);
+    $("#stLoopState").innerHTML='<span class="pill dn">NO HEARTBEAT '+secs+'s — hard wedge</span>';
+    if(!window._hbLostEp){ window._hbLostEp=true;
+      log("🛑 heartbeat lost — no status blob for "+secs+"s: hard wedge (USB stack dead too, so the live wedge reporter can't run) — check the flight trail after the reset");
+      trailAdd("HEARTBEAT LOST — no status for "+secs+"s (hard wedge: USB silent, whole MCU likely stopped)");
+      const el=$("#flightWedge"); if(el) el.innerHTML=' &nbsp;<span class="pill dn">last wedge: hard (no heartbeat)</span>';
+    }
+  } else if(window._hbLostEp){ window._hbLostEp=false;
+    trailAdd("heartbeat back — status blobs resumed without a reset");
+  }
+}, 1000);
+// ---- Flight recorder (0xA8 stream) ----
+// The firmware keeps a .noinit ring of the last high-level events (usbd SET/GET, relays, RF up/down, radio
+// self-heal, USB mount/suspend, bond flash writes, per-250ms heartbeats) that survives a watchdog reset. On
+// 0x10 it streams them: one header (T=2, vitals@wedge + counts), then entries (T=1), then an end (T=0). We
+// pump 0x10 (restart) then 0x10 (continue) until the end frame, accumulating across FIFO-sized batches.
+let flightBusy=false, flightHdr=null, flightEvents=[];
+const FR_EVT=["none","beat","SET","GET","relay","rf-up","rf-DN","HEAL!","mount","SUSPEND","resume","OFF","RINGF","save"];
+function frEvtName(e){ return FR_EVT[e]!==undefined?FR_EVT[e]:("evt"+e); }
+async function loadFlightTrail(){
+  if(!dev||flightBusy) return; flightBusy=true;
+  $("#flightSummary").textContent="loading…";
+  flightHdr=null; flightEvents=[];
+  const STAGE_NAMES=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
+  try{
+    let restart=true, done=false, guard=0, acc=new Uint8Array(0);
+    while(!done && guard++<600){
+      await send([0x10, restart?1:0]); restart=false;
+      const r=await dev.transferIn(epIn,192);
+      if(r.status!=="ok") break;
+      const d=new Uint8Array(r.data.buffer);
+      const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
+      let i=0;
+      while(i<acc.length){
+        if(acc[i]!==0xA8){ i++; continue; }
+        if(i+2>acc.length) break;
+        const L=acc[i+1]; if(i+2+L>acc.length) break;
+        const f=acc.slice(i+2,i+2+L); i+=2+L;
+        const T=f[0];
+        if(T===0){ done=true; break; }
+        else if(T===2 && L>=27){
+          flightHdr={ count:f[1]|(f[2]<<8), total:f[3]|(f[4]<<8),
+            loopPerSec:f[5]|(f[6]<<8), stallMs:f[7], stage:f[8],
+            usbdStk:f[9]|(f[10]<<8), loopStk:f[11]|(f[12]<<8),
+            heap:((f[13]|(f[14]<<8)|(f[15]<<16)|(f[16]<<24))>>>0),
+            pollsps:f[17]|(f[18]<<8), relayps:f[19]|(f[20]<<8),
+            crc:f[21], norx:f[22], heal:f[23]|(f[24]<<8), ringF:f[25]|(f[26]<<8) };
+        } else if(T===1 && L>=9){
+          const dt=((f[1]|(f[2]<<8)|(f[3]<<16)|(f[4]<<24))>>>0);
+          flightEvents.push({dt, evt:f[5], stage:f[6], arg:f[7]|(f[8]<<8)});
+        }
+      }
+      acc=acc.slice(i);
+    }
+  }catch(e){ log("flight err: "+e.message); }
+  flightBusy=false;
+  renderFlight(STAGE_NAMES);
+}
+function renderFlight(STAGE_NAMES){
+  const h=flightHdr, out=$("#flightOut"), sum=$("#flightSummary"), wedge=$("#flightWedge");
+  const stage=s=>(STAGE_NAMES[s]||("st"+s));
+  if(!h || (h.count===0 && flightEvents.length===0)){
+    sum.textContent="no trail — last boot wasn't a hang, or the recorder didn't survive this board's reset";
+    wedge.textContent=""; out.textContent="—"; return;
+  }
+  const hx=v=>"0x"+v.toString(16).padStart(4,"0");
+  sum.textContent="showing "+flightEvents.length+" of "+h.total+" events before the last hang";
+  // usbd stack free trending to 0 is the leading overflow suspect -> flag it inline.
+  const usbdFlag=(h.usbdStk>0 && h.usbdStk<16)?" ⚠LOW":"";
+  wedge.innerHTML=" &nbsp;<b>@wedge:</b> stuck in "+stage(h.stage)+", stall "+h.stallMs+"ms, loop "+h.loopPerSec+"/s"
+    +" · usbdStk "+h.usbdStk+"w"+usbdFlag+" · loopStk "+h.loopStk+"w · heap "+h.heap+"B"
+    +" · poll "+h.pollsps+" relay "+h.relayps+" crc "+h.crc+" norx "+h.norx+" heal "+h.heal+" ringF "+h.ringF;
+  const pad=(s,n)=>(""+s).padEnd(n);
+  out.textContent =
+    pad("Δms",8)+pad("event",9)+pad("stage",10)+"arg\n"
+    +"".padEnd(30,"─")+"\n"
+    + flightEvents.map(e=>pad("-"+e.dt,8)+pad(frEvtName(e.evt),9)+pad(stage(e.stage),10)+hx(e.arg)).join("\n");
+}
 async function startCapture(){
   if(!dev||capturing) return;
   capturing=true; capLines=[];
@@ -829,7 +1054,10 @@ async function stopCapture(){
 }
 async function startPolling(){
   polling=true; await refresh();
-  while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
+  while(polling && dev){
+    await new Promise(r=>setTimeout(r,600)); await refresh();
+    if(window._autoFlight){ window._autoFlight=false; await loadFlightTrail(); }
+  }
 }
 
 // UI wiring
@@ -843,11 +1071,12 @@ $("#capStop").onclick=stopCapture;
 $("#stabBtn").onclick=async()=>{
   if(!dev){ log("connect first"); return; }
   stabArmed=!stabArmed;
-  if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); stabLastRun=null; log("stability test STARTED — buzzing every 10s, timing uptime until reset"); }
-  else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); }
+  if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); stabLastRun=null; log("stability test STARTED — buzzing every 10s, timing uptime until reset"); trailAdd("stability test started (buzz every 10s)"); }
+  else { await send([0x0F,0]); stabStart=0; log("stability test stopped"); trailAdd("stability test stopped"); }
   updateStabUI();
 };
 $("#hangClear").onclick=()=>{ hangLog=[]; renderHangLog(); };
+$("#flightLoad").onclick=loadFlightTrail;
 $("#hangCsv").onclick=()=>{
   const rows=[["time","uptime_s","reason","stage","pc","lr","usbd_free_words"]].concat(
     hangLog.map(e=>[e.time, e.uptime!=null?Math.round(e.uptime):"", e.reason, e.stage, e.pc, e.lr, e.usbd]));
@@ -856,7 +1085,13 @@ $("#hangCsv").onclick=()=>{
   a.download="openpuck-hanglog.csv"; a.click();
 };
 $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
-$("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
+$("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status");
+  trailAdd("power-off sent to controller (panel button) — stress action, correlate with what follows"); } };
+$("#debugCdc").onclick=async()=>{
+  if(!dev){ log("not connected"); return; }
+  if(!confirm("Reboot with the CDC serial console enabled?\n\nThe puck reboots and comes back with a serial port (115200 baud) instead of WebUSB — this panel will disconnect and NOT reconnect until the next reboot.\n\nConnect a serial monitor to capture logs, then replug (or reboot) to return to normal WebUSB mode. The debug console auto-reverts after one boot.")) return;
+  await send([0x02,20,1]); log("debug-CDC reboot sent — device disconnecting; reconnect a serial monitor at 115200 baud");
+};
 $("#dfuSerial").onclick=async()=>{
   if(!dev){ log("not connected"); return; }
   if(!confirm("Reboot into serial DFU?\n\nThe puck will disconnect immediately. Flash with adafruit-nrfutil, then replug.")) return;
@@ -877,6 +1112,7 @@ $("#factoryErase").onclick=async()=>{
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
+$("#landAll87").onclick=async()=>{ const on=$("#landAll87").classList.contains("active"); await setField(29, on?0:1); log("land-all-0x87 "+(on?"disabled":"enabled")); };
 // Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
 for(const id of ["mDiv","mFric","rumble"]){
   const field={mDiv:1,mFric:2,rumble:22}[id];