Przeglądaj źródła

separate controller configs and add a haptics tgl

Jari 4 miesięcy temu
rodzic
commit
9da59444cc

+ 49 - 16
OpenPuck/config.cpp

@@ -18,12 +18,44 @@ bool g_debugCdcThisBoot = false;
 static uint8_t g_debugCdc = 0;
 
 int g_mDiv = 64, g_mFric = 94;
+
+// Per-type button config. back default {5,6,7,8} = L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad,
+// 16/17 PS touch/mute, 18 Switch Capture). Switch differs: QAM defaults to Capture(18), A/B swap on, and
+// trackpad haptics off. qamMap 0 = unmapped (hardcoded per-mode behavior).
+TypeCfg g_type[ET_COUNT] = {
+	/* ET_XBOX   */ { { 5, 6, 7, 8 }, 0, 0, 1 },
+	/* ET_SWITCH */ { { 5, 6, 7, 8 }, 18, 1, 0 },
+	/* ET_DS4    */ { { 5, 6, 7, 8 }, 0, 0, 1 },
+	/* ET_DS5    */ { { 5, 6, 7, 8 }, 0, 0, 1 },
+};
+uint8_t g_etype = ET_NONE;
+
+// Live mirrors of the active type (puck modes use the harmless defaults below).
 uint8_t g_abSwap = 0;
-uint8_t g_back[4] = {
-	5, 6, 7, 8
-}; // L4->LB R4->RB L5->L3 R5->R3 (0..11 buttons, 12..15 D-pad U/D/L/R)
-// 0 = default (unmapped, uses hardcoded behavior per mode)
+uint8_t g_back[4] = { 5, 6, 7, 8 };
 uint8_t g_qamMap = 0;
+uint8_t g_padHaptics = 1;
+
+void applyActiveType()
+{
+	g_etype = etypeForMode(g_usbMode);
+	if (g_etype >= ET_COUNT) { // puck mode (Steam/Lizard): no remap, haptics on
+		g_back[0] = 5;
+		g_back[1] = 6;
+		g_back[2] = 7;
+		g_back[3] = 8;
+		g_qamMap = 0;
+		g_abSwap = 0;
+		g_padHaptics = 1;
+		return;
+	}
+	const TypeCfg &t = g_type[g_etype];
+	for (int i = 0; i < 4; i++)
+		g_back[i] = t.back[i];
+	g_qamMap = t.qamMap;
+	g_abSwap = t.abSwap;
+	g_padHaptics = t.padHaptics;
+}
 // rumble strength % (200 = double); adjustable from the WebUSB panel
 uint8_t g_rumbleScale = 200;
 
@@ -32,11 +64,12 @@ uint8_t g_rumbleScale = 200;
 const uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
-// bumped (rumbleScale): old cfg ignored -> clean defaults on first boot
-#define CFG_MAGIC 0xC9
+// bumped (per-type TypeCfg blob replaces scalar abSwap/back/qamMap): old cfg ignored -> clean defaults on first boot
+#define CFG_MAGIC 0xCA
 struct Cfg {
-	uint8_t magic, mode, mDiv, mFric, rsvd0, abSwap, back[4], pollU100,
-		persistMode, bootMode, chordBtn[3], qamMap, rumbleScale;
+	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode, bootMode,
+		chordBtn[3], rumbleScale;
+	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 void saveCfg()
@@ -46,14 +79,14 @@ void saveCfg()
 		  (uint8_t)g_mDiv,
 		  (uint8_t)g_mFric,
 		  g_debugCdc,
-		  g_abSwap,
-		  { g_back[0], g_back[1], g_back[2], g_back[3] },
 		  (uint8_t)(g_pollUs / 100),
 		  (uint8_t)(g_persistMode ? 1 : 0),
 		  g_bootMode,
 		  { g_chordBtn[0], g_chordBtn[1], g_chordBtn[2] },
-		  g_qamMap,
-		  g_rumbleScale };
+		  g_rumbleScale,
+		  {} };
+	for (int i = 0; i < ET_COUNT; i++)
+		c.type[i] = g_type[i];
 	InternalFS.remove(CFG_FILE);
 	File f(InternalFS);
 	if (f.open(CFG_FILE, FILE_O_WRITE)) {
@@ -72,9 +105,8 @@ void loadCfg()
 		    c.magic == CFG_MAGIC) {
 			g_mDiv = c.mDiv ? c.mDiv : 64;
 			g_mFric = c.mFric;
-			g_abSwap = c.abSwap;
-			for (int i = 0; i < 4; i++)
-				g_back[i] = c.back[i];
+			for (int i = 0; i < ET_COUNT; i++)
+				g_type[i] = c.type[i];
 			g_persistMode = c.persistMode ? true : false;
 			// one-shot debug-CDC (Cfg.rsvd0): honor for THIS boot, then consume so the next boot reverts to normal.
 			g_debugCdcThisBoot = c.rsvd0 ? true : false;
@@ -103,13 +135,14 @@ void loadCfg()
 				g_chordBtn[i] = modeValid(c.chordBtn[i]) ?
 							c.chordBtn[i] :
 							CHORD_DEF[i];
-			g_qamMap = c.qamMap;
 
 			// 0 is a valid setting (rumble off)
 			g_rumbleScale = c.rumbleScale;
 		}
 		f.close();
 	}
+	// resolve the active emulated type's settings into the live mirrors the mode builders read
+	applyActiveType();
 	// clear the one-shot so the NEXT cold boot reverts to the default/persist policy
 	if (consume) {
 		g_bootMode = 0xFF;

+ 49 - 3
OpenPuck/config.h

@@ -56,6 +56,35 @@ static inline bool modeValid(uint8_t m)
 	return m <= MODE_MAX;
 }
 
+// ---- Emulated controller TYPES. Button-remap config (back paddles / QAM / A-B swap / trackpad haptics) is kept
+//      per-type, not per-mode: the right mapping depends on what controller we present, and the variant modes of
+//      one controller share it. Steam + Lizard are native puck presentations -- they forward raw input to Steam,
+//      which does its own remapping -- so they are NOT emulated types (ET_NONE) and ignore this config. ----
+#define ET_XBOX 0
+#define ET_SWITCH 1
+#define ET_DS4 2
+#define ET_DS5 3
+#define ET_COUNT 4
+#define ET_NONE 0xFF
+static inline uint8_t etypeForMode(uint8_t m)
+{
+	switch (m) {
+	case MODE_XBOX:
+		return ET_XBOX;
+	case MODE_SW_HORI:
+	case MODE_SW_PRO:
+		return ET_SWITCH;
+	case MODE_HIDGYRO:
+	case MODE_DS4_GAME:
+		return ET_DS4;
+	case MODE_PS5:
+	case MODE_PS5_GAME:
+		return ET_DS5;
+	default:
+		return ET_NONE; // Steam / Lizard
+	}
+}
+
 extern uint8_t g_usbMode; // loaded from flash at boot
 
 // true for all non-puck presentations (cached !modeIsPuck(g_usbMode))
@@ -79,12 +108,29 @@ void armDebugCdcNextBoot(); // persist the one-shot (caller reboots)
 
 // persisted, runtime-tunable config:
 extern int g_mDiv, g_mFric; // xbox/lizard mouse sensitivity divisor / friction%
-extern uint8_t g_abSwap; // 1 = swap A/B and X/Y (Nintendo face-button layout)
 
-// back paddles L4,R4,L5,R5 -> button codes (0..15 standard, 16=PS Touch Click, 17=PS5 Mute)
+// Per-emulated-type button config. One entry per ET_* type. back[] = paddle L4,R4,L5,R5 -> button codes
+// (0..15 standard, 16=PS Touch Click, 17=PS5 Mute, 18=Switch Capture/Screenshot). qamMap = QAM (3 dots)
+// physical button -> same code space (0 = default/unmapped). abSwap = swap A/B and X/Y (Nintendo layout).
+// padHaptics = 1 keeps the controller's autonomous trackpad haptics, 0 disables them for this type.
+struct TypeCfg {
+	uint8_t back[4];
+	uint8_t qamMap;
+	uint8_t abSwap;
+	uint8_t padHaptics;
+};
+extern TypeCfg g_type[ET_COUNT];
+extern uint8_t g_etype; // etypeForMode(g_usbMode), resolved at boot (ET_NONE for puck modes)
+
+// Live, resolved-active-type mirrors of g_type[g_etype], read by the hot-path mode builders. Refreshed by
+// applyActiveType() at boot and after any edit to the active type.
+extern uint8_t g_abSwap; // 1 = swap A/B and X/Y (Nintendo face-button layout)
 extern uint8_t g_back[4];
-// QAM (3 dots) physical button -> same code space (0 = default/unmapped)
 extern uint8_t g_qamMap;
+extern uint8_t g_padHaptics; // 1 = trackpad haptics on (default), 0 = disabled for the active type
+
+// 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

+ 22 - 0
OpenPuck/haptics.cpp

@@ -386,6 +386,24 @@ void rfConnFlushRelay(uint8_t ch, uint8_t s1)
 // register writes). Brightness (reg 0x2D) is omitted to avoid stomping the LED. The 0x87 frames go out on
 // legacy framing so the controller discards them; the three 0x81 frames are the effective reset. slot=0xFF
 // broadcasts to all connected controllers (the re-init is settings-only and harmless on healthy controllers).
+// One-shot SET_SETTINGS (0x87) write of the controller's GLOBAL trackpad-haptics enable. The controller plays
+// touchpad haptic sequences autonomously (haptics-sequencer-touchpad) gated by settings/haptics/enabled; in the
+// emulated (non-Steam) modes the host never sends SC haptic reports, so the only way to silence the pad buzz is
+// to write this setting. Wire format [id][val u16 LE] -- confirmed from the controller's SET_SETTINGS handler
+// (decomp FUN_0001f61c; id 0x30 == settings/sensors/imu/mode is the cross-check anchor in the same id space).
+// The exact id for settings/haptics/enabled needs a one-time `scmd labels` HW read (candidates from the captured
+// reinit: 0x18 / 0x2e / 0x34 / 0x35). Until it is set, SETTING_HAPTICS_ENABLED stays 0xFF and this is inert.
+#define SETTING_HAPTICS_ENABLED 0xFF
+void hapticSetPadEnabled(uint8_t slot, bool on)
+{
+#if SETTING_HAPTICS_ENABLED != 0xFF
+	uint8_t pl[3] = { SETTING_HAPTICS_ENABLED, (uint8_t)(on ? 1 : 0), 0 };
+	relayEnqueue(0x87, pl, 3, slot);
+#else
+	(void)slot;
+	(void)on;
+#endif
+}
 void hapticReinit(uint8_t slot)
 {
 	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
@@ -410,6 +428,10 @@ void hapticReinit(uint8_t slot)
 	relayEnqueue(0x87, H35, sizeof H35, slot);
 	relayEnqueue(0x81, T81A, sizeof T81A, slot);
 	relayEnqueue(0x81, T81B, sizeof T81B, slot);
+	// Re-apply the active emulated type's trackpad-haptics preference last (after the haptic-config writes
+	// above, which would otherwise re-enable it). Default-on types send "enable"; Switch (padHaptics=0)
+	// disables. Inert until the setting id is captured.
+	hapticSetPadEnabled(slot, g_padHaptics != 0);
 }
 void hapticInit()
 {

+ 2 - 0
OpenPuck/haptics.h

@@ -116,3 +116,5 @@ void hapticReinit(uint8_t slot = 0xFF);
 // controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
 // Per-slot: only the slot that just reconnected is blocked, the others keep relaying.
 void hapticOnReconnect(int slot);
+// Write the controller's global trackpad-haptics enable setting (per active emulated type). slot 0xFF = broadcast.
+void hapticSetPadEnabled(uint8_t slot, bool on);

+ 10 - 5
OpenPuck/mode_switch_hori.cpp

@@ -31,7 +31,7 @@ static const uint8_t SWITCH_HID_DESC[] = {
 static Adafruit_USBD_HID g_switch[NSLOT];
 static unsigned long g_swLastMs[NSLOT] = { 0 };
 
-// back-paddle code (g_back[]) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home)
+// back-paddle / QAM code (g_back[], g_qamMap) -> Switch button bit. 0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back(Minus) 10=Start(Plus) 11=Guide(Home) 18=Capture
 static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
 			     uint16_t fY)
 {
@@ -58,6 +58,8 @@ static uint16_t codeToSwitch(uint8_t c, uint16_t fA, uint16_t fB, uint16_t fX,
 		return 0x200;
 	case 11:
 		return 0x1000;
+	case 18:
+		return 0x2000; // Capture / Screenshot (Switch-only target)
 	default:
 		return 0;
 	}
@@ -81,10 +83,9 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 {
 	uint32_t b = g_in[slot].buttons;
 	uint16_t btn = 0;
-	if (g_qamMap && (b & TB_QAM)) {
-		b &= ~(uint32_t)TB_QAM;
-		b |= tritonFromCode(g_qamMap);
-	}
+	// QAM (3 dots) remap: the configured code is applied to the Switch output just like a back paddle below
+	// (so Capture(18) and every Switch target work). 0 = unmapped -> QAM does nothing on Switch.
+	bool qam = g_qamMap && (b & TB_QAM);
 	// Mode-switch chord (all 4 back + A/X/Y): don't pass the face press to the console while the back-4 are held.
 	if ((b & CHORD_BACK4) == CHORD_BACK4)
 		b &= ~(uint32_t)(TB_A | TB_X | TB_Y);
@@ -127,6 +128,8 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 		btn |= codeToSwitch(g_back[2], fA, fB, fX, fY);
 	if (b & TB_R5)
 		btn |= codeToSwitch(g_back[3], fA, fB, fX, fY);
+	if (qam)
+		btn |= codeToSwitch(g_qamMap, fA, fB, fX, fY);
 	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF,
 	     r = b & TB_DRT; // hat: 0=N..7=NW, 8=neutral
 	if (b & TB_L4)
@@ -137,6 +140,8 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 		backCodeToHatDirs(g_back[2], u, d, l, r);
 	if (b & TB_R5)
 		backCodeToHatDirs(g_back[3], u, d, l, r);
+	if (qam)
+		backCodeToHatDirs(g_qamMap, u, d, l, r);
 	uint8_t hat = 8;
 	if (u && r)
 		hat = 1;

+ 7 - 4
OpenPuck/mode_switch_pro.cpp

@@ -97,6 +97,7 @@ static const uint8_t SWPRO_HID_DESC[] = {
 #define JC_BTN_RSTICK (1u << 10)
 #define JC_BTN_LSTICK (1u << 11)
 #define JC_BTN_HOME (1u << 12)
+#define JC_BTN_CAPTURE (1u << 13)
 #define JC_BTN_DOWN (1u << 16)
 #define JC_BTN_UP (1u << 17)
 #define JC_BTN_RIGHT (1u << 18)
@@ -129,6 +130,8 @@ static uint32_t codeToJc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
 		return JC_BTN_PLUS;
 	case 11:
 		return JC_BTN_HOME;
+	case 18:
+		return JC_BTN_CAPTURE; // Capture / Screenshot (Switch-only target)
 	case 12:
 		return JC_BTN_UP;
 	case 13:
@@ -209,10 +212,8 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
 static void jcInputPrefix(uint8_t slot, uint8_t *out)
 {
 	uint32_t b = g_in[slot].buttons;
-	if (g_qamMap && (b & TB_QAM)) {
-		b &= ~(uint32_t)TB_QAM;
-		b |= tritonFromCode(g_qamMap);
-	}
+	// QAM (3 dots) remap -> applied via codeToJc below like a back paddle (so Capture(18)/any target work).
+	bool qam = g_qamMap && (b & TB_QAM);
 	if ((b & CHORD_BACK4) == CHORD_BACK4)
 		b &= ~(uint32_t)(TB_A | TB_B | TB_X | TB_Y);
 	uint32_t fA = g_abSwap ? JC_BTN_B : JC_BTN_A,
@@ -262,6 +263,8 @@ static void jcInputPrefix(uint8_t slot, uint8_t *out)
 		jc |= codeToJc(g_back[2], fA, fB, fX, fY);
 	if (b & TB_R5)
 		jc |= codeToJc(g_back[3], fA, fB, fX, fY);
+	if (qam)
+		jc |= codeToJc(g_qamMap, fA, fB, fX, fY);
 	out[0] = g_jcTimer[slot]++;
 
 	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.

+ 32 - 15
OpenPuck/serial_console.cpp

@@ -214,34 +214,51 @@ void serialConsolePoll()
 					"# xbox-mouse friction=%d%% (higher=more glide/momentum)\n",
 					g_mFric);
 			} else if (line[0] == 'W') {
-				g_abSwap = !g_abSwap;
-				saveCfg();
-				Serial.printf(
-					"# A/B + X/Y swap %s (Nintendo layout)\n",
-					g_abSwap ? "ON" : "off");
+				// A/B swap edits the ACTIVE emulated type (Xbox/Switch/DS4/DS5); puck modes have none.
+				if (g_etype < ET_COUNT) {
+					g_type[g_etype].abSwap =
+						!g_type[g_etype].abSwap;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# A/B + X/Y swap %s (Nintendo layout) [type %u]\n",
+						g_abSwap ? "ON" : "off", g_etype);
+				} else
+					Serial.println(
+						"# A/B swap N/A in this mode (Steam/Lizard are not emulated types)");
 			} else if (line[0] == 'K') {
 				int i = line[1] - '0';
 				uint8_t code = strtoul(line + 2, 0, 10);
-				if (i >= 0 && i < 4) {
-					g_back[i] = code;
+				// back paddles edit the ACTIVE emulated type. code 18 = Capture/Screenshot (Switch only).
+				if (i >= 0 && i < 4 && g_etype < ET_COUNT) {
+					g_type[g_etype].back[i] = code;
+					applyActiveType();
 					saveCfg();
 					Serial.printf(
-						"# back[%d] (%s) -> code %u  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",
+						"# back[%d] (%s) -> code %u [type %u]  [0=none 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute 18=Capture(Switch)]\n",
 						i,
 						(const char *[]){ "L4", "R4",
 								  "L5",
 								  "R5" }[i],
-						code);
-				} else
+						code, g_etype);
+				} else if (g_etype >= ET_COUNT)
+					Serial.println(
+						"# back map N/A in this mode (Steam/Lizard are not emulated types)");
+				else
 					Serial.println(
 						"# usage: K<0-3> <code>  (0=L4 1=R4 2=L5 3=R5)");
 			} else if (line[0] == 'Q') {
 				uint8_t code = strtoul(line + 1, 0, 10);
-				g_qamMap = code;
-				saveCfg();
-				Serial.printf(
-					"# QAM -> code %u  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute]\n",
-					code);
+				if (g_etype < ET_COUNT) {
+					g_type[g_etype].qamMap = code;
+					applyActiveType();
+					saveCfg();
+					Serial.printf(
+						"# QAM -> code %u [type %u]  [0=default 1=A 2=B 3=X 4=Y 5=LB 6=RB 7=L3 8=R3 9=Back 10=QAM 11=Guide 12=Dup 13=Ddown 14=Dleft 15=Dright 16=TouchClick 17=Mute 18=Capture(Switch)]\n",
+						code, g_etype);
+				} else
+					Serial.println(
+						"# QAM map N/A in this mode (Steam/Lizard are not emulated types)");
 			} else if (line[0] == 'J') {
 				char *sp = 0;
 				uint8_t id = strtoul(line + 1, &sp, 0);

+ 56 - 9
OpenPuck/webusb_config.cpp

@@ -11,15 +11,17 @@
 
 Adafruit_USBD_WebUSB usb_web;
 
-// blob payload = [ver=8][mode][mDiv][mFric][rsvd=0][abSwap][back0..3][connSlot(0xFF=none)][linkUp]
+// blob payload = [ver=9][mode][mDiv][mFric][qamMap(active)][abSwap(active)][back0..3(active)][connSlot(0xFF=none)][linkUp]
 //                [f1ps_lo][f1ps_hi][pollU100][newps_lo][newps_hi][e7b][relayOp][relaySub][fwdNewOnly]
 //                [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][gitHash 12B ASCII, NUL-padded][rumbleScale][swPro120][swGyroScale10][raw accel ax ay az 3x s16 LE]
 //                [bondedCount][slot0_up][slot0_batt][slot0_rssi]...[slot3_up][slot3_batt][slot3_rssi]
-// v8 extends to 73 bytes (75 total incl header); browser reads with transferIn(128) to span the two USB-FS packets.
-#define WB_PAYLEN 73
+//                [v9: per-type cfg, 4x7B: ET_XBOX/SWITCH/DS4/DS5 each {back0..3, qam, abSwap, padHaptics}]
+// p[6]/p[7]/p[8..11] mirror the ACTIVE type (legacy display). v9 extends to 101 bytes (103 total incl header);
+// browser reads with transferIn(128) to span the two USB-FS packets.
+#define WB_PAYLEN 101
 static void webusbSendBlob()
 {
 	if (!usb_web.connected())
@@ -33,8 +35,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 8;
+	// protocol version (9 = +per-type cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
+	p[2] = 9;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -113,6 +115,17 @@ static void webusbSendBlob()
 			p[65 + s * 3] = g_linkRssi[s];
 		}
 	}
+	// per-emulated-type button config (protocol v9): 4 types x 7 bytes from p[75]
+	for (int et = 0; et < ET_COUNT; et++) {
+		uint8_t *q = &p[75 + et * 7];
+		q[0] = g_type[et].back[0];
+		q[1] = g_type[et].back[1];
+		q[2] = g_type[et].back[2];
+		q[3] = g_type[et].back[3];
+		q[4] = g_type[et].qamMap;
+		q[5] = g_type[et].abSwap;
+		q[6] = g_type[et].padHaptics;
+	}
 	usb_web.write(p, sizeof p);
 	usb_web.flush();
 }
@@ -241,6 +254,30 @@ void webusbPoll()
 
 				// every settable field persists (poll rate is no longer settable)
 				bool persist = true;
+				// per-type cfg writes (protocol v9): field = 40 + et*8 + k, k: 0..3 back, 4 qam, 5 abSwap,
+				// 6 padHaptics. Edits g_type[et]; refresh the live mirrors if it's the active type.
+				if (f >= 40 && f < 40 + ET_COUNT * 8) {
+					uint8_t et = (uint8_t)((f - 40) / 8),
+						k = (uint8_t)((f - 40) % 8);
+					if (et < ET_COUNT) {
+						if (k < 4)
+							g_type[et].back[k] = v;
+						else if (k == 4)
+							g_type[et].qamMap = v;
+						else if (k == 5)
+							g_type[et].abSwap = v ? 1 : 0;
+						else if (k == 6)
+							g_type[et].padHaptics =
+								v ? 1 : 0;
+						if (et == g_etype)
+							applyActiveType();
+					}
+					saveCfg();
+					webusbSendBlob();
+					memmove(buf, buf + need, n - need);
+					n -= need;
+					continue;
+				}
 				switch (f) {
 				case 1:
 					g_mDiv = v < 4 ? 4 : v;
@@ -249,14 +286,21 @@ void webusbPoll()
 					g_mFric = v > 99 ? 99 : v;
 					break;
 				// case 3 (padSmooth) removed -- Steam-mode pad coords are forwarded raw; Steam does its own smoothing.
+				// Legacy single-value fields (4 abSwap, 5-8 back, 21 qam) edit the ACTIVE emulated type.
 				case 4:
-					g_abSwap = v ? 1 : 0;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].abSwap = v ? 1 : 0;
+						applyActiveType();
+					}
 					break;
 				case 5:
 				case 6:
 				case 7:
 				case 8:
-					g_back[f - 5] = v;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].back[f - 5] = v;
+						applyActiveType();
+					}
 					break;
 				// case 9 (pollU100) removed -- poll rate is fixed at POLL_US_DEFAULT and no longer configurable.
 
@@ -310,9 +354,12 @@ void webusbPoll()
 					NVIC_SystemReset();
 					break;
 
-				// QAM physical button remap code (0=default/unmapped)
+				// QAM physical button remap code (0=default/unmapped) -- active emulated type
 				case 21:
-					g_qamMap = v;
+					if (g_etype < ET_COUNT) {
+						g_type[g_etype].qamMap = v;
+						applyActiveType();
+					}
 					break;
 
 				// rumble strength % (0=off, 100=1x, 200=double)

+ 75 - 28
docs/index.html

@@ -161,16 +161,8 @@
 
     <div class="card">
       <h2>Button mapping</h2>
-      <div class="row"><label>A/B + X/Y swap</label>
-        <button id="abSwap">off</button>
-        <span class="note" style="flex:1">Nintendo layout (affects Xbox &amp; Switch modes)</span>
-      </div>
-      <div class="row"><label>L4 (back upper-left)</label><select class="back" data-i="0"></select></div>
-      <div class="row"><label>R4 (back upper-right)</label><select class="back" data-i="1"></select></div>
-      <div class="row"><label>L5 (back lower-left)</label><select class="back" data-i="2"></select></div>
-      <div class="row"><label>R5 (back lower-right)</label><select class="back" data-i="3"></select></div>
-      <div class="row"><label>QAM (3 dots)</label><select id="qamMap"></select></div>
-      <p class="note">Back-paddle → button, applied in Xbox &amp; Switch modes. QAM remap applies to all modes (0 = default per-mode behavior).</p>
+      <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>
+      <div id="typeCfgs"></div>
     </div>
 
     <div class="card logonly">
@@ -192,19 +184,67 @@
 <script>
 const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)"];
 const CHORD_FIELD = [17, 18, 19];
-const BTN_CODES = [[0,"— none —"],[1,"A"],[2,"B"],[3,"X"],[4,"Y"],[5,"LB"],[6,"RB"],[7,"L3"],[8,"R3"],[9,"Back"],[10,"Quick Access Menu"],[11,"Guide"],[12,"D-pad Up"],[13,"D-pad Down"],[14,"D-pad Left"],[15,"D-pad Right"],[16,"PS Touch Click"],[17,"PS5 Mute"]];
 let dev=null, epIn=0, epOut=0, ifNum=0, polling=false, capturing=false;
 const $ = s=>document.querySelector(s);
 function log(m){ const l=$("#log"); l.textContent=(new Date().toLocaleTimeString()+"  "+m+"\n"+l.textContent).slice(0,2000); }
 
-// populate back-paddle + chord dropdowns
-for(const sel of document.querySelectorAll("select.back")){
-  for(const [c,n] of BTN_CODES){ const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
-}
-{ const sel = document.getElementById("qamMap");
-  const d = document.createElement("option"); d.value=0; d.textContent="Default (varies by mode)"; sel.appendChild(d);
-  for(const [c,n] of BTN_CODES){ if(c===0) continue; const o=document.createElement("option"); o.value=c; o.textContent=n; sel.appendChild(o); }
+// Per-emulated-type config (must match firmware ET_* order: Xbox=0, Switch=1, DS4=2, DS5=3). Each type lists
+// only the remap targets that exist on that controller. Field id sent to firmware = 40 + et*8 + k
+// (k: 0..3 back paddles, 4 QAM, 5 A/B-swap, 6 trackpad-haptics). Blob v9 reads them back at p[75 + et*7 + ...].
+const DPAD = {12:"D-pad Up",13:"D-pad Down",14:"D-pad Left",15:"D-pad Right"};
+const TYPE_DEFS = [
+  {key:"XBOX", name:"Xbox", labels:{1:"A",2:"B",3:"X",4:"Y",5:"LB",6:"RB",7:"L3",8:"R3",9:"Back",10:"Start",11:"Guide",...DPAD}},
+  {key:"SWITCH", name:"Switch", labels:{1:"A",2:"B",3:"X",4:"Y",5:"L",6:"R",7:"L-Stick",8:"R-Stick",9:"Minus",10:"Plus",11:"Home",...DPAD,18:"Capture / Screenshot"}},
+  {key:"DS4", name:"DS4", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",...DPAD,16:"Touchpad Click"}},
+  {key:"DS5", name:"DS5", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",...DPAD,16:"Touchpad Click",17:"Mute"}},
+];
+// Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
+function etypeForMode(m){ switch(m){case 1:return 0; case 2:case 4:return 1; case 6:case 8:return 2; case 5:case 7:return 3; default:return -1;} }
+const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
+const typeEls = []; // per-type {sec, abSwap, pad, back[4], qam}
+function mkSelect(def, includeNone){
+  const sel=document.createElement("select");
+  if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
+  else { const o=document.createElement("option"); o.value=0; o.textContent="Default (per-mode)"; sel.appendChild(o); }
+  for(const c of Object.keys(def.labels).map(Number).sort((a,b)=>a-b)){
+    const o=document.createElement("option"); o.value=c; o.textContent=def.labels[c]; sel.appendChild(o); }
+  return sel;
 }
+(function buildTypeCfgs(){
+  const host=document.getElementById("typeCfgs");
+  TYPE_DEFS.forEach((def,et)=>{
+    const sec=document.createElement("div"); sec.className="card"; sec.style.cssText="background:#0e1017;margin:0 0 12px;padding:12px 14px";
+    const h=document.createElement("div"); h.style.cssText="font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:8px";
+    h.innerHTML='<span>'+def.name+'</span><span class="active-tag" style="display:none;font-size:11px;color:var(--acc)">● current mode</span>';
+    sec.appendChild(h);
+    const rec={sec,activeTag:h.querySelector(".active-tag"),back:[],qam:null,abSwap:null,pad:null};
+    // A/B swap + trackpad haptics toggles
+    const tog=document.createElement("div"); tog.className="row";
+    tog.innerHTML='<label>A/B + X/Y swap</label>';
+    const ab=document.createElement("button"); ab.textContent="off"; tog.appendChild(ab);
+    const padLbl=document.createElement("label"); padLbl.textContent="Trackpad haptics"; padLbl.style.marginLeft="14px"; tog.appendChild(padLbl);
+    const pad=document.createElement("button"); pad.textContent="on"; tog.appendChild(pad);
+    sec.appendChild(tog);
+    rec.abSwap=ab; rec.pad=pad;
+    ab.onclick=()=>{ const on=ab.classList.contains("active"); setField(40+et*8+5, on?0:1); };
+    pad.onclick=()=>{ const on=pad.classList.contains("active"); setField(40+et*8+6, on?0:1); };
+    // back paddles
+    for(let i=0;i<4;i++){
+      const row=document.createElement("div"); row.className="row";
+      const lab=document.createElement("label"); lab.textContent=BACK_LABELS[i]; row.appendChild(lab);
+      const sel=mkSelect(def,true); row.appendChild(sel); sec.appendChild(row);
+      sel.addEventListener("change",()=>setField(40+et*8+i, +sel.value));
+      rec.back.push(sel);
+    }
+    // QAM
+    { const row=document.createElement("div"); row.className="row";
+      const lab=document.createElement("label"); lab.textContent="QAM (3 dots)"; row.appendChild(lab);
+      const sel=mkSelect(def,false); row.appendChild(sel); sec.appendChild(row);
+      sel.addEventListener("change",()=>setField(40+et*8+4, +sel.value));
+      rec.qam=sel; }
+    host.appendChild(sec); typeEls.push(rec);
+  });
+})();
 for(const sel of document.querySelectorAll("select.chord")){
   MODE_NAMES.forEach((n,i)=>{ if(i===7||i===8) return;
     const o=document.createElement("option"); o.value=i; o.textContent=n; sel.appendChild(o); });
@@ -300,8 +340,8 @@ async function readBlob(){
   }catch(e){ if(dev) log("read err: "+e.message); return null; }
 }
 function applyBlob(p){
-  const mode=p[1], mDiv=p[2], mFric=p[3], qamMap=p[4], abSwap=p[5];
-  const back=[p[6],p[7],p[8],p[9]], slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
+  const mode=p[1], mDiv=p[2], mFric=p[3]; // p[4..11] mirror the active type (per-type block below is authoritative)
+  const slot=p[10], up=p[11], f1=(p[12]|(p[13]<<8));
   const newps=(p.length>16?(p[15]|(p[16]<<8)):0), persistMode=(p.length>22?p[22]:0);
   const chord=(p.length>25)?[p[23],p[24],p[25]]:[3,1,4];
   const polls=(p.length>27?(p[26]|(p[27]<<8)):0);
@@ -413,10 +453,21 @@ function applyBlob(p){
     ? (gitHash + (gitDirty ? ' <span class="pill dn">dirty</span>' : ' <span class="pill up">clean</span>'))
     : "—";
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
-  $("#abSwap").textContent = abSwap? "on":"off"; $("#abSwap").classList.toggle("active",!!abSwap);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
-  for(const sel of document.querySelectorAll("select.back")){ if(document.activeElement!==sel) sel.value=back[+sel.dataset.i]; }
-  { const sel=$("#qamMap"); if(document.activeElement!==sel) sel.value=qamMap; }
+  // per-type button config (blob v9): 4 types x 7 bytes from p[75]
+  if(p.length>=75+TYPE_DEFS.length*7){
+    const activeEt=etypeForMode(mode);
+    typeEls.forEach((rec,et)=>{
+      const q=75+et*7;
+      const back=[p[q],p[q+1],p[q+2],p[q+3]], qam=p[q+4], ab=p[q+5], pad=p[q+6];
+      rec.back.forEach((sel,i)=>{ if(document.activeElement!==sel) sel.value=back[i]; });
+      if(document.activeElement!==rec.qam) rec.qam.value=qam;
+      rec.abSwap.textContent=ab?"on":"off"; rec.abSwap.classList.toggle("active",!!ab);
+      rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
+      rec.activeTag.style.display = (et===activeEt) ? "" : "none";
+      rec.sec.style.borderColor = (et===activeEt) ? "var(--acc2)" : "#272d3d";
+    });
+  }
   if(p.length>=60){ const s16=(o)=>{ let v=p[o]|(p[o+1]<<8); return v>32767?v-65536:v; };
     const ax=s16(54),ay=s16(56),az=s16(58);
     const amag=Math.round(Math.sqrt(ax*ax+ay*ay+az*az));
@@ -534,17 +585,13 @@ $("#factoryErase").onclick=async()=>{
   await send([0x0A,0x45,0x52,0x53]);
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };
-$("#abSwap").onclick=()=>{ const on=$("#abSwap").classList.contains("active"); setField(4, on?0:1); };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
 for(const id of ["mDiv","mFric","rumble"]){
   const field={mDiv:1,mFric:2,rumble:22}[id];
   $("#"+id).addEventListener("input", ()=>{ $("#"+id+"V").textContent=fmtSlider(id,$("#"+id).value); });
   $("#"+id).addEventListener("change", ()=>setField(field, +$("#"+id).value));
 }
-for(const sel of document.querySelectorAll("select.back")){
-  sel.addEventListener("change", ()=>setField(5 + +sel.dataset.i, +sel.value));
-}
-$("#qamMap").addEventListener("change", ()=>setField(21, +$("#qamMap").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));
 for(const sel of document.querySelectorAll("select.chord")){