Просмотр исходного кода

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

Jari 2 месяцев назад
Родитель
Сommit
da8cd66fec
9 измененных файлов с 135 добавлено и 153 удалено
  1. 7 5
      CODE_MAP.md
  2. 4 7
      OpenPuck/config.cpp
  3. 6 10
      OpenPuck/config.h
  4. 5 4
      OpenPuck/haptics.cpp
  5. 3 0
      OpenPuck/haptics.h
  6. 26 52
      OpenPuck/mode_switch_pro.cpp
  7. 21 25
      OpenPuck/webusb_config.cpp
  8. 1 1
      docs/PROTOCOL.md
  9. 62 49
      docs/index.html

+ 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`,
 - **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
   `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`/
   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`,
 - `struct Cfg` is serialized to `/cfg.bin` (LittleFS), magic `0xCF`. `rxWin10`,
   `lizKeep`, `landAll87` and the per-type table travel with it; `rsvd0` is the
   `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`),
   `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.
   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;
 - `loadCfg()` resolves the boot mode policy: one-shot `bootMode` wins once then clears;
@@ -309,7 +310,8 @@ JC_SETCB[s])`).
   + `sendReport(0x30, p, 63)`.
   + `sendReport(0x30, p, 63)`.
 - **Buffers**: `JC_REPLEN = 63` (all input/reply reports are 63 payload bytes);
 - **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]`;
   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**:
 - **Cross-task shared data**:
   - Reply ring `g_jcQ` / `g_jcQh` / `g_jcQt` — SPSC (usbd produces, loop consumes);
   - Reply ring `g_jcQ` / `g_jcQh` / `g_jcQt` — SPSC (usbd produces, loop consumes);
     head/tail `volatile`, ring body **not** volatile.
     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_testHaptic`, `g_hapticStop` (`volatile`), `g_hapticBlockOn`, `g_hapticBlockMs`,
   `g_hapticBlockUntil[NSLOT]`, `g_relayOp`, `g_relaySub`.
   `g_hapticBlockUntil[NSLOT]`, `g_relayOp`, `g_relaySub`.
 - `hapticSendShutdown()` — bursts 0x9F "off!" (`{6f 66 66 21}`) ×3 broadcast.
 - `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**.
   `relayEnqueue(0x80, p, 9, slot)`; **called from usbd (mode rumble callbacks) and loop**.
   Per-slot stuck-rumble tracking `g_rumble80On/Ms[NSLOT]`.
   Per-slot stuck-rumble tracking `g_rumble80On/Ms[NSLOT]`.
 - `haptic82Blocked`/`hapticLinkUp`/`hapticRelaySlotOk` — link-up + block gates (read
 - `haptic82Blocked`/`hapticLinkUp`/`hapticRelaySlotOk` — link-up + block gates (read

+ 4 - 7
OpenPuck/config.cpp

@@ -72,9 +72,6 @@ void applyActiveType()
 	g_rumble = t.rumble;
 	g_rumble = t.rumble;
 	g_ledBright = t.ledBright;
 	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
 // 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
 // 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
 // "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
 #define CFG_MAGIC 0xCF
 struct Cfg {
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 	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
 	// 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).
 	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
 	uint8_t rxWin10, lizKeep, landAll87;
 	uint8_t rxWin10, lizKeep, landAll87;
@@ -113,7 +112,7 @@ void saveCfg()
 		  (uint8_t)(g_persistMode ? 1 : 0),
 		  (uint8_t)(g_persistMode ? 1 : 0),
 		  g_bootMode,
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
-		  g_rumbleScale,
+		  0, // rsvd1 (ex rumble strength)
 		  (uint8_t)(g_rxWin / 10),
 		  (uint8_t)(g_rxWin / 10),
 		  g_lizKeep,
 		  g_lizKeep,
 		  g_landAll87,
 		  g_landAll87,
@@ -184,8 +183,6 @@ void loadCfg()
 			if (got < (int)sizeof c)
 			if (got < (int)sizeof c)
 				consume = true;
 				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
 			// lizard-suppression keepalive enable (0/1; anything else = a pre-0xCE cfg leaked
 			// through -> keep the on default)
 			// through -> keep the on default)
 			if (c.lizKeep <= 1)
 			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).
 // Copy g_type[g_etype] into the live mirrors above (safe defaults when g_etype == ET_NONE).
 void applyActiveType();
 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();
 void swProSaveCfg();
 
 
 // RF poll cadence: 250 Hz (4000 us) -- matches the real Valve puck's rate. The controller does NOT stream
 // 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)
 	if (slot >= NSLOT)
 		return false;
 		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;
 		lowFreq = (l > 0xFFFF) ? 0xFFFF : (uint16_t)l;
 		highFreq = (h > 0xFFFF) ? 0xFFFF : (uint16_t)h;
 		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
 // 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.
 // 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
 #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) ----
 // ---- 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
 // 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;
 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
 // SC2 accel is +/-2g (16384/g); /4 -> the genuine Pro +/-8g (4096/g) the Switch cal expects
 #define SW_ACCEL_DIV 4
 #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"
 #define SWPRO_CFG_FILE "/swprocfg.bin"
 void swProSaveCfg()
 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);
 	InternalFS.remove(SWPRO_CFG_FILE);
 	File f(InternalFS);
 	File f(InternalFS);
 	if (f.open(SWPRO_CFG_FILE, FILE_O_WRITE)) {
 	if (f.open(SWPRO_CFG_FILE, FILE_O_WRITE)) {
@@ -45,31 +44,13 @@ void swProSaveCfg()
 static void swProLoadCfg()
 static void swProLoadCfg()
 {
 {
 	File f(InternalFS);
 	File f(InternalFS);
-	uint8_t b[3];
+	uint8_t b[2];
 	if (f.open(SWPRO_CFG_FILE, FILE_O_READ)) {
 	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();
 		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[] = {
 static const uint8_t SWPRO_HID_DESC[] = {
 	0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01,
 	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 aY = (int16_t)((-(int16_t)g_in[bond].ax) / SW_ACCEL_DIV);
 	int16_t aZ = (int16_t)(g_in[bond].az / 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++) {
 	for (int k = 0; k < 3; k++) {
 		int o = 12 + k * 12;
 		int o = 12 + k * 12;
@@ -991,15 +973,7 @@ void SwitchProController::task()
 		// not until the host has finished init + selected 0x30
 		// not until the host has finished init + selected 0x30
 		if (g_swProReportMode[s] != 0x30)
 		if (g_swProReportMode[s] != 0x30)
 			continue;
 			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;
 			continue;
 		g_swProLastMs[s] = millis();
 		g_swProLastMs[s] = millis();
 		uint8_t p[63];
 		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]
 //                [qos][persistMode][chordBtn B][chordBtn X][chordBtn Y][pollsps_lo][pollsps_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [loopPeriod_lo][loopPeriod_hi][loopWorstIdx][loopWorstUs_lo][loopWorstUs_hi]
 //                [pollPeriod_lo][pollPeriod_hi][logEnabled][battery%][rssi|dBm|]
 //                [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]
 //                [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}]
 //                [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]
 //                [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)]
 //                 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)]
 //                [v14/v17: p[181] landAll87 (verbatim-0x87-relay experiment toggle)]
 //                [v18: p[182..185] chordDpad left/up/right/down (back4+D-pad mode assignments)]
 //                [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
 // 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
 // -- 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
 // 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[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 	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
 	// 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
 	// 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
 	// 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
 	// 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
 	// 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)
 	// cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 18;
+	p[2] = 19;
 	p[3] = g_usbMode;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
 	p[5] = (uint8_t)g_mFric;
@@ -145,13 +148,11 @@ static void webusbSendBlob()
 		for (uint8_t i = 0; i < 12 && buildId[i]; i++)
 		for (uint8_t i = 0; i < 12 && buildId[i]; i++)
 			p[41 + i] = (uint8_t)buildId[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
 		// 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.
 		// 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[183] = g_chordDpad[CHD_UP];
 	p[184] = g_chordDpad[CHD_RIGHT];
 	p[184] = g_chordDpad[CHD_RIGHT];
 	p[185] = g_chordDpad[CHD_DOWN];
 	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
 	// 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
 	// 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
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -958,24 +961,17 @@ void webusbPoll()
 					}
 					}
 					break;
 					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();
 					swProSaveCfg();
 					persist = false;
 					persist = false;
 					break;
 					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)
 					// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
 					// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
 					// (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
 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
 `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
 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.
 backup file as plain `0x02` field values, not in the bond commands.
 
 
 ## 11. Timing notes
 ## 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 .k{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.05em}
   .stat .v{font-size:18px;margin-top:2px}
   .stat .v{font-size:18px;margin-top:2px}
   .modes{display:flex;gap:10px;flex-wrap:wrap}
   .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}
   .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}
   #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}
   .hide{display:none}
@@ -168,17 +174,23 @@
     <div class="card">
     <div class="card">
       <h2>Back4 chords (face + D-pad)</h2>
       <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>
       <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>
       </div>
       <p id="dpadOld" class="note hide">This puck's firmware predates the D-pad chords — update it to configure them.</p>
       <p id="dpadOld" class="note hide">This puck's firmware predates the D-pad chords — update it to configure them.</p>
     </div>
     </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>
       <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>
 
 
-    <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">
     <div class="card">
       <h2>Switch Pro motion</h2>
       <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>
 
 
     <div class="card wide">
     <div class="card wide">
@@ -662,12 +671,8 @@ for(const sel of document.querySelectorAll("select.chordD")){
   MODE_NAMES.forEach((n,i)=>{
   MODE_NAMES.forEach((n,i)=>{
     const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
     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); }
     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.
   // 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";
   $("#lizardCard").style.display = (mode===3 && p[0]>=16)?"":"none";
   const battery=(p.length>36?p[36]:0);
   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);
   const rssi=(p.length>37?p[37]:0);
 
 
   // per-slot link status (protocol v8, p.length >= 73)
   // per-slot link status (protocol v8, p.length >= 73)
@@ -1043,7 +1050,7 @@ function applyBlob(p){
   updateVersionUI();
   updateVersionUI();
   updateFwGate(); // (re)evaluate the firmware-tab gate on every blob — version is at p[0]
   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)
   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);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
   // land-all-0x87 experiment toggle (blob v14/v17, firmware p[181] -> JS p[179])
   // 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); }
   { 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));
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
     $("#stImu").textContent = `a=(${ax}, ${ay}, ${az})  |a|=${amag}`;
     $("#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]; }
   for(const sel of document.querySelectorAll("select.chord")){ if(document.activeElement!==sel) sel.value=chord[+sel.dataset.i]; }
   $("#dpadChords").classList.toggle("hide", !dpadCap);
   $("#dpadChords").classList.toggle("hide", !dpadCap);
   $("#dpadOld").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]; }
   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); }
 function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ el.value=v; } $("#"+id+"V").textContent=fmtSlider(id,el.value); }
 
 
 let inflight=false;
 let inflight=false;
@@ -1170,11 +1179,12 @@ function buildBackup(p, bp){
   const types=[];
   const types=[];
   for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*9;
   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]}); }
     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]>=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.
   // 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
   // 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
   // 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.
   // driven while the import replays every setting onto the puck, then closes on Done / on error.
   modalOpen("Restore backup");
   modalOpen("Restore backup");
   const c=obj.config;
   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 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 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="";
   let step=0, stage="";
   const tick=()=>modalStage(stage, Math.min(99,Math.floor(step*100/total)));
   const tick=()=>modalStage(stage, Math.min(99,Math.floor(step*100/total)));
   try{
   try{
@@ -1247,11 +1259,13 @@ async function importBackup(file){
     const sf=async(f,v)=>{ await w([0x02, f, (v|0)&0xff]); };
     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.
     // replay every setting (each persists; none reboot). mode is applied by the 0x0E commit below.
     stage="Restoring settings"; tick();
     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]);
     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
     // 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]);
     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
     // 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.
     // was added to the panel export; guard so a missing field never crashes the import mid-flight.
     if(typeN){
     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+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+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); }
         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+.
     // 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.
     // pre-lizard-map backups simply lack c.lizardMap; skip silently so they still import cleanly.
     if(lizardN){
     if(lizardN){
@@ -2167,14 +2181,13 @@ $("#wipeBoard").onclick=async()=>{
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 $("#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")); };
 $("#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.)
 // 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("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
 }
 }
 // per-type back/QAM/A-B-swap/haptics handlers are wired in buildTypeCfgs()
 // 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")){
 for(const sel of document.querySelectorAll("select.chord")){
   sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
   sel.addEventListener("change", ()=>setField(CHORD_FIELD[+sel.dataset.i], +sel.value));
 }
 }