Kaynağa Gözat

Add configurable rumble style and strength (#224)

Revives the rumble-strength setting removed in protocol v19 and adds a style selector that reshapes the two motor amplitudes before scaling. Strength persists in the cfg byte the old slider used (as percent/2), style in a new tail field after padStick, so existing configs load unchanged. Panel gains a Rumble card with both selectors and a test-buzz button (op 0x16, which also extends the parser's accepted opcode range); console gains RS/RY/TR. Protocol v21, rebased over #216.

Co-authored-by: Jari <safijari@isu.edu>
TheFilip 2 ay önce
ebeveyn
işleme
033dbfbd7e

+ 11 - 5
CODE_MAP.md

@@ -78,8 +78,9 @@ re-add wake mouse + `g_active->mountSlots(k)` + WebUSB in locked instance order,
   (`g_swGyroLegacy` is declared here but lives in `mode_switch_pro.cpp`/`swprocfg.bin`.)
 - `struct Cfg` is serialized to `/cfg.bin` (LittleFS), magic `0xCF`. `rxWin10`,
   `lizKeep`, `landAll87` and the per-type table travel with it; `rsvd0` is the
-  one-shot debug-CDC arm and `rsvd1` the ex-rumble-strength slot (both kept so the
-  on-flash layout is unchanged). New fields are **appended to the tail** (`chordDpad[4]`) —
+  one-shot debug-CDC arm and `rumbScale2` the rumble-strength slot (removed in protocol
+  v19, revived in v21 as percent/2; 0 = never set -> compiled default, so the on-flash
+  layout is unchanged). New fields are **appended to the tail** (`chordDpad[4]`) —
   `loadCfg()` prefills `0xFF` and accepts a file short by only the tail (`CFG_LEN_MIN`),
   so an upgrade keeps every existing setting and unwritten tail bytes fall back to defaults.
 - `loadCfg()` resolves the boot mode policy: one-shot `bootMode` wins once then clears;
@@ -416,9 +417,13 @@ Reads `g_qamMap`/`g_abSwap`/`g_back[]`. Pure transforms, no buffers beyond calle
 - `g_testHaptic`, `g_hapticStop` (`volatile`), `g_hapticBlockOn`, `g_hapticBlockMs`,
   `g_hapticBlockUntil[NSLOT]`, `g_relayOp`, `g_relaySub`.
 - `hapticSendShutdown()` — bursts 0x9F "off!" (`{6f 66 66 21}`) ×3 broadcast.
-- `hapticSteamRumble(low, high, slot)` — builds a 9-byte 0x80 report (×`RUMBLE_SCALE_PCT`),
-  `relayEnqueue(0x80, p, 9, slot)`; **called from usbd (mode rumble callbacks) and loop**.
-  Per-slot stuck-rumble tracking `g_rumble80On/Ms[NSLOT]`.
+- `hapticSteamRumble(low, high, slot)` — shapes the two amplitudes (`g_rumbleStyle`, then
+  `g_rumbleScale` %; integer-only, this runs in the USB OUT callback), builds a 9-byte 0x80
+  report, `relayEnqueue(0x80, p, 9, slot)`; **called from usbd (mode rumble callbacks) and
+  loop**. Per-slot stuck-rumble tracking `g_rumble80On/Ms[NSLOT]`.
+- `hapticTestRumble()` — one `RUMBLE_TEST_AMP` buzz to every linked slot through the same
+  shaping path (panel op `0x16`, console `TR`); `hapticTask()` sends the stop after
+  `RUMBLE_TEST_MS` — the actuator latches, so the stop is not optional.
 - `haptic82Blocked`/`hapticLinkUp`/`hapticRelaySlotOk` — link-up + block gates (read
   `g_connReplyMs`, `g_slot`).
 - `hapticOnReconnect(slot)` — arms the per-slot block + schedules `g_reinitLeft` re-init
@@ -471,6 +476,7 @@ Reads `g_qamMap`/`g_abSwap`/`g_back[]`. Pure transforms, no buffers beyond calle
   mutates config tunables (writes `g_type`, `g_mDiv/Fric`, `g_rxWin` (clamped 600..3000),
   `g_hapticBlockMs` (≤60 s), etc., then `applyActiveType`/`saveCfg`/`swProSaveCfg`);
   `0x03 mode`→`saveMode`+reset; `0x07`→`hapticReinit`; `0x08`→`hapticSendShutdown`;
+  `0x16`→`hapticTestRumble`;
   `0x0A` ("ERS")→`factoryErase`+reset; `0x0B/0x0C`→DFU; capture drain (`OPK_LOG`).
   Each reset/DFU path does `delay(40)` then `NVIC_SystemReset()` (harmless — reboots).
 

+ 22 - 5
OpenPuck/config.cpp

@@ -91,9 +91,10 @@ uint32_t g_pollUs = POLL_US_DEFAULT;
 #define CFG_MAGIC 0xCF
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
-		bootMode, chordBtn[3], rsvd1;
-	// rsvd1: legacy rumble-strength slot (strength now fixed at RUMBLE_SCALE_PCT; ignored -- kept so the
-	// on-flash layout is unchanged and an existing cfg.bin still loads).
+		bootMode, chordBtn[3], rumbScale2;
+	// rumbScale2: host-rumble strength as PERCENT/2 (so 500% fits a byte). 0 = never set -> keep the
+	// RUMBLE_SCALE_PCT default. This revives the byte the removed rumble-strength slider used, so the
+	// on-flash layout is unchanged and an existing cfg.bin still loads.
 	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the id9=0 hold enable (see
 	// haptics.h LIZKEEP_MS). rsvd2 used to be landAll87, this is now ignored.
 	uint8_t rxWin10, lizKeep, rsvd2;
@@ -103,6 +104,8 @@ struct Cfg {
 	uint8_t chordDpad[4];
 	// per-type trackpad->stick mapping: [et][0] = left pad, [et][1] = right pad (PS_*)
 	uint8_t padStick[ET_COUNT][2];
+	// RUMBLE_STYLE_*; 0xFF (short pre-tail file) -> compiled default
+	uint8_t rumbleStyle;
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 // Shortest cfg.bin we still accept: the layout as of CFG_MAGIC 0xCF, i.e. everything before the appended tail.
@@ -121,14 +124,15 @@ void saveCfg()
 		  (uint8_t)(g_persistMode ? 1 : 0),
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
-		  0, // rsvd1 (ex rumble strength)
+		  (uint8_t)(g_rumbleScale / 2), // host-rumble strength, pct/2
 		  (uint8_t)(g_rxWin / 10),
 		  g_lizKeep,
 		  0, //rsvd2, used to be g_landAll87
 		  {},
 		  { g_chordDpad[0], g_chordDpad[1], g_chordDpad[2],
 		    g_chordDpad[3] },
-		  {} };
+		  {},
+		  g_rumbleStyle };
 	for (int i = 0; i < ET_COUNT; i++) {
 		c.type[i] = g_type[i];
 		c.padStick[i][0] = g_padStickCfg[i][0];
@@ -207,6 +211,19 @@ void loadCfg()
 			// through -> keep the on default)
 			if (c.lizKeep <= 1)
 				g_lizKeep = c.lizKeep;
+			// host-rumble strength (pct/2; 0 = never set, or a cfg.bin from before this
+			// field was revived -> keep the RUMBLE_SCALE_PCT default)
+			if (c.rumbScale2) {
+				uint16_t pct = (uint16_t)c.rumbScale2 * 2;
+				if (pct < RUMBLE_SCALE_MIN)
+					pct = RUMBLE_SCALE_MIN;
+				else if (pct > RUMBLE_SCALE_MAX)
+					pct = RUMBLE_SCALE_MAX;
+				g_rumbleScale = pct;
+			}
+			// host-rumble style (0xFF = short pre-tail file -> keep the default)
+			if (c.rumbleStyle <= RUMBLE_STYLE_MAX)
+				g_rumbleStyle = c.rumbleStyle;
 			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();

+ 74 - 4
OpenPuck/haptics.cpp

@@ -27,6 +27,9 @@ uint16_t g_hapticBlockMs = HAPTIC_BLOCK_MS_DEFAULT;
 // or Steam-closed fallback) so the autonomous pad layer -- the trackpad-tick source -- is on.
 // Persisted; console 'u' toggles for A/B.
 uint8_t g_lizKeep = 1;
+// Host-rumble shaping (persisted in cfg.bin; console "RS<pct>" / "RY<n>").
+uint16_t g_rumbleScale = RUMBLE_SCALE_PCT;
+uint8_t g_rumbleStyle = RUMBLE_STYLE_NORMAL;
 // Master enable for the puck->controller haptic RELAY (Steam OUTPUT reports 0x80-0x86, incl. the trackpad
 // texture-feedback stream Steam pushes WHILE you drag). Each relayed frame is an extra TX that precedes the
 // E3 poll and steals its reply window, and the controller must stop to process it -- both can depress the
@@ -286,16 +289,62 @@ static void hapticCancelPendingOn(int slot)
 	}
 	__set_PRIMASK(pm);
 }
+// Integer square root (binary restoring). Used by RUMBLE_STYLE_SOFT; avoids pulling float math into the USB
+// OUT callback that calls hapticSteamRumble().
+static uint32_t isqrt32(uint32_t v)
+{
+	uint32_t r = 0, b = 1UL << 30;
+	while (b > v)
+		b >>= 2;
+	while (b) {
+		if (v >= r + b) {
+			v -= r + b;
+			r = (r >> 1) + b;
+		} else {
+			r >>= 1;
+		}
+		b >>= 2;
+	}
+	return r;
+}
 
 bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 {
 	if (slot >= NSLOT)
 		return false;
-	// Fixed rumble strength: the decoded amplitude doubled, which is what the (now removed) adjustable
-	// rumble-strength setting shipped as its default. Clamp to 16-bit.
+	// Shape the decoded host amplitudes: style first (which motor plays, and the response curve), then the
+	// strength scale. Integer-only -- this runs in the USB OUT callback (ISR context), so no float math.
 	{
-		uint32_t l = (uint32_t)lowFreq * RUMBLE_SCALE_PCT / 100,
-			 h = (uint32_t)highFreq * RUMBLE_SCALE_PCT / 100;
+		uint32_t l = lowFreq, h = highFreq, t;
+		switch (g_rumbleStyle) {
+		case RUMBLE_STYLE_MONO:
+			l = h = (l > h) ? l : h;
+			break;
+		case RUMBLE_STYLE_HEAVY:
+			h = 0;
+			break;
+		case RUMBLE_STYLE_LIGHT:
+			l = 0;
+			break;
+		case RUMBLE_STYLE_SWAP:
+			t = l;
+			l = h;
+			h = t;
+			break;
+		case RUMBLE_STYLE_PUNCHY:
+			// x^2/FS: 0xFFFF*0xFFFF fits uint32 exactly, so no intermediate overflow
+			l = l * l / 0xFFFF;
+			h = h * h / 0xFFFF;
+			break;
+		case RUMBLE_STYLE_SOFT:
+			l = isqrt32(l * 0xFFFF);
+			h = isqrt32(h * 0xFFFF);
+			break;
+		default: // RUMBLE_STYLE_NORMAL
+			break;
+		}
+		l = l * g_rumbleScale / 100;
+		h = h * g_rumbleScale / 100;
 		lowFreq = (l > 0xFFFF) ? 0xFFFF : (uint16_t)l;
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 	}
@@ -606,8 +655,29 @@ void hapticOnReconnect(int slot)
 	uint8_t mk = 2;
 	hapLogAdd(0xFD, 0xEE, &mk, 1);
 }
+// Panel/console rumble test: buzz every connected controller at RUMBLE_TEST_AMP through the normal
+// hapticSteamRumble() path (so g_rumbleStyle / g_rumbleScale / the per-type rumble toggle all apply), and
+// schedule the stop. Deadline 0 = idle; millis()+MS can be 0 only once every 49 days, so bias it off zero.
+static unsigned long g_rumbleTestStop = 0;
+
+void hapticTestRumble()
+{
+	for (uint8_t s = 0; s < NSLOT; s++)
+		if (hapticLinkUp((int)s))
+			hapticSteamRumble(RUMBLE_TEST_AMP, RUMBLE_TEST_AMP, s);
+	g_rumbleTestStop = millis() + RUMBLE_TEST_MS;
+	if (!g_rumbleTestStop)
+		g_rumbleTestStop = 1;
+}
+
 void hapticTask()
 {
+	// stop the test buzz -- signed compare so the millis() rollover cannot strand a latched actuator
+	if (g_rumbleTestStop && (long)(millis() - g_rumbleTestStop) >= 0) {
+		g_rumbleTestStop = 0;
+		for (uint8_t s = 0; s < NSLOT; s++)
+			hapticSteamRumble(0, 0, s);
+	}
 	// id9 steering (SET_SETTINGS index 9 = digital-mappings / the controller's AUTONOMOUS mapping+haptic
 	// engine, which is what generates the trackpad tick haptics). We decide per mode whether that autonomous
 	// engine should be ON, then either land id9=1 ONCE per connect episode (engine on) or hold id9=0 every

+ 27 - 2
OpenPuck/haptics.h

@@ -28,9 +28,34 @@
 // Controller power-off: hapticSendShutdown() relays Steam's confirmed "turn off controller" command (feature-0x01
 // cmd 0x9F, payload "off!" -- captured from the real puck). Sent as a small burst because the RF relay is NO-ACK.
 #define HAPTIC_SHUTDOWN_SHOTS 3u
-// Rumble strength: percent of the decoded host amplitude applied in every mode. Fixed at 200 (double), the
-// default the panel's removed rumble-strength slider shipped with, so pucks feel the same as before.
+// Rumble strength: percent of the decoded host amplitude applied in every translated (non-puck) mode. 200
+// (double) is the default the panel's removed rumble-strength slider shipped with, so an untouched puck feels
+// exactly as before. Runtime-adjustable and persisted -- console "RS<pct>".
 #define RUMBLE_SCALE_PCT 200u
+#define RUMBLE_SCALE_MIN 10u
+#define RUMBLE_SCALE_MAX 500u
+// Rumble style: how the decoded low/high motor amplitudes are shaped before the 0x80 frame is built. Applied
+// BEFORE the strength scale, on the raw host amplitudes. Persisted -- console "RY<n>".
+#define RUMBLE_STYLE_NORMAL \
+	0 // as captured: low -> left speed, high -> right speed
+#define RUMBLE_STYLE_MONO 1 // both motors at max(low,high) -- heavier, fuller
+#define RUMBLE_STYLE_HEAVY 2 // low-frequency motor only (mute the buzzy one)
+#define RUMBLE_STYLE_LIGHT \
+	3 // high-frequency motor only (crisp, no deep rumble)
+#define RUMBLE_STYLE_SWAP 4 // swap the two motors
+#define RUMBLE_STYLE_PUNCHY \
+	5 // squared curve: weak effects softer, strong ones untouched
+#define RUMBLE_STYLE_SOFT \
+	6 // sqrt curve: lifts weak effects so subtle rumble is felt
+#define RUMBLE_STYLE_MAX 6
+extern uint16_t g_rumbleScale; // percent, RUMBLE_SCALE_MIN..RUMBLE_SCALE_MAX
+extern uint8_t g_rumbleStyle; // RUMBLE_STYLE_*
+// Test buzz for the panel/console: a fixed mid-scale amplitude pushed through the SAME shaping path host
+// rumble takes, so what you feel is what a game at that amplitude would feel like. Auto-stops in hapticTask()
+// -- the controller's haptic LATCHES, so the stop is not optional.
+#define RUMBLE_TEST_AMP 0x8000u
+#define RUMBLE_TEST_MS 500u
+void hapticTestRumble();
 
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // Enqueue one host->controller report. `slot` = bond slot (0..NSLOT-1) or 0xFF to broadcast to every

+ 37 - 0
OpenPuck/serial_console.cpp

@@ -71,6 +71,43 @@ void serialConsolePoll()
 				Serial.printf(
 					"# haptic relay (Steam 0x80-0x86) %s\n",
 					g_hapticRelay ? "ON" : "off");
+			} else if (!strcmp(line, "TR")) {
+				// fire one test buzz at the current style/strength
+				hapticTestRumble();
+				Serial.printf(
+					"# test rumble (style %u, %u%%)\n",
+					g_rumbleStyle, g_rumbleScale);
+			} else if (!strncmp(line, "RS", 2) &&
+				   (line[2] >= '0' && line[2] <= '9')) {
+				// "RS<pct>": host-rumble strength as a percent of the amplitude the host asked
+				// for. 200 = the shipped default (double). Persisted.
+				long p = atol(line + 2);
+				if (p < (long)RUMBLE_SCALE_MIN)
+					p = RUMBLE_SCALE_MIN;
+				else if (p > (long)RUMBLE_SCALE_MAX)
+					p = RUMBLE_SCALE_MAX;
+				g_rumbleScale =
+					(uint16_t)(p & ~1L); // stored as pct/2
+				saveCfg();
+				Serial.printf("# rumble strength -> %u%%\n",
+					      g_rumbleScale);
+			} else if (!strncmp(line, "RY", 2) &&
+				   (line[2] >= '0' && line[2] <= '9')) {
+				// "RY<n>": host-rumble style, see RUMBLE_STYLE_* in haptics.h. Persisted.
+				static const char *const RY_NAME[] = {
+					"normal", "mono",   "heavy", "light",
+					"swap",	  "punchy", "soft"
+				};
+				long n = atol(line + 2);
+				if (n < 0)
+					n = 0;
+				else if (n > (long)RUMBLE_STYLE_MAX)
+					n = RUMBLE_STYLE_MAX;
+				g_rumbleStyle = (uint8_t)n;
+				saveCfg();
+				Serial.printf("# rumble style -> %u (%s)\n",
+					      g_rumbleStyle,
+					      RY_NAME[g_rumbleStyle]);
 			} else if (!strcmp(line, "FC")) {
 				// feature-command capture: log Steam's USB SET/GET commands to serial + suppress I45
 				g_cmdCapture = !g_cmdCapture;

+ 43 - 11
OpenPuck/webusb_config.cpp

@@ -98,7 +98,9 @@ static bool boardCommand(uint8_t op)
 //                [v18: p[182..185] chordDpad left/up/right/down (back4+D-pad mode assignments)]
 //                [v19: p[186] swGyroLegacy (Switch Pro gyro mapping: 0 = corrected, 1 = legacy/pre-#189)]
 //                [v20: p[187..194] per-type trackpad->stick map, 4x2B {left pad, right pad} (PS_OFF/LEFT/RIGHT)]
-#define WB_PAYLEN 193
+//                [v21: p[53] rumble strength as PERCENT/2 (field 22, revived); p[195] rumble style
+//                 (field 39, RUMBLE_STYLE_* in haptics.h)]
+#define WB_PAYLEN 194
 // 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
@@ -123,17 +125,17 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (20 = +per-type trackpad->stick mapping (fields 80..87, blob p[187..194]);
-	// 19 = +Switch Pro legacy-gyro select (field 38, blob p[186]); the rumble-strength,
-	// Switch report-rate and Switch gyro-scale settings (fields 22/23/24, blob p[53..55]) are GONE -- those
-	// bytes now read 0; 18 = +configurable back4+D-pad chords (fields 34..37, blob p[182..185]);
+	// protocol version (21 = +rumble style (field 39, blob p[195]) and the REVIVED rumble-strength field 22
+	// at blob p[53], now carrying percent/2; 20 = +per-type trackpad->stick mapping (fields 80..87, blob
+	// p[187..194]); 19 = +Switch Pro legacy-gyro select (field 38, blob p[186]); the Switch report-rate and
+	// gyro-scale settings (fields 23/24, blob p[54..55]) are GONE -- those bytes read 0; 18 = +configurable back4+D-pad chords (fields 34..37, blob p[182..185]);
 	// 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] = 20;
+	p[2] = 21;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -190,9 +192,10 @@ static void webusbSendBlob()
 		for (uint8_t i = 0; i < 12 && buildId[i]; i++)
 			p[41 + i] = (uint8_t)buildId[i];
 	}
-	// v19: reserved. Were rumble strength % (v5) and the Switch Pro report rate + gyro scale (v6); all three
-	// settings were removed, so these read 0 and the panel ignores them.
-	p[53] = 0;
+	// p[53]: rumble strength as PERCENT/2 (v5 field, removed in v19, revived in v21 -- halved so the full
+	// RUMBLE_SCALE_MAX range fits a byte). p[54]/p[55] were the Switch Pro report rate + gyro scale (v6),
+	// still removed, so they read 0 and the panel ignores them.
+	p[53] = (uint8_t)(g_rumbleScale / 2);
 	p[54] = 0;
 	p[55] = 0;
 	{
@@ -307,6 +310,8 @@ static void webusbSendBlob()
 	p[185] = g_chordDpad[CHD_DOWN];
 	// v19: Switch Pro gyro mapping (0 = corrected/default, 1 = legacy pre-#189 raw axes)
 	p[186] = g_swGyroLegacy;
+	// v21: host-rumble style (RUMBLE_STYLE_* -- see haptics.h)
+	p[195] = g_rumbleStyle;
 	// v20: per-type trackpad->stick mapping, {left pad, right pad} per emulated type
 	for (int et = 0; et < ET_COUNT; et++) {
 		p[187 + et * 2] = g_padStickCfg[et][0];
@@ -647,7 +652,9 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if ((op < 0x01 || op > 0x15) &&
+			// 0x16 = test rumble (v21); extend this range whenever a new opcode is added, or the
+			// parser drops it as garbage and the handler below never runs.
+			if ((op < 0x01 || op > 0x16) &&
 			    (op < 0x20 || op > 0x25)) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
@@ -724,6 +731,11 @@ void webusbPoll()
 				hapticReinit();
 			}
 
+			// rumble test buzz: exercises the current style/strength (protocol v21)
+			else if (op == 0x16) {
+				hapticTestRumble();
+			}
+
 			// trigger controller power-off (same path Steam 0x9F / host-suspend use)
 			else if (op == 0x08) {
 				hapticSendShutdown();
@@ -1035,6 +1047,27 @@ void webusbPoll()
 					break;
 				}
 
+				// Host-rumble strength, as PERCENT/2 so the full range fits the
+				// one value byte. Revived in protocol v21; clamped to
+				// RUMBLE_SCALE_MIN..RUMBLE_SCALE_MAX.
+				case 22: {
+					uint16_t pct = (uint16_t)v * 2;
+					if (pct < RUMBLE_SCALE_MIN)
+						pct = RUMBLE_SCALE_MIN;
+					else if (pct > RUMBLE_SCALE_MAX)
+						pct = RUMBLE_SCALE_MAX;
+					g_rumbleScale = pct;
+					break;
+				}
+
+				// Host-rumble style (RUMBLE_STYLE_*). Protocol v21.
+				case 39:
+					g_rumbleStyle =
+						v > RUMBLE_STYLE_MAX ?
+							RUMBLE_STYLE_MAX :
+							v;
+					break;
+
 				// Switch Pro gyro mapping: 0 = corrected (default), 1 = legacy
 				// (pre-#189 raw axes, no sensitivity trim). Protocol v19.
 				case 38:
@@ -1043,7 +1076,6 @@ void webusbPoll()
 					persist = false;
 					break;
 
-					// (field 22, rumble strength, removed -- fixed at RUMBLE_SCALE_PCT)
 					// (fields 23/24, Switch Pro report rate + gyro scale, removed -- rate is
 					//  fixed at full and the gyro mapping is now field 38)
 					// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)

+ 2 - 0
OpenPuck/webusb_config.h

@@ -5,6 +5,8 @@
 //               0x03 <mode>          switch USB mode (0..6): persist + reboot
 //               0x0B                 reboot into serial DFU (adafruit-nrfutil)
 //               0x0C                 reboot into UF2 bootloader (USB mass storage)
+//               0x16                 test rumble: buzz every connected controller with the current
+//                                    style/strength for RUMBLE_TEST_MS (auto-stops)
 //               0x20 <size u32> <crc32 u32>        firmware update: begin staging (disarms any previous)
 //               0x21 <off u32> <len> <data...>     firmware update: sequential chunk (len<=128, %4==0)
 //               0x22                 firmware update: verify staged image + COMMIT (applies on next reboot)

+ 2 - 0
README.md

@@ -67,6 +67,8 @@ battery level are all reported natively and simultaneously, with rumble coming b
 SDL build that ships the SInput driver (SDL 3.4+ / a current Steam client); older hosts fall back to seeing a
 plain HID gamepad.
 
+**Rumble tuning.** In the translated modes (Xbox, Switch, PlayStation) the puck decodes the host's rumble packet itself, so the WebUSB panel's **Rumble** card can reshape it before it reaches the controller. *Strength* scales the amplitude the game asked for (200% is the default and matches previous firmware). *Style* picks how the two motors are driven: mono runs both at the stronger value, heavy and light mute one motor each, punchy softens weak effects while leaving strong ones alone, and soft lifts weak ones so subtle rumble is felt. A **Test rumble** button buzzes the controller with the current settings so you can compare without launching a game. Steam mode relays Steam's own haptics untouched, so these settings don't apply there.
+
 I'm also adding various QOL items as I go as well. For example having to hold the Steam button for like 6 seconds feels like an eternity. If Steam is open you can do Steam + Y for a shutdown. I'm adding Steam + Y for 2 seconds as a shutdown chort in ALL modes now.
 
 Note: to use the Switch mode on a real Switch you'll need to [enable the pro controller wired communication option](https://www.nintendo.com/en-gb/Support/Troubleshooting/How-to-Enable-Disable-Pro-Controller-Wired-Communication-1516284.html).

+ 6 - 1
docs/PROTOCOL.md

@@ -388,10 +388,15 @@ Messages:
 
 - Host to device:
   - `0x01`: get status blob
-  - `0x02 <field> <value>`: set one field
+  - `0x02 <field> <value>`: set one field. Notable fields: `22` host-rumble strength as **percent/2**
+    (10–500%, revived in blob version 21), `39` host-rumble style (`RUMBLE_STYLE_*` in `haptics.h`:
+    0 normal, 1 mono, 2 heavy, 3 light, 4 swapped, 5 punchy, 6 soft), `38` Switch Pro gyro mapping
   - `0x03 <mode>`: switch mode and reboot
   - `0x07`: re-init haptics (clear a stuck buzz)
   - `0x08`: send controller power-off
+  - `0x16`: test rumble — buzz every linked controller with the configured style/strength for 500 ms,
+    auto-stopped by the firmware. **Requires status-blob version ≥ 21**; older firmware drops it silently
+    (the parser only accepts `0x01`–`0x15` and `0x20`–`0x25`).
   - `0x09`: export all bond slots (reply: `0xA7` frame) — see §10.1
   - `0x0A 0x45 0x52 0x53`: factory erase (`"ERS"` magic), then reboot
   - `0x0B` / `0x0C`: reboot into serial DFU / UF2 bootloader

+ 3 - 0
docs/TESTING_GUIDE.md

@@ -8,6 +8,9 @@
 - Can you change modes via the chords? (back4 + A/B/X/Y **and** back4 + each D-pad direction; the chorded
   press must not reach the game, and a D-pad reassignment made in the panel must take effect)
 - Can you change modes via webusb?
+- Does rumble still work in the translated modes, and do the panel's Rumble style/strength settings
+  change how it feels? (heavy vs light drive different motors, so they must feel clearly different;
+  the Test rumble button must buzz and then stop on its own)
 - Do the modes work as expected (including gyro in gyro modes)
 - Is signal quality shown?
 - Is battery level shown and correct?

+ 41 - 0
docs/index.html

@@ -267,6 +267,21 @@
       <p id="swGyroOld" class="note hide">This puck's firmware predates the gyro-mapping setting — update it to configure it.</p>
     </div>
 
+    <div class="card">
+      <h2>Rumble</h2>
+      <!-- Host-rumble shaping (firmware >= v21). Applies to the translated modes (Xbox / Switch / PlayStation),
+           where the puck decodes the host's rumble packet itself. Steam mode relays Steam's own haptics
+           untouched, so these do nothing there. Older firmware hides the block. -->
+      <div id="rumbleBlock">
+        <div class="row"><label>Style</label><select id="rumbleStyle"></select></div>
+        <div class="row"><label>Strength</label><select id="rumbleScale"></select></div>
+        <div class="row" style="margin:10px 0 0"><button id="rumbleTest">Test rumble</button>
+          <span class="note" style="flex:1">Buzzes every connected controller for half a second at the settings above, so you can compare styles without launching a game.</span></div>
+        <p class="note">Strength is a percentage of the amplitude the game asked for; 200% is the shipped default, so leave it there for stock feel. Style reshapes the two motors before that scale: <strong>mono</strong> drives both at the stronger value, <strong>heavy</strong> and <strong>light</strong> mute one motor each, <strong>punchy</strong> softens weak effects while leaving strong ones alone, and <strong>soft</strong> lifts weak ones so subtle rumble is felt.</p>
+      </div>
+      <p id="rumbleOld" class="note hide">This puck's firmware predates the rumble-shaping settings — update it to configure them.</p>
+    </div>
+
     <div class="card wide">
       <h2>Button mapping</h2>
       <p class="note" style="margin-top:0">Separate mappings per emulated controller type — each only offers targets that exist on that controller. The type matching the current mode is highlighted. Steam &amp; Lizard are native puck modes (Steam does its own remapping) and aren't configured here.</p>
@@ -692,6 +707,18 @@ for(const sel of document.querySelectorAll("select.chordD")){
   for(const [lbl,v] of [["Corrected (default)",0],["Legacy (untrimmed)",1]]){
     const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
 }
+// Rumble style names track RUMBLE_STYLE_* in haptics.h; the value IS the wire value for field 39.
+const RUMBLE_STYLES=[["Normal (default)",0],["Mono (both motors)",1],["Heavy (low only)",2],["Light (high only)",3],["Swapped motors",4],["Punchy (squared)",5],["Soft (boost weak)",6]];
+// Strength is sent as percent/2 (field 22), so every value here must be even.
+const RUMBLE_SCALES=[50,75,100,150,200,250,300,400,500];
+{ const sel=document.getElementById("rumbleStyle");
+  for(const [lbl,v] of RUMBLE_STYLES){
+    const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
+}
+{ const sel=document.getElementById("rumbleScale");
+  for(const v of RUMBLE_SCALES){
+    const o=document.createElement("option"); o.value=v; o.textContent=v+"%"+(v===200?" (default)":""); sel.appendChild(o); }
+}
 
 // ---- per-slot tab state ----
 let g_activeSlot = 0;
@@ -890,6 +917,9 @@ function applyBlob(p){
   // carry rumble strength / Switch report rate / Switch gyro scale, all removed settings.
   const gyroMapCap=(p[0]>=19 && p.length>184);
   const swGyroLegacy=gyroMapCap?p[184]:0;
+  // Rumble shaping (protocol v21): firmware p[53]/p[195] = payload p[51]/p[193]. Strength is percent/2.
+  const rumbleCap=(p[0]>=21 && p.length>193);
+  const rumbleScale=rumbleCap?p[51]*2:200, rumbleStyle=rumbleCap?p[193]:0;
   const rssi=(p.length>37?p[37]:0);
 
   // per-slot link status (protocol v8, p.length >= 73)
@@ -1106,6 +1136,14 @@ function applyBlob(p){
     $("#swGyroMapBlock").classList.toggle("hide", !gyroMapCap);
     $("#swGyroOld").classList.toggle("hide", gyroMapCap);
     if(gyroMapCap && document.activeElement!==sel) sel.value=swGyroLegacy; }
+  { const sty=$("#rumbleStyle"), scl=$("#rumbleScale");
+    $("#rumbleBlock").classList.toggle("hide", !rumbleCap);
+    $("#rumbleOld").classList.toggle("hide", rumbleCap);
+    if(rumbleCap){
+      if(document.activeElement!==sty) sty.value=rumbleStyle;
+      // a strength set from the console can land between the presets -- show the nearest one
+      if(document.activeElement!==scl) scl.value=RUMBLE_SCALES.reduce((a,b)=>Math.abs(b-rumbleScale)<Math.abs(a-rumbleScale)?b:a);
+    } }
   for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
   $("#dpadChords").classList.toggle("hide", !dpadCap);
   $("#dpadOld").classList.toggle("hide", dpadCap);
@@ -2215,6 +2253,9 @@ for(const id of ["mDiv","mFric"]){
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
 }
 // per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
+$("#rumbleTest").onclick=async()=>{ if(dev){ await send([0x16]); log("test rumble sent (style "+$("#rumbleStyle").value+", "+$("#rumbleScale").value+"%)"); } };
+$("#rumbleStyle").addEventListener("change", ()=>setField(39, +$("#rumbleStyle").value));
+$("#rumbleScale").addEventListener("change", ()=>setField(22, (+$("#rumbleScale").value)/2));
 $("#swGyroMap").addEventListener("change", ()=>setField(38, +$("#swGyroMap").value));
 for(const sel of document.querySelectorAll("select.chord")){
   sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));