Переглянути джерело

Map trackpads to joysticks (#216)

* Add trackpad-to-joystick mapping configurable from the WebUI

Co-authored-by: Philip-Scott <10786508+Philip-Scott@users.noreply.github.com>

* Attach test build: OpenPuck .uf2 (trackpad-to-joystick)

Co-authored-by: Philip-Scott <10786508+Philip-Scott@users.noreply.github.com>

* Move trackpad->stick config fields off the per-type cfg range

Co-authored-by: Philip-Scott <10786508+Philip-Scott@users.noreply.github.com>

* Allow manual dispatch of the Build workflow

Co-authored-by: Philip-Scott <10786508+Philip-Scott@users.noreply.github.com>

* Revert ci and .uf2 file

* Touchpad does not override stick

* Code review

* Correct docs

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Philip-Scott <10786508+Philip-Scott@users.noreply.github.com>
Felipe Escoto 2 місяців тому
батько
коміт
fd69c5b0f7

+ 1 - 0
CODE_MAP.md

@@ -489,6 +489,7 @@ State touched by **both** the loop task and the usbd task (the synchronization-c
 | **`g_rq[NSLOT][32]` relay rings** + `g_rqHead/Tail` (haptics) | usbd HID callbacks (`handleSet`, mode rumble cbs) + loop (console/test/`hapticTask`) | loop `rfConnFlushRelay` | **Producers under PRIMASK**; `volatile` head/tail; ring body not volatile; SPSC-drain per slot |
 | **`g_slot[NSLOT].rec/used/resp`** (bonds) | usbd `handleSet` (0xA2 / reply staging) + loop (`loadBonds`) | loop RF/webusb/console, usbd `handleGet` | `resp` is same-task (usbd); `g_dirty` (`volatile`) defers the flash write to loop |
 | **`g_dirty`** | usbd `handleSet` | loop `loop()` → `saveBonds` | `volatile` flag |
+| **`g_padStick[2]`** (config, trackpad→stick live mirror) | loop `applyActiveType()` (boot + WebUSB setter) | loop mode builders via `slotSticks`; **usbd `jcInputPrefix` (switch pro)** | none; plain `uint8_t` byte writes, worst case one stale frame (same shape as `g_abSwap`/`g_back[]`) |
 | **`g_in[NSLOT]`** (decoded input) | loop `rf_link` decode | loop mode `task()`/`onReport45`; **usbd `jcInputPrefix` (switch pro)**; webusb | none (single writer; readers tolerate staleness) |
 | **`g_connReplyMs[NSLOT]`** | loop `rf_link` | loop puck_hid/usb_mount/haptics/webusb; usbd haptic gates | none (plain `unsigned long`) |
 | **`g_battery/g_batteryState/g_linkRssi[NSLOT]`** | loop `rf_link` | loop puck_hid `task`/webusb | declared `volatile` |

+ 22 - 2
OpenPuck/config.cpp

@@ -39,6 +39,10 @@ TypeCfg g_type[ET_COUNT] = {
 };
 uint8_t g_etype = ET_NONE;
 
+// Trackpad -> stick mapping, off for every type by default (pads keep their touch/mouse behavior).
+uint8_t g_padStickCfg[ET_COUNT][2] = {};
+uint8_t g_padStick[2] = { PS_OFF, PS_OFF };
+
 // 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 };
@@ -61,6 +65,7 @@ void applyActiveType()
 		g_padHaptics = 1;
 		g_rumble = 1;
 		g_ledBright = 0;
+		g_padStick[0] = g_padStick[1] = PS_OFF;
 		return;
 	}
 	const TypeCfg &t = g_type[g_etype];
@@ -71,6 +76,8 @@ void applyActiveType()
 	g_padHaptics = t.padHaptics;
 	g_rumble = t.rumble;
 	g_ledBright = t.ledBright;
+	g_padStick[0] = g_padStickCfg[g_etype][0];
+	g_padStick[1] = g_padStickCfg[g_etype][1];
 }
 // 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
@@ -94,6 +101,8 @@ struct Cfg {
 	// TAIL (appended after CFG_MAGIC 0xCF shipped): back4+D-pad mode assignments. New tail fields go HERE, at
 	// the end, and loadCfg accepts a short file so an upgrade keeps every existing setting -- see CFG_LEN_MIN.
 	uint8_t chordDpad[4];
+	// per-type trackpad->stick mapping: [et][0] = left pad, [et][1] = right pad (PS_*)
+	uint8_t padStick[ET_COUNT][2];
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 // Shortest cfg.bin we still accept: the layout as of CFG_MAGIC 0xCF, i.e. everything before the appended tail.
@@ -118,9 +127,13 @@ void saveCfg()
 		  g_landAll87,
 		  {},
 		  { g_chordDpad[0], g_chordDpad[1], g_chordDpad[2],
-		    g_chordDpad[3] } };
-	for (int i = 0; i < ET_COUNT; i++)
+		    g_chordDpad[3] },
+		  {} };
+	for (int i = 0; i < ET_COUNT; i++) {
 		c.type[i] = g_type[i];
+		c.padStick[i][0] = g_padStickCfg[i][0];
+		c.padStick[i][1] = g_padStickCfg[i][1];
+	}
 	InternalFS.remove(CFG_FILE);
 	File f(InternalFS);
 	if (f.open(CFG_FILE, FILE_O_WRITE)) {
@@ -179,6 +192,13 @@ void loadCfg()
 			for (int i = 0; i < 4; i++)
 				if (modeValid(c.chordDpad[i]))
 					g_chordDpad[i] = c.chordDpad[i];
+			// Pad->stick mapping: 0xFF (a file predating this tail field) or an out-of-range
+			// value keeps the compiled default (off).
+			for (int i = 0; i < ET_COUNT; i++)
+				for (int k = 0; k < 2; k++)
+					if (c.padStick[i][k] <= PS_MAX)
+						g_padStickCfg[i][k] =
+							c.padStick[i][k];
 			// grow a short file to the current layout on the next save
 			if (got < (int)sizeof c)
 				consume = true;

+ 12 - 0
OpenPuck/config.h

@@ -139,6 +139,16 @@ struct TypeCfg {
 	uint8_t rumble;
 };
 extern TypeCfg g_type[ET_COUNT];
+
+// Trackpad -> analog stick mapping, per emulated type: {left pad, right pad}, values PS_OFF/PS_LEFT/PS_RIGHT.
+// While the mapped pad is touched its coordinates drive that stick; releasing it re-centers the stick (the
+// physical stick still drives it whenever the pad is untouched). Kept OUTSIDE TypeCfg so the on-flash Cfg
+// layout only grows in its tail -- an existing cfg.bin still loads and keeps every other setting.
+#define PS_OFF 0
+#define PS_LEFT 1
+#define PS_RIGHT 2
+#define PS_MAX 2
+extern uint8_t g_padStickCfg[ET_COUNT][2];
 extern uint8_t
 	g_etype; // etypeForMode(g_usbMode), resolved at boot (ET_NONE for puck modes)
 
@@ -153,6 +163,8 @@ extern uint8_t
 extern uint8_t g_rumble;
 // LED brightness for the active emulated type (0 = no override, 1-100 = brightness %)
 extern uint8_t g_ledBright;
+// Live mirror of g_padStickCfg[g_etype]: {left pad, right pad} -> stick (PS_*).
+extern uint8_t g_padStick[2];
 
 // Copy g_type[g_etype] into the live mirrors above (safe defaults when g_etype == ET_NONE).
 void applyActiveType();

+ 51 - 0
OpenPuck/gamepad_util.cpp

@@ -32,6 +32,57 @@ uint8_t swStick(int16_t v, bool invert)
 	return (uint8_t)a;
 }
 
+// Pick whichever of the two sources is deflected further from center, keeping its sign. -32768 has no
+// positive twin in int16, so clamp its magnitude to 32767 before comparing -- otherwise the negation
+// overflows and a full-left stick would lose to anything.
+static inline int16_t strongerAxis(int16_t a, int16_t b)
+{
+	int32_t ma = (a == -32768) ? 32767 : (a < 0 ? -(int32_t)a : (int32_t)a);
+	int32_t mb = (b == -32768) ? 32767 : (b < 0 ? -(int32_t)b : (int32_t)b);
+	return (ma >= mb) ? a : b;
+}
+
+void padStickBlend(uint32_t b, int16_t lpx, int16_t lpy, int16_t rpx,
+		   int16_t rpy, int16_t *lx, int16_t *ly, int16_t *rx,
+		   int16_t *ry)
+{
+	// pad index 0 = left pad, 1 = right pad; both share the same s16, center-0 coordinate space as the sticks
+	const int16_t px[2] = { lpx, rpx };
+	const int16_t py[2] = { lpy, rpy };
+	const bool touch[2] = { (b & TB_LPADT) != 0, (b & TB_RPADT) != 0 };
+	for (int pad = 0; pad < 2; pad++) {
+		int16_t *sx, *sy;
+		if (g_padStick[pad] == PS_LEFT) {
+			sx = lx;
+			sy = ly;
+		} else if (g_padStick[pad] == PS_RIGHT) {
+			sx = rx;
+			sy = ry;
+		} else
+			continue;
+		// An untouched pad contributes NOTHING rather than forcing center, so the physical
+		// stick keeps working while the pad is idle. (No touch means no pad value that could
+		// get stuck on the axis after a release.)
+		if (!touch[pad])
+			continue;
+		// Both sources live: send whichever is pushed further, per axis, sign preserved.
+		*sx = strongerAxis(*sx, px[pad]);
+		*sy = strongerAxis(*sy, py[pad]);
+	}
+}
+
+void slotSticks(uint8_t slot, int16_t *lx, int16_t *ly, int16_t *rx,
+		int16_t *ry)
+{
+	const PuckInput &in = g_in[slot];
+	*lx = in.lx;
+	*ly = in.ly;
+	*rx = in.rx;
+	*ry = in.ry;
+	padStickBlend(in.buttons, in.lpx, in.lpy, in.rpx, in.rpy, lx, ly, rx,
+		      ry);
+}
+
 // Map Steam trackpad s16 coords into a 0..max axis (centered touch -> mid-range).
 uint16_t padNormU16(int16_t v, uint16_t maxv)
 {

+ 14 - 0
OpenPuck/gamepad_util.h

@@ -40,6 +40,20 @@ void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy,
 		      int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
 		      uint16_t *rx, uint16_t *ry);
 
+// Trackpad -> analog stick blend (WebUI "map trackpad to joystick", g_padStick). A pad mapped to a stick
+// BLENDS with it rather than replacing it: while the pad is touched each axis reports whichever of the two
+// sources is deflected further from center (signed), so pad and stick stay usable at the same time. An
+// untouched pad contributes nothing and the physical stick passes through unchanged -- which also means a
+// release can never leave a stale pad value stuck on the axis. b = raw TB_* buttons (touch bits); the
+// sticks are updated in place.
+void padStickBlend(uint32_t b, int16_t lpx, int16_t lpy, int16_t rpx,
+		   int16_t rpy, int16_t *lx, int16_t *ly, int16_t *rx,
+		   int16_t *ry);
+
+// g_in[slot] sticks with padStickBlend applied -- what every emulated mode should send to the host.
+void slotSticks(uint8_t slot, int16_t *lx, int16_t *ly, int16_t *rx,
+		int16_t *ry);
+
 // Convert configurable button code -> TB_* flag (shared across modes).
 static inline uint32_t tritonFromCode(uint8_t c)
 {

+ 16 - 10
OpenPuck/mode_hidgyro.cpp

@@ -143,13 +143,19 @@ static ds4_setcb_t const DS4_SETCB[NSLOT] = { hidGyroSet0, hidGyroSet1,
 static void hidGyroBuild(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 {
 	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
-	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
-	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
+	// A pad mapped to a stick must NOT also report as a touchpad contact -- the host would read the same
+	// finger twice (stick deflection AND a cursor drag).
+	bool lTouch = g_padStick[0] == PS_OFF &&
+		      ((b & TB_LPADT) || (b & TB_LPADC)),
+	     rTouch = g_padStick[1] == PS_OFF &&
+		      ((b & TB_RPADT) || (b & TB_RPADC));
 	memset(out, 0, 63);
-	out[0] = swStick(g_in[slot].lx, false);
-	out[1] = swStick(g_in[slot].ly, true);
-	out[2] = swStick(g_in[slot].rx, false);
-	out[3] = swStick(g_in[slot].ry, true);
+	int16_t lx, ly, rx, ry;
+	slotSticks(slot, &lx, &ly, &rx, &ry);
+	out[0] = swStick(lx, false);
+	out[1] = swStick(ly, true);
+	out[2] = swStick(rx, false);
+	out[3] = swStick(ry, true);
 	out[4] = psHatNibble(b) | psFaceNibble(b);
 	out[5] = psShouldersByte(b, g_in[slot].lt, g_in[slot].rt);
 	static uint8_t ctr[NSLOT] = { 0 };
@@ -172,14 +178,14 @@ static void hidGyroBuild(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 	out[23] = g_in[slot].az >> 8;
 	out[29] = DS4_STATUS_USB;
 	if (lTouch || rTouch) {
-		uint16_t lx, ly, rx, ry;
+		uint16_t tlx, tly, trx, trry;
 		steamPadsToTouch(b, DS4_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
-				 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx,
-				 &ry);
+				 g_in[slot].rpx, g_in[slot].rpy, &tlx, &tly,
+				 &trx, &trry);
 		static uint8_t tstamp[NSLOT] = { 0 };
 		out[32] = 1;
 		out[33] = tstamp[usbSlot]++;
-		touchPackPads(out + 34, lTouch, rTouch, lx, ly, rx, ry);
+		touchPackPads(out + 34, lTouch, rTouch, tlx, tly, trx, trry);
 	} else {
 		out[32] = 0;
 		touchPackPads(

+ 6 - 4
OpenPuck/mode_ps3.cpp

@@ -284,10 +284,12 @@ static void ds3Build(uint8_t slot, uint8_t out[48])
 	out[3] = (b & TB_STEAM) ? 0x01 : 0;
 
 	// out[5..8] (rd[6..9]): LX LY RX RY, center 0x80
-	out[5] = swStick(g_in[slot].lx, false);
-	out[6] = swStick(g_in[slot].ly, true);
-	out[7] = swStick(g_in[slot].rx, false);
-	out[8] = swStick(g_in[slot].ry, true);
+	int16_t lx, ly, rx, ry;
+	slotSticks(slot, &lx, &ly, &rx, &ry);
+	out[5] = swStick(lx, false);
+	out[6] = swStick(ly, true);
+	out[7] = swStick(rx, false);
+	out[8] = swStick(ry, true);
 
 	// out[13..24] (rd[14..25]): analog pressures, order Up Right Down Left L2 R2 L1 R1 Tri Cir Cross Square
 	out[13] = (b & TB_DUP) ? 0xFF : 0;

+ 16 - 9
OpenPuck/mode_ps5.cpp

@@ -164,13 +164,19 @@ static ps5_setcb_t const PS5_SETCB[NSLOT] = { ps5Set0, ps5Set1, ps5Set2,
 static void ps5Build(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 {
 	uint32_t b = psButtonsFromSteam(g_in[slot].buttons);
-	bool lTouch = (b & TB_LPADT) || (b & TB_LPADC),
-	     rTouch = (b & TB_RPADT) || (b & TB_RPADC);
+	// A pad mapped to a stick must NOT also report as a touchpad contact -- the host would read the same
+	// finger twice (stick deflection AND a cursor drag).
+	bool lTouch = g_padStick[0] == PS_OFF &&
+		      ((b & TB_LPADT) || (b & TB_LPADC)),
+	     rTouch = g_padStick[1] == PS_OFF &&
+		      ((b & TB_RPADT) || (b & TB_RPADC));
 	memset(out, 0, 63);
-	out[0] = swStick(g_in[slot].lx, false);
-	out[1] = swStick(g_in[slot].ly, true);
-	out[2] = swStick(g_in[slot].rx, false);
-	out[3] = swStick(g_in[slot].ry, true);
+	int16_t lx, ly, rx, ry;
+	slotSticks(slot, &lx, &ly, &rx, &ry);
+	out[0] = swStick(lx, false);
+	out[1] = swStick(ly, true);
+	out[2] = swStick(rx, false);
+	out[3] = swStick(ry, true);
 	out[4] = g_in[slot].lt;
 	out[5] = g_in[slot].rt;
 	static uint8_t seq[NSLOT] = { 0 };
@@ -192,10 +198,11 @@ static void ps5Build(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 	out[24] = g_in[slot].ay >> 8;
 	out[25] = g_in[slot].az & 0xFF;
 	out[26] = g_in[slot].az >> 8;
-	uint16_t lx, ly, rx, ry;
+	uint16_t tlx, tly, trx, trry;
 	steamPadsToTouch(b, PS5_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
-			 g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx, &ry);
-	touchPackPads(out + 32, lTouch, rTouch, lx, ly, rx, ry);
+			 g_in[slot].rpx, g_in[slot].rpy, &tlx, &tly, &trx,
+			 &trry);
+	touchPackPads(out + 32, lTouch, rTouch, tlx, tly, trx, trry);
 	out[52] = PS5_STATUS_USB;
 }
 

+ 6 - 5
OpenPuck/mode_switch_hori.cpp

@@ -169,11 +169,12 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 	out[0] = btn & 0xFF;
 	out[1] = btn >> 8;
 	out[2] = hat;
-	out[3] = swStick(g_in[slot].lx, false);
-	out[4] = swStick(g_in[slot].ly,
-			 true); // HID Y is down-positive -> invert
-	out[5] = swStick(g_in[slot].rx, false);
-	out[6] = swStick(g_in[slot].ry, true);
+	int16_t lx, ly, rx, ry;
+	slotSticks(slot, &lx, &ly, &rx, &ry);
+	out[3] = swStick(lx, false);
+	out[4] = swStick(ly, true); // HID Y is down-positive -> invert
+	out[5] = swStick(rx, false);
+	out[6] = swStick(ry, true);
 	out[7] = 0;
 }
 

+ 4 - 2
OpenPuck/mode_switch_pro.cpp

@@ -481,8 +481,10 @@ static void jcInputPrefix(uint8_t slot, uint8_t *out)
 	out[2] = (uint8_t)(jc);
 	out[3] = (uint8_t)(jc >> 8);
 	out[4] = (uint8_t)(jc >> 16);
-	jcPackStick(out + 5, g_in[bond].lx, g_in[bond].ly);
-	jcPackStick(out + 8, g_in[bond].rx, g_in[bond].ry);
+	int16_t lx, ly, rx, ry;
+	slotSticks(bond, &lx, &ly, &rx, &ry);
+	jcPackStick(out + 5, lx, ly);
+	jcPackStick(out + 8, rx, ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	out[11] = 0x09;
 }

+ 11 - 5
OpenPuck/mode_xinput.cpp

@@ -400,10 +400,14 @@ static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 		else if (bc[i] == 20)
 			rt = 0xFF;
 	}
-	xinputSend(slot, btn, lt, rt, (int16_t)s16off(r, 8),
-		   (int16_t)s16off(r, 10), // L stick X/Y
-		   (int16_t)s16off(r, 12),
-		   (int16_t)s16off(r, 14)); // R stick X/Y
+	// Raw-report offsets, not slotSticks(): this mode already decodes 0x45 in place and never
+	// touches g_in. 16/18 = left pad X/Y, 22/24 = right pad X/Y (same pair rfXboxMouse reads).
+	int16_t lx = (int16_t)s16off(r, 8), ly = (int16_t)s16off(r, 10),
+		rx = (int16_t)s16off(r, 12), ry = (int16_t)s16off(r, 14);
+	padStickBlend(b, (int16_t)s16off(r, 16), (int16_t)s16off(r, 18),
+		      (int16_t)s16off(r, 22), (int16_t)s16off(r, 24), &lx, &ly,
+		      &rx, &ry);
+	xinputSend(slot, btn, lt, rt, lx, ly, rx, ry);
 }
 // Right pad -> mouse on a second HID-mouse interface alongside the XInput gamepad. Same glide model as Lizard's
 // right pad; RPad click = left button, LPad click = right. SINGLE shared mouse: the desktop can only consume
@@ -415,7 +419,9 @@ static void rfXboxMouse(const uint8_t *r)
 	static bool prt = false;
 	static float vx = 0, vy = 0, rmx = 0, rmy = 0;
 	static uint8_t pmb = 0;
-	bool rtouch = b & TB_RPADT;
+	// The right pad drives a stick now, so it must not also glide the mouse -- treat it as untouched and let
+	// the residual velocity decay away instead of stopping dead.
+	bool rtouch = (g_padStick[1] == PS_OFF) && (b & TB_RPADT);
 	int rx = s16off(r, 22), ry = s16off(r, 24);
 	if (rtouch) {
 		if (prt) {

+ 32 - 3
OpenPuck/webusb_config.cpp

@@ -77,7 +77,8 @@ static bool boardCommand(uint8_t op)
 //                [v14/v17: p[181] landAll87 (verbatim-0x87-relay experiment toggle)]
 //                [v18: p[182..185] chordDpad left/up/right/down (back4+D-pad mode assignments)]
 //                [v19: p[186] swGyroLegacy (Switch Pro gyro mapping: 0 = corrected, 1 = legacy/pre-#189)]
-#define WB_PAYLEN 185
+//                [v20: p[187..194] per-type trackpad->stick map, 4x2B {left pad, right pad} (PS_OFF/LEFT/RIGHT)]
+#define WB_PAYLEN 193
 // 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
@@ -102,7 +103,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (19 = +Switch Pro legacy-gyro select (field 38, blob p[186]); the rumble-strength,
+	// protocol version (20 = +per-type trackpad->stick mapping (fields 80..87, blob p[187..194]);
+	// 19 = +Switch Pro legacy-gyro select (field 38, blob p[186]); the rumble-strength,
 	// Switch report-rate and Switch gyro-scale settings (fields 22/23/24, blob p[53..55]) are GONE -- those
 	// bytes now read 0; 18 = +configurable back4+D-pad chords (fields 34..37, blob p[182..185]);
 	// 17 = per-type rumble field (TypeCfg k=8), per-type stride 8->9; 16 = +configurable
@@ -111,7 +113,7 @@ static void webusbSendBlob()
 	// 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] = 19;
+	p[2] = 20;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -285,6 +287,11 @@ static void webusbSendBlob()
 	p[185] = g_chordDpad[CHD_DOWN];
 	// v19: Switch Pro gyro mapping (0 = corrected/default, 1 = legacy pre-#189 raw axes)
 	p[186] = g_swGyroLegacy;
+	// v20: per-type trackpad->stick mapping, {left pad, right pad} per emulated type
+	for (int et = 0; et < ET_COUNT; et++) {
+		p[187 + et * 2] = g_padStickCfg[et][0];
+		p[188 + et * 2] = g_padStickCfg[et][1];
+	}
 	// 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
@@ -986,6 +993,28 @@ void webusbPoll()
 					}
 					break;
 
+				// 80..87: per-type trackpad->stick mapping, field = 80 + et*2 + pad
+				// (pad 0 = left, 1 = right; value PS_OFF/PS_LEFT/PS_RIGHT).
+				// Must stay clear of the per-type cfg range 40..40+ET_COUNT*9-1
+				// (40..75), which is claimed before this switch runs.
+				case 80:
+				case 81:
+				case 82:
+				case 83:
+				case 84:
+				case 85:
+				case 86:
+				case 87: {
+					uint8_t et = (uint8_t)((f - 80) / 2),
+						pad = (uint8_t)((f - 80) % 2);
+					if (et < ET_COUNT && v <= PS_MAX) {
+						g_padStickCfg[et][pad] = v;
+						if (et == g_etype)
+							applyActiveType();
+					}
+					break;
+				}
+
 				// Switch Pro gyro mapping: 0 = corrected (default), 1 = legacy
 				// (pre-#189 raw axes, no sensitivity trim). Protocol v19.
 				case 38:

+ 9 - 0
docs/PROTOCOL.md

@@ -466,6 +466,15 @@ Status blob payload:
 [22] persist-mode flag
 ```
 
+Later fields are appended (the version byte says how far the payload goes). From version 20 the tail carries
+the per-emulated-type trackpad-to-stick mapping at payload bytes 187..194 — two bytes per type
+(`{left pad, right pad}`), each `0` off / `1` left stick / `2` right stick. Set with
+`0x02 <80 + type*2 + pad> <value>`. Fields 40..75 are the per-type config block, so the mapping starts at 80.
+A mapped pad **blends** with its stick: while the pad is touched each axis reports whichever of the two
+sources is deflected further from center (signed); an untouched pad contributes nothing and the physical
+stick passes straight through. A mapped pad also stops reporting as a touchpad contact / mouse.
+
+
 ### 10.1 Backup / clone (bond export & import)
 
 A puck's whole portable identity is its four 24-byte bond records (`[proteus_uuid 4][ibex_uuid 4][serial 16]`,

+ 33 - 5
docs/index.html

@@ -591,7 +591,13 @@ let lizardBindings = [];   // [{outType, od:[7], trig, hold}]
 let lizardBusy = false;
 let lizardLoaded = false;  // one-shot: the lizard map is fetched lazily, only once the connected puck proves it speaks v16+
 const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
-const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV, rumble}
+const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV, rumble, padStick[]}
+// Trackpad -> joystick mapping (firmware PS_OFF/PS_LEFT/PS_RIGHT). Field id = PAD_STICK_FIELD0 + et*2 + pad
+// (pad 0 = left trackpad, 1 = right). While mapped and touched the pad drives that stick; on release the
+// stick re-centers, and an untouched mapped pad leaves the physical stick in control.
+const PAD_STICK_FIELD0 = 80;
+const PAD_STICK_OPTS = [[0,"Off (touchpad)"],[1,"Left stick"],[2,"Right stick"]];
+const PAD_STICK_LABELS = ["Left trackpad \u2192 stick","Right trackpad \u2192 stick"];
 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); }
@@ -619,7 +625,7 @@ function setTab(et){
     tab.onclick=()=>setTab(et); bar.appendChild(tab);
     // panel
     const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
-    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null,rumble:null};
+    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null,rumble:null,padStick:[]};
     // A/B swap + trackpad haptics + rumble toggles
     const tog=document.createElement("div"); tog.className="row";
     tog.innerHTML='<label>A/B + X/Y swap</label>';
@@ -657,6 +663,16 @@ function setTab(et){
       sl.addEventListener("change",()=>setField(40+et*9+7, +sl.value));
       sec.appendChild(row);
       rec.led=sl; rec.ledV=vspan; }
+    // trackpad -> joystick mapping (one select per pad)
+    for(let pad=0; pad<2; pad++){
+      const row=document.createElement("div"); row.className="row";
+      const lab=document.createElement("label"); lab.textContent=PAD_STICK_LABELS[pad]; row.appendChild(lab);
+      const sel=document.createElement("select");
+      for(const [v,lbl] of PAD_STICK_OPTS){ const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
+      row.appendChild(sel); sec.appendChild(row);
+      sel.addEventListener("change",()=>setField(PAD_STICK_FIELD0+et*2+pad, +sel.value));
+      rec.padStick.push(sel);
+    }
     host.appendChild(sec); typeEls.push(rec);
   });
   setTab(0);
@@ -1041,6 +1057,8 @@ function applyBlob(p){
     if (RR_FAULT.has(code) && !window._flightAuto){ window._flightAuto=true; window._autoFlight=true; }
   }
 
+  // per-type trackpad->stick mapping (blob v20, firmware p[187..194] -> JS p[185..192])
+  const padStickCap=(p[0]>=20 && p.length>192);
   const gitDirty=(p.length>38?p[38]:0); let buildId="";
   for(let i=39;i<51 && i<p.length && p[i];i++) buildId+=String.fromCharCode(p[i]);
   $("#stBuild").innerHTML = buildId
@@ -1067,6 +1085,11 @@ function applyBlob(p){
       rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
       rec.rumble.textContent=rum?"on":"off"; rec.rumble.classList.toggle("active",!!rum);
       if(document.activeElement!==rec.led){ rec.led.value=led; rec.ledV.textContent=(led===0)?"Auto":led+"%"; }
+      rec.padStick.forEach((sel,pad)=>{
+        // hidden entirely on firmware too old to speak the mapping, so the panel never shows a dead control
+        sel.parentNode.classList.toggle("hide", !padStickCap);
+        if(padStickCap && document.activeElement!==sel) sel.value=p[185+et*2+pad];
+      });
       // dot marks the type matching the current puck mode
       rec.activeDot.style.display = (et===activeEt) ? "" : "none";
     });
@@ -1178,7 +1201,10 @@ function buildBackup(p, bp){
     bonds.push({slot:s, used:!!((mask>>s)&1), rec:hexEnc(rec)}); }
   const types=[];
   for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*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 t={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]};
+    // pad->stick mapping only exists from protocol v20; omit on older pucks so a restore can't replay defaults
+    if(p[0]>=21 && p.length>192) t.padStick=[p[185+et*2],p[186+et*2]];
+    types.push(t); }
   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
@@ -1237,7 +1263,8 @@ async function importBackup(file){
   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 baseN=6 + (Array.isArray(c.chordD)?4:0) + (c.swGyroLegacy!==undefined?1:0);
-  const total=baseN + typeN*9 + lizardN + 4 + 1;
+  const padStickN=Array.isArray(c.types)?c.types.slice(0,typeN).filter(t=>Array.isArray(t.padStick)).length*2:0;
+  const total=baseN + typeN*9 + padStickN + lizardN + 4 + 1;
   let step=0, stage="";
   const tick=()=>modalStage(stage, Math.min(99,Math.floor(step*100/total)));
   try{
@@ -1273,7 +1300,8 @@ async function importBackup(file){
         for(let k=0;k<4;k++) await sf(40+et*9+k, t.back[k]);
         await sf(40+et*9+4, t.qam); await sf(40+et*9+5, t.abSwap?1:0);
         await sf(40+et*9+6, t.pad?1:0); await sf(40+et*9+7, t.led);
-        await sf(40+et*9+8, t.rumble!==undefined?t.rumble:1); }
+        await sf(40+et*9+8, t.rumble!==undefined?t.rumble:1);
+        if(Array.isArray(t.padStick)){ await sf(PAD_STICK_FIELD0+et*2, t.padStick[0]); await sf(PAD_STICK_FIELD0+et*2+1, t.padStick[1]); } }
       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+.