Jelajahi Sumber

Add a "go back" option for gyro, remove dead controls

Jari 2 bulan lalu
induk
melakukan
da8cd66fec

+ 7 - 5
CODE_MAP.md

@@ -72,11 +72,12 @@ re-add wake mouse + `g_active->mountSlots(k)` + WebUSB in locked instance order,
 - **Owns**: `g_usbMode`, `g_xbox`, `g_chordBtn[3]`, `g_chordDpad[4]`, `g_persistMode`, `g_bootMode`,
   `g_debugCdcThisBoot`, `g_mDiv`, `g_mFric`, `g_type[ET_COUNT]` (per-emulated-type
   button config), `g_etype`, live mirrors `g_abSwap`/`g_back[4]`/`g_qamMap`/
-  `g_padHaptics`/`g_ledBright`, `g_rumbleScale`, `g_swProRate`, `g_swGyroScale10`,
-  and the constant `g_pollUs = 4000` (250 Hz, **fixed**).
+  `g_padHaptics`/`g_ledBright`, and the constant `g_pollUs = 4000` (250 Hz, **fixed**).
+  (`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. New fields are **appended to the tail** (`chordDpad[4]`) —
+  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]`) —
   `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;
@@ -309,7 +310,8 @@ JC_SETCB[s])`).
   + `sendReport(0x30, p, 63)`.
 - **Buffers**: `JC_REPLEN = 63` (all input/reply reports are 63 payload bytes);
   reply ring `g_jcQ[NSLOT][8]` of `{rid, data[63]}`; `g_userCal[NSLOT][0x100]`;
-  `g_spiStickCal[18]`; `g_jcMac[NSLOT][6]`; config files `b[3]`.
+  `g_spiStickCal[18]`; `g_jcMac[NSLOT][6]`; config file `b[2]` (`swprocfg.bin` v2 =
+  `[ver][g_swGyroLegacy]`; the report rate is fixed at `USB_STREAM_MS`).
 - **Cross-task shared data**:
   - Reply ring `g_jcQ` / `g_jcQh` / `g_jcQt` — SPSC (usbd produces, loop consumes);
     head/tail `volatile`, ring body **not** volatile.
@@ -378,7 +380,7 @@ 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 (×`g_rumbleScale`),
+- `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]`.
 - `haptic82Blocked`/`hapticLinkUp`/`hapticRelaySlotOk` — link-up + block gates (read

+ 4 - 7
OpenPuck/config.cpp

@@ -72,9 +72,6 @@ void applyActiveType()
 	g_rumble = t.rumble;
 	g_ledBright = t.ledBright;
 }
-// rumble strength % (200 = double); adjustable from the WebUSB panel
-uint8_t g_rumbleScale = 200;
-
 // poll rate defaults to POLL_US_DEFAULT (250 Hz), matching the real Valve puck (see config.h). The
 // delivered report rate equals the poll rate (fresh IMU in every reply). Live-adjustable via console
 // "PR<hz>" for on-HW sweeps; session-only, so any rate persisted by an older build is ignored and boot
@@ -87,7 +84,9 @@ 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], rumbleScale;
+		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).
 	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the id9=0 hold enable (see
 	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
 	uint8_t rxWin10, lizKeep, landAll87;
@@ -113,7 +112,7 @@ void saveCfg()
 		  (uint8_t)(g_persistMode ? 1 : 0),
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
-		  g_rumbleScale,
+		  0, // rsvd1 (ex rumble strength)
 		  (uint8_t)(g_rxWin / 10),
 		  g_lizKeep,
 		  g_landAll87,
@@ -184,8 +183,6 @@ void loadCfg()
 			if (got < (int)sizeof c)
 				consume = true;
 
-			// 0 is a valid setting (rumble off)
-			g_rumbleScale = c.rumbleScale;
 			// lizard-suppression keepalive enable (0/1; anything else = a pre-0xCE cfg leaked
 			// through -> keep the on default)
 			if (c.lizKeep <= 1)

+ 6 - 10
OpenPuck/config.h

@@ -156,16 +156,12 @@ extern uint8_t g_ledBright;
 
 // Copy g_type[g_etype] into the live mirrors above (safe defaults when g_etype == ET_NONE).
 void applyActiveType();
-// rumble strength, percent of decoded amplitude (100 = 1x, 200 = 2x default), all modes
-extern uint8_t g_rumbleScale;
-// Switch Pro motion settings. Persisted in their OWN flash file (mode_switch_pro.cpp), NOT in Cfg -- so changing
-// them never resets the rest of the config. Set from the WebUSB panel.
-// Switch Pro report cadence: 0 = 66Hz (15ms, compat), 1 = 120Hz (8ms, DEFAULT), 2 = full (~250Hz)
-extern uint8_t g_swProRate;
-// Switch Pro gyro sensitivity x10 (10 = 1.0x default; 5/15/20/25/30 = 0.5..3.0x)
-extern uint8_t g_swGyroScale10;
-
-// persist g_swProRate + g_swGyroScale10 to their flash file
+// Switch Pro motion setting. Persisted in its OWN flash file (mode_switch_pro.cpp), NOT in Cfg -- so changing
+// it never resets the rest of the config. Set from the WebUSB panel.
+// Switch Pro gyro mapping: 0 = corrected (default, PR #189 sensitivity trim), 1 = legacy (raw, pre-#189)
+extern uint8_t g_swGyroLegacy;
+
+// persist g_swGyroLegacy to its flash file
 void swProSaveCfg();
 
 // RF poll cadence: 250 Hz (4000 us) -- matches the real Valve puck's rate. The controller does NOT stream

+ 5 - 4
OpenPuck/haptics.cpp

@@ -322,10 +322,11 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 {
 	if (slot >= NSLOT)
 		return false;
-	// user rumble-strength scale (percent; 200 = double). Clamp to 16-bit.
-	if (g_rumbleScale != 100) {
-		uint32_t l = (uint32_t)lowFreq * g_rumbleScale / 100,
-			 h = (uint32_t)highFreq * g_rumbleScale / 100;
+	// 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.
+	{
+		uint32_t l = (uint32_t)lowFreq * RUMBLE_SCALE_PCT / 100,
+			 h = (uint32_t)highFreq * RUMBLE_SCALE_PCT / 100;
 		lowFreq = (l > 0xFFFF) ? 0xFFFF : (uint16_t)l;
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 	}

+ 3 - 0
OpenPuck/haptics.h

@@ -40,6 +40,9 @@
 // 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.
+#define RUMBLE_SCALE_PCT 200u
 
 // ---- 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

+ 26 - 52
OpenPuck/mode_switch_pro.cpp

@@ -17,24 +17,23 @@ using namespace Adafruit_LittleFS_Namespace;
 
 SwitchProController g_switchPro;
 
-// Switch full-report (0x30) cadence. The console integrates the 3 IMU samples per report assuming ~5 ms/sample
-// (3 samples / 15 ms genuine); too high a rate over-integrates the gyro. 120 Hz is drift-free and lower-latency
-// (default); 66 Hz is the genuine compat fallback; "full" is the 4 ms PC rate (lowest latency).
-#define SW_PRO_REPORT_MS 15u // 66 Hz
-#define SW_PRO_REPORT_MS_120 8u // ~120 Hz
+// Switch full-report (0x30) cadence: the 4 ms PC rate (lowest latency). The console integrates the 3 IMU samples
+// per report by sample COUNT at a fixed ~5 ms/sample, so slower cadences were once offered as drift fallbacks;
+// they are gone -- full rate is what everything ships with.
 // SC2 accel is +/-2g (16384/g); /4 -> the genuine Pro +/-8g (4096/g) the Switch cal expects
 #define SW_ACCEL_DIV 4
 
-uint8_t g_swProRate =
-	2; // 0 = 66Hz, 1 = 120Hz, 2 = full (~250Hz / USB_STREAM_MS, default)
-uint8_t g_swGyroScale10 = 10; // gyro sensitivity x10 (10 = 1.0x)
+// 0 = corrected gyro mapping (default, PR #189: roll x0.8, pitch/yaw x0.9 -- matches a genuine Pro Controller and
+// Steam mode); 1 = legacy mapping, the raw pre-#189 axes with no trim.
+uint8_t g_swGyroLegacy = 0;
 
-// Persist the two Switch Pro motion settings in their own tiny file so they never trigger a global-Cfg reset.
+// Persist the Switch Pro motion setting in its own tiny file so it never triggers a global-Cfg reset.
 #define SWPRO_CFG_FILE "/swprocfg.bin"
 void swProSaveCfg()
 {
-	uint8_t b[3] = { 0x01, g_swProRate,
-			 g_swGyroScale10 }; // [ver][rate 0/1/2][gyroScale x10]
+	// ver 0x02 = [ver][gyroLegacy]. ver 0x01 was [ver][rate][gyroScale x10]; both knobs are gone, so such a
+	// file is simply ignored on load (defaults win) rather than migrated.
+	uint8_t b[2] = { 0x02, g_swGyroLegacy };
 	InternalFS.remove(SWPRO_CFG_FILE);
 	File f(InternalFS);
 	if (f.open(SWPRO_CFG_FILE, FILE_O_WRITE)) {
@@ -45,31 +44,13 @@ void swProSaveCfg()
 static void swProLoadCfg()
 {
 	File f(InternalFS);
-	uint8_t b[3];
+	uint8_t b[2];
 	if (f.open(SWPRO_CFG_FILE, FILE_O_READ)) {
-		if (f.read(b, 3) == 3 && b[0] == 0x01) {
-			// 0=66Hz, 1=120Hz, 2=full; bad value -> default full
-			g_swProRate = (b[1] <= 2) ? b[1] : 2;
-			g_swGyroScale10 =
-				(b[2] >= 5 && b[2] <= 30) ?
-					b[2] :
-					10; // sane bounds (0.5x..3.0x)
-		}
+		if (f.read(b, 2) == 2 && b[0] == 0x02)
+			g_swGyroLegacy = b[1] ? 1 : 0;
 		f.close();
 	}
 }
-// Scale a gyro axis by g_swGyroScale10/10, clamped to int16 (3x can saturate at high rates -- expected).
-static int16_t gscale(int16_t v)
-{
-	if (g_swGyroScale10 == 10)
-		return v;
-	int32_t s = (int32_t)v * (int32_t)g_swGyroScale10 / 10;
-	if (s > 32767)
-		s = 32767;
-	else if (s < -32768)
-		s = -32768;
-	return (int16_t)s;
-}
 
 static const uint8_t SWPRO_HID_DESC[] = {
 	0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01,
@@ -528,18 +509,19 @@ static void switchProBuild(uint8_t slot, uint8_t out[63])
 	int16_t aY = (int16_t)((-(int16_t)g_in[bond].ax) / SW_ACCEL_DIV);
 	int16_t aZ = (int16_t)(g_in[bond].az / SW_ACCEL_DIV);
 
-	// gyro outputs scaled by the user sensitivity factor
-	int16_t groll = gscale((int16_t)g_in[bond].gy);
-	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[bond].gx));
-	int16_t gyaw = gscale((int16_t)g_in[bond].gz);
+	int16_t groll = (int16_t)g_in[bond].gy;
+	int16_t gpitch = (int16_t)(-(int16_t)g_in[bond].gx);
+	int16_t gyaw = (int16_t)g_in[bond].gz;
 
-	// The physical yaw of the SC is making the roll (or yaw if facing to wall like on switch) move to fast in switch mode.
-	// Reducing it to 80% sensitivity. Using int32 to fix a possible overflow during the multiplication.
-	groll = (int16_t)(((int32_t)groll * 4) / 5);
-
-	// Other sensors were also a little off. Reducing to 90% sensitivity matches Switch Pro Controller and Steam mode.
-	gpitch = (int16_t)(((int32_t)gpitch * 9) / 10);
-	gyaw = (int16_t)(((int32_t)gyaw * 9) / 10);
+	// Sensitivity trim (PR #189), skipped in legacy mode (g_swGyroLegacy, panel-selectable):
+	//  - the physical yaw of the SC made the roll (or yaw, if facing a wall like on the Switch) move too fast in
+	//    Switch mode -> 80%. int32 for the multiply so it cannot overflow.
+	//  - pitch/yaw were also a little off -> 90% matches a genuine Switch Pro Controller and Steam mode.
+	if (!g_swGyroLegacy) {
+		groll = (int16_t)(((int32_t)groll * 4) / 5);
+		gpitch = (int16_t)(((int32_t)gpitch * 9) / 10);
+		gyaw = (int16_t)(((int32_t)gyaw * 9) / 10);
+	}
 
 	for (int k = 0; k < 3; k++) {
 		int o = 12 + k * 12;
@@ -991,15 +973,7 @@ void SwitchProController::task()
 		// not until the host has finished init + selected 0x30
 		if (g_swProReportMode[s] != 0x30)
 			continue;
-		// The Switch integrates the report's 3 IMU samples by SAMPLE COUNT at a fixed ~5 ms/sample (it ignores
-		// the timer byte and assumes a 3-samples-per-15ms genuine cadence). Streaming faster (e.g. 250 Hz x 3
-		// = 750 samples/s) over-credits gyro rotation ~3.75x, so residual bias accumulates into the slow
-		// orientation lean that builds over minutes and resets on replug. 15 ms matches the genuine push,
-		// making integration 1:1.
-		uint32_t interval = (g_swProRate == 2) ? USB_STREAM_MS :
-				    (g_swProRate == 1) ? SW_PRO_REPORT_MS_120 :
-							 SW_PRO_REPORT_MS;
-		if (millis() - g_swProLastMs[s] < interval)
+		if (millis() - g_swProLastMs[s] < USB_STREAM_MS)
 			continue;
 		g_swProLastMs[s] = millis();
 		uint8_t p[63];

+ 21 - 25
OpenPuck/webusb_config.cpp

@@ -44,7 +44,7 @@ static void fwupAckPost(uint8_t status)
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [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]
+//                [gitDirty][buildId 12B ASCII, NUL-padded][rsvd x3: ex rumbleScale/swProRate/swGyroScale10][raw accel ax ay az 3x s16 LE]
 //                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
 //                [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]
@@ -56,7 +56,8 @@ static void fwupAckPost(uint8_t status)
 //                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
 //                [v14/v17: p[181] landAll87 (verbatim-0x87-relay experiment toggle)]
 //                [v18: p[182..185] chordDpad left/up/right/down (back4+D-pad mode assignments)]
-#define WB_PAYLEN 184
+//                [v19: p[186] swGyroLegacy (Switch Pro gyro mapping: 0 = corrected, 1 = legacy/pre-#189)]
+#define WB_PAYLEN 185
 // 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
@@ -81,14 +82,16 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (18 = +configurable back4+D-pad chords (fields 34..37, blob p[182..185]);
+	// protocol version (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]);
 	// 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] = 18;
+	p[2] = 19;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -145,13 +148,11 @@ static void webusbSendBlob()
 		for (uint8_t i = 0; i < 12 && buildId[i]; i++)
 			p[41 + i] = (uint8_t)buildId[i];
 	}
-	p[53] = g_rumbleScale; // rumble strength % (protocol v5)
-
-	// Switch Pro report rate 0=66/1=120/2=full (protocol v6)
-	p[54] = g_swProRate;
-
-	// Switch Pro gyro sensitivity x10 (protocol v6)
-	p[55] = g_swGyroScale10;
+	// 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[54] = 0;
+	p[55] = 0;
 	{
 		// Read the ACTIVE slot's accel, not a hardcoded g_in[0]: a controller bonded to a non-zero
 		// slot leaves g_in[0] zeroed, which made this readout (and lizard) look dead.
@@ -262,6 +263,8 @@ static void webusbSendBlob()
 	p[183] = g_chordDpad[CHD_UP];
 	p[184] = g_chordDpad[CHD_RIGHT];
 	p[185] = g_chordDpad[CHD_DOWN];
+	// v19: Switch Pro gyro mapping (0 = corrected/default, 1 = legacy pre-#189 raw axes)
+	p[186] = g_swGyroLegacy;
 	// 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
@@ -958,24 +961,17 @@ void webusbPoll()
 					}
 					break;
 
-				// rumble strength % (0=off, 100=1x, 200=double)
-				case 22:
-					g_rumbleScale = v;
-					break;
-
-				// Switch Pro report rate (0=66Hz,1=120Hz,2=full)
-				case 23:
-					g_swProRate = (v <= 2) ? v : 2;
+				// Switch Pro gyro mapping: 0 = corrected (default), 1 = legacy
+				// (pre-#189 raw axes, no sensitivity trim). Protocol v19.
+				case 38:
+					g_swGyroLegacy = v ? 1 : 0;
 					swProSaveCfg();
 					persist = false;
 					break;
-				case 24:
-					g_swGyroScale10 =
-						(v >= 5 && v <= 30) ? v : 10;
-					swProSaveCfg();
-					persist = false;
-					break; // Switch Pro gyro scale x10
 
+					// (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)
 					// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
 

+ 1 - 1
docs/PROTOCOL.md

@@ -469,7 +469,7 @@ Bonds are written one slot at a time so no command exceeds a single USB-FS packe
 acked with a status blob so the host's read-after-write keeps the OUT pipe flowing. The panel sends all four
 `0x0D` slot writes plus every config field via `0x02`, then a single `0x0E` to persist and reboot — so the
 reboot regenerates the session addresses from the new UUIDs and the restored puck reproduces both the pairings
-and all settings. Other settings (mouse, rumble, chords, per-type button maps, Switch Pro motion) ride in the
+and all settings. Other settings (mouse, chords, per-type button maps, Switch Pro gyro mapping) ride in the
 backup file as plain `0x02` field values, not in the bond commands.
 
 ## 11. Timing notes

+ 62 - 49
docs/index.html

@@ -48,6 +48,12 @@
   .stat .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
   .stat .v{font-size:18px;margin-top:2px}
   .modes{display:flex;gap:10px;flex-wrap:wrap}
+  /* back4 chords: face buttons in the left column, D-pad in the right; auto-fit collapses to one column on a
+     narrow viewport, and also when the D-pad column is hidden on pre-v18 firmware (display:none drops the item) */
+  .chord-cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:0 24px}
+  .chord-cols .row{margin:10px 0}
+  .chord-cols .row label{flex:0 0 108px}
+  .colhead{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.06em;margin:2px 0 0}
   .note{color:var(--mut);font-size:12px;margin-top:10px}
   #log{font:12px ui-monospace,Menlo,monospace;color:var(--mut);white-space:pre-wrap;margin-top:8px;max-height:140px;overflow:auto}
   .hide{display:none}
@@ -168,17 +174,23 @@
     <div class="card">
       <h2>Back4 chords (face + D-pad)</h2>
       <p class="note" style="margin-top:0">Hold all four back paddles (L4+R4+L5+R5) plus a face button or D-pad direction to switch mode without the WebUI. <strong>A is always Steam</strong> — that is the way back out of any mode. B / X / Y and all four D-pad directions are configurable below (defaults: B=Lizard, X=Xbox, Y=Switch Pro).</p>
-      <div class="row"><label>back4 + B</label><select class="chord" data-i="0"></select></div>
-      <div class="row"><label>back4 + X</label><select class="chord" data-i="1"></select></div>
-      <div class="row"><label>back4 + Y</label><select class="chord" data-i="2"></select></div>
-      <!-- D-pad chords (firmware >= v18). Unlike the face selects these offer the game/clean modes too: those
-           modes have no WebUSB interface, so a chord is the only way IN, and back4+A is always the way out.
-           Older firmware has no D-pad chords at all -> the whole block hides (see #dpadChords in applyBlob). -->
-      <div id="dpadChords" class="hide">
-        <div class="row"><label>back4 + Left</label><select class="chordD" data-i="0"></select></div>
-        <div class="row"><label>back4 + Up</label><select class="chordD" data-i="1"></select></div>
-        <div class="row"><label>back4 + Right</label><select class="chordD" data-i="2"></select></div>
-        <div class="row"><label>back4 + Down</label><select class="chordD" data-i="3"></select></div>
+      <div class="chord-cols">
+        <div>
+          <div class="colhead">Face buttons</div>
+          <div class="row"><label>back4 + B</label><select class="chord" data-i="0"></select></div>
+          <div class="row"><label>back4 + X</label><select class="chord" data-i="1"></select></div>
+          <div class="row"><label>back4 + Y</label><select class="chord" data-i="2"></select></div>
+        </div>
+        <!-- D-pad chords (firmware >= v18). Unlike the face selects these offer the game/clean modes too: those
+             modes have no WebUSB interface, so a chord is the only way IN, and back4+A is always the way out.
+             Older firmware has no D-pad chords at all -> the whole column hides (see #dpadChords in applyBlob). -->
+        <div id="dpadChords" class="hide">
+          <div class="colhead">D-pad</div>
+          <div class="row"><label>back4 + Left</label><select class="chordD" data-i="0"></select></div>
+          <div class="row"><label>back4 + Up</label><select class="chordD" data-i="1"></select></div>
+          <div class="row"><label>back4 + Right</label><select class="chordD" data-i="2"></select></div>
+          <div class="row"><label>back4 + Down</label><select class="chordD" data-i="3"></select></div>
+        </div>
       </div>
       <p id="dpadOld" class="note hide">This puck's firmware predates the D-pad chords — update it to configure them.</p>
     </div>
@@ -243,18 +255,15 @@
       <p class="note">Right-pad cursor in Steam-mode lizard (Steam closed) and in Xbox mode. Sensitivity is a divisor — lower = faster pointer. Glide = how long the cursor coasts after a flick.</p>
     </div>
 
-    <div class="card">
-      <h2>Rumble</h2>
-      <div class="row"><label>Strength</label><input type="range" id="rumble" min="0" max="255" step="5"><span class="val" id="rumbleV"></span></div>
-      <p class="note">Scales rumble amplitude across all modes (100% = native, 200% = double, 0% = off).</p>
-    </div>
-
     <div class="card">
       <h2>Switch Pro motion</h2>
-      <div class="row"><label>Report rate</label><select id="swRate"></select></div>
-      <p class="note" style="margin:0 0 6px">Full rate (~250 Hz, default) is lowest-latency. 120 Hz is low-latency and drift-free. 66 Hz is a genuine-cadence compatibility fallback — drop to it only if your console rejects the faster rates.</p>
-      <div class="row"><label>Gyro scale</label><select id="swGyro"></select></div>
-      <p class="note">Gyro sensitivity multiplier for Switch Pro mode (1.0× = unchanged).</p>
+      <!-- Gyro mapping (firmware >= v19). Older firmware always uses the corrected mapping and has no field
+           to set -> the whole block hides (see #swGyroMapBlock in applyBlob). -->
+      <div id="swGyroMapBlock">
+        <div class="row"><label>Gyro mapping</label><select id="swGyroMap"></select></div>
+        <p class="note">Corrected (default) trims the gyro so Switch Pro mode matches a genuine Pro Controller and Steam mode (roll ×0.8, pitch/yaw ×0.9). Legacy sends the raw axes untrimmed — the pre-fix behavior, if you preferred the faster feel or tuned your in-game sensitivity around it.</p>
+      </div>
+      <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 wide">
@@ -662,12 +671,8 @@ for(const sel of document.querySelectorAll("select.chordD")){
   MODE_NAMES.forEach((n,i)=>{
     const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
 }
-{ const sel=document.getElementById("swRate");
-  for(const [lbl,v] of [["Full rate (~250 Hz, default)",2],["120 Hz",1],["66 Hz (compatibility)",0]]){
-    const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
-}
-{ const sel=document.getElementById("swGyro");
-  for(const [lbl,v] of [["0.5×",5],["1.0×",10],["1.5×",15],["2.0×",20],["2.5×",25],["3.0×",30]]){
+{ const sel=document.getElementById("swGyroMap");
+  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); }
 }
 
@@ -861,9 +866,11 @@ function applyBlob(p){
   // the built-in default behavior, so the editor is shown only when the device is in Lizard mode.
   $("#lizardCard").style.display = (mode===3 && p[0]>=16)?"":"none";
   const battery=(p.length>36?p[36]:0);
-  const rumbleScale=(p.length>51?p[51]:200);
-  const swProRate=(p.length>52?p[52]:2);
-  const swGyro10=(p.length>53?p[53]:10);
+  // Switch Pro gyro mapping (protocol v19, firmware p[186] = payload p[184]). Older firmware has no such
+  // setting at all -> the select hides (see #swGyroMap below). p[51..53] are the reserved bytes that used to
+  // 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;
   const rssi=(p.length>37?p[37]:0);
 
   // per-slot link status (protocol v8, p.length >= 73)
@@ -1043,7 +1050,7 @@ function applyBlob(p){
   updateVersionUI();
   updateFwGate(); // (re)evaluate the firmware-tab gate on every blob — version is at p[0]
   checkUpdateNotice(); // needs BOTH the installed version (this blob) and relCache (fetched on connect)
-  setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
+  setSlider("mDiv",mDiv); setSlider("mFric",mFric);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   // 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); }
@@ -1071,14 +1078,16 @@ function applyBlob(p){
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
     $("#stImu").textContent = `a=(${ax}, ${ay}, ${az})  |a|=${amag}`;
   }
-  { const sel=$("#swRate"); if(document.activeElement!==sel) sel.value=swProRate; }
-  { const sel=$("#swGyro"); if(document.activeElement!==sel) sel.value=swGyro10; }
+  { const sel=$("#swGyroMap");
+    $("#swGyroMapBlock").classList.toggle("hide", !gyroMapCap);
+    $("#swGyroOld").classList.toggle("hide", gyroMapCap);
+    if(gyroMapCap && document.activeElement!==sel) sel.value=swGyroLegacy; }
   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);
   if(dpadCap) for(const sel of document.querySelectorAll("select.chordD")){ if(document.activeElement!==sel) sel.value=chordD[+sel.dataset.i]; }
 }
-function fmtSlider(id,v){ return id==="rumble" ? v+"%" : id==="hapBlockS" ? v+" s" : ""+v; }
+function fmtSlider(id,v){ return id==="hapBlockS" ? v+" s" : ""+v; }
 function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
 
 let inflight=false;
@@ -1170,11 +1179,12 @@ function buildBackup(p, bp){
   const types=[];
   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]}); }
-  const cfg={ 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 };
-  // D-pad chords only exist from protocol v18; omit the key entirely on older pucks so a restore doesn't
-  // replay defaults over a newer puck's real settings.
+  const cfg={ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]], types };
+  // D-pad chords only exist from protocol v18, the Switch gyro mapping from v19; omit the keys entirely on
+  // older pucks so a restore doesn't replay defaults over a newer puck's real settings. (Backups from before
+  // those settings existed carry rumble/swProRate/swGyro10 keys instead — now-removed settings, ignored.)
   if(p[0]>=18 && p.length>183) cfg.chordD=[p[180],p[181],p[182],p[183]];
+  if(p[0]>=19 && p.length>184) cfg.swGyroLegacy=p[184];
   // version 2: include the lizard map so it survives a backup/restore cycle.
   // Guard on !lizardBusy to avoid a race with the one-shot lazy load (lizardExchange sets
   // lizardBusy while the initial 0x11 fetch is in flight); if the load is still pending the
@@ -1221,11 +1231,13 @@ async function importBackup(file){
   // driven while the import replays every setting onto the puck, then closes on Done / on error.
   modalOpen("Restore backup");
   const c=obj.config;
-  // Pre-count every write so the bar tracks real progress: settings (9 base fields + 4 D-pad chords when the
-  // backup carries them), per-type config (9 each), the lizard bindings, 4 bond slots, and the final commit.
+  // Pre-count every write so the bar tracks real progress: settings (6 base fields, + 4 D-pad chords and the
+  // Switch gyro mapping when the backup carries them), per-type config (9 each), the lizard bindings, 4 bond
+  // slots, and the final commit.
   const typeN=Array.isArray(c.types)?Math.min(c.types.length,4):0;
   const lizardN=(Array.isArray(c.lizardMap) && lizardCapable())?Math.min(c.lizardMap.length,LZ_MAX):0;
-  const total=9 + (Array.isArray(c.chordD)?4:0) + typeN*9 + lizardN + 4 + 1;
+  const baseN=6 + (Array.isArray(c.chordD)?4:0) + (c.swGyroLegacy!==undefined?1:0);
+  const total=baseN + typeN*9 + lizardN + 4 + 1;
   let step=0, stage="";
   const tick=()=>modalStage(stage, Math.min(99,Math.floor(step*100/total)));
   try{
@@ -1247,11 +1259,13 @@ async function importBackup(file){
     const sf=async(f,v)=>{ await w([0x02, f, (v|0)&0xff]); };
     // replay every setting (each persists; none reboot). mode is applied by the 0x0E commit below.
     stage="Restoring settings"; tick();
-    await sf(1,c.mDiv); await sf(2,c.mFric); await sf(22,c.rumble); await sf(16,c.persistMode?1:0);
+    await sf(1,c.mDiv); await sf(2,c.mFric); await sf(16,c.persistMode?1:0);
     await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
     // absent in backups taken before the D-pad chords existed (or from a pre-v18 puck) -- skip, don't default
     if(Array.isArray(c.chordD)) for(let i=0;i<4;i++) await sf(CHORD_DPAD_FIELD[i], c.chordD[i]);
-    await sf(23,c.swProRate); await sf(24,c.swGyro10);
+    // Switch Pro gyro mapping (v19+ backups only). Backups also carry rumble/swProRate/swGyro10 from the
+    // removed settings; those fields no longer exist on the puck, so they are not replayed.
+    if(c.swGyroLegacy!==undefined) await sf(38, c.swGyroLegacy?1:0);
     // c.types is absent in some very old backups created before per-type paddle/haptic config
     // was added to the panel export; guard so a missing field never crashes the import mid-flight.
     if(typeN){
@@ -1260,8 +1274,8 @@ async function importBackup(file){
         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+typeN*9)+" fields)");
-    } else { log("settings replayed (9 fields — no per-type config in this backup)"); }
+      log("settings replayed ("+(baseN+typeN*9)+" fields)");
+    } else { log("settings replayed ("+baseN+" fields — no per-type config in this backup)"); }
     // restore the lizard map if the backup includes it (version 2+) and the puck speaks v16+.
     // pre-lizard-map backups simply lack c.lizardMap; skip silently so they still import cleanly.
     if(lizardN){
@@ -2167,14 +2181,13 @@ $("#wipeBoard").onclick=async()=>{
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 $("#landAll87").onclick=async()=>{ const on=$("#landAll87").classList.contains("active"); await setField(29, on?0:1); log("land-all-0x87 "+(on?"disabled":"enabled")); };
 // Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
-for(const id of ["mDiv","mFric","rumble"]){
-  const field={mDiv:1,mFric:2,rumble:22}[id];
+for(const id of ["mDiv","mFric"]){
+  const field={mDiv:1,mFric:2}[id];
   $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
 }
 // per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
-$("#swRate").addEventListener("change", ()=>setField(23, +$("#swRate").value));
-$("#swGyro").addEventListener("change", ()=>setField(24, +$("#swGyro").value));
+$("#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));
 }