瀏覽代碼

Expand Lizard input mapping (#270)

* Expand Lizard input mapping

* Lizard: map both trackpads and both sticks to mouse motion

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: ixelyth <314675972+ixelyth@users.noreply.github.com>
Co-authored-by: Jari <safijari@isu.edu>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Edgar 1 周之前
父節點
當前提交
0020aa674a
共有 5 個文件被更改,包括 532 次插入 和 103 次删除
  1. 64 12
      OpenPuck/lizard_map.cpp
  2. 19 15
      OpenPuck/lizard_map.h
  3. 127 51
      OpenPuck/mode_lizard.cpp
  4. 111 21
      OpenPuck/webusb_config.cpp
  5. 211 4
      docs/index.html

+ 64 - 12
OpenPuck/lizard_map.cpp

@@ -9,7 +9,32 @@ LizardMap g_lizardMap;
 
 #define LZ_FILE "/lizard_map.bin"
 #define LZ_MAGIC 0xB1u
-#define LZ_VERSION 1u
+#define LZ_VERSION 2u
+
+// Version-1 records use 32-bit masks and borrow bits 28..31 for virtual
+// left-stick directions. The current format stores those directions above the physical word.
+struct LizardBindingV1 {
+	uint8_t outType;
+	uint8_t outData[7];
+	uint32_t trigMask;
+	uint32_t holdMask;
+};
+static_assert(sizeof(LizardBindingV1) == 16,
+	      "version-1 lizard binding layout changed");
+
+static uint64_t lizardMaskFromV1(uint32_t m)
+{
+	uint64_t out = (uint64_t)(m & 0x0FFFFFFFu);
+	if (m & 0x10000000u)
+		out |= LZ_BTN_LSTICK_RT;
+	if (m & 0x20000000u)
+		out |= LZ_BTN_LSTICK_LF;
+	if (m & 0x40000000u)
+		out |= LZ_BTN_LSTICK_DN;
+	if (m & 0x80000000u)
+		out |= LZ_BTN_LSTICK_UP;
+	return out;
+}
 
 // KB modifier bits (from TinyUSB hid.h)
 #define KM_LCTRL 0x01u
@@ -18,8 +43,8 @@ LizardMap g_lizardMap;
 #define KM_LGUI 0x08u
 
 // Helper: append a binding to g_lizardMap
-static void addBind(uint8_t type, const uint8_t *od7, uint32_t trig,
-		    uint32_t hold)
+static void addBind(uint8_t type, const uint8_t *od7, uint64_t trig,
+		    uint64_t hold)
 {
 	if (g_lizardMap.count >= LZ_MAX_BINDINGS)
 		return;
@@ -41,23 +66,23 @@ static inline void addScroll(uint8_t src)
 	uint8_t d[7] = { src, 0, 0, 0, 0, 0, 0 };
 	addBind(LZ_OUT_SCROLL, d, 0, 0);
 }
-static inline void addMouseBtn(uint8_t btn, uint32_t trig, uint32_t hold)
+static inline void addMouseBtn(uint8_t btn, uint64_t trig, uint64_t hold)
 {
 	uint8_t d[7] = { btn, 0, 0, 0, 0, 0, 0 };
 	addBind(LZ_OUT_MOUSE_BTN, d, trig, hold);
 }
-static inline void addKey(uint8_t mod, uint8_t k0, uint32_t trig, uint32_t hold)
+static inline void addKey(uint8_t mod, uint8_t k0, uint64_t trig, uint64_t hold)
 {
 	uint8_t d[7] = { mod, k0, 0, 0, 0, 0, 0 };
 	addBind(LZ_OUT_KBD_CHORD, d, trig, hold);
 }
 static inline void addKey3(uint8_t mod, uint8_t k0, uint8_t k1, uint8_t k2,
-			   uint32_t trig, uint32_t hold)
+			   uint64_t trig, uint64_t hold)
 {
 	uint8_t d[7] = { mod, k0, k1, k2, 0, 0, 0 };
 	addBind(LZ_OUT_KBD_CHORD, d, trig, hold);
 }
-static inline void addConsumer(uint8_t bits, uint32_t trig, uint32_t hold)
+static inline void addConsumer(uint8_t bits, uint64_t trig, uint64_t hold)
 {
 	uint8_t d[7] = { bits, 0, 0, 0, 0, 0, 0 };
 	addBind(LZ_OUT_CONSUMER, d, trig, hold);
@@ -133,17 +158,42 @@ void saveLizardMap()
 
 void loadLizardMap()
 {
+	bool migrated = false;
 	File f(InternalFS);
 	if (f.open(LZ_FILE, FILE_O_READ)) {
 		uint8_t hdr[3] = { 0, 0, 0 };
 		if (f.read(hdr, 3) == 3 && hdr[0] == LZ_MAGIC &&
-		    hdr[1] == LZ_VERSION && hdr[2] <= LZ_MAX_BINDINGS) {
+		    hdr[2] <= LZ_MAX_BINDINGS) {
 			uint8_t cnt = hdr[2];
 			g_lizardMap.count = 0;
-			int got = f.read((uint8_t *)g_lizardMap.bindings,
-					 cnt * sizeof(LizardBinding));
-			if (got == (int)(cnt * sizeof(LizardBinding)))
-				g_lizardMap.count = cnt;
+			if (hdr[1] == LZ_VERSION) {
+				int got =
+					f.read((uint8_t *)g_lizardMap.bindings,
+					       cnt * sizeof(LizardBinding));
+				if (got == (int)(cnt * sizeof(LizardBinding)))
+					g_lizardMap.count = cnt;
+			} else if (hdr[1] == 1u) {
+				static LizardBindingV1 old[LZ_MAX_BINDINGS];
+				int got = f.read((uint8_t *)old,
+						 cnt * sizeof(LizardBindingV1));
+				if (got ==
+				    (int)(cnt * sizeof(LizardBindingV1))) {
+					for (uint8_t i = 0; i < cnt; i++) {
+						LizardBinding &b =
+							g_lizardMap.bindings[i];
+						b.outType = old[i].outType;
+						memcpy(b.outData,
+						       old[i].outData,
+						       sizeof b.outData);
+						b.trigMask = lizardMaskFromV1(
+							old[i].trigMask);
+						b.holdMask = lizardMaskFromV1(
+							old[i].holdMask);
+					}
+					g_lizardMap.count = cnt;
+					migrated = true;
+				}
+			}
 		}
 		f.close();
 	}
@@ -151,5 +201,7 @@ void loadLizardMap()
 	if (g_lizardMap.count == 0) {
 		defaultLizardMap();
 		saveLizardMap();
+	} else if (migrated) {
+		saveLizardMap();
 	}
 }

+ 19 - 15
OpenPuck/lizard_map.h

@@ -18,6 +18,7 @@
 // Mouse button: outData[0]=button bitmask (bit0=left, bit1=right, bit2=middle).
 #define LZ_OUT_MOUSE_BTN 2u
 // Mouse axis: outData[0]=source (LZ_MSRC_*), outData[1]=gyro activation (LZ_GYRO_*).
+// Pads and sticks all drive the one shared cursor; several sources may be bound at once.
 #define LZ_OUT_MOUSE_AXIS 3u
 // Scroll wheel from an analog source: outData[0]=source (LZ_MSRC_*).
 #define LZ_OUT_SCROLL 4u
@@ -30,6 +31,8 @@
 #define LZ_MSRC_GYRO \
 	2u // gyroscope (MOUSE_AXIS; activation-gated, see LZ_GYRO_*)
 #define LZ_MSRC_LPAD 0u // left trackpad (SCROLL; touch-gated by TB_LPADT)
+#define LZ_MSRC_LPAD_MOUSE 3u // left trackpad (MOUSE_AXIS; touch-gated)
+#define LZ_MSRC_RSTICK 4u // right analog stick (MOUSE_AXIS)
 
 // ---- gyro activation condition (outData[1] when src=LZ_MSRC_GYRO) ----
 #define LZ_GYRO_ALWAYS 0u // gyro always drives mouse
@@ -39,26 +42,27 @@
 #define LZ_GYRO_BTN 3u
 
 // ---- virtual stick-deflection trigger bits (usable in trigMask / holdMask) ----
-// These reuse bits 28..31 of the button word, which the SC2 *does* use in its 0x45 report (bit 28 =
-// right grip touch, bit 29 = left grip touch; PROTOCOL.md §8). To avoid grip-touch firing these,
-// lizardButtons() masks the controller's top 4 bits off before OR-ing in the deflection flags below,
-// so in MODE_LIZARD these bits mean ONLY stick deflection (grips are not a bindable lizard input).
-#define LZ_BTN_LSTICK_RT 0x10000000u // lx >  12000
-#define LZ_BTN_LSTICK_LF 0x20000000u // lx < -12000
-#define LZ_BTN_LSTICK_DN 0x40000000u // ly < -12000
-#define LZ_BTN_LSTICK_UP 0x80000000u // ly >  12000
-
-// ---- binding (16 bytes, naturally 4-byte aligned) ----
-// trigMask: (buttons & trigMask) != 0 activates the binding.
-// holdMask: ALL bits must also be set at the same time (AND-guard).
+// Low 32 bits preserve the complete native Triton button word. Virtual
+// analog-direction triggers live above it and cannot alias physical buttons.
+#define LZ_BTN_LSTICK_RT (1ULL << 32) // lx >  12000
+#define LZ_BTN_LSTICK_LF (1ULL << 33) // lx < -12000
+#define LZ_BTN_LSTICK_DN (1ULL << 34) // ly < -12000
+#define LZ_BTN_LSTICK_UP (1ULL << 35) // ly >  12000
+#define LZ_BTN_RSTICK_RT (1ULL << 36) // rx >  12000
+#define LZ_BTN_RSTICK_LF (1ULL << 37) // rx < -12000
+#define LZ_BTN_RSTICK_DN (1ULL << 38) // ry < -12000
+#define LZ_BTN_RSTICK_UP (1ULL << 39) // ry >  12000
+// ---- binding v2 (24 bytes) ----
+// trigMask: any matching bit activates the binding.
+// holdMask: all matching bits must also be held.
 // MOUSE_AXIS and SCROLL bindings ignore trigMask/holdMask; the analog source drives them.
 struct LizardBinding {
 	uint8_t outType; // LZ_OUT_*
 	uint8_t outData[7]; // type-specific payload (see constants above)
-	uint32_t trigMask; // physical/virtual button bits: any-of (OR)
-	uint32_t holdMask; // physical/virtual button bits: all-of (AND guard)
+	uint64_t trigMask; // physical/virtual button bits: any-of (OR)
+	uint64_t holdMask; // physical/virtual button bits: all-of (AND guard)
 };
-
+static_assert(sizeof(LizardBinding) == 24, "LizardBinding v2 layout changed");
 #define LZ_MAX_BINDINGS 32u
 
 struct LizardMap {

+ 127 - 51
OpenPuck/mode_lizard.cpp

@@ -7,24 +7,24 @@
 
 // Evaluate the binding table for ONE controller's button word, merging its keyboard/mouse-button/
 // consumer outputs into the shared accumulators. Pulled out of rfLizard so every bonded controller can
-// feed the same outputs. doRpadMouse/doLpadScroll are map-level (set if such a binding exists at all).
-static void lizardEvalSlot(uint32_t buttons, uint8_t &outMod,
+// feed the same outputs. mouseSrc/doLpadScroll are map-level (set if such a binding exists at all).
+static void lizardEvalSlot(uint64_t buttons, uint8_t &outMod,
 			   uint8_t outKeys[6], uint8_t &nKeys, uint8_t &outMBtn,
-			   uint8_t &consumerBits, bool &doRpadMouse,
+			   uint8_t &consumerBits, uint8_t &mouseSrc,
 			   bool &doLpadScroll)
 {
 	// kbdConsumed (per controller): trig bits claimed by a hold-modifier binding so simpler
 	// single-button bindings sharing those bits are suppressed for THIS controller.
-	uint32_t kbdConsumed = 0;
+	uint64_t kbdConsumed = 0;
 	for (uint8_t i = 0; i < g_lizardMap.count; i++) {
 		const LizardBinding &b = g_lizardMap.bindings[i];
 		if (b.outType == LZ_OUT_NONE)
 			continue;
 
 		if (b.outType == LZ_OUT_MOUSE_AXIS) {
-			if (b.outData[0] == LZ_MSRC_RPAD)
-				doRpadMouse = true;
-			// LZ_MSRC_LSTICK and LZ_MSRC_GYRO could be added later
+			// bit per LZ_MSRC_* source; gyro is not driven here
+			if (b.outData[0] < 8)
+				mouseSrc |= (uint8_t)(1u << b.outData[0]);
 			continue;
 		}
 		if (b.outType == LZ_OUT_SCROLL) {
@@ -76,9 +76,9 @@ static void lizardEvalSlot(uint32_t buttons, uint8_t &outMod,
 // grip touch, bit 29 = left grip touch (PROTOCOL.md §8). Merely holding the controller sets the grip
 // bits, which would masquerade as an L-stick deflection and fire the bound key. Clear the top 4 bits of
 // the physical word (grips are not a bindable lizard input) so these flags reflect ONLY stick deflection.
-static uint32_t lizardButtons(const PuckInput &in)
+static uint64_t lizardButtons(const PuckInput &in)
 {
-	uint32_t buttons = in.buttons & 0x0FFFFFFFu;
+	uint64_t buttons = (uint64_t)in.buttons;
 	if (in.lx > 12000)
 		buttons |= LZ_BTN_LSTICK_RT;
 	if (in.lx < -12000)
@@ -87,6 +87,14 @@ static uint32_t lizardButtons(const PuckInput &in)
 		buttons |= LZ_BTN_LSTICK_DN;
 	if (in.ly > 12000)
 		buttons |= LZ_BTN_LSTICK_UP;
+	if (in.rx > 12000)
+		buttons |= LZ_BTN_RSTICK_RT;
+	if (in.rx < -12000)
+		buttons |= LZ_BTN_RSTICK_LF;
+	if (in.ry < -12000)
+		buttons |= LZ_BTN_RSTICK_DN;
+	if (in.ry > 12000)
+		buttons |= LZ_BTN_RSTICK_UP;
 	return buttons;
 }
 
@@ -94,12 +102,34 @@ static uint32_t lizardButtons(const PuckInput &in)
 // function-local statics) so rfLizardRelease() can reset them on a lizard->Steam handoff: without that reset
 // a key/mouse-button that was DOWN the instant Steam takes over is never released (the change-dedup below
 // thinks it is still "down") and sticks on the desktop until a reconnect/power-cycle.
-static int prx[NSLOT] = { 0 }, pry[NSLOT] = { 0 };
-static bool prt[NSLOT] = { false };
-static float vx[NSLOT] = { 0 }, vy[NSLOT] = { 0 }, rmx = 0, rmy = 0;
+// Stick deflection -> -1..1 with a deadzone and a squared response.
+#define STICK_MOUSE_DEADZONE 3500
+#define STICK_MOUSE_PX_PER_S 1100.0f
+static float stickAxis(int v)
+{
+	int a = v < 0 ? -v : v;
+	if (a <= STICK_MOUSE_DEADZONE)
+		return 0;
+	float n = (float)(a - STICK_MOUSE_DEADZONE) /
+		  (float)(32767 - STICK_MOUSE_DEADZONE);
+	if (n > 1.0f)
+		n = 1.0f;
+	n *= n;
+	return v < 0 ? -n : n;
+}
+
+struct PadMouse {
+	int px, py; // last touched position
+	bool touching;
+	float vx, vy; // glide velocity
+};
+enum { PM_RIGHT, PM_LEFT, PM_N };
+static PadMouse g_pm[PM_N][NSLOT];
+static float rmx = 0, rmy = 0;
 static int ply[NSLOT] = { 0 };
 static bool plt[NSLOT] = { false };
 static float sacc = 0;
+static uint32_t g_stickLastUs = 0;
 static uint8_t pmbtn = 0; // last-sent mouse buttons
 static uint8_t prevCC = 0; // last-sent consumer bits
 static uint8_t pmod = 0, pkc[6] = {
@@ -114,56 +144,103 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 	uint8_t outKeys[6] = { 0, 0, 0, 0, 0, 0 };
 	uint8_t nKeys = 0;
 	uint8_t outMBtn = 0;
-	bool doRpadMouse = false, doLpadScroll = false;
+	uint8_t mouseSrc = 0;
+	bool doLpadScroll = false;
 	uint8_t consumerBits = 0;
 
 	for (int s = 0; s < NSLOT; s++) {
 		if (!g_slot[s].used)
 			continue;
 		lizardEvalSlot(lizardButtons(g_in[s]), outMod, outKeys, nKeys,
-			       outMBtn, consumerBits, doRpadMouse,
-			       doLpadScroll);
+			       outMBtn, consumerBits, mouseSrc, doLpadScroll);
 	}
 
-	// ---- right pad -> mouse motion with glide ----
-	// Per-slot velocity/last-position so each controller's pad integrates independently; the resulting
-	// deltas are SUMMED into one cursor (sub-pixel carry rmx/rmy is shared -- one mouse). State is
-	// file-scope (see the block above rfLizard) so rfLizardRelease() can zero it on a handoff.
+	// ---- pads + sticks -> mouse motion ----
+	// Every bound source feeds ONE cursor. Pads integrate touch deltas per slot and then glide with friction
+	// (state file-scope so rfLizardRelease() can zero it on a handoff); the deltas are SUMMED across
+	// controllers and sources, with the sub-pixel carry rmx/rmy shared.
 	int dx = 0, dy = 0;
-	if (doRpadMouse) {
+	{
 		float sumx = 0, sumy = 0;
 		float f = g_mFric / 100.0f;
-		for (int s = 0; s < NSLOT; s++) {
-			if (!g_slot[s].used) {
-				vx[s] = vy[s] = 0;
-				prt[s] = false;
-				continue;
+		for (int p = 0; p < PM_N; p++) {
+			const uint8_t src = (p == PM_RIGHT) ?
+						    LZ_MSRC_RPAD :
+						    LZ_MSRC_LPAD_MOUSE;
+			const bool bound = mouseSrc & (1u << src);
+			for (int s = 0; s < NSLOT; s++) {
+				PadMouse &pm = g_pm[p][s];
+				if (!bound || !g_slot[s].used) {
+					pm.vx = pm.vy = 0;
+					pm.touching = false;
+					continue;
+				}
+				const PuckInput &in = g_in[s];
+				bool touch = (in.buttons &
+					      (p == PM_RIGHT ? TB_RPADT :
+							       TB_LPADT)) != 0;
+				int x = (p == PM_RIGHT) ? in.rpx : in.lpx;
+				int y = (p == PM_RIGHT) ? in.rpy : in.lpy;
+				if (touch && pm.touching) {
+					pm.vx += (x - pm.px);
+					pm.vy += (y - pm.py);
+				}
+				if (touch) {
+					pm.px = x;
+					pm.py = y;
+				}
+				pm.touching = touch;
+				sumx += pm.vx;
+				sumy += pm.vy;
+				// friction = glide/decay (per slot)
+				pm.vx *= f;
+				pm.vy *= f;
+				if (pm.vx > -1 && pm.vx < 1)
+					pm.vx = 0;
+				if (pm.vy > -1 && pm.vy < 1)
+					pm.vy = 0;
 			}
-			const PuckInput &in = g_in[s];
-			bool rtouch = (in.buttons & TB_RPADT) != 0;
-			int rx = in.rpx, ry = in.rpy;
-			if (rtouch && prt[s]) {
-				vx[s] += (rx - prx[s]);
-				vy[s] += (ry - pry[s]);
-			}
-			if (rtouch) {
-				prx[s] = rx;
-				pry[s] = ry;
-			}
-			prt[s] = rtouch;
-			sumx += vx[s];
-			sumy += vy[s];
-			// friction = glide/decay (per slot)
-			vx[s] *= f;
-			vy[s] *= f;
-			if (vx[s] > -1 && vx[s] < 1)
-				vx[s] = 0;
-			if (vy[s] > -1 && vy[s] < 1)
-				vy[s] = 0;
 		}
 		// Y inverted; *10 = desktop cursor sensitivity (g_mDiv 64 -> eff 640)
-		float mxf = sumx / (float)(g_mDiv * 10) + rmx,
-		      myf = -(sumy / (float)(g_mDiv * 10)) + rmy;
+		float mxf = sumx / (float)(g_mDiv * 10);
+		float myf = -(sumy / (float)(g_mDiv * 10));
+
+		// Sticks are rate controls, not position deltas: scale by elapsed time so the speed is the same
+		// whatever the report rate, and square the deflection for fine control near the deadzone.
+		if (mouseSrc &
+		    ((1u << LZ_MSRC_LSTICK) | (1u << LZ_MSRC_RSTICK))) {
+			uint32_t nowUs = micros();
+			float dt = g_stickLastUs ?
+					   (uint32_t)(nowUs - g_stickLastUs) *
+						   1e-6f :
+					   0;
+			g_stickLastUs = nowUs;
+			// a stalled or first report must not fling the cursor
+			if (dt > 0.05f)
+				dt = 0;
+			for (int s = 0; s < NSLOT; s++) {
+				if (!g_slot[s].used)
+					continue;
+				const PuckInput &in = g_in[s];
+				float ax = 0, ay = 0;
+				if (mouseSrc & (1u << LZ_MSRC_LSTICK)) {
+					ax += stickAxis(in.lx);
+					ay += stickAxis(in.ly);
+				}
+				if (mouseSrc & (1u << LZ_MSRC_RSTICK)) {
+					ax += stickAxis(in.rx);
+					ay += stickAxis(in.ry);
+				}
+				float speed = STICK_MOUSE_PX_PER_S * 64.0f /
+					      (float)g_mDiv;
+				mxf += ax * speed * dt;
+				myf -= ay * speed * dt;
+			}
+		} else
+			g_stickLastUs = 0;
+
+		mxf += rmx;
+		myf += rmy;
 		dx = (int)mxf;
 		dy = (int)myf;
 		rmx = mxf - dx;
@@ -270,13 +347,12 @@ void rfLizardRelease(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev,
 	}
 	// Zero the integrators + last-sent shadows so re-entry starts clean (no cursor fling from stale
 	// velocity, and held keys/buttons re-send correctly on the next rfLizard pass).
+	memset(g_pm, 0, sizeof g_pm);
 	for (int s = 0; s < NSLOT; s++) {
-		prx[s] = pry[s] = 0;
-		prt[s] = false;
-		vx[s] = vy[s] = 0;
 		ply[s] = 0;
 		plt[s] = false;
 	}
+	g_stickLastUs = 0;
 	rmx = rmy = sacc = 0;
 	pmbtn = 0;
 	prevCC = 0;

+ 111 - 21
OpenPuck/webusb_config.cpp

@@ -651,6 +651,34 @@ void webusbInit(void)
 //   [0xAA][count][ per binding (16B): outType, outData[0..6], trigMask LE(4), holdMask LE(4) ]
 // Total = 2 + count*16 (max 2 + 32*16 = 514). The panel reads byte[1]=count and accumulates
 // transferIn packets until it has the full frame.
+static uint64_t webusbLizardMaskFromLegacy(uint32_t m)
+{
+	uint64_t out = (uint64_t)(m & 0x0FFFFFFFu);
+	if (m & 0x10000000u)
+		out |= LZ_BTN_LSTICK_RT;
+	if (m & 0x20000000u)
+		out |= LZ_BTN_LSTICK_LF;
+	if (m & 0x40000000u)
+		out |= LZ_BTN_LSTICK_DN;
+	if (m & 0x80000000u)
+		out |= LZ_BTN_LSTICK_UP;
+	return out;
+}
+
+static uint32_t webusbLizardMaskToLegacy(uint64_t m)
+{
+	uint32_t out = (uint32_t)(m & 0x0FFFFFFFu);
+	if (m & LZ_BTN_LSTICK_RT)
+		out |= 0x10000000u;
+	if (m & LZ_BTN_LSTICK_LF)
+		out |= 0x20000000u;
+	if (m & LZ_BTN_LSTICK_DN)
+		out |= 0x40000000u;
+	if (m & LZ_BTN_LSTICK_UP)
+		out |= 0x80000000u;
+	return out;
+}
+
 static void webusbSendLizard()
 {
 	if (!usb_web.connected())
@@ -670,18 +698,45 @@ static void webusbSendLizard()
 		q[0] = b.outType;
 		for (int k = 0; k < 7; k++)
 			q[1 + k] = b.outData[k];
-		q[8] = (uint8_t)b.trigMask;
-		q[9] = (uint8_t)(b.trigMask >> 8);
-		q[10] = (uint8_t)(b.trigMask >> 16);
-		q[11] = (uint8_t)(b.trigMask >> 24);
-		q[12] = (uint8_t)b.holdMask;
-		q[13] = (uint8_t)(b.holdMask >> 8);
-		q[14] = (uint8_t)(b.holdMask >> 16);
-		q[15] = (uint8_t)(b.holdMask >> 24);
+		uint32_t trig = webusbLizardMaskToLegacy(b.trigMask);
+		uint32_t hold = webusbLizardMaskToLegacy(b.holdMask);
+		q[8] = (uint8_t)trig;
+		q[9] = (uint8_t)(trig >> 8);
+		q[10] = (uint8_t)(trig >> 16);
+		q[11] = (uint8_t)(trig >> 24);
+		q[12] = (uint8_t)hold;
+		q[13] = (uint8_t)(hold >> 8);
+		q[14] = (uint8_t)(hold >> 16);
+		q[15] = (uint8_t)(hold >> 24);
 	}
 	usb_web.write(f, (uint16_t)(2 + count * 16));
 	usb_web.flush();
 }
+
+static void webusbSendLizardV2()
+{
+	if (!usb_web.connected())
+		return;
+	uint8_t count = g_lizardMap.count;
+	if (count > LZ_MAX_BINDINGS)
+		count = LZ_MAX_BINDINGS;
+	static uint8_t f[2 + LZ_MAX_BINDINGS * 24];
+	f[0] = 0xAA;
+	f[1] = count;
+	for (uint8_t i = 0; i < count; i++) {
+		const LizardBinding &b = g_lizardMap.bindings[i];
+		uint8_t *q = &f[2 + i * 24];
+		q[0] = b.outType;
+		for (int k = 0; k < 7; k++)
+			q[1 + k] = b.outData[k];
+		for (int k = 0; k < 8; k++) {
+			q[8 + k] = (uint8_t)(b.trigMask >> (k * 8));
+			q[16 + k] = (uint8_t)(b.holdMask >> (k * 8));
+		}
+	}
+	usb_web.write(f, (uint16_t)(2 + count * 24));
+	usb_web.flush();
+}
 #if OPK_LOG
 // Stream the capture ring (haptics / relayed host commands) to the panel as 0xA6 frames. Frame formats:
 //   entry: [0xA6][L][T=1][age u32 LE][slot][rid][n][bytes n]   (L = 8 + n)
@@ -755,7 +810,7 @@ void webusbPoll()
 			uint8_t op = buf[0];
 			// 0x16 = test rumble (v21); extend this range whenever a new opcode is added, or the
 			// parser drops it as garbage and the handler below never runs.
-			if ((op < 0x01 || op > 0x16) &&
+			if ((op < 0x01 || op > 0x1A) &&
 			    (op < 0x20 || op > 0x25)) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
@@ -776,6 +831,7 @@ void webusbPoll()
 			uint8_t need =
 				(op == 0x0D) ? 27 :
 				(op == 0x12) ? 18 :
+				(op == 0x18) ? 26 :
 				(op == 0x02) ? 3 :
 				(op == 0x03 || op == 0x05 || op == 0x0E ||
 				 op == 0x0F || op == 0x10 || op == 0x13) ?
@@ -925,8 +981,8 @@ void webusbPoll()
 				// which the pre-merge branch had collided with. The panel JS uses the same 0x11..0x15.
 				// 0x11: dump the current map as a 0xAA frame.
 			} else if (op == 0x11) {
+				// Compatibility dump for panels using the compact map encoding.
 				webusbSendLizard();
-
 				// 0x13 [count]: BEGIN a map edit -- set the binding count (clamped). The
 				// panel then sends one 0x12 per binding (0..count-1) and a 0x14 to persist.
 			} else if (op == 0x13) {
@@ -934,9 +990,9 @@ void webusbPoll()
 					(buf[1] <= LZ_MAX_BINDINGS) ?
 						buf[1] :
 						LZ_MAX_BINDINGS;
-
 				// 0x12 [idx][outType][od0..6][trig LE4][hold LE4]: set one binding.
 			} else if (op == 0x12) {
+				// Compatibility 16-byte binding. Translate virtual L-stick bits.
 				uint8_t idx = buf[1];
 				if (idx < LZ_MAX_BINDINGS) {
 					LizardBinding &b =
@@ -944,26 +1000,60 @@ void webusbPoll()
 					b.outType = buf[2];
 					for (int k = 0; k < 7; k++)
 						b.outData[k] = buf[3 + k];
-					b.trigMask = (uint32_t)buf[10] |
-						     ((uint32_t)buf[11] << 8) |
-						     ((uint32_t)buf[12] << 16) |
-						     ((uint32_t)buf[13] << 24);
-					b.holdMask = (uint32_t)buf[14] |
-						     ((uint32_t)buf[15] << 8) |
-						     ((uint32_t)buf[16] << 16) |
-						     ((uint32_t)buf[17] << 24);
+					uint32_t trig =
+						(uint32_t)buf[10] |
+						((uint32_t)buf[11] << 8) |
+						((uint32_t)buf[12] << 16) |
+						((uint32_t)buf[13] << 24);
+					uint32_t hold =
+						(uint32_t)buf[14] |
+						((uint32_t)buf[15] << 8) |
+						((uint32_t)buf[16] << 16) |
+						((uint32_t)buf[17] << 24);
+					b.trigMask = webusbLizardMaskFromLegacy(
+						trig);
+					b.holdMask = webusbLizardMaskFromLegacy(
+						hold);
 				}
-
 				// 0x14: COMMIT the edited map to flash and echo it back.
 			} else if (op == 0x14) {
 				saveLizardMap();
 				webusbSendLizard();
-
 				// 0x15: reset the map to the built-in defaults, persist, echo back.
 			} else if (op == 0x15) {
 				defaultLizardMap();
 				saveLizardMap();
 				webusbSendLizard();
+			} else if (op == 0x17) {
+				// Native lizard-map dump: 24-byte records with uint64 masks.
+				webusbSendLizardV2();
+			} else if (op == 0x18) {
+				// [idx][outType][od0..6][trig LE8][hold LE8]
+				uint8_t idx = buf[1];
+				if (idx < LZ_MAX_BINDINGS) {
+					LizardBinding &b =
+						g_lizardMap.bindings[idx];
+					b.outType = buf[2];
+					for (int k = 0; k < 7; k++)
+						b.outData[k] = buf[3 + k];
+					b.trigMask = 0;
+					b.holdMask = 0;
+					for (int k = 0; k < 8; k++) {
+						b.trigMask |=
+							(uint64_t)buf[10 + k]
+							<< (k * 8);
+						b.holdMask |=
+							(uint64_t)buf[18 + k]
+							<< (k * 8);
+					}
+				}
+			} else if (op == 0x19) {
+				saveLizardMap();
+				webusbSendLizardV2();
+			} else if (op == 0x1A) {
+				defaultLizardMap();
+				saveLizardMap();
+				webusbSendLizardV2();
 
 				// 0x20..0x24: staged firmware update (see fw_update.h). Each op is acked with an 0xAB
 				// frame from the usbd task; the panel ping-pongs on those acks, so at most one command

+ 211 - 4
docs/index.html

@@ -644,7 +644,8 @@ const LZ_BTNS = [
   [0x2000,"D-pad Up"],[0x400,"D-pad Down"],[0x1000,"D-pad Left"],[0x800,"D-pad Right"],
   [0x400000,"Right pad click"],[0x4000000,"Left pad click"],
   [0x200000,"Right pad touch"],[0x2000000,"Left pad touch"],
-  [0x10000000,"L-stick → right"],[0x20000000,"L-stick → left"],[0x40000000,"L-stick → down"],[0x80000000,"L-stick → up"],
+  [Math.pow(2,32),"L-stick → right"],[Math.pow(2,33),"L-stick → left"],[Math.pow(2,34),"L-stick → down"],[Math.pow(2,35),"L-stick → up"],
+[Math.pow(2,36),"R-stick → right"],[Math.pow(2,37),"R-stick → left"],[Math.pow(2,38),"R-stick → down"],[Math.pow(2,39),"R-stick → up"],
 ];
 // Keyboard modifier bits (od[0] for KBD output).
 const LZ_MODS = [[0x01,"Ctrl"],[0x02,"Shift"],[0x04,"Alt"],[0x08,"Win/⌘"]];
@@ -658,11 +659,11 @@ const LZ_KEYS = [[0,"— none —"]];
  [0x2d,"- _"],[0x2e,"= +"],[0x46,"Print Screen"]].forEach(k=>LZ_KEYS.push(k));
 for(let i=0;i<12;i++) LZ_KEYS.push([0x3a+i,"F"+(i+1)]);
 const LZ_MBTNS = [[1,"Left click"],[2,"Right click"],[4,"Middle click"]];
-const LZ_AXIS_SRC = [[0,"Right trackpad"],[1,"Left stick"],[2,"Gyro"]];
+const LZ_AXIS_SRC = [[0,"Right trackpad"],[3,"Left trackpad"],[1,"Left stick"],[4,"Right stick"],[2,"Gyro"]];
 const LZ_GYRO_ACT = [[0,"Always"],[1,"While right pad touched"],[2,"While left stick deflected"],[3,"While hold-button held"]];
 const LZ_CONSUMER = [[1,"Volume +"],[2,"Volume −"]];
 
-function lzBtnLabel(mask){ const m=LZ_BTNS.find(b=>b[0]===(mask>>>0)); return m?m[1]:(mask?("0x"+(mask>>>0).toString(16)):""); }
+function lzBtnLabel(mask){ const v=Number(mask)||0; const m=LZ_BTNS.find(b=>Number(b[0])===v); return m?m[1]:(v?("0x"+v.toString(16)):""); }
 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+
@@ -1332,7 +1333,7 @@ function applyBlob(p){
   // Lazy, capability-gated lizard-map load: only after a blob proves the puck speaks v16+ (the op-0x11 dump).
   // On older firmware 0x11 is dropped silently and readLizard() would block the endpoint forever, so we must
   // NEVER send it blind at connect. One-shot per connection (reset in openDevice).
-  if(!lizardLoaded && p[0]>=16){ lizardLoaded=true; loadLizard(); }
+  if(!lizardLoaded && p[0]>=16){ lizardLoaded=true; lzV2Load(); }
   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);
@@ -2901,6 +2902,212 @@ updateUf2UI();
 updateFwGate(); // start gated: stays inert until the first status blob proves the firmware speaks v15+
 if(!("usb" in navigator)) $("#connectBtn").outerHTML='<b style="color:var(--bad)">WebUSB not supported — use Chrome or Edge.</b>';
 else autoConnect(); // reopen an already-authorized puck on load, no picker (first-ever connect still needs the button)
+
+// OPENPUCK_LIZARD_MAP_V2_R_STICK
+// Binding-v2 format appeared at protocol 20. Protocol 21 (upstream #224)
+// claimed 0x16 for rumble-test, so the v2 map transport shifts one slot.
+const LZ_MAP_V2_PROTOCOL = 20;
+const LZ_MAP_V2_SHIFTED_OPS_PROTOCOL = 21;
+const LZ_V1_LOW_MASK = 0x0fffffff;
+const lzPow2 = bit => Math.pow(2, bit);
+
+function lzReadLE(d,o,n){
+	let v=0;
+	for(let i=n-1;i>=0;i--) v=v*256+d[o+i];
+	return v;
+}
+function lzWriteLE(out,v,n){
+	v=Number(v)||0;
+	for(let i=0;i<n;i++) out.push(Math.floor(v/Math.pow(2,8*i))&0xff);
+}
+function lzLegacyMaskToV2(v){
+	v=Number(v)>>>0;
+	let out=v&LZ_V1_LOW_MASK;
+	if(v&0x10000000) out+=lzPow2(32);
+	if(v&0x20000000) out+=lzPow2(33);
+	if(v&0x40000000) out+=lzPow2(34);
+	if(v&0x80000000) out+=lzPow2(35);
+	return out;
+}
+function lzV2MaskToLegacy(v){
+	v=Number(v)||0;
+	let out=(v%lzPow2(28))>>>0;
+	if(Math.floor(v/lzPow2(32))&1) out|=0x10000000;
+	if(Math.floor(v/lzPow2(33))&1) out|=0x20000000;
+	if(Math.floor(v/lzPow2(34))&1) out|=0x40000000;
+	if(Math.floor(v/lzPow2(35))&1) out|=0x80000000;
+	return out>>>0;
+}
+function lzPanelV2(){ return !!(lastP&&lastP[0]>=LZ_MAP_V2_PROTOCOL); }
+function lzV2Ops(){
+	const shifted=!!(lastP&&lastP[0]>=LZ_MAP_V2_SHIFTED_OPS_PROTOCOL);
+	return shifted
+		? {dump:0x17,set:0x18,save:0x19,reset:0x1A}
+		: {dump:0x16,set:0x17,save:0x18,reset:0x19};
+}
+function lzHasRStickVirtual(v){ return Number(v||0)>=lzPow2(36); }
+
+function lzV2Select(options,value,onchange,noneLabel){
+	const s=document.createElement("select");
+	if(noneLabel!==undefined){
+		const o=document.createElement("option"); o.value="0"; o.textContent=noneLabel; s.appendChild(o);
+	}
+	for(const [v,label] of options){
+		const o=document.createElement("option"); o.value=String(v); o.textContent=label; s.appendChild(o);
+	}
+	s.value=String(Number(value)||0);
+	s.onchange=()=>onchange(Number(s.value));
+	return s;
+}
+function lzV2Render(){
+	const host=document.getElementById("lizardList");
+	if(!host) return;
+	host.innerHTML="";
+	lizardBindings.forEach((b,idx)=>{
+		if(!b.od) b.od=[0,0,0,0,0,0,0];
+		while(b.od.length<7) b.od.push(0);
+		const box=document.createElement("div");
+		box.style.cssText="border:1px solid #232a3a;border-radius:8px;padding:9px;margin:7px 0;background:#0e1017";
+		const top=document.createElement("div");
+		top.className="row"; top.style.flexWrap="wrap";
+
+		const trig=lzV2Select(LZ_BTNS,b.trig,v=>b.trig=v,"— trigger —");
+		const hold=lzV2Select(LZ_BTNS,b.hold,v=>b.hold=v,"— no hold —");
+		const types=Object.keys(LZ_OUT_LABELS).map(Number).map(v=>[v,LZ_OUT_LABELS[v]]);
+		const typ=lzV2Select(types,b.outType,v=>{b.outType=v;lzV2Render();});
+		top.append("Input ",trig," Hold ",hold," → ",typ);
+
+		const del=document.createElement("button"); del.textContent="Remove";
+		del.style.marginLeft="auto";
+		del.onclick=()=>{lizardBindings.splice(idx,1);lzV2Render();};
+		top.appendChild(del); box.appendChild(top);
+
+		const detail=document.createElement("div");
+		detail.className="row"; detail.style.cssText="margin-top:7px;flex-wrap:wrap";
+		if(b.outType===LZO.KBD){
+			detail.append("Modifiers ");
+			for(const [bit,label] of LZ_MODS){
+				const c=document.createElement("input"); c.type="checkbox"; c.checked=!!(b.od[0]&bit);
+				c.onchange=()=>{ if(c.checked)b.od[0]|=bit;else b.od[0]&=~bit; };
+				const l=document.createElement("label"); l.style.marginRight="7px"; l.append(c," "+label); detail.appendChild(l);
+			}
+			for(let k=1;k<7;k++){
+				detail.append(" Key "+k+" ");
+				detail.appendChild(lzV2Select(LZ_KEYS,b.od[k],v=>b.od[k]=v));
+			}
+		}else if(b.outType===LZO.MBTN){
+			detail.append("Mouse ");
+			detail.appendChild(lzV2Select(LZ_MBTNS,b.od[0],v=>b.od[0]=v));
+		}else if(b.outType===LZO.AXIS){
+			detail.append("Source ");
+			detail.appendChild(lzV2Select(LZ_AXIS_SRC,b.od[0],v=>{b.od[0]=v;lzV2Render();}));
+			if(b.od[0]===2){
+				detail.append(" Gyro activation ");
+				detail.appendChild(lzV2Select(LZ_GYRO_ACT,b.od[1],v=>b.od[1]=v));
+			}
+			trig.disabled=true; hold.disabled=true;
+		}else if(b.outType===LZO.SCROLL){
+			detail.append("Source: Left trackpad");
+			b.od[0]=0; trig.disabled=true; hold.disabled=true;
+		}else if(b.outType===LZO.CONSUMER){
+			detail.append("Media ");
+			detail.appendChild(lzV2Select(LZ_CONSUMER,b.od[0],v=>b.od[0]=v));
+		}
+		box.appendChild(detail); host.appendChild(box);
+	});
+}
+
+async function lzV2ReadMap(op){
+	await send([op]);
+	const recSize=lzPanelV2()?24:16;
+	let buf=[];
+	let expected=0;
+	for(;;){
+		const r=await dev.transferIn(epIn,256);
+		if(r.status!=="ok") throw new Error("lizard-map read failed");
+		const d=new Uint8Array(r.data.buffer);
+		for(const x of d) buf.push(x);
+		if(!expected){
+			let start=-1;
+			for(let i=0;i+1<buf.length;i++){
+				if(buf[i]===0xAA && buf[i+1]<=LZ_MAX){ start=i; break; }
+			}
+			if(start<0){ if(buf.length>1024)buf=[]; continue; }
+			if(start) buf=buf.slice(start);
+			expected=2+buf[1]*recSize;
+		}
+		if(buf.length>=expected) break;
+	}
+	const count=buf[1], out=[];
+	for(let i=0;i<count;i++){
+		const q=2+i*recSize, od=[];
+		for(let k=0;k<7;k++) od.push(buf[q+1+k]||0);
+		const trig=recSize===24?lzReadLE(buf,q+8,8):lzLegacyMaskToV2(lzReadLE(buf,q+8,4));
+		const hold=recSize===24?lzReadLE(buf,q+16,8):lzLegacyMaskToV2(lzReadLE(buf,q+12,4));
+		out.push({outType:buf[q]||0,od,trig,hold});
+	}
+	return out;
+}
+async function lzV2Load(){
+	if(lizardBusy||!dev)return;
+	lizardBusy=true;
+	try{
+		const ops=lzV2Ops();
+		lizardBindings=await lzV2ReadMap(lzPanelV2()?ops.dump:0x11);
+		lzV2Render();
+		const st=document.getElementById("lzStatus");
+		if(st)st.textContent="loaded "+lizardBindings.length+" binding(s)"+(lzPanelV2()?" · map v2":" · legacy map");
+	}catch(e){ log("lizard load failed: "+e.message); }
+	finally{lizardBusy=false;}
+}
+async function lzV2Save(){
+	if(lizardBusy||!dev)return;
+	lizardBusy=true;
+	try{
+		const v2=lzPanelV2(), ops=lzV2Ops();
+		if(!v2&&lizardBindings.some(b=>lzHasRStickVirtual(b.trig)||lzHasRStickVirtual(b.hold)))
+			throw new Error("R-stick directions require firmware protocol v20+");
+		const count=Math.min(LZ_MAX,lizardBindings.length);
+		await send([0x13,count]);
+		for(let i=0;i<count;i++){
+			const b=lizardBindings[i], cmd=[v2?ops.set:0x12,i,b.outType||0];
+			for(let k=0;k<7;k++)cmd.push((b.od&&b.od[k])||0);
+			if(v2){lzWriteLE(cmd,b.trig,8);lzWriteLE(cmd,b.hold,8);}
+			else{lzWriteLE(cmd,lzV2MaskToLegacy(b.trig),4);lzWriteLE(cmd,lzV2MaskToLegacy(b.hold),4);}
+			await send(cmd);
+		}
+		lizardBindings=await lzV2ReadMap(v2?ops.save:0x14);
+		lzV2Render();
+		const st=document.getElementById("lzStatus"); if(st)st.textContent="saved "+count+" binding(s)";
+	}catch(e){const st=document.getElementById("lzStatus");if(st)st.textContent="save failed: "+e.message;log("lizard save failed: "+e.message);}
+	finally{lizardBusy=false;}
+}
+async function lzV2Reset(){
+	if(lizardBusy||!dev)return;
+	lizardBusy=true;
+	try{
+		const ops=lzV2Ops();
+		lizardBindings=await lzV2ReadMap(lzPanelV2()?ops.reset:0x15);
+		lzV2Render();
+		const st=document.getElementById("lzStatus");if(st)st.textContent="reset to defaults";
+	}catch(e){log("lizard reset failed: "+e.message);}
+	finally{lizardBusy=false;}
+}
+function lzV2InstallHandlers(){
+	for(const id of ["lzAdd","lzSave","lzReload","lzReset"]){
+		const old=document.getElementById(id); if(!old)continue;
+		const fresh=old.cloneNode(true); old.replaceWith(fresh);
+	}
+	const add=document.getElementById("lzAdd");
+	const save=document.getElementById("lzSave");
+	const reload=document.getElementById("lzReload");
+	const reset=document.getElementById("lzReset");
+	if(add)add.onclick=()=>{if(lizardBindings.length<LZ_MAX){lizardBindings.push({outType:1,od:[0,0,0,0,0,0,0],trig:0,hold:0});lzV2Render();}};
+	if(save)save.onclick=lzV2Save;
+	if(reload)reload.onclick=lzV2Load;
+	if(reset)reset.onclick=lzV2Reset;
+}
+lzV2InstallHandlers();
 </script>
 </body>
 </html>