Bläddra i källkod

Add per-type rumble disable for emulated controller modes

copilot-swe-agent[bot] 3 månader sedan
förälder
incheckning
77437e415c
6 ändrade filer med 136 tillägg och 112 borttagningar
  1. 10 6
      OpenPuck/config.cpp
  2. 4 0
      OpenPuck/config.h
  3. 3 0
      OpenPuck/haptics.cpp
  4. 9 6
      OpenPuck/mode_xinput.cpp
  5. 61 56
      OpenPuck/webusb_config.cpp
  6. 49 44
      docs/index.html

+ 10 - 6
OpenPuck/config.cpp

@@ -24,11 +24,12 @@ int g_mDiv = 64, g_mFric = 94;
 // Per-type button config. back default {5,6,7,8} = L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad,
 // 16/17 PS touch/mute, 18 Switch Capture). Switch differs: QAM defaults to Capture(18), A/B swap on, and
 // trackpad haptics off. qamMap 0 = unmapped (hardcoded per-mode behavior). ledBright 0 = no override.
+// rumble 1 = enabled (default), 0 = host rumble silenced for that type.
 TypeCfg g_type[ET_COUNT] = {
-	/* ET_XBOX   */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
-	/* ET_SWITCH */ { { 5, 6, 7, 8 }, 18, 1, 0, 0 },
-	/* ET_DS4    */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
-	/* ET_DS5    */ { { 5, 6, 7, 8 }, 0, 0, 1, 0 },
+	/* ET_XBOX   */ { { 5, 6, 7, 8 }, 0, 0, 1, 0, 1 },
+	/* ET_SWITCH */ { { 5, 6, 7, 8 }, 18, 1, 0, 0, 1 },
+	/* ET_DS4    */ { { 5, 6, 7, 8 }, 0, 0, 1, 0, 1 },
+	/* ET_DS5    */ { { 5, 6, 7, 8 }, 0, 0, 1, 0, 1 },
 };
 uint8_t g_etype = ET_NONE;
 
@@ -37,6 +38,7 @@ uint8_t g_abSwap = 0;
 uint8_t g_back[4] = { 5, 6, 7, 8 };
 uint8_t g_qamMap = 0;
 uint8_t g_padHaptics = 1;
+uint8_t g_rumble = 1;
 uint8_t g_ledBright = 0;
 
 void applyActiveType()
@@ -51,6 +53,7 @@ void applyActiveType()
 		g_qamMap = 0;
 		g_abSwap = 0;
 		g_padHaptics = 1;
+		g_rumble = 1;
 		g_ledBright = 0;
 		return;
 	}
@@ -60,6 +63,7 @@ void applyActiveType()
 	g_qamMap = t.qamMap;
 	g_abSwap = t.abSwap;
 	g_padHaptics = t.padHaptics;
+	g_rumble = t.rumble;
 	g_ledBright = t.ledBright;
 }
 // rumble strength % (200 = double); adjustable from the WebUSB panel
@@ -70,9 +74,9 @@ uint8_t g_rumbleScale = 200;
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-// Struct layout/semantics changed (haptic-block bytes repurposed); bump so old flash format is discarded ->
+// Struct layout/semantics changed (TypeCfg gained rumble byte); bump so old flash format is discarded ->
 // clean defaults once.
-#define CFG_MAGIC 0xCE
+#define CFG_MAGIC 0xCF
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;

+ 4 - 0
OpenPuck/config.h

@@ -118,6 +118,7 @@ extern int g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
 // (0..15 standard, 16=PS Touch Click, 17=PS5 Mute, 18=Switch Capture/Screenshot). qamMap = QAM (3 dots)
 // physical button -> same code space (0 = default/unmapped). abSwap = swap A/B and X/Y (Nintendo layout).
 // padHaptics = 1 keeps the controller's autonomous trackpad haptics, 0 disables them for this type.
+// rumble = 1 enables host rumble relay (default), 0 silences it for this type.
 // ledBright = LED brightness sent to the controller on connect: 0 = no override (controller default),
 // 1-100 = brightness %. Steam sets brightness each session; emulated modes never do, so the controller
 // comes up at full brightness. Setting a value here preserves the preferred brightness across mode switches.
@@ -127,6 +128,7 @@ struct TypeCfg {
 	uint8_t abSwap;
 	uint8_t padHaptics;
 	uint8_t ledBright;
+	uint8_t rumble;
 };
 extern TypeCfg g_type[ET_COUNT];
 extern uint8_t
@@ -139,6 +141,8 @@ extern uint8_t g_back[4];
 extern uint8_t g_qamMap;
 extern uint8_t
 	g_padHaptics; // 1 = trackpad haptics on (default), 0 = disabled for the active type
+// 1 = host rumble relay on (default), 0 = silenced for the active emulated type
+extern uint8_t g_rumble;
 // LED brightness for the active emulated type (0 = no override, 1-100 = brightness %)
 extern uint8_t g_ledBright;
 

+ 3 - 0
OpenPuck/haptics.cpp

@@ -313,6 +313,9 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 	}
 	bool on = lowFreq || highFreq;
+	// per-type rumble disable: drop ON commands; zero/stop still pass to clear any queued relay
+	if (on && !g_rumble)
+		return false;
 	// Per-slot settle gate (the per-slot reconnect block + link-up check). 0x82 haptics in Steam mode use the
 	// same gate; for XInput, the host only sends a stream while a controller is connected, so this also doubles
 	// as "no controller here, no relay".

+ 9 - 6
OpenPuck/mode_xinput.cpp

@@ -164,12 +164,15 @@ static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t res, uint32_t n)
 			if (n >= 5 && S.outBuf[0] == 0x00 &&
 			    S.outBuf[1] == 0x08) {
 				uint8_t big = S.outBuf[3], sml = S.outBuf[4];
-				uint16_t lo = (uint16_t)big * 257u;
-				uint16_t hi = (uint16_t)sml * 257u;
-				S.rumbleLow = lo;
-				S.rumbleHigh = hi;
-				S.rumbleMs = millis();
-				hapticSteamRumble(lo, hi, (uint8_t)s);
+				// skip tracking+relay when rumble is disabled for this type
+				if (g_rumble) {
+					uint16_t lo = (uint16_t)big * 257u;
+					uint16_t hi = (uint16_t)sml * 257u;
+					S.rumbleLow = lo;
+					S.rumbleHigh = hi;
+					S.rumbleMs = millis();
+					hapticSteamRumble(lo, hi, (uint8_t)s);
+				}
 			}
 			usbd_edpt_xfer(rhport, S.epOut, S.outBuf,
 				       sizeof S.outBuf);

+ 61 - 56
OpenPuck/webusb_config.cpp

@@ -45,16 +45,16 @@ static void fwupAckPost(uint8_t status)
 //                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
 //                [gitDirty][buildId 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
 //                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
-//                [v10: per-type cfg, 4x8B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics, ledBright}]
+//                [v10/v17: per-type cfg, 4x9B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics, ledBright, rumble}]
 //                [v11: resetReason(RR_* code)][resetReas raw u32 LE][rxWin/10 us][hapticBlockOn][hapticBlock s]
 // 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)][hangPC u32][hangLR u32][usbdStackFree(lo,hi)][loopStackFree(lo,hi)]
-//                [v13: per-slot link stats, 4x9B from p[141]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
+//                [v13: per-slot link stats, 4x9B from p[145]: {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
+//                [v14/v17: p[181] landAll87 (verbatim-0x87-relay experiment toggle)]
+#define WB_PAYLEN 180
 // 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
@@ -79,12 +79,13 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (16 = +configurable lizard-map ops 0x11..0x15 / 0xAA frame, payload unchanged -- the
-	// panel MUST see >=16 before it dares send 0x11, or a blocking readLizard() would hang forever against a
-	// firmware that silently drops the unknown op; 15 = +staged firmware-update ops 0x20..0x24; 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] = 16;
+	// protocol version (17 = per-type rumble field (TypeCfg k=8), per-type stride 8->9; 16 = +configurable
+	// lizard-map ops 0x11..0x15 / 0xAA frame, payload unchanged -- the panel MUST see >=16 before it dares
+	// send 0x11, or a blocking readLizard() would hang forever against a firmware that silently drops the
+	// unknown op; 15 = +staged firmware-update ops 0x20..0x24; 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] = 17;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -167,9 +168,9 @@ static void webusbSendBlob()
 			p[65 + s * 3] = g_linkRssi[s];
 		}
 	}
-	// per-emulated-type button config (protocol v10): 4 types x 8 bytes from p[75]
+	// per-emulated-type button config (v10/v17): 4 types x 9 bytes from p[75]
 	for (int et = 0; et < ET_COUNT; et++) {
-		uint8_t *q = &p[75 + et * 8];
+		uint8_t *q = &p[75 + et * 9];
 		q[0] = g_type[et].back[0];
 		q[1] = g_type[et].back[1];
 		q[2] = g_type[et].back[2];
@@ -178,68 +179,69 @@ static void webusbSendBlob()
 		q[5] = g_type[et].abSwap;
 		q[6] = g_type[et].padHaptics;
 		q[7] = g_type[et].ledBright;
+		q[8] = g_type[et].rumble;
 	}
 	// last-boot reset cause (protocol v11): why we (re)booted -- watchdog hang vs HardFault vs intentional
-	// reboot vs power-on (issue #72). p[107] = RR_* code; p[108..111] = raw RESETREAS for the curious.
-	p[107] = faultDiagReason();
+	// reboot vs power-on (issue #72). p[111] = RR_* code; p[112..115] = raw RESETREAS for the curious.
+	p[111] = faultDiagReason();
 	uint32_t rr = faultDiagResetReas();
-	p[108] = (uint8_t)rr;
-	p[109] = (uint8_t)(rr >> 8);
-	p[110] = (uint8_t)(rr >> 16);
-	p[111] = (uint8_t)(rr >> 24);
+	p[112] = (uint8_t)rr;
+	p[113] = (uint8_t)(rr >> 8);
+	p[114] = (uint8_t)(rr >> 16);
+	p[115] = (uint8_t)(rr >> 24);
 	// Connection tunables (protocol v11): poll RX window in 10us units, post-connect haptic block enable,
 	// and block duration in seconds.
-	p[112] = (uint8_t)(g_rxWin / 10);
-	p[113] = g_hapticBlockOn;
-	p[114] = (uint8_t)(g_hapticBlockMs / 1000);
+	p[116] = (uint8_t)(g_rxWin / 10);
+	p[117] = g_hapticBlockOn;
+	p[118] = (uint8_t)(g_hapticBlockMs / 1000);
 	// RF wedge diagnostics (always populated, even when the link reads down): CRC-fail/s and no-reply/s,
 	// capped at 255. With Polls/s this distinguishes poll-loop-stopped vs RX-dead vs corrupt-replies.
-	p[115] = (uint8_t)(g_crcps > 255 ? 255 : g_crcps);
-	p[116] = (uint8_t)(g_norxps > 255 ? 255 : g_norxps);
-	p[117] = (uint8_t)(g_rfStallRecover > 255 ? 255 : g_rfStallRecover);
+	p[119] = (uint8_t)(g_crcps > 255 ? 255 : g_crcps);
+	p[120] = (uint8_t)(g_norxps > 255 ? 255 : g_norxps);
+	p[121] = (uint8_t)(g_rfStallRecover > 255 ? 255 : g_rfStallRecover);
 	// v12: relay-frame TX rate (separated from true poll cycles -- Polls/s now reads ~250, Relay/s exposes the
 	// host output-report flood that steals reply windows), and the clock fingerprint for clone-board triage.
-	p[118] = (uint8_t)g_relayps;
-	p[119] = (uint8_t)(g_relayps >> 8);
-	p[120] = clockLfSrc();
-	p[121] = clockHfSrc();
+	p[122] = (uint8_t)g_relayps;
+	p[123] = (uint8_t)(g_relayps >> 8);
+	p[124] = clockLfSrc();
+	p[125] = clockHfSrc();
 	uint16_t upm = clockUsPerMs();
-	p[122] = (uint8_t)upm;
-	p[123] = (uint8_t)(upm >> 8);
+	p[126] = (uint8_t)upm;
+	p[127] = (uint8_t)(upm >> 8);
 	// last hang's loop stage (0xFF = last boot wasn't a watchdog/lockup hang, or GPREGRET2 didn't survive)
-	p[124] = faultDiagHangStage();
+	p[128] = faultDiagHangStage();
 	// LIVE hang localization: current loop stage + how long loop() has been stuck (40ms units, capped). When
 	// loop() wedges these keep updating because the blob is sent from the SOF interrupt, not loop().
-	p[125] = faultDiagCurStage();
+	p[129] = faultDiagCurStage();
 	uint32_t sms = faultDiagStallMs();
-	p[126] = (uint8_t)(sms / 40 > 255 ? 255 : sms / 40);
+	p[130] = (uint8_t)(sms / 40 > 255 ? 255 : sms / 40);
 	// relay-ring fault count: non-zero = we caught+recovered a desynced/corrupt ring that would otherwise be an
 	// 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);
+	p[131] = (uint8_t)g_ringFault;
+	p[132] = (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);
+	p[133] = (uint8_t)hpc;
+	p[134] = (uint8_t)(hpc >> 8);
+	p[135] = (uint8_t)(hpc >> 16);
+	p[136] = (uint8_t)(hpc >> 24);
+	p[137] = (uint8_t)hlr;
+	p[138] = (uint8_t)(hlr >> 8);
+	p[139] = (uint8_t)(hlr >> 16);
+	p[140] = (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);
+	p[141] = (uint8_t)usbdFree;
+	p[142] = (uint8_t)(usbdFree >> 8);
+	p[143] = (uint8_t)loopFree;
+	p[144] = (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];
+		uint8_t *q = &p[145 + s * 9];
 		q[0] = (uint8_t)g_slotPollsps[s];
 		q[1] = (uint8_t)(g_slotPollsps[s] >> 8);
 		q[2] = (uint8_t)g_slotF1ps[s];
@@ -250,8 +252,8 @@ static void webusbSendBlob()
 		q[7] = g_slotNoRxps[s];
 		q[8] = g_slotRelayps[s];
 	}
-	// v14: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
-	p[177] = g_landAll87;
+	// v14/v17: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
+	p[181] = 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
@@ -807,11 +809,11 @@ void webusbPoll()
 
 				// every settable field persists (poll rate is no longer settable)
 				bool persist = true;
-				// per-type cfg writes (protocol v10): field = 40 + et*8 + k, k: 0..3 back, 4 qam, 5 abSwap,
-				// 6 padHaptics, 7 ledBright. Edits g_type[et]; refresh the live mirrors if it's the active type.
-				if (f >= 40 && f < 40 + ET_COUNT * 8) {
-					uint8_t et = (uint8_t)((f - 40) / 8),
-						k = (uint8_t)((f - 40) % 8);
+				// per-type cfg writes (protocol v10/v17): field = 40 + et*9 + k, k: 0..3 back, 4 qam, 5 abSwap,
+				// 6 padHaptics, 7 ledBright, 8 rumble. Edits g_type[et]; refresh the live mirrors if it's the active type.
+				if (f >= 40 && f < 40 + ET_COUNT * 9) {
+					uint8_t et = (uint8_t)((f - 40) / 9),
+						k = (uint8_t)((f - 40) % 9);
 					if (et < ET_COUNT) {
 						if (k < 4)
 							g_type[et].back[k] = v;
@@ -827,6 +829,9 @@ void webusbPoll()
 							g_type[et].ledBright =
 								v > 100 ? 100 :
 									  v;
+						else if (k == 8)
+							g_type[et].rumble =
+								v ? 1 : 0;
 						if (et == g_etype)
 							applyActiveType();
 					}
@@ -944,7 +949,7 @@ void webusbPoll()
 					// (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.
+				// instead of the discard-whitelist. Persisted; blob p[181] reflects state.
 				case 29:
 					g_landAll87 = v ? 1 : 0;
 					break;

+ 49 - 44
docs/index.html

@@ -428,8 +428,8 @@ $("#trailClear").onclick=()=>{ loopTrail=[]; try{ localStorage.removeItem(TRAIL_
 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 + ...].
+// only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*9 + k
+// (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics, 7 LED brightness, 8 rumble). Blob v10/v17 reads them back at p[73 + et*9 + ...].
 const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
 const TYPE_DEFS = [
   {key:"XBOX", name:"Xbox", labels:{1:"A",2:"B",3:"X",4:"Y",5:"LB",6:"RB",7:"L3",8:"R3",9:"Back",10:"Start",11:"Guide",19:"LT",20:"RT",...DPAD}},
@@ -481,7 +481,7 @@ let lizardBindings = [];   // [{outType, od:[7], trig, hold}]
 let lizardBusy = false;
 let lizardLoaded = false;  // one-shot: the lizard map is fetched lazily, only once the connected puck proves it speaks v16+
 const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
-const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV}
+const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV, rumble}
 function mkSelect(def, includeNone){
   const sel=document.createElement("select");
   if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
@@ -509,30 +509,33 @@ function setTab(et){
     tab.onclick=()=>setTab(et); bar.appendChild(tab);
     // panel
     const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
-    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null};
-    // A/B swap + trackpad haptics toggles
+    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null,rumble:null};
+    // A/B swap + trackpad haptics + rumble toggles
     const tog=document.createElement("div"); tog.className="row";
     tog.innerHTML='<label>A/B + X/Y swap</label>';
     const ab=document.createElement("button"); ab.textContent="off"; tog.appendChild(ab);
     const padLbl=document.createElement("label"); padLbl.textContent="Trackpad haptics"; padLbl.style.marginLeft="14px"; tog.appendChild(padLbl);
     const pad=document.createElement("button"); pad.textContent="on"; tog.appendChild(pad);
+    const rumbleLbl=document.createElement("label"); rumbleLbl.textContent="Rumble"; rumbleLbl.style.marginLeft="14px"; tog.appendChild(rumbleLbl);
+    const rumble=document.createElement("button"); rumble.textContent="on"; tog.appendChild(rumble);
     sec.appendChild(tog);
-    rec.abSwap=ab; rec.pad=pad;
-    ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*8+5, on?0:1); };
-    pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*8+6, on?0:1); };
+    rec.abSwap=ab; rec.pad=pad; rec.rumble=rumble;
+    ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*9+5, on?0:1); };
+    pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*9+6, on?0:1); };
+    rumble.onclick=()=>{ const on=rumble.classList.contains("active"); setField(40+et*9+8, on?0:1); };
     // back paddles
     for(let i=0;i<4;i++){
       const row=document.createElement("div"); row.className="row";
       const lab=document.createElement("label"); lab.textContent=BACK_LABELS[i]; row.appendChild(lab);
       const sel=mkSelect(def,true); row.appendChild(sel); sec.appendChild(row);
-      sel.addEventListener("change",()=>setField(40+et*8+i, +sel.value));
+      sel.addEventListener("change",()=>setField(40+et*9+i, +sel.value));
       rec.back.push(sel);
     }
     // QAM
     { const row=document.createElement("div"); row.className="row";
       const lab=document.createElement("label"); lab.textContent="QAM (3 dots)"; row.appendChild(lab);
       const sel=mkSelect(def,false); row.appendChild(sel); sec.appendChild(row);
-      sel.addEventListener("change",()=>setField(40+et*8+4, +sel.value));
+      sel.addEventListener("change",()=>setField(40+et*9+4, +sel.value));
       rec.qam=sel; }
     // LED brightness
     { const row=document.createElement("div"); row.className="row";
@@ -541,7 +544,7 @@ function setTab(et){
       const vspan=document.createElement("span"); vspan.className="val"; row.appendChild(vspan);
       function fmtLed(v){ return (+v===0)?"Auto":v+"%"; }
       sl.addEventListener("input",()=>{ vspan.textContent=fmtLed(sl.value); });
-      sl.addEventListener("change",()=>setField(40+et*8+7, +sl.value));
+      sl.addEventListener("change",()=>setField(40+et*9+7, +sl.value));
       sec.appendChild(row);
       rec.led=sl; rec.ledV=vspan; }
     host.appendChild(sec); typeEls.push(rec);
@@ -789,12 +792,12 @@ 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
+    // v13: per-slot link stats (fw p[145..180] -> js index 143..178): 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) {
+    if (p.length >= 179) {
       for (let s=0; s<4; s++) {
         if (!g_slotData[s]) continue;
-        const b = 139 + s*9;
+        const b = 143 + 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] };
       }
@@ -814,29 +817,29 @@ function applyBlob(p){
   // Poll TX + RF-fail counts: shown ALWAYS (even when the link reads down) so a wedge is diagnosable --
   // polls>0 here while Delivered is "—" means the puck is still polling but getting no usable replies.
   $("#stPolls").textContent = polls+" /s";
-  { const crc=(p.length>113?p[113]:0), norx=(p.length>114?p[114]:0), heal=(p.length>115?p[115]:0);
-    const rf=(p.length>126?(p[125]|(p[126]<<8)):0);
+  { const crc=(p.length>117?p[117]:0), norx=(p.length>118?p[118]:0), heal=(p.length>119?p[119]:0);
+    const rf=(p.length>130?(p[129]|(p[130]<<8)):0);
     $("#stRfFail").innerHTML = crc+" · "+norx+" · "+heal + (rf?' · <span class="pill dn">ring '+rf+'</span>':"");
     // ring fault = we caught+recovered a relay-ring corruption that would otherwise be an invisible IRQ-off
     // watchdog hang. Log each new one (persists across the device reset + panel reconnect).
     if(rf>(window._ringFault||0)) log("⚠ RING FAULT #"+rf+" recovered — caught a relay-ring desync/corruption (would have hung loop with IRQs off)");
     window._ringFault=rf; }
   // v12: relay-frame rate + clock fingerprint (browser index = firmware index − 2)
-  { const relay=(p.length>117?(p[116]|(p[117]<<8)):null);
+  { const relay=(p.length>121?(p[120]|(p[121]<<8)):null);
     $("#stRelay").textContent = relay==null ? "—" : (relay+" /s"); }
-  if(p.length>121){
+  if(p.length>125){
     const LF=["stopped","RC","xtal","synth"], HF=["RC","?","xtal","?"];
-    const lf=p[118], hf=p[119], upm=(p[120]|(p[121]<<8));
+    const lf=p[122], hf=p[123], upm=(p[124]|(p[125]<<8));
     const lfBad=(lf!==2), hfBad=(hf!==2), upmBad=(upm && (upm<985||upm>1015));
     const tag=(t,bad)=> bad ? '<span class="pill dn">'+t+'</span>' : t;
     $("#stClock").innerHTML = tag(LF[lf]||lf, lfBad)+" / "+tag(HF[hf]||hf, hfBad);
     $("#stUsPerMs").innerHTML = upm ? tag(""+upm, upmBad) : "—";
   }
-  // v12: LIVE loop heartbeat — curStage (firmware p[125]) + ms-since-beat (p[126], 40ms units). Updates even
+  // v12: LIVE loop heartbeat — curStage (firmware p[129]) + ms-since-beat (p[130], 40ms units). Updates even
   // while loop() is wedged (blob is pushed from the SOF interrupt), so a hang shows "STALLED @ <stage>" live.
-  if(p.length>124){
+  if(p.length>128){
     const STAGE=["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
-    const cur=p[123], stallMs=p[124]*40, name=(STAGE[cur]||("stage "+cur));
+    const cur=p[127], stallMs=p[128]*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)");
@@ -853,8 +856,8 @@ function applyBlob(p){
     }
   }
   // 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));
+  // hang hypothesis). Firmware p[141] -> browser p[139]. Red under 24 words (~96B), amber under 48.
+  if(p.length>140){ const usbdW=(p[139]|(p[140]<<8));
     $("#stUsbdStack").innerHTML = usbdW<24 ? ('<span class="pill dn">'+usbdW+'</span>')
       : (usbdW<48 ? ('<span class="pill" style="background:#3a3216;color:#e5c76b">'+usbdW+'</span>') : (""+usbdW));
   }
@@ -867,19 +870,19 @@ function applyBlob(p){
   const RR_NAMES = ["unknown","power-on","pin/replug","watchdog (hang)","CPU lockup",
                     "HARDFAULT","reboot","soft reset","wake-from-off"];
   const RR_FAULT = new Set([3,4,5]); // watchdog / lockup / hardfault = a real crash worth reporting (#72)
-  if (p.length > 109) {
-    const code = p[105];
-    const raw = ((p[106]|(p[107]<<8)|(p[108]<<16)|(p[109]<<24))>>>0);
+  if (p.length > 113) {
+    const code = p[109];
+    const raw = ((p[110]|(p[111]<<8)|(p[112]<<16)|(p[113]<<24))>>>0);
     const name = RR_NAMES[code] || ("code "+code);
     // v12: which loop stage was stuck when the last watchdog/lockup hang fired (0xFF = last boot wasn't a hang)
     const STAGE_NAMES = ["webusb","ctrl.task","serial","rfdiag","rflink","haptic","led","usbmount","usbtx"];
-    const hangStage = (p.length>122 ? p[122] : 0xFF);
+    const hangStage = (p.length>126 ? p[126] : 0xFF);
     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);
+    // v12: PC/LR captured by the WDT pre-reset ISR (browser idx = firmware−2: hangPC@131, hangLR@135)
+    const hpc=(p.length>134?((p[131]|(p[132]<<8)|(p[133]<<16)|(p[134]<<24))>>>0):0);
+    const hlr=(p.length>138?((p[135]|(p[136]<<8)|(p[137]<<16)|(p[138]<<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")
@@ -889,7 +892,7 @@ function applyBlob(p){
     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"); }
     // append the queued reset to the running hang log, now that we have this boot's reason/PC/stack
     if (pendingHang){
-      const usbdW2=(p.length>136?(p[135]|(p[136]<<8)):0);
+      const usbdW2=(p.length>140?(p[139]|(p[140]<<8)):0);
       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 });
@@ -925,19 +928,20 @@ function applyBlob(p){
   checkUpdateNotice(); // needs BOTH the installed version (this blob) and relCache (fetched on connect)
   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
+  // land-all-0x87 experiment toggle (blob v14/v17, firmware p[181] -> JS p[179])
+  { const la=(p.length>179?p[179]:0); $("#landAll87").textContent = la?"on":"off"; $("#landAll87").classList.toggle("active",!!la); }
+  // per-type button config (blob v10/v17): 4 types x 9 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){
+  if(p.length>=73+TYPE_DEFS.length*9){
     const activeEt=etypeForMode(mode);
     typeEls.forEach((rec,et)=>{
-      const q=73+et*8;
-      const back=[p[q],p[q+1],p[q+2],p[q+3]], qam=p[q+4], ab=p[q+5], pad=p[q+6], led=p[q+7];
+      const q=73+et*9;
+      const back=[p[q],p[q+1],p[q+2],p[q+3]], qam=p[q+4], ab=p[q+5], pad=p[q+6], led=p[q+7], rum=p[q+8];
       rec.back.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=back[i]; });
       if(document.activeElement!==rec.qam) rec.qam.value=qam;
       rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
       rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
+      rec.rumble.textContent=rum?"on":"off"; rec.rumble.classList.toggle("active",!!rum);
       if(document.activeElement!==rec.led){ rec.led.value=led; rec.ledV.textContent=(led===0)?"Auto":led+"%"; }
       // dot marks the type matching the current puck mode
       rec.activeDot.style.display = (et===activeEt) ? "" : "none";
@@ -978,8 +982,8 @@ function buildBackup(p, bp){
   for(let s=0;s<4;s++){ const rec=bp.slice(2+s*24, 2+s*24+24);
     bonds.push({slot:s, used:!!((mask>>s)&1), rec:hexEnc(rec)}); }
   const types=[];
-  for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*8;
-    types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7]}); }
+  for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*9;
+    types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7], rumble:p[q+8]}); }
   return { magic:"openpuck-backup", version:1, bonds,
     config:{ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]],
              rumble:p[51], swProRate:p[52], swGyro10:p[53], types } };
@@ -1039,10 +1043,11 @@ async function importBackup(file){
     await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
     await sf(23,c.swProRate); await sf(24,c.swGyro10);
     for(let et=0; et<c.types.length && et<4; et++){ const t=c.types[et];
-      for(let k=0;k<4;k++) await sf(40+et*8+k, t.back[k]);
-      await sf(40+et*8+4, t.qam); await sf(40+et*8+5, t.abSwap?1:0);
-      await sf(40+et*8+6, t.pad?1:0); await sf(40+et*8+7, t.led); }
-    log("settings replayed ("+(9+c.types.length*8)+" fields)");
+      for(let k=0;k<4;k++) await sf(40+et*9+k, t.back[k]);
+      await sf(40+et*9+4, t.qam); await sf(40+et*9+5, t.abSwap?1:0);
+      await sf(40+et*9+6, t.pad?1:0); await sf(40+et*9+7, t.led);
+      await sf(40+et*9+8, t.rumble!==undefined?t.rumble:1); }
+    log("settings replayed ("+(9+c.types.length*9)+" fields)");
     // write each bond slot with its own small command [0x0D][slot][used][24-byte rec]
     let n=0;
     for(let s=0;s<4;s++){