Explorar o código

Actually working now

Jari hai 4 meses
pai
achega
55cce32194
Modificáronse 7 ficheiros con 449 adicións e 76 borrados
  1. 10 0
      OpenPuck/OpenPuck.ino
  2. 101 59
      OpenPuck/mode_lizard.cpp
  3. 4 1
      OpenPuck/mode_lizard.h
  4. 15 7
      OpenPuck/puck_hid.cpp
  5. 89 6
      OpenPuck/webusb_config.cpp
  6. 16 1
      docs/PROTOCOL.md
  7. 214 2
      docs/index.html

+ 10 - 0
OpenPuck/OpenPuck.ino

@@ -31,6 +31,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "rf_link.h"
 #include "rf_diag.h"
 #include "webusb_config.h"
+#include "lizard_map.h"
 #include "serial_console.h"
 #include "wake_hid.h"
 #include "status_led.h"
@@ -94,6 +95,15 @@ void setup()
 #endif
 	loadCfg();
 	loadBonds();
+	// Lizard (desktop) keyboard/mouse binding table. Custom remapping applies ONLY in pure
+	// MODE_LIZARD: there we install the user's saved/editable map. In every other mode the
+	// seamless lizard (Steam mode with Steam closed) uses the built-in DEFAULT map, so edits
+	// made for pure lizard never change Steam-mode desktop behavior. (Without either call
+	// g_lizardMap.count stays 0 and lizard produces no keyboard/mouse output at all.)
+	if (g_usbMode == MODE_LIZARD)
+		loadLizardMap();
+	else
+		defaultLizardMap();
 	// regenerate per-slot session addresses from each bond UUID (deterministic, stable across reboots)
 	for (int s = 0; s < NSLOT; s++)
 		if (g_slot[s].used)

+ 101 - 59
OpenPuck/mode_lizard.cpp

@@ -4,34 +4,17 @@
 #include "config.h"
 #include <string.h>
 
-void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
-	      uint8_t krid)
+// 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, uint8_t outKeys[6],
+			   uint8_t &nKeys, uint8_t &outMBtn,
+			   uint8_t &consumerBits, bool &doRpadMouse,
+			   bool &doLpadScroll)
 {
-	const PuckInput &in = g_in[0];
-	uint32_t buttons = in.buttons;
-
-	// Append virtual stick-deflection bits so bindings can trigger on stick direction
-	if (in.lx > 12000)
-		buttons |= LZ_BTN_LSTICK_RT;
-	if (in.lx < -12000)
-		buttons |= LZ_BTN_LSTICK_LF;
-	if (in.ly < -12000)
-		buttons |= LZ_BTN_LSTICK_DN;
-	if (in.ly > 12000)
-		buttons |= LZ_BTN_LSTICK_UP;
-
-	// ---- accumulate outputs from the binding table ----
-	uint8_t outMod = 0;
-	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 consumerBits = 0;
-
-	// kbdConsumed: any trig bits claimed by a hold-modifier binding so that simpler
-	// single-button bindings sharing those bits are suppressed.
+	// 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;
-
 	for (uint8_t i = 0; i < g_lizardMap.count; i++) {
 		const LizardBinding &b = g_lizardMap.bindings[i];
 		if (b.outType == LZ_OUT_NONE)
@@ -85,27 +68,82 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 			break;
 		}
 	}
+}
+
+// Add the virtual stick-deflection bits so bindings can trigger on left-stick direction.
+static uint32_t lizardButtons(const PuckInput &in)
+{
+	uint32_t buttons = in.buttons;
+	if (in.lx > 12000)
+		buttons |= LZ_BTN_LSTICK_RT;
+	if (in.lx < -12000)
+		buttons |= LZ_BTN_LSTICK_LF;
+	if (in.ly < -12000)
+		buttons |= LZ_BTN_LSTICK_DN;
+	if (in.ly > 12000)
+		buttons |= LZ_BTN_LSTICK_UP;
+	return buttons;
+}
+
+void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
+	      uint8_t krid)
+{
+	// ---- merge binding outputs across every bonded controller ----
+	uint8_t outMod = 0;
+	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 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);
+	}
 
 	// ---- right pad -> mouse motion with glide ----
-	static int prx = 0, pry = 0;
-	static bool prt = false;
-	static float vx = 0, vy = 0, rmx = 0, rmy = 0;
+	// 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).
+	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;
 	int dx = 0, dy = 0;
 	if (doRpadMouse) {
-		bool rtouch = (buttons & TB_RPADT) != 0;
-		int rx = in.rpx, ry = in.rpy;
-		if (rtouch && prt) {
-			vx += (rx - prx);
-			vy += (ry - pry);
-		}
-		if (rtouch) {
-			prx = rx;
-			pry = ry;
+		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;
+			}
+			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;
 		}
-		prt = rtouch;
 		// Y inverted; *10 = desktop cursor sensitivity (g_mDiv 64 -> eff 640)
-		float mxf = vx / (float)(g_mDiv * 10) + rmx,
-		      myf = -(vy / (float)(g_mDiv * 10)) + rmy;
+		float mxf = sumx / (float)(g_mDiv * 10) + rmx,
+		      myf = -(sumy / (float)(g_mDiv * 10)) + rmy;
 		dx = (int)mxf;
 		dy = (int)myf;
 		rmx = mxf - dx;
@@ -118,30 +156,34 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 			dy = 127;
 		if (dy < -127)
 			dy = -127;
-		float f = g_mFric / 100.0f;
-		vx *= f;
-		vy *= f;
-		if (vx > -1 && vx < 1)
-			vx = 0;
-		if (vy > -1 && vy < 1)
-			vy = 0;
 	}
 
 	// ---- left pad -> scroll wheel ----
-	static int ply = 0;
-	static bool plt = false;
+	// Per-slot last-position; accumulator shared (one wheel). Reset only when NO controller is scrolling.
+	static int ply[NSLOT] = { 0 };
+	static bool plt[NSLOT] = { false };
 	static float sacc = 0;
 	int dw = 0;
 	if (doLpadScroll) {
-		bool ltouch = (buttons & TB_LPADT) != 0;
-		int ly = in.lpy;
-		if (ltouch && plt)
-			sacc += (ly - ply) / (float)(g_mDiv * 24);
-		if (ltouch)
-			ply = ly;
-		else
+		bool anyTouch = false;
+		for (int s = 0; s < NSLOT; s++) {
+			if (!g_slot[s].used) {
+				plt[s] = false;
+				continue;
+			}
+			const PuckInput &in = g_in[s];
+			bool ltouch = (in.buttons & TB_LPADT) != 0;
+			int ly = in.lpy;
+			if (ltouch && plt[s])
+				sacc += (ly - ply[s]) / (float)(g_mDiv * 24);
+			if (ltouch) {
+				ply[s] = ly;
+				anyTouch = true;
+			}
+			plt[s] = ltouch;
+		}
+		if (!anyTouch)
 			sacc = 0;
-		plt = ltouch;
 		dw = (int)sacc;
 		sacc -= dw;
 		if (dw > 15)
@@ -184,7 +226,7 @@ void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 		pmod = outMod;
 		for (int i = 0; i < 6; i++)
 			pkc[i] = outKeys[i];
-		uint8_t krep[8] = { outMod,    0,	    outKeys[0],
+		uint8_t krep[8] = { outMod,	0,	    outKeys[0],
 				    outKeys[1], outKeys[2], outKeys[3],
 				    outKeys[4], outKeys[5] };
 		if (kdev->ready())

+ 4 - 1
OpenPuck/mode_lizard.h

@@ -9,6 +9,9 @@
 #include <Adafruit_TinyUSB.h>
 #include <stdint.h>
 
-// mdev / kdev may be the same object (puck mode sends to the same HID slot for both reports).
+// Drives the desktop mouse/keyboard from the binding table. Merges input from ALL bonded controllers
+// (g_in[s] for every used slot) onto the one shared mouse/keyboard -- a controller on any RF slot
+// contributes, and multiple controllers drive the same cursor/keys together. mdev/kdev may be the same
+// object (puck mode sends both reports to hid[0]).
 void rfLizard(Adafruit_USBD_HID *mdev, Adafruit_USBD_HID *kdev, uint8_t mrid,
 	      uint8_t krid);

+ 15 - 7
OpenPuck/puck_hid.cpp

@@ -406,9 +406,9 @@ void SteamPuckController::begin()
 
 // Forward the controller's report 0x45 to Steam, or drive lizard kb/mouse when Steam is closed. PURELY a
 // USB-side decision -- changes nothing about the RF poll or the host->controller relay. Per-slot: each
-// controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard remains
-// single-controller (slot 0); the other slots stay quiet in lizard mode -- one mouse + one keyboard is
-// the only thing a desktop can consume, and the comment in mode_lizard.h documents this.
+// controller's 0x45 goes to its OWN hid[slot], so Steam sees four independent inputs. Lizard, by contrast,
+// presents ONE mouse + keyboard (a desktop can only consume one): rfLizard merges every bonded controller's
+// input onto hid[0], so all connected controllers drive the same cursor/keys together.
 void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				     uint8_t bodyTlen)
 {
@@ -428,10 +428,18 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 	if (!g_slot[slot].used)
 		return;
 	if (lizardActive()) {
-		// Lizard single-controller: only slot 0's interface drives the mouse/keyboard. Other slots
-		// stay quiet in lizard mode.
-		if (slot == 0)
-			rfLizard(rep, &hid[0], &hid[0], 0x40, 0x41);
+		// All bonded controllers share ONE desktop mouse/keyboard (mounted on hid[0] regardless of
+		// which RF slots are in use). rfLizard merges g_in[] across every used slot internally, so we
+		// fire it ONCE per cycle -- gated on the lowest bonded slot's report so it runs at a steady
+		// cadence (not once per slot, which would multiply the output rate).
+		int lizSlot = -1;
+		for (int s = 0; s < NSLOT; s++)
+			if (g_slot[s].used) {
+				lizSlot = s;
+				break;
+			}
+		if (slot == lizSlot)
+			rfLizard(&hid[0], &hid[0], 0x40, 0x41);
 	} else {
 		uint8_t blen = bodyTlen - 1;
 		if (blen > 45)

+ 89 - 6
OpenPuck/webusb_config.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "puck_hid.h"
 #include "triton.h" // g_in raw IMU (diagnostic readout)
+#include "lizard_map.h"
 #include "build_info.h"
 #include <Arduino.h>
 #include <string.h>
@@ -99,7 +100,9 @@ static void webusbSendBlob()
 	// Switch Pro gyro sensitivity x10 (protocol v6)
 	p[55] = g_swGyroScale10;
 	{
-		int16_t a[3] = { g_in[0].ax, g_in[0].ay, g_in[0].az };
+		// Read the ACTIVE slot's accel, not a hardcoded g_in[0]: a controller bonded to a non-zero
+		// slot leaves g_in[0] zeroed, which made this readout (and lizard) look dead.
+		int16_t a[3] = { g_in[cs].ax, g_in[cs].ay, g_in[cs].az };
 		memcpy(&p[56], a, 6);
 	} // raw accelerometer for scale diagnostics (protocol v7)
 
@@ -129,6 +132,40 @@ static void webusbSendBlob()
 	usb_web.write(p, sizeof p);
 	usb_web.flush();
 }
+
+// Send the whole lizard binding table as one 0xA7 frame so the panel can edit it:
+//   [0xA7][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 void webusbSendLizard()
+{
+	if (!usb_web.connected())
+		return;
+	uint8_t count = g_lizardMap.count;
+	if (count > LZ_MAX_BINDINGS)
+		count = LZ_MAX_BINDINGS;
+	// static (not stack): 514 B is large for the USB task stack, and webusbPoll is single-threaded.
+	static uint8_t f[2 + LZ_MAX_BINDINGS * 16];
+	f[0] = 0xA7;
+	f[1] = count;
+	for (uint8_t i = 0; i < count; i++) {
+		const LizardBinding &b = g_lizardMap.bindings[i];
+		uint8_t *q = &f[2 + i * 16];
+		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);
+	}
+	usb_web.write(f, (uint16_t)(2 + count * 16));
+	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)
@@ -177,7 +214,8 @@ static void webusbDrainCapture()
 #endif // OPK_LOG
 void webusbPoll()
 {
-	static uint8_t buf[16];
+	// 32B: the lizard "set binding" command (0x0E) is 18 bytes; every other command is <=4.
+	static uint8_t buf[32];
 	static uint8_t n = 0;
 	while (usb_web.available()) {
 		int c = usb_web.read();
@@ -191,12 +229,15 @@ void webusbPoll()
 				break;
 			uint8_t op = buf[0];
 
-			// 0x05 carries a value byte; 0x0A carries a 3-byte magic
-			uint8_t need = (op == 0x02)		  ? 3 :
-				       (op == 0x03 || op == 0x05) ? 2 :
+			// 0x05/0x0F carry one value byte; 0x02 carries field+value; 0x0A carries a 3-byte
+			// magic; 0x0E carries idx + a 16-byte serialized lizard binding (18 total).
+			uint8_t need = (op == 0x0E)		  ? 18 :
+				       (op == 0x02)		  ? 3 :
+				       (op == 0x03 || op == 0x05 ||
+					op == 0x0F)		  ? 2 :
 				       (op == 0x0A)		  ? 4 :
 								    1;
-			if (op < 0x01 || op > 0x0C) { // resync: drop one byte
+			if (op < 0x01 || op > 0x11) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
 			}
@@ -249,6 +290,48 @@ void webusbPoll()
 				delay(40);
 				enterUf2Dfu();
 
+				// ---- lizard (desktop) binding map editor ----
+				// 0x0D: dump the current map as a 0xA7 frame.
+			} else if (op == 0x0D) {
+				webusbSendLizard();
+
+				// 0x0F [count]: BEGIN a map edit -- set the binding count (clamped). The
+				// panel then sends one 0x0E per binding (0..count-1) and a 0x10 to persist.
+			} else if (op == 0x0F) {
+				g_lizardMap.count = (buf[1] <= LZ_MAX_BINDINGS) ?
+							    buf[1] :
+							    LZ_MAX_BINDINGS;
+
+				// 0x0E [idx][outType][od0..6][trig LE4][hold LE4]: set one binding.
+			} else if (op == 0x0E) {
+				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 = (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);
+				}
+
+				// 0x10: COMMIT the edited map to flash and echo it back.
+			} else if (op == 0x10) {
+				saveLizardMap();
+				webusbSendLizard();
+
+				// 0x11: reset the map to the built-in defaults, persist, echo back.
+			} else if (op == 0x11) {
+				defaultLizardMap();
+				saveLizardMap();
+				webusbSendLizard();
+
 			} else if (op == 0x02) {
 				uint8_t f = buf[1], v = buf[2];
 

+ 16 - 1
docs/PROTOCOL.md

@@ -370,8 +370,23 @@ Messages:
   - `0x01`: get status blob
   - `0x02 <field> <value>`: set one field
   - `0x03 <mode>`: switch mode and reboot
+  - `0x0D`: get the lizard binding map (device replies `0xA7`)
+  - `0x0F <count>`: begin a lizard-map edit — set the binding count
+  - `0x0E <idx> <16-byte binding>`: set one lizard binding (see below)
+  - `0x10`: commit the edited lizard map to flash (device echoes `0xA7`)
+  - `0x11`: reset the lizard map to built-in defaults (device echoes `0xA7`)
 - Device to host:
-  - `0xA5 <len> <payload>`
+  - `0xA5 <len> <payload>`: status blob
+  - `0xA7 <count> <count×16-byte bindings>`: lizard binding map
+
+Lizard binding wire format (16 bytes), matching `LizardBinding` in `lizard_map.h`:
+
+```text
+[0]     outType   (0 none, 1 keyboard chord, 2 mouse btn, 3 mouse axis, 4 scroll, 5 consumer)
+[1..7]  outData[0..6]  (type-specific payload)
+[8..11] trigMask  u32 LE  (button bits: any-of)
+[12..15] holdMask u32 LE  (button bits: all-of guard)
+```
 
 Status blob payload:
 

+ 214 - 2
docs/index.html

@@ -10,7 +10,7 @@
   body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--fg);padding:24px}
   h1{font-size:20px;margin:0 0 4px}
   .sub{color:var(--mut);margin:0 0 20px}
-  .wrap{max-width:680px;margin:0 auto}
+  .wrap{max-width:960px;margin:0 auto}
   .card{background:var(--card);border:1px solid #272d3d;border-radius:12px;padding:18px 20px;margin-bottom:16px}
   .card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 14px}
   .row{display:flex;align-items:center;gap:14px;margin:12px 0}
@@ -165,6 +165,19 @@
       <div id="typeCfgs"></div>
     </div>
 
+    <div class="card" id="lizardCard" style="display:none">
+      <h2>Lizard / desktop mapping</h2>
+      <p class="note" style="margin-top:0">Keyboard, mouse &amp; media bindings for <strong>Lizard (always)</strong> mode — each row maps a controller input to a desktop action. (Steam-mode seamless lizard, when Steam is closed, keeps the built-in default behavior and is not affected by this map.) Edits apply after <strong>Save to device</strong>.</p>
+      <div id="lizardList"></div>
+      <div class="row" style="margin:12px 0 0;flex-wrap:wrap;gap:8px">
+        <button id="lzAdd">+ Add binding</button>
+        <button id="lzSave" class="primary">Save to device</button>
+        <button id="lzReload">Reload</button>
+        <button id="lzReset" style="margin-left:auto">Reset to defaults</button>
+      </div>
+      <p class="note" id="lzStatus" style="margin-top:8px">—</p>
+    </div>
+
     <div class="card logonly">
       <h2>Capture — host → controller commands</h2>
       <p class="note" style="margin-top:0">The firmware logs everything from boot into a big RAM ring: Steam's writes (<code>ifN</code>), the frames we transmit to the controller (<code>TX→ctlr</code>), and RF <code>LINK UP/DOWN</code> edges. For the reconnect buzz: trigger it (it appears moments after the puck boots / the controller reconnects), then connect here and click <b>Dump from boot</b> — the trigger is still in the ring. (Only present in a logging build, <code>-DOPK_LOG=1</code>.)</p>
@@ -200,6 +213,46 @@ const TYPE_DEFS = [
 ];
 // 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;} }
+
+// ===================== Lizard (desktop) binding map =====================
+// Mirrors firmware lizard_map.h. A binding is {outType, od:[7], trig, hold}. Output types and the
+// per-type payload (od[]) layout must match the constants in lizard_map.h exactly.
+const LZ_MAX = 32;
+const LZO = {NONE:0, KBD:1, MBTN:2, AXIS:3, SCROLL:4, CONSUMER:5};
+const LZ_OUT_LABELS = {0:"(disabled)", 1:"Keyboard key", 2:"Mouse button", 3:"Mouse move", 4:"Scroll wheel", 5:"Media key"};
+// Controller input bits (triton.h TB_* + virtual left-stick deflection bits). Single-bit triggers only.
+const LZ_BTNS = [
+  [0x1,"A"],[0x2,"B"],[0x4,"X"],[0x8,"Y"],
+  [0x10,"QAM (• • •)"],[0x40,"View"],[0x4000,"Menu"],[0x10000,"Steam"],
+  [0x20,"R3 (stick click)"],[0x8000,"L3 (stick click)"],
+  [0x80,"R4 (back upper-right)"],[0x100,"R5 (back lower-right)"],
+  [0x20000,"L4 (back upper-left)"],[0x40000,"L5 (back lower-left)"],
+  [0x200,"RB (bumper)"],[0x80000,"LB (bumper)"],
+  [0x800000,"R2 (trigger pull)"],[0x8000000,"L2 (trigger pull)"],
+  [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"],
+];
+// Keyboard modifier bits (od[0] for KBD output).
+const LZ_MODS = [[0x01,"Ctrl"],[0x02,"Shift"],[0x04,"Alt"],[0x08,"Win/⌘"]];
+// Curated HID keycodes (od[1] for KBD output). Value 0 = no key (modifier-only).
+const LZ_KEYS = [[0,"— none —"]];
+"ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").forEach((c,i)=>LZ_KEYS.push([0x04+i,c]));
+"1234567890".split("").forEach((c,i)=>LZ_KEYS.push([0x1e+i,c]));
+[[0x28,"Enter"],[0x29,"Esc"],[0x2a,"Backspace"],[0x2b,"Tab"],[0x2c,"Space"],
+ [0x4f,"Arrow Right"],[0x50,"Arrow Left"],[0x51,"Arrow Down"],[0x52,"Arrow Up"],
+ [0x4a,"Home"],[0x4d,"End"],[0x4b,"Page Up"],[0x4e,"Page Down"],[0x49,"Insert"],[0x4c,"Delete"],
+ [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_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)):""); }
+let lizardBindings = [];   // [{outType, od:[7], trig, hold}]
+let lizardBusy = false;
 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){
@@ -326,6 +379,7 @@ async function connect(){
     $("#panel").classList.remove("hide");
     log(`connected — VID ${dev.vendorId.toString(16)} iface ${ifNum} epIn ${epIn} epOut ${epOut}`);
     navigator.usb.addEventListener("disconnect", e=>{ if(e.device===dev){ onGone(); }});
+    await loadLizard();
     startPolling();
   }catch(e){ log("connect failed: "+e.message); }
 }
@@ -367,6 +421,9 @@ function applyBlob(p){
   for(const el of document.querySelectorAll(".logonly")) el.style.display = logEnabled ? "" : "none";
   for(const b of document.querySelectorAll(".modebtn")) b.classList.toggle("active", +b.dataset.mode===mode);
   $("#mouseCard").style.opacity = (mode===1||mode===3)?1:0.5;
+  // The custom map applies ONLY to pure Lizard (always) mode. Steam-mode seamless lizard keeps
+  // the built-in default behavior, so the editor is shown only when the device is in Lizard mode.
+  $("#lizardCard").style.display = (mode===3)?"":"none";
   const battery=(p.length>36?p[36]:0);
   const rumbleScale=(p.length>51?p[51]:200);
   const swProRate=(p.length>52?p[52]:2);
@@ -498,7 +555,7 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight||capturing) return; inflight=true;
+  if(!dev||inflight||capturing||lizardBusy) return; inflight=true;
   await send([0x01]); const p=await readBlob(); if(p) applyBlob(p);
   inflight=false;
 }
@@ -577,11 +634,166 @@ async function startPolling(){
   while(polling && dev){ await new Promise(r=>setTimeout(r,600)); await refresh(); }
 }
 
+// ---- lizard map transfers ----
+// The status-blob poll and these map transfers share one bulk pipe, so we pause polling
+// (lizardBusy) and wait out any in-flight refresh before sending a 0x0D/0x10/0x11 that
+// expects a 0xA7 reply.
+async function lizardExchange(fn){
+  lizardBusy=true;
+  for(let i=0;i<25 && inflight;i++) await new Promise(r=>setTimeout(r,20));
+  try{ return await fn(); }
+  catch(e){ log("lizard err: "+e.message); return null; }
+  finally{ lizardBusy=false; }
+}
+// Read one [0xA7][count][count*16] frame, accumulating transferIn packets until complete.
+async function readLizard(){
+  let acc=new Uint8Array(0);
+  for(let guard=0; guard<64; guard++){
+    const r=await dev.transferIn(epIn,128);
+    if(r.status!=="ok") break;
+    const d=new Uint8Array(r.data.buffer);
+    const m=new Uint8Array(acc.length+d.length); m.set(acc); m.set(d,acc.length); acc=m;
+    let i=0; while(i<acc.length && acc[i]!==0xA7) i++;
+    if(i>0) acc=acc.slice(i);
+    if(acc.length<2) continue;
+    const count=acc[1], total=2+count*16;
+    if(acc.length>=total){
+      const out=[];
+      for(let b=0;b<count;b++){
+        const q=2+b*16;
+        out.push({
+          outType:acc[q],
+          od:[...acc.slice(q+1,q+8)],
+          trig:((acc[q+8])|(acc[q+9]<<8)|(acc[q+10]<<16)|(acc[q+11]<<24))>>>0,
+          hold:((acc[q+12])|(acc[q+13]<<8)|(acc[q+14]<<16)|(acc[q+15]<<24))>>>0,
+        });
+      }
+      return out;
+    }
+  }
+  return null;
+}
+async function loadLizard(){
+  await lizardExchange(async()=>{
+    await send([0x0D]);
+    const b=await readLizard();
+    if(b){ lizardBindings=b; renderLizard(); log(`lizard map loaded — ${b.length} bindings`); }
+  });
+}
+async function saveLizard(){
+  if(!dev) return;
+  // A digital binding (keyboard/mouse/media) with neither a trigger nor a hold fires
+  // unconditionally in the firmware (the trigMask==0 guard is skipped) -- it would type/click
+  // forever. Drop those rows so a half-finished binding can't brick desktop input.
+  const digital=b=>b.outType===LZO.KBD||b.outType===LZO.MBTN||b.outType===LZO.CONSUMER;
+  const valid=lizardBindings.filter(b=> b.outType!==LZO.NONE && (!digital(b) || (b.trig>>>0)||(b.hold>>>0)) );
+  const dropped=lizardBindings.length-valid.length;
+  await lizardExchange(async()=>{
+    await send([0x0F, valid.length&0xff]);
+    for(let i=0;i<valid.length;i++){
+      const b=valid[i];
+      const od=(b.od||[]).slice(0,7); while(od.length<7) od.push(0);
+      const t=b.trig>>>0, h=b.hold>>>0;
+      await send([0x0E, i, b.outType, ...od.map(x=>x&0xff),
+        t&0xff,(t>>>8)&0xff,(t>>>16)&0xff,(t>>>24)&0xff,
+        h&0xff,(h>>>8)&0xff,(h>>>16)&0xff,(h>>>24)&0xff]);
+    }
+    await send([0x10]); // commit + persist to flash
+    const b=await readLizard(); if(b){ lizardBindings=b; renderLizard(); }
+    log(`lizard map saved — ${valid.length} bindings`+(dropped?` (skipped ${dropped} incomplete: no trigger/hold)`:""));
+  });
+}
+async function resetLizard(){
+  if(!dev) return;
+  if(!confirm("Reset the lizard map to the built-in defaults? Unsaved edits are lost.")) return;
+  await lizardExchange(async()=>{
+    await send([0x11]);
+    const b=await readLizard(); if(b){ lizardBindings=b; renderLizard(); }
+    log("lizard map reset to defaults");
+  });
+}
+
+// ---- lizard map UI ----
+function mkSel(opts, val, onchange){
+  const s=document.createElement("select"); s.style.flex="0 1 auto"; s.style.minWidth="0";
+  for(const [v,l] of opts){ const o=document.createElement("option"); o.value=v; o.textContent=l; s.appendChild(o); }
+  s.value=val; s.addEventListener("change",()=>onchange(+s.value));
+  return s;
+}
+function renderLizard(){
+  const host=$("#lizardList"); host.innerHTML="";
+  lizardBindings.forEach((b,idx)=>host.appendChild(buildLizardRow(b,idx)));
+  $("#lzStatus").textContent = `${lizardBindings.length} / ${LZ_MAX} bindings`;
+  $("#lzAdd").disabled = lizardBindings.length>=LZ_MAX;
+}
+function buildLizardRow(b, idx){
+  const row=document.createElement("div");
+  row.style.cssText="display:flex;align-items:center;gap:8px;flex-wrap:wrap;background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:8px 10px;margin:8px 0";
+  const analog = (b.outType===LZO.AXIS || b.outType===LZO.SCROLL);
+
+  // Trigger (When) + Hold — meaningful only for digital outputs
+  if(!analog){
+    const when=document.createElement("span"); when.className="note"; when.textContent="When"; when.style.flex="0 0 auto"; row.appendChild(when);
+    row.appendChild(mkSel([[0,"— pick input —"],...LZ_BTNS], b.trig, v=>{ b.trig=v>>>0; }));
+    const plus=document.createElement("span"); plus.className="note"; plus.textContent="+ hold"; plus.style.flex="0 0 auto"; row.appendChild(plus);
+    row.appendChild(mkSel([[0,"(none)"],...LZ_BTNS], b.hold, v=>{ b.hold=v>>>0; }));
+  } else {
+    const lab=document.createElement("span"); lab.className="note"; lab.textContent="Analog source"; lab.style.flex="0 0 auto"; row.appendChild(lab);
+  }
+
+  // Arrow + output type
+  const arrow=document.createElement("span"); arrow.textContent="→"; arrow.style.color="var(--mut)"; row.appendChild(arrow);
+  row.appendChild(mkSel(Object.entries(LZ_OUT_LABELS).map(([v,l])=>[+v,l]), b.outType, v=>{
+    b.outType=v;
+    // reset payload to sane defaults for the new type
+    if(v===LZO.AXIS){ b.od=[0,0,0,0,0,0,0]; }
+    else if(v===LZO.SCROLL){ b.od=[0,0,0,0,0,0,0]; }
+    else if(v===LZO.MBTN){ b.od=[1,0,0,0,0,0,0]; }
+    else if(v===LZO.CONSUMER){ b.od=[1,0,0,0,0,0,0]; }
+    else if(v===LZO.KBD){ b.od=[0,0,0,0,0,0,0]; }
+    renderLizard();
+  }));
+
+  // Output-specific controls
+  if(b.outType===LZO.KBD){
+    for(const [bit,name] of LZ_MODS){
+      const w=document.createElement("label"); w.style.cssText="flex:0 0 auto;display:flex;align-items:center;gap:3px;color:var(--fg)";
+      const cb=document.createElement("input"); cb.type="checkbox"; cb.checked=!!(b.od[0]&bit);
+      cb.addEventListener("change",()=>{ b.od[0]=cb.checked?(b.od[0]|bit):(b.od[0]&~bit); });
+      w.appendChild(cb); w.appendChild(document.createTextNode(name)); row.appendChild(w);
+    }
+    row.appendChild(mkSel(LZ_KEYS, b.od[1]||0, v=>{ b.od[1]=v; for(let k=2;k<7;k++) b.od[k]=0; }));
+  } else if(b.outType===LZO.MBTN){
+    row.appendChild(mkSel(LZ_MBTNS, b.od[0]||1, v=>{ b.od[0]=v; }));
+  } else if(b.outType===LZO.AXIS){
+    row.appendChild(mkSel(LZ_AXIS_SRC, b.od[0]||0, v=>{ b.od[0]=v; renderLizard(); }));
+    if((b.od[0]||0)===2){ // gyro: activation selector
+      const g=document.createElement("span"); g.className="note"; g.textContent="when"; g.style.flex="0 0 auto"; row.appendChild(g);
+      row.appendChild(mkSel(LZ_GYRO_ACT, b.od[1]||0, v=>{ b.od[1]=v; }));
+    }
+  } else if(b.outType===LZO.SCROLL){
+    const s=document.createElement("span"); s.className="note"; s.textContent="Left trackpad → scroll"; row.appendChild(s);
+  } else if(b.outType===LZO.CONSUMER){
+    row.appendChild(mkSel(LZ_CONSUMER, b.od[0]||1, v=>{ b.od[0]=v; }));
+  }
+
+  // delete
+  const del=document.createElement("button"); del.textContent="✕"; del.title="Delete binding";
+  del.style.cssText="margin-left:auto;flex:0 0 auto;padding:4px 10px";
+  del.onclick=()=>{ lizardBindings.splice(idx,1); renderLizard(); };
+  row.appendChild(del);
+  return row;
+}
+
 // UI wiring
 $("#connectBtn").onclick=connect;
 $("#capDl").onclick=downloadCap;
 $("#capStart").onclick=startCapture;
 $("#capStop").onclick=stopCapture;
+$("#lzAdd").onclick=()=>{ if(lizardBindings.length<LZ_MAX){ lizardBindings.push({outType:LZO.KBD,od:[0,0,0,0,0,0,0],trig:0,hold:0}); renderLizard(); } };
+$("#lzSave").onclick=saveLizard;
+$("#lzReload").onclick=loadLizard;
+$("#lzReset").onclick=resetLizard;
 $("#hapClear").onclick=async()=>{ if(dev){ await send([0x07]); log("haptic re-init sent (clear stuck buzz)"); } };
 $("#ctlrOff").onclick=async()=>{ if(dev){ await send([0x08]); log("controller power-off attempt sent — watch link status"); } };
 $("#dfuSerial").onclick=async()=>{