Ver Fonte

stability: hang localization + usbd-stack diagnosis + uptime test harness

Instrumentation to pin down the interrupts-off watchdog hangs seen across
all boards (worse on clones), which correlate with the haptic/relay path
and regressed at multi-controller support.

Diagnostics (protocol still v12, blob extended):
- WDT pre-reset PC capture: top-priority WDT TIMEOUT ISR reads the stacked
  PC/LR of the wedged code into .noinit RAM that survives the reset (the
  clone bootloader wipes GPREGRET2). Reported in the blob + reset detail;
  map with addr2line. No pc reported => the hang hard-masked interrupts.
- Live loop-stall monitor: loop heartbeat + current stage; the SOF-driven
  blob force-sends when loop() stops beating, so a wedge shows "STALLED @
  <stage>" live even though loop is dead. Panel latches/logs it.
- Per-task stack headroom via uxTaskGetSystemState: the usbd task is a fixed
  800 B and runs handleSet->relayEnqueue; "usbd stack free (words)" trending
  to 0 under haptic load confirms the leading root-cause hypothesis
  (usbd-task stack overflow).
- Clock fingerprint (LFCLK/HFCLK source + us-per-ms) to rule the clocks
  in/out per board.

Stability test harness:
- WebUSB cmd 0x0F toggles a keepalive that buzzes all controllers every 10s
  (so an unattended run isn't ended by idle-sleep). Panel "Test stability"
  button times uptime; on reset it records the run length (in-page, survives
  the reset+reconnect) and shows it next to the last reset reason, then
  auto-resumes after the puck reconnects.

Panel: auto-reconnect without the device picker (getDevices + connect event
+ disconnect retry) so reset-heavy testing needs no clicks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Jari há 3 meses atrás
pai
commit
3a50624935

+ 4 - 0
OpenPuck/OpenPuck.ino

@@ -265,6 +265,8 @@ void setup()
 	NRF_WDT->CRV = 8UL * 32768UL - 1; // timeout in 32.768 kHz ticks (~8 s)
 	NRF_WDT->RREN = WDT_RREN_RR0_Msk; // arm reload register 0
 	NRF_WDT->TASKS_START = 1;
+	// Capture the stuck PC on the WDT's pre-reset interrupt (software stand-in for SWD on the clone hangs).
+	faultDiagArmHangCapture();
 }
 
 // loop-timing diagnostics: poll rate is capped by loop ITERATION time (pacing wants 4000us but the poll only
@@ -282,6 +284,8 @@ void loop()
 	// feed the watchdog; if we ever stop, the ~8s WDT auto-resets us
 	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
+	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) {

+ 100 - 0
OpenPuck/fault_diag.cpp

@@ -1,5 +1,8 @@
 #include "fault_diag.h"
 #include <Arduino.h> // readResetReason(), NRF_POWER, NVIC_SystemReset, POWER_RESETREAS_*_Msk
+#include <FreeRTOS.h>
+#include <task.h> // uxTaskGetSystemState -> per-task stack high-water (overflow hypothesis check)
+#include <string.h>
 
 // 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.
@@ -11,6 +14,56 @@ static uint8_t g_reason = RR_UNKNOWN;
 static uint32_t g_resetReas = 0;
 static uint8_t g_hangStage = 0xFF;
 
+// ---- watchdog pre-reset PC capture ("software SWD") --------------------------------------------------------
+// The nRF52 WDT raises a TIMEOUT interrupt ~2 LFCLK cycles BEFORE it resets the chip. We take that interrupt at
+// the highest NVIC priority (0) -- above FreeRTOS/TinyUSB BASEPRI critical sections -- read the stacked PC of
+// whatever code was wedged, and stash it in a .noinit RAM struct that survives the reset. Next boot reports it
+// on the panel; map it with addr2line to name the stuck function. Caveat: this fires only if interrupts aren't
+// hard-masked (PRIMASK/CPSID). If a watchdog reset reports NO pc, the hang was a PRIMASK-off / flash-stall type
+// (a different, narrower class) -- itself a useful signal. .noinit isn't zeroed by the C runtime, so it rides
+// through the watchdog reset (unlike GPREGRET2, which this board's bootloader wipes).
+struct HangFrame {
+	uint32_t pc, lr, magic;
+};
+#define HANGPC_MAGIC 0x48414E47u // "HANG"
+__attribute__((section(".noinit"))) volatile struct HangFrame g_hangFrame;
+static uint32_t g_reportHangPC = 0, g_reportHangLR = 0;
+
+__attribute__((naked)) void WDT_IRQHandler(void)
+{
+	__asm volatile(
+		"tst lr, #4            \n" // EXC_RETURN bit2: 0=frame on MSP, 1=on PSP
+		"ite eq                \n"
+		"mrseq r0, msp         \n"
+		"mrsne r0, psp         \n"
+		"ldr r1, [r0, #24]     \n" // stacked PC  (frame[6])
+		"ldr r3, [r0, #20]     \n" // stacked LR  (frame[5])
+		"ldr r2, =g_hangFrame  \n"
+		"str r1, [r2, #0]      \n"
+		"str r3, [r2, #4]      \n"
+		"ldr r1, =0x48414E47   \n"
+		"str r1, [r2, #8]      \n"
+		"b .                   \n" // hold until the WDT reset lands (microseconds away)
+		".ltorg                \n");
+}
+
+// Enable the WDT TIMEOUT interrupt at top priority. Call once, right after NRF_WDT->TASKS_START in setup().
+void faultDiagArmHangCapture()
+{
+	NRF_WDT->INTENSET = WDT_INTENSET_TIMEOUT_Msk;
+	NVIC_SetPriority(WDT_IRQn, 0); // above FreeRTOS configMAX_SYSCALL priority -> fires through BASEPRI guards
+	NVIC_ClearPendingIRQ(WDT_IRQn);
+	NVIC_EnableIRQ(WDT_IRQn);
+}
+uint32_t faultDiagHangPC()
+{
+	return g_reportHangPC;
+}
+uint32_t faultDiagHangLR()
+{
+	return g_reportHangLR;
+}
+
 // Hang breadcrumb: loop() stage written to GPREGRET2 as 0x80|stage. 0x80..0x9F can never collide with the
 // intentional-reboot (0xB1) / HardFault (0xFA) markers or the power-on default (0), so the same register
 // disambiguates all of them at boot.
@@ -102,6 +155,17 @@ void faultDiagBoot()
 	else
 		g_hangStage = 0xFF;
 
+	// Recover the PC the WDT pre-reset ISR captured (if it fired -- i.e. the hang wasn't PRIMASK-off). Consume
+	// the magic so a later watchdog reset that DIDN'T capture (PRIMASK-off) can't report a stale address.
+	if (isHang && g_hangFrame.magic == HANGPC_MAGIC) {
+		g_reportHangPC = g_hangFrame.pc;
+		g_reportHangLR = g_hangFrame.lr;
+	} else {
+		g_reportHangPC = 0;
+		g_reportHangLR = 0;
+	}
+	g_hangFrame.magic = 0;
+
 	uint8_t reason;
 	// Precedence: a physical pin reset / watchdog / lockup is unambiguous from RESETREAS; only a SREQ needs the
 	// GPREGRET2 marker to split intentional reboot vs HardFault.
@@ -129,6 +193,10 @@ void faultDiagBoot()
 	if (g_hangStage != 0xFF)
 		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);
 }
 
 // ---- clock fingerprint -------------------------------------------------------------------------------------
@@ -191,6 +259,38 @@ uint16_t clockUsPerMs()
 	return g_clkUsPerMs;
 }
 
+// ---- per-task stack headroom (usbd-overflow hypothesis check) ---------------------------------------------
+// uxTaskGetSystemState (configUSE_TRACE_FACILITY=1) reports each task's stack high-water mark = the LEAST free
+// stack it ever had, in words. The "usbd" task (200 words / 800 B, core-fixed) runs handleSet->relayEnqueue;
+// if this trends toward 0 under haptic load, the stack overflow is confirmed. Repo-scoped, read-only, no core
+// changes -- just observing FreeRTOS.
+static uint16_t g_usbdStackMin = 0xFFFF;
+static uint16_t g_loopStackMin = 0xFFFF;
+void faultDiagStackTick()
+{
+	static unsigned long ms = 0;
+	if ((uint32_t)(millis() - ms) < 1000)
+		return;
+	ms = millis();
+	static TaskStatus_t st[12];
+	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;
+	}
+}
+uint16_t faultDiagUsbdStackFree()
+{
+	return g_usbdStackMin == 0xFFFF ? 0 : g_usbdStackMin;
+}
+uint16_t faultDiagLoopStackFree()
+{
+	return g_loopStackMin == 0xFFFF ? 0 : g_loopStackMin;
+}
+
 uint8_t faultDiagReason()
 {
 	return g_reason;

+ 13 - 0
OpenPuck/fault_diag.h

@@ -57,6 +57,19 @@ void faultDiagBeat();
 uint8_t faultDiagCurStage();
 uint32_t faultDiagStallMs();
 const char *faultDiagStageStr(uint8_t s);
+
+// Watchdog pre-reset PC capture ("software SWD"): arm in setup() right after NRF_WDT->TASKS_START. After a
+// 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();
+uint32_t faultDiagHangPC();
+uint32_t faultDiagHangLR();
+
+// Per-task stack headroom (words of stack never used = free). Call faultDiagStackTick() from loop() (self-gated
+// to ~1 Hz). faultDiagUsbdStackFree() trending toward 0 under haptic load confirms the usbd-task overflow.
+void faultDiagStackTick();
+uint16_t faultDiagUsbdStackFree();
+uint16_t faultDiagLoopStackFree();
 // Last-boot classification (RR_*) + the raw RESETREAS, for the WebUSB panel / console.
 uint8_t faultDiagReason();
 uint32_t faultDiagResetReas();

+ 23 - 0
OpenPuck/haptics.cpp

@@ -333,6 +333,29 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 // haptics broadcast to all connected slots (slot 0xFF); the stop frame is broadcast too (a stuck latch can
 // affect any controller, and the haptic-engine clear-re-init is settings-only so it's harmless on healthy
 // ones).
+// Stability-test keepalive: while g_stabTest, buzz every connected controller for ~150ms once per 10s so an
+// unattended uptime measurement isn't ended by a controller idle-sleeping. Toggled by WebUSB cmd 0x0F; the
+// panel times uptime-until-reset. Reboots clear g_stabTest (the panel re-arms it on reconnect).
+bool g_stabTest = false;
+void hapticStabTask()
+{
+	if (!g_stabTest)
+		return;
+	static const uint8_t on[3] = { 0x01, 0x01, 0xF7 };
+	static const uint8_t off[3] = { 0x01, 0x01, 0x00 };
+	static unsigned long lastOn = 0, offAt = 0;
+	unsigned long now = millis();
+	if (lastOn == 0 || (uint32_t)(now - lastOn) >= 10000u) {
+		lastOn = now;
+		relayEnqueue(0x82, on, 3, 0xFF); // broadcast buzz-on to all slots
+		offAt = now + 150;
+	}
+	if (offAt && (int32_t)(now - offAt) >= 0) {
+		relayEnqueue(0x82, off, 3, 0xFF);
+		offAt = 0;
+	}
+}
+
 void rfConnQueueHapticRelay()
 {
 	if (relayPending())

+ 5 - 0
OpenPuck/haptics.h

@@ -106,6 +106,11 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
 // rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
 // PID counter), so the controller doesn't dedup the GET as a retransmit of the relay.
+// Stability test: when g_stabTest (WebUSB cmd 0x0F), buzz all controllers every 10s to keep them awake for an
+// unattended uptime-until-hang measurement. hapticStabTask() is called from loop().
+extern bool g_stabTest;
+void hapticStabTask();
+
 void rfConnQueueHapticRelay();
 // returns true if a relay frame was actually transmitted this call (queue had an entry), so the poll loop can
 // count relay TXs separately from poll cycles.

+ 29 - 6
OpenPuck/webusb_config.cpp

@@ -35,8 +35,8 @@ static volatile bool g_bondExportRequest = false;
 // p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v11 extends to 113 bytes (115 total incl header);
 // 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)]
-#define WB_PAYLEN 127
+//                [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
 // 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
@@ -187,6 +187,24 @@ static void webusbSendBlob()
 	// invisible IRQ-off watchdog hang. A climbing count points at the haptic relay path / memory corruption.
 	p[127] = (uint8_t)g_ringFault;
 	p[128] = (uint8_t)(g_ringFault >> 8);
+	// PC/LR of the stuck code captured by the WDT pre-reset ISR on the last watchdog hang (0 = none captured =
+	// the hang hard-masked interrupts). Map with addr2line on the .elf to name the function.
+	uint32_t hpc = faultDiagHangPC(), hlr = faultDiagHangLR();
+	p[129] = (uint8_t)hpc;
+	p[130] = (uint8_t)(hpc >> 8);
+	p[131] = (uint8_t)(hpc >> 16);
+	p[132] = (uint8_t)(hpc >> 24);
+	p[133] = (uint8_t)hlr;
+	p[134] = (uint8_t)(hlr >> 8);
+	p[135] = (uint8_t)(hlr >> 16);
+	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();
+	p[137] = (uint8_t)usbdFree;
+	p[138] = (uint8_t)(usbdFree >> 8);
+	p[139] = (uint8_t)loopFree;
+	p[140] = (uint8_t)(loopFree >> 8);
 	// 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
@@ -323,16 +341,16 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if (op < 0x01 || op > 0x0E) { // resync: drop one byte
+			if (op < 0x01 || op > 0x0F) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
-			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x05/0x0E carry one
-			// value byte; 0x02 a field+value; 0x0A a 3-byte magic.
+			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x05/0x0E/0x0F carry
+			// one value byte; 0x02 a field+value; 0x0A a 3-byte magic.
 			uint8_t need = (op == 0x0D)		      ? 27 :
 				       (op == 0x02)		      ? 3 :
 				       (op == 0x03 || op == 0x05 ||
-					op == 0x0E)		      ? 2 :
+					op == 0x0E || op == 0x0F)     ? 2 :
 				       (op == 0x0A)		      ? 4 :
 								        1;
 			if (n < need)
@@ -345,6 +363,11 @@ void webusbPoll()
 			else if (op == 0x09) {
 				g_bondExportRequest = true;
 			}
+			// 0x0F: stability test on/off -- when on, the puck buzzes all controllers every 10s to keep them
+			// awake so an unattended uptime-until-hang measurement isn't ended by a controller idle-sleeping.
+			else if (op == 0x0F) {
+				g_stabTest = (buf[1] != 0);
+			}
 #if OPK_LOG
 
 			// rewind drain: buf[1]=1 from boot (whole ring), 0 from now

+ 48 - 1
docs/index.html

@@ -112,6 +112,7 @@
         <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>
         <div class="stat"><div class="k">Loop</div><div class="v" id="stLoopState">—</div></div>
+        <div class="stat"><div class="k">usbd stack free (words)</div><div class="v" id="stUsbdStack">—</div></div>
         <div class="stat logonly"><div class="k">Loop period</div><div class="v" id="stLoop">—</div></div>
         <div class="stat logonly"><div class="k">Slowest stage</div><div class="v" id="stWorst">—</div></div>
         <div class="stat logonly"><div class="k">Poll period act/want</div><div class="v" id="stPollPer">—</div></div>
@@ -129,6 +130,10 @@
       <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>
+        <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>
       <div class="row" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px">
         <button id="dfuSerial">Reboot to Serial DFU</button>
         <span class="note" style="flex:1">Reboots the puck into serial DFU mode (adafruit-nrfutil). The device drops off USB — flash with <code>adafruit-nrfutil dfu serial …</code> then replug.</span>
@@ -202,6 +207,22 @@ const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always
 const CHORD_FIELD = [17, 18, 19];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false, backupBusy=false;
 let lastP=null; // most recent status-blob payload (config source for Export)
+// Stability test: armed = active across reconnects; stabStart = ms of the current run; stabLastRun = last
+// completed run {secs} (ended by a reset). Kept in-page so it survives the device reset (not a page refresh).
+let stabArmed=false, stabStart=0, stabLastRun=null;
+function fmtDur(s){ s=Math.round(s); return s>=60 ? (Math.floor(s/60)+"m "+(s%60)+"s") : (s+"s"); }
+function updateStabUI(){
+  const btn=$("#stabBtn"), st=$("#stabStatus");
+  if(!btn) return;
+  btn.textContent = stabArmed ? "Stop stability test" : "Test stability";
+  btn.classList.toggle("active", stabArmed);
+  let txt="";
+  if(stabArmed && dev && stabStart) txt="up "+fmtDur((Date.now()-stabStart)/1000);
+  else if(stabArmed && !dev) txt="reconnecting…";
+  if(stabLastRun) txt += (txt?"  ·  ":"")+"last run: "+fmtDur(stabLastRun.secs);
+  st.textContent = txt||"—";
+}
+setInterval(updateStabUI, 500);
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
@@ -355,6 +376,9 @@ async function openDevice(d){
     $("#connectBtn").textContent="Reconnect";
     $("#panel").classList.remove("hide");
     log(`connected — puck ${dev.serialNumber||"?"} (VID ${dev.vendorId.toString(16)} iface ${ifNum})`);
+    // resume a stability test across the reset: re-arm the firmware buzz and start timing the next run
+    if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
+    updateStabUI();
     startPolling();
     return true;
   }catch(e){ log("connect failed: "+e.message); dev=null; return false; }
@@ -375,6 +399,9 @@ async function autoConnect(){
   return false;
 }
 function onGone(){
+  // if a stability test is running, this disconnect is the reset that ended the run -- record its uptime
+  // (kept in-page so it survives the reset + reconnect, shown next to Last reset).
+  if(stabArmed && stabStart){ stabLastRun={secs:(Date.now()-stabStart)/1000}; log("⏱ stability: stayed up "+fmtDur(stabLastRun.secs)+", then reset"); stabStart=0; }
   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");
@@ -522,6 +549,12 @@ function applyBlob(p){
       $("#stLoopState").innerHTML='<span class="pill up">running</span>';
     }
   }
+  // v12: least-ever free stack on the usbd task (words). Low = overflow risk (multi-controller haptic-relay
+  // hang hypothesis). Firmware p[137] -> browser p[135]. Red under 24 words (~96B), amber under 48.
+  if(p.length>136){ const usbdW=(p[135]|(p[136]<<8));
+    $("#stUsbdStack").innerHTML = usbdW<24 ? ('<span class="pill dn">'+usbdW+'</span>')
+      : (usbdW<48 ? ('<span class="pill" style="background:#3a3216;color:#e5c76b">'+usbdW+'</span>') : (""+usbdW));
+  }
   $("#stLoop").textContent = loopUs? loopUs+" µs" : "—";
   $("#stWorst").textContent = loopUs? (WORST[worstIdx]||worstIdx)+" "+worstUs+"µs" : "—";
   $("#stPollPer").textContent = up? (pollUs+" / "+pollIntended+" µs") : "—";
@@ -541,9 +574,16 @@ function applyBlob(p){
     let label = name;
     if (hangStage!==0xFF) label += " @ "+(STAGE_NAMES[hangStage]||("stage "+hangStage));
     $("#stReset").innerHTML = RR_FAULT.has(code) ? ('<span class="pill dn">'+label+'</span>') : label;
+    // v12: PC/LR captured by the WDT pre-reset ISR (browser idx = firmware−2: hangPC@127, hangLR@131)
+    const hpc=(p.length>130?((p[127]|(p[128]<<8)|(p[129]<<16)|(p[130]<<24))>>>0):0);
+    const hlr=(p.length>134?((p[131]|(p[132]<<8)|(p[133]<<16)|(p[134]<<24))>>>0):0);
+    const hx=v=>"0x"+v.toString(16).padStart(8,"0");
     const el = $("#resetHist");
     if (el) el.textContent = "RESETREAS=0x"+raw.toString(16).padStart(8,"0")
-      + (hangStage!==0xFF ? "  hung in: "+(STAGE_NAMES[hangStage]||hangStage) : "");
+      + (hangStage!==0xFF ? "  hung in: "+(STAGE_NAMES[hangStage]||hangStage) : "")
+      + (hpc ? "  hang PC="+hx(hpc)+" LR="+hx(hlr) : "")
+      + (stabLastRun ? "  ·  stability: stayed up "+fmtDur(stabLastRun.secs)+" before this reset" : "");
+    if (hpc && hpc!==window._hangPC){ window._hangPC=hpc; log("⚑ HANG PC="+hx(hpc)+" LR="+hx(hlr)+" — send me this; I'll map it to the stuck function"); }
   }
 
   const gitDirty=(p.length>38?p[38]:0); let gitHash="";
@@ -764,6 +804,13 @@ $("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await imp
 $("#capDl").onclick=downloadCap;
 $("#capStart").onclick=startCapture;
 $("#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"); }
+  updateStabUI();
+};
 $("#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"); } };
 $("#dfuSerial").onclick=async()=>{