Browse Source

multiple fixes including less active haptics on init

Jari 3 tháng trước cách đây
mục cha
commit
b881cb9f64

+ 14 - 10
OpenPuck/config.cpp

@@ -70,14 +70,16 @@ 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 lizard-suppression
+	// keepalive enable (see haptics.h LIZKEEP_MS) -- repurposed from the dead post-connect haptic-block
+	// bytes. rsvd1: the block's old duration byte, free for the next tunable.
+	uint8_t rxWin10, lizKeep, rsvd1;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
@@ -94,8 +96,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,
+		  0,
 		  {} };
 	for (int i = 0; i < ET_COUNT; i++)
 		c.type[i] = g_type[i];
@@ -150,9 +152,11 @@ 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;
+			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();
 	}

+ 9 - 5
OpenPuck/fault_diag.cpp

@@ -51,7 +51,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);
 }
@@ -191,8 +193,8 @@ 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,
@@ -211,7 +213,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",
@@ -278,7 +281,8 @@ void faultDiagStackTick()
 		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)
+		else if (!strcmp(st[i].pcTaskName, "loop") &&
+			 hw < g_loopStackMin)
 			g_loopStackMin = hw;
 	}
 }

+ 2 - 1
OpenPuck/fault_diag.h

@@ -86,4 +86,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)

+ 74 - 51
OpenPuck/haptics.cpp

@@ -2,6 +2,7 @@
 #include "bonds.h"
 #include "config.h"
 #include "rf_link.h"
+#include "puck_hid.h" // puckLizardActive() -- gate the lizard-suppression keepalive
 
 // USBDevice.suspended() -> autonomous controller power-off on host sleep
 #include <Adafruit_TinyUSB.h>
@@ -18,6 +19,9 @@ 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;
+// Lizard-suppression keepalive (see hapticTask): OFF by default (it silences autonomous touchpad haptics;
+// Steam sends its own id9=0 anyway). Persisted; console 'u' toggles for A/B.
+uint8_t g_lizKeep = 0;
 // 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 +30,15 @@ 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.
 	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 +54,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,11 +107,9 @@ 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);
 	return true;
 }
@@ -237,12 +230,16 @@ 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
@@ -280,9 +277,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);
@@ -347,7 +341,8 @@ void hapticStabTask()
 	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
+		relayEnqueue(0x82, on, 3,
+			     0xFF); // broadcast buzz-on to all slots
 		offAt = now + 150;
 	}
 	if (offAt && (int32_t)(now - offAt) >= 0) {
@@ -412,12 +407,20 @@ 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.
 			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);
 			uint8_t p[5 + RELAY_MAXP], plen;
 			if (land01) {
 				p[0] = g_relayOp;
@@ -516,9 +519,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 +531,45 @@ 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()
 {
+	// Lizard-suppression keepalive (OFF by default; 'u' toggles for A/B): land SET_SETTINGS index 9 = 0 on
+	// each connected controller every LIZKEEP_MS, the way Steam does through the real puck (sniff1.json:
+	// id9=0 every 3.000s). Holding settings[9]=0 keeps the controller's revert timer (ibex FUN_0004265c)
+	// from re-enabling autonomous mode + resetting all settings to defaults (the amp-pop / spurious buzz).
+	// BUT settings[9]=0 disables the controller's whole pad-input layer (ibex FUN_0001bd80 early-returns when
+	// id9==0), and the autonomous TOUCHPAD HAPTIC tick is generated by that layer -- so holding id9=0 kills
+	// the pad ticks (confirmed on HW). There is NO id9 setting that keeps ticks while suppressing lizard;
+	// id9 gates the entire pad layer. So NEVER send it while lizard is active -- pure MODE_LIZARD and
+	// Steam-closed both want those ticks; leaving id9 alone (default 1) keeps them for free. In Steam-driven
+	// mode Steam sends its own id9=0 (now landed by the whitelist), so this synthesized copy is redundant
+	// there; off by default, it's the experiment behind 'u'. NOTE the framing: this uses 0x87 (SET_SETTINGS),
+	// which is idempotent -- a repeated same-value id9=0 is change-guarded in the controller and does NOT
+	// re-fire the click-causing 0x81 side effects, so holding via 0x87 is silent where a looped 0x81 was not.
+	if (g_lizKeep && !puckLizardActive()) {
+		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++) {
@@ -555,13 +587,8 @@ void hapticTask()
 		}
 		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
@@ -593,10 +620,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.)
 }

+ 12 - 2
OpenPuck/haptics.h

@@ -47,6 +47,15 @@
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot = 0xFF);
 
+// Lizard-suppression keepalive: hold the controller's SET_SETTINGS index 9 (digital-mappings/lizard-active)
+// at 0, once per LIZKEEP_MS per connected slot, exactly like the real puck (Steam lands id9=0 every 3.000s,
+// sniff1.json). Without the hold, the controller's revert timer (ibex FUN_0004265c) re-enables autonomous
+// mode and resets ALL settings to defaults -> amp pop + autonomous touchpad ticks = spurious buzz with zero
+// host/RF traffic. 2s cadence = comfortable margin under the (unmeasured, SC1-era ~5s) revert timeout.
+#define LIZKEEP_MS 2000u
+extern uint8_t
+	g_lizKeep; // 1 = keepalive on (default, persisted); console 'u' toggles for A/B
+
 // 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 +73,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. ----

+ 5 - 4
OpenPuck/mode_xinput.cpp

@@ -508,10 +508,11 @@ 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. A new-firmware controller sends its main
-	// input as report 0x42 (different front-section layout, not yet reversed) -- decoding it here as 0x45
-	// would emit garbage sticks/buttons. Skip until the 0x42 front layout lands; IMU-only modes are unaffected.
-	if (rep[0] != 0x45)
+	// 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

+ 37 - 15
OpenPuck/puck_hid.cpp

@@ -118,6 +118,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 +158,15 @@ 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 &&
+		// NEVER relay output report 0x81 (= CLEAR DIGITAL MAPPINGS, ibex FUN_0001f554) to the controller.
+		// It is NOT a haptic despite sitting in the 0x80-0x86 range; it is non-idempotent (each one re-arms a
+		// hardware block -> an audible actuator "deep-inside" click) and OpenPuck does its OWN input
+		// translation, so it never needs the controller's internal mapping engine. Steam sends CLEAR DIGITAL
+		// MAPPINGS as part of its per-controller CONNECT init (this is why the click is seen only in Steam
+		// mode, only with Steam open, and only at connect). sniff1.json was captured mid-session (established
+		// link, no connect handshake), so it never showed the connect init -- absence of 0x81 there is not
+		// evidence Steam never sends it. Dropping the relay is safe (Steam still gets 0x45) and quiets connect.
+		if (rid >= 0x80 && rid <= 0x86 && rid != 0x81 && n >= 1 &&
 		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
 			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
@@ -200,10 +213,11 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 	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 +228,10 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 
-		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop)
-		bool relayOk = hapticRelaySlotOk(slot) &&
+		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop); and NEVER
+		// relay 0x81 CLEAR DIGITAL MAPPINGS (same reason as the OUTPUT path above -- non-idempotent actuator
+		// click, unused by OpenPuck's own translation, and the source of the Steam-mode connect clicks).
+		bool relayOk = hapticRelaySlotOk(slot) && cmd != 0x81 &&
 			       !(haptic82 && (lizardActive() || muted));
 		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
 			// Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's
@@ -334,9 +350,13 @@ 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)
@@ -453,15 +473,16 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 	// 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() && rid == 0x45) {
+	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.
-		// NOTE: rfLizard decodes 0x45 field offsets; the 0x42 front layout isn't reversed yet, so a
-		// new-firmware controller has no driverless lizard output until that decode lands (Steam still works).
+		// 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 if (!lizardActive()) {
+	} 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;
@@ -472,8 +493,9 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 		// 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], rid, rep + 1,
-				 blen); // Steam/SDL Triton: input report 0x45 (old) / 0x42 (new fw)
+			usbTxHid(
+				&hid[slot], rid, rep + 1,
+				blen); // Steam/SDL Triton: input report 0x45 (old) / 0x42 (new fw)
 	}
 }
 

+ 6 - 0
OpenPuck/puck_hid.h

@@ -18,6 +18,12 @@
 // 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();
+
 class SteamPuckController : public IController {
     public:
 	void begin() override;

+ 71 - 87
OpenPuck/rf_link.cpp

@@ -100,18 +100,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;
@@ -273,7 +284,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)
@@ -313,7 +324,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
@@ -384,7 +395,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];
 						}
@@ -589,59 +600,6 @@ 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 && tlen >= 46 &&
-						   (size_t)(idx + 2) + tlen <=
-							   sizeof(rfrx) &&
-						   rfrx[idx + 2] == 0x42) {
-						// NEW-FIRMWARE main input report (SC2 beta update ~2026-07): the controller moved
-						// its gamepad input from report 0x45 (45B body) to 0x42 (53B body). The IMU tail
-						// [34..45] is byte-identical to 0x45; the FRONT section [2..29] (buttons/sticks/
-						// triggers/pads) is a different layout still being reversed. Interim handling:
-						//  (a) forward 0x42 VERBATIM to Steam -- onReport45 reads rep[0] for the id, and
-						//      report 0x42 is already declared in PUCK_HID_DESC (53B), so this is exactly
-						//      what the real puck does -> restores Steam for new-fw controllers;
-						//  (b) decode the (identical) IMU into g_in so gyro-reading stream modes stay right.
-						// TODO(front-section): once the 0x42 [2..29] layout is captured, decode buttons/
-						// sticks/pads into g_in here (and drive the Steam-wake / Steam+Y-shutdown / mode
-						// chord like the 0x45 branch) so emulated modes (Switch/PS/Xbox/lizard) work too.
-						// The proven 0x45 branch above is left untouched so OLD controllers cannot regress.
-						const uint8_t *rep = &rfrx[idx + 2];
-						bool fresh =
-							(rep[1] != g_lastSeq[g_curSlot]);
-						if (fresh) {
-							g_stNew++;
-							g_lastSeq[g_curSlot] = rep[1];
-						}
-						g_connF1++;
-						imuFrom45(rep, &g_in[g_curSlot].ax,
-							  &g_in[g_curSlot].ay,
-							  &g_in[g_curSlot].az,
-							  &g_in[g_curSlot].gx,
-							  &g_in[g_curSlot].gy,
-							  &g_in[g_curSlot].gz);
-						if (g_active)
-							g_active->onReport45(g_curSlot,
-									     rep, fresh,
-									     tlen);
-						// RE capture aid: dump the full 0x42 report so the front-section [2..29] layout
-						// can be reversed by holding one control at a time. ~20/s, non-blocking. Remove
-						// once the layout is pinned.
-						{
-							static unsigned long lastI42 =
-								0;
-							if (Serial.availableForWrite() >
-								    120 &&
-							    millis() - lastI42 >= 50) {
-								lastI42 = millis();
-								Serial.print("I42 ");
-								for (uint8_t i = 0;
-								     i < tlen; i++)
-									Serial.printf(
-										"%02X",
-										rep[i]);
-								Serial.println();
-							}
-						}
 					} else if (ttype == 6 &&
 						   (size_t)(idx + 2) + tlen <=
 							   sizeof(rfrx) &&
@@ -650,16 +608,23 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						// 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() > 110 &&
+						static unsigned long lastUnk =
+							0;
+						if (Serial.availableForWrite() >
+							    110 &&
 						    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(
+								"UNK rid=%02X tlen=%u: ",
+								rfrx[idx + 2],
+								tlen);
+							for (uint8_t i = 0;
+							     i < tlen; i++)
 								Serial.printf(
 									"%02X",
-									rfrx[idx + 2 + i]);
+									rfrx[idx +
+									     2 +
+									     i]);
 							Serial.println();
 						}
 					}
@@ -741,7 +706,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;
@@ -833,7 +798,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]++;
@@ -853,7 +818,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++;
 	};
 
@@ -870,7 +835,8 @@ 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);
+		bool cold = everReplied &&
+			    (nowMs - g_connReplyMs[k] > SLOT_COLD_MS);
 		bool phantom = !everReplied && linkUp;
 		if ((cold || phantom) &&
 		    nowMs - g_slotLastAttemptMs[k] < SLOT_COLD_RETRY_MS)
@@ -989,12 +955,37 @@ 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;
@@ -1002,18 +993,11 @@ void rfLinkTask()
 		if (Serial.availableForWrite() > 70)
 			Serial.printf(
 				"# stat polls=%lu/s F1=%lu/s new=%lu/s F3=%lu/s(v%d) e7b=%u crcfail=%lu noRx=%lu slot=%d\n",
-				(unsigned long)g_stPoll, (unsigned long)g_stF1,
-				(unsigned long)g_stNew, (unsigned long)g_stF3,
-				(int8_t)g_connF3v, g_e7b,
-				(unsigned long)g_stCrc, (unsigned long)g_stNoRx,
-				g_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;

+ 8 - 0
OpenPuck/serial_console.cpp

@@ -90,6 +90,14 @@ void serialConsolePoll()
 				g_rfRespond = false;
 				NRF_RADIO->TASKS_DISABLE = 1;
 				Serial.println("# RF off");
+			} else if (line[0] == 'u') {
+				// lizard-suppression keepalive A/B toggle (persisted; see haptics.h LIZKEEP_MS)
+				g_lizKeep = !g_lizKeep;
+				saveCfg();
+				Serial.printf(
+					"# lizard keepalive (id9=0 / %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)

+ 31 - 13
OpenPuck/webusb_config.cpp

@@ -36,7 +36,9 @@ 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)]
+#define WB_PAYLEN 175
 // 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
@@ -57,8 +59,8 @@ static void webusbSendBlob()
 	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 (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] = 13;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -200,11 +202,26 @@ 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];
+	}
 	// 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
@@ -347,12 +364,13 @@ void webusbPoll()
 			}
 			// 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 :
+			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) ?
+						      2 :
+				       (op == 0x0A) ? 4 :
+						      1;
 			if (n < need)
 				break; // wait for more bytes
 			if (op == 0x01) {
@@ -429,8 +447,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 +608,8 @@ 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)
 				}
 				if (persist)
 					saveCfg();

+ 29 - 7
docs/index.html

@@ -108,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>
@@ -351,10 +354,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) {
@@ -527,6 +539,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