ソースを参照

Add configurable lizard-mode binding table (replaces hardcoded button map) (#87)

* Add lizard_map binding system and rewrite mode_lizard to use it

* Actually working now

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jari <safijari@isu.edu>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Copilot 3 ヶ月 前
親
コミット
a60a5d16fd

+ 10 - 0
OpenPuck/OpenPuck.ino

@@ -32,6 +32,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "puck_hid.h" // puckCmdLogDrain()
 #include "rf_diag.h"
 #include "webusb_config.h"
+#include "lizard_map.h"
 #include "serial_console.h"
 #include "wake_hid.h"
 #include "status_led.h"
@@ -118,6 +119,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)

+ 155 - 0
OpenPuck/lizard_map.cpp

@@ -0,0 +1,155 @@
+#include "lizard_map.h"
+#include <Adafruit_LittleFS.h>
+#include <InternalFileSystem.h>
+#include <string.h>
+#include <class/hid/hid.h>
+using namespace Adafruit_LittleFS_Namespace;
+
+LizardMap g_lizardMap;
+
+#define LZ_FILE "/lizard_map.bin"
+#define LZ_MAGIC 0xB1u
+#define LZ_VERSION 1u
+
+// KB modifier bits (from TinyUSB hid.h)
+#define KM_LCTRL 0x01u
+#define KM_LSHIFT 0x02u
+#define KM_LALT 0x04u
+#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)
+{
+	if (g_lizardMap.count >= LZ_MAX_BINDINGS)
+		return;
+	LizardBinding &b = g_lizardMap.bindings[g_lizardMap.count++];
+	b.outType = type;
+	for (int i = 0; i < 7; i++)
+		b.outData[i] = od7[i];
+	b.trigMask = trig;
+	b.holdMask = hold;
+}
+
+static inline void addAxis(uint8_t src, uint8_t gyroAct)
+{
+	uint8_t d[7] = { src, gyroAct, 0, 0, 0, 0, 0 };
+	addBind(LZ_OUT_MOUSE_AXIS, d, 0, 0);
+}
+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)
+{
+	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)
+{
+	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)
+{
+	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)
+{
+	uint8_t d[7] = { bits, 0, 0, 0, 0, 0, 0 };
+	addBind(LZ_OUT_CONSUMER, d, trig, hold);
+}
+
+void defaultLizardMap()
+{
+	g_lizardMap.count = 0;
+
+	// Analog sources (always active; no trigger mask)
+	addAxis(LZ_MSRC_RPAD, LZ_GYRO_ALWAYS); // right pad → mouse
+	addScroll(LZ_MSRC_LPAD); // left pad → scroll
+
+	// Mouse buttons
+	// right pad click → left mouse
+	addMouseBtn(1, 0x400000u /*TB_RPADC*/, 0);
+	// right trigger (digital click) → left mouse
+	addMouseBtn(1, 0x800000u /*TB_R2*/, 0);
+	// left trigger → right mouse
+	addMouseBtn(2, 0x8000000u /*TB_L2*/, 0);
+	// left pad click → middle mouse
+	addMouseBtn(4, 0x4000000u /*TB_LPADC*/, 0);
+
+	// Hold-modifier shortcuts (Steam-button combos: place BEFORE generic X/L4 so they consume first)
+	// Steam+L4 → Ctrl+Alt+Delete
+	addKey3(KM_LCTRL | KM_LALT, HID_KEY_DELETE, 0, 0, 0x20000u /*TB_L4*/,
+		0x10000u /*TB_STEAM*/);
+	// Steam+X → Win+Ctrl+O (on-screen keyboard)
+	addKey3(KM_LGUI | KM_LCTRL, HID_KEY_O, 0, 0, 0x4u /*TB_X*/,
+		0x10000u /*TB_STEAM*/);
+	// Steam+L5 → volume down (consumer bit1=0x02)
+	addConsumer(0x02, 0x40000u /*TB_L5*/, 0x10000u /*TB_STEAM*/);
+	// Steam+R5 → volume up (consumer bit0=0x01)
+	addConsumer(0x01, 0x100u /*TB_R5*/, 0x10000u /*TB_STEAM*/);
+
+	// Keyboard keys
+	addKey(0, HID_KEY_ENTER, 0x1u /*TB_A*/, 0);
+	addKey(0, HID_KEY_ESCAPE, 0x2u /*TB_B*/, 0);
+	addKey(0, HID_KEY_PAGE_UP, 0x4u /*TB_X*/, 0);
+	addKey(0, HID_KEY_PAGE_DOWN, 0x8u /*TB_Y*/, 0);
+	addKey(0, HID_KEY_TAB, 0x40u /*TB_VIEW*/, 0);
+	addKey(0, HID_KEY_ESCAPE, 0x4000u /*TB_MENU*/, 0);
+
+	// D-pad → arrow keys
+	addKey(0, HID_KEY_ARROW_UP, 0x2000u /*TB_DUP*/, 0);
+	addKey(0, HID_KEY_ARROW_DOWN, 0x400u /*TB_DDN*/, 0);
+	addKey(0, HID_KEY_ARROW_LEFT, 0x1000u /*TB_DLF*/, 0);
+	addKey(0, HID_KEY_ARROW_RIGHT, 0x800u /*TB_DRT*/, 0);
+
+	// Left stick → arrow keys (virtual deflection bits)
+	addKey(0, HID_KEY_ARROW_UP, LZ_BTN_LSTICK_UP, 0);
+	addKey(0, HID_KEY_ARROW_DOWN, LZ_BTN_LSTICK_DN, 0);
+	addKey(0, HID_KEY_ARROW_LEFT, LZ_BTN_LSTICK_LF, 0);
+	addKey(0, HID_KEY_ARROW_RIGHT, LZ_BTN_LSTICK_RT, 0);
+
+	// Shoulder buttons → modifier keys only (no keycode)
+	addKey(KM_LCTRL, 0, 0x80000u /*TB_LB*/, 0);
+	addKey(KM_LALT, 0, 0x200u /*TB_RB*/, 0);
+}
+
+void saveLizardMap()
+{
+	InternalFS.remove(LZ_FILE);
+	File f(InternalFS);
+	if (!f.open(LZ_FILE, FILE_O_WRITE))
+		return;
+	uint8_t hdr[3] = { LZ_MAGIC, LZ_VERSION, g_lizardMap.count };
+	f.write(hdr, 3);
+	f.write((uint8_t *)g_lizardMap.bindings,
+		g_lizardMap.count * sizeof(LizardBinding));
+	f.close();
+}
+
+void loadLizardMap()
+{
+	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) {
+			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;
+		}
+		f.close();
+	}
+	// If nothing loaded, install + persist defaults
+	if (g_lizardMap.count == 0) {
+		defaultLizardMap();
+		saveLizardMap();
+	}
+}

+ 72 - 0
OpenPuck/lizard_map.h

@@ -0,0 +1,72 @@
+// lizard_map.h -- configurable input→keyboard/mouse/consumer binding table for lizard (desktop)
+// mode.
+//
+// g_lizardMap replaces the hardcoded button→key map in mode_lizard.cpp with a runtime-configurable
+// list of LizardBinding entries. Each binding maps a trigger condition (button bitmask + optional
+// hold-modifier bitmask) to a single output action. rfLizard() walks the table every input frame.
+//
+// Designed for forward compatibility: the output union can gain a GAMEPAD variant to remap emulated-
+// controller buttons without changing the trigger model, storage format, or WebUSB protocol.
+#pragma once
+#include <stdint.h>
+
+// ---- output type (LizardBinding::outType) ----
+// 0 = disabled; skip this entry.
+#define LZ_OUT_NONE 0u
+// Keyboard chord: outData[0]=modifier bits, outData[1..6]=HID keycodes (up to 6, zero=unused).
+#define LZ_OUT_KBD_CHORD 1u
+// 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_*).
+#define LZ_OUT_MOUSE_AXIS 3u
+// Scroll wheel from an analog source: outData[0]=source (LZ_MSRC_*).
+#define LZ_OUT_SCROLL 4u
+// Consumer control (edge-triggered, MODE_LIZARD only): outData[0]=report bitmask (bit0=vol+, bit1=vol-).
+#define LZ_OUT_CONSUMER 5u
+
+// ---- analog source (outData[0] for MOUSE_AXIS and SCROLL) ----
+#define LZ_MSRC_RPAD 0u // right trackpad (MOUSE_AXIS; touch-gated by TB_RPADT)
+#define LZ_MSRC_LSTICK 1u // left analog stick (MOUSE_AXIS)
+#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)
+
+// ---- gyro activation condition (outData[1] when src=LZ_MSRC_GYRO) ----
+#define LZ_GYRO_ALWAYS 0u // gyro always drives mouse
+#define LZ_GYRO_RPAD 1u // only when right trackpad is touched
+#define LZ_GYRO_LSTICK 2u // only when left stick is deflected past threshold
+// only when all bits in LizardBinding::holdMask are held
+#define LZ_GYRO_BTN 3u
+
+// ---- virtual stick-deflection trigger bits (usable in trigMask / holdMask) ----
+// The SC2 controller never sets bits 28..31 in its 0x45 report; rfLizard ORs these in after
+// reading the physical button word so bindings can fire on stick deflection.
+#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).
+// 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)
+};
+
+#define LZ_MAX_BINDINGS 32u
+
+struct LizardMap {
+	uint8_t count;
+	LizardBinding bindings[LZ_MAX_BINDINGS];
+};
+
+extern LizardMap g_lizardMap;
+
+// fill g_lizardMap with defaults (does NOT save to flash)
+void defaultLizardMap();
+void loadLizardMap();
+void saveLizardMap();

+ 207 - 158
OpenPuck/mode_lizard.cpp

@@ -1,187 +1,236 @@
 #include "mode_lizard.h"
+#include "lizard_map.h"
 #include "triton.h"
 #include "config.h"
-#include "usb_tx.h"
-#include "bonds.h" // NSLOT
+#include <string.h>
 
-// Per-controller lizard state. rfLizard runs once per input report on the report's bond slot, so the glide
-// velocity / sub-pixel carry / button + keyboard edge trackers must be PER SLOT -- shared statics would let
-// one controller's motion clobber another's (garbage deltas). Zero-init (static storage) matches the old
-// per-function initial values (all 0 / false).
-struct LizardState {
-	int prx, pry; // last right-pad sample (mouse)
-	bool prt; // right pad was touched last frame
-	float vx, vy, rmx, rmy; // mouse velocity + sub-pixel carry
-	int ply; // last left-pad Y (scroll)
-	bool plt; // left pad was touched last frame
-	float sacc; // scroll accumulator
-	uint8_t pmbtn; // last mouse-button mask
-	bool prevL5, prevR5; // MODE_LIZARD media-key edges (L5/R5)
-	uint8_t pmod, pkc[6]; // last keyboard modifier + keycodes (edge-send)
-};
-static LizardState g_lz[NSLOT];
+// 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)
+{
+	// 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)
+			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
+			continue;
+		}
+		if (b.outType == LZ_OUT_SCROLL) {
+			if (b.outData[0] == LZ_MSRC_LPAD)
+				doLpadScroll = true;
+			continue;
+		}
+
+		// Check hold modifier (AND) then trigger (any-of OR)
+		if (b.holdMask && (buttons & b.holdMask) != b.holdMask)
+			continue;
+		if (b.trigMask && (buttons & b.trigMask) == 0)
+			continue;
+
+		switch (b.outType) {
+		case LZ_OUT_MOUSE_BTN:
+			if (!b.trigMask || (buttons & b.trigMask))
+				outMBtn |= b.outData[0];
+			break;
+
+		case LZ_OUT_KBD_CHORD:
+			// suppress if trig bits already claimed by a hold-modifier binding
+			if (b.holdMask == 0 && b.trigMask != 0 &&
+			    (b.trigMask & kbdConsumed))
+				break;
+			outMod |= b.outData[0];
+			for (int j = 1; j < 7 && b.outData[j]; j++) {
+				if (nKeys < 6)
+					outKeys[nKeys++] = b.outData[j];
+			}
+			// claim trig bits so simpler bindings don't double-fire
+			if (b.holdMask != 0)
+				kbdConsumed |= b.trigMask;
+			break;
 
-void rfLizard(int slot, const uint8_t *r, Adafruit_USBD_HID *mdev,
-	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid)
+		case LZ_OUT_CONSUMER:
+			if (g_usbMode == MODE_LIZARD)
+				consumerBits |= b.outData[0];
+			break;
+
+		default:
+			break;
+		}
+	}
+}
+
+// Add the virtual stick-deflection bits so bindings can trigger on left-stick direction.
+static uint32_t lizardButtons(const PuckInput &in)
 {
-	if (slot < 0 || slot >= NSLOT)
-		return;
-	LizardState &ls = g_lz[slot];
-	uint32_t b = btnsOf(r);
-	bool qamHeld = (b & TB_QAM) != 0;
-	// --- right pad -> mouse motion with glide (mirrors mode_xinput's rfXboxMouse) ---
-	bool rtouch = b & TB_RPADT;
-	int rx = s16off(r, 22), ry = s16off(r, 24);
-	if (rtouch) {
-		if (ls.prt) {
-			ls.vx += (rx - ls.prx);
-			ls.vy += (ry - ls.pry);
+	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 ----
+	// 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) {
+		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;
 		}
-		ls.prx = rx;
-		ls.pry = ry;
+		// 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;
+		dx = (int)mxf;
+		dy = (int)myf;
+		rmx = mxf - dx;
+		rmy = myf - dy; // sub-pixel carry
+		if (dx > 127)
+			dx = 127;
+		if (dx < -127)
+			dx = -127;
+		if (dy > 127)
+			dy = 127;
+		if (dy < -127)
+			dy = -127;
 	}
-	ls.prt = rtouch;
 
-	// Y inverted; *10 = desktop-cursor sensitivity (g_mDiv 64 -> eff 640). Lower g_mDiv via WebUI slider = faster.
-	float mxf = ls.vx / (float)(g_mDiv * 10) + ls.rmx,
-	      myf = -(ls.vy / (float)(g_mDiv * 10)) + ls.rmy;
-	int dx = (int)mxf, dy = (int)myf;
-	ls.rmx = mxf - dx;
-	ls.rmy = myf - dy; // sub-pixel carry
-	if (dx > 127)
-		dx = 127;
-	if (dx < -127)
-		dx = -127;
-	if (dy > 127)
-		dy = 127;
-	if (dy < -127)
-		dy = -127;
-	float f = g_mFric / 100.0f;
-	ls.vx *= f;
-	ls.vy *= f;
-	if (ls.vx > -1 && ls.vx < 1)
-		ls.vx = 0;
-	if (ls.vy > -1 && ls.vy < 1)
-		ls.vy = 0; // friction = glide/decay
-	// --- left pad -> vertical scroll wheel (no momentum; only while touching, coarse) ---
-	bool ltouch = b & TB_LPADT;
-	int ly = s16off(r, 18);
-	if (ltouch) {
-		if (ls.plt) {
-			ls.sacc += (ly - ls.ply) / (float)(g_mDiv * 24);
+	// ---- left pad -> scroll wheel ----
+	// 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 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;
 		}
-		ls.ply = ly;
-	} else
-		ls.sacc = 0;
-	ls.plt = ltouch;
-	int dw = (int)ls.sacc;
-	ls.sacc -= dw;
-	if (dw > 15)
-		dw = 15;
-	if (dw < -15)
-		dw = -15; // finger up = wheel up (positive)
-	// --- mouse buttons: left=R-pad-click|R-trigger, right=L-trigger, middle=L-pad-click ---
-	// trigU8 scales full-range so a full pull reaches ~0xFF; a raw >>8 tops out ~0x80, never crossing threshold.
-	uint8_t rtrig = trigU8(u16off(r, 6)), ltrig = trigU8(u16off(r, 4));
-	uint8_t mbtn = 0;
-	if ((b & TB_RPADC) || rtrig > 180)
-		mbtn |= 1; // right trigger -> left (primary) click
-	if (ltrig > 180)
-		mbtn |= 2; // left trigger  -> right click
-	if (b & TB_LPADC)
-		mbtn |= 4;
-	if (dx || dy || dw || mbtn != ls.pmbtn) {
-		ls.pmbtn = mbtn;
+		if (!anyTouch)
+			sacc = 0;
+		dw = (int)sacc;
+		sacc -= dw;
+		if (dw > 15)
+			dw = 15;
+		if (dw < -15)
+			dw = -15;
+	}
+
+	// ---- mouse report ----
+	static uint8_t pmbtn = 0;
+	if (dx || dy || dw || outMBtn != pmbtn) {
+		pmbtn = outMBtn;
 		hid_mouse_report_t m;
-		m.buttons = mbtn;
+		m.buttons = outMBtn;
 		m.x = (int8_t)dx;
 		m.y = (int8_t)dy;
 		m.wheel = (int8_t)dw;
 		m.pan = 0;
 		if (mdev->ready())
-			usbTxHid(mdev, mrid, &m, sizeof m);
+			mdev->sendReport(mrid, &m, sizeof m);
 	}
-	// --- keyboard: modifiers + up to 6 keycodes ---
-	uint8_t mod = 0, kc[6] = { 0, 0, 0, 0, 0, 0 }, nk = 0;
-	if (b & TB_LB)
-		mod |= KEYBOARD_MODIFIER_LEFTCTRL;
-	if (b & TB_RB)
-		mod |= KEYBOARD_MODIFIER_LEFTALT;
-#define LZK(cond, code)                    \
-	do {                               \
-		if ((cond) && nk < 6)      \
-			kc[nk++] = (code); \
-	} while (0)
-	LZK(b & TB_A, HID_KEY_ENTER);
-	LZK(b & TB_B, HID_KEY_ESCAPE);
-	LZK(b & TB_X, HID_KEY_PAGE_UP);
-	LZK(b & TB_Y, HID_KEY_PAGE_DOWN);
-	LZK(b & TB_VIEW,
-	    (g_usbMode == MODE_LIZARD) ? HID_KEY_ESCAPE : HID_KEY_TAB);
-	LZK(b & TB_MENU,
-	    (g_usbMode == MODE_LIZARD) ? HID_KEY_TAB : HID_KEY_ESCAPE);
 
-	// left stick (XInput sign: +Y = up); deflect ~37% acts as a d-pad
-	int sx = s16off(r, 8), sy = s16off(r, 10);
-	LZK((b & TB_DUP) || sy > 12000, HID_KEY_ARROW_UP);
-	LZK((b & TB_DDN) || sy < -12000, HID_KEY_ARROW_DOWN);
-	LZK((b & TB_DLF) || sx < -12000, HID_KEY_ARROW_LEFT);
-	LZK((b & TB_DRT) || sx > 12000, HID_KEY_ARROW_RIGHT);
-#undef LZK
+	// ---- consumer control (edge-triggered) ----
+	static uint8_t prevCC = 0;
 	if (g_usbMode == MODE_LIZARD) {
-		bool mh = (b & TB_STEAM) || qamHeld;
-		bool nL5 = mh && (b & TB_L5), nR5 = mh && (b & TB_R5);
-		if (nL5 && !ls.prevL5) {
-			uint8_t cc = 0x02;
+		if (consumerBits != prevCC) {
 			if (mdev->ready())
-				usbTxHid(mdev, 0x03, &cc, 1);
-		}
-		if (nR5 && !ls.prevR5) {
-			uint8_t cc = 0x01;
-			if (mdev->ready())
-				usbTxHid(mdev, 0x03, &cc, 1);
-		}
-		if ((!nL5 && ls.prevL5) || (!nR5 && ls.prevR5)) {
-			uint8_t cc = 0x00;
-			if (mdev->ready())
-				usbTxHid(mdev, 0x03, &cc, 1);
-		}
-		ls.prevL5 = nL5;
-		ls.prevR5 = nR5;
-		if (mh && (b & TB_X)) {
-			mod = KEYBOARD_MODIFIER_LEFTGUI |
-			      KEYBOARD_MODIFIER_LEFTCTRL;
-			kc[0] = HID_KEY_O;
-			kc[1] = 0;
-			kc[2] = 0;
-			kc[3] = 0;
-			kc[4] = 0;
-			kc[5] = 0;
-			nk = 1;
-		}
-		if (mh && (b & TB_L4)) {
-			mod = KEYBOARD_MODIFIER_LEFTCTRL |
-			      KEYBOARD_MODIFIER_LEFTALT;
-			kc[0] = HID_KEY_DELETE;
-			kc[1] = 0;
-			kc[2] = 0;
-			kc[3] = 0;
-			kc[4] = 0;
-			kc[5] = 0;
-			nk = 1;
+				mdev->sendReport(0x03, &consumerBits, 1);
+			prevCC = consumerBits;
 		}
 	}
-	bool chg = (mod != ls.pmod);
+
+	// ---- keyboard report ----
+	static uint8_t pmod = 0, pkc[6] = { 0, 0, 0, 0, 0, 0 };
+	bool chg = (outMod != pmod);
 	for (int i = 0; i < 6; i++)
-		if (kc[i] != ls.pkc[i])
+		if (outKeys[i] != pkc[i])
 			chg = true;
 	if (chg) {
-		ls.pmod = mod;
+		pmod = outMod;
 		for (int i = 0; i < 6; i++)
-			ls.pkc[i] = kc[i];
-		uint8_t krep[8] = { mod,   0,	  kc[0], kc[1],
-				    kc[2], kc[3], kc[4], kc[5] };
+			pkc[i] = outKeys[i];
+		uint8_t krep[8] = { outMod,	0,	    outKeys[0],
+				    outKeys[1], outKeys[2], outKeys[3],
+				    outKeys[4], outKeys[5] };
 		if (kdev->ready())
-			usbTxHid(kdev, krid, krep, 8);
+			kdev->sendReport(krid, krep, 8);
 	}
 }

+ 16 - 12
OpenPuck/mode_lizard.h

@@ -1,18 +1,22 @@
-// mode_lizard.h -- the controller's native "lizard" desktop behavior (driverless keyboard + mouse).
+// mode_lizard.h -- configurable lizard (desktop) keyboard+mouse mode.
 //
-// Not a standalone IController: lizard rides ON the puck interface (puck_hid.cpp calls rfLizard when Steam is
-// closed), driving mouse(0x40)+keyboard(0x41) on the puck HID slot. Canonical Valve SC1 map: right pad ->
-// mouse (trackball glide), left pad -> scroll wheel + middle-click, R-trigger/R-pad-click -> left mouse,
-// L-trigger -> right mouse; A=Enter B=Esc X=PgUp Y=PgDn, d-pad/left-stick -> arrows, LB=LeftCtrl RB=LeftAlt,
-// View=Tab, Menu=Esc. Mouse reuses the Xbox-mode velocity+friction+sub-pixel glide model (g_mDiv / g_mFric).
+// Not a standalone IController: lizard rides ON the puck interface (puck_hid.cpp calls rfLizard
+// when Steam is closed or in MODE_LIZARD), driving mouse(0x40)+keyboard(0x41)+consumer(0x03) on
+// the same puck HID slot. The mapping table g_lizardMap (lizard_map.h) drives all outputs; the
+// default map mirrors the original hardcoded Valve SC1 behavior. Mouse reuses the Xbox-mode
+// velocity+friction+sub-pixel glide model (g_mDiv / g_mFric).
 //
-// EVERY connected controller drives lizard independently: puck_hid.cpp calls rfLizard once per input report on
-// the report's own bond `slot`, routing to that slot's own HID interface. The OS merges multiple HID mice /
-// keyboards onto the one desktop, so all controllers move the cursor / type together. `slot` also indexes the
-// per-controller glide/edge state, so one controller's motion never clobbers another's.
+// EVERY connected controller drives lizard: rfLizard merges g_in[s] across all used bond slots
+// (populated by the per-slot RF decode) onto the ONE shared desktop mouse/keyboard, so any controller
+// on any bond slot contributes and multiple controllers drive the same cursor/keys together. Per-slot
+// glide/edge state keeps one controller's motion from clobbering another's.
 #pragma once
 #include <Adafruit_TinyUSB.h>
 #include <stdint.h>
 
-void rfLizard(int slot, const uint8_t *r, Adafruit_USBD_HID *mdev,
-	      Adafruit_USBD_HID *kdev, uint8_t mrid, uint8_t krid);
+// 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);

+ 24 - 11
OpenPuck/puck_hid.cpp

@@ -656,9 +656,10 @@ 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 is likewise
-// per-slot now: each connected controller drives keyboard+mouse on its own hid[slot] (the OS merges the
-// several HID mice/keyboards onto the one desktop), so any controller -- on any bond slot -- works.
+// 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 (g_in[] across all used slots, populated by the per-slot decode) onto hid[0], so all connected
+// controllers drive the same cursor/keys together -- and it works regardless of which bond slot is live.
 void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				     uint8_t bodyTlen)
 {
@@ -682,14 +683,26 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 	// and this is exactly what the real puck does. rep[0] is the id byte (rep points at it in the F1 TLV).
 	const uint8_t rid = rep[0];
 	if (lizardActive()) {
-		// Every connected controller drives keyboard+mouse on ITS OWN slot interface; the OS merges the
-		// multiple HID mice/keyboards onto the one desktop. (Was hardcoded to slot 0, which went silent
-		// whenever the live controller bonded to a non-zero slot -- e.g. slot 0 holding a stale/phantom
-		// bond from a cloned backup.) rfLizard keeps its glide/edge state per-slot so they don't clobber.
-		// rfLizard reads 0x45 field offsets; report 0x42 is VERIFIED byte-identical over [0..45] (see the
-		// rf_link decode note), so both ids drive lizard -- covers MODE_LIZARD and Steam-mode-with-Steam-
-		// closed on new-firmware controllers.
-		rfLizard(slot, rep, &hid[slot], &hid[slot], 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 CONNECTED slot's report so it runs at a steady
+		// cadence (not once per slot, which would multiply the output rate). g_in[] is populated by the
+		// per-slot decode for BOTH report 0x45 and the beta 0x42 (byte-identical front section), so
+		// lizard works on new-firmware controllers too without reading rep[] here.
+		// Gate on the lowest RECENTLY-REPLYING slot, not merely the lowest bonded one: a bonded-but-
+		// offline low slot (e.g. a stale/phantom bond from a cloned backup) must not starve lizard when
+		// a higher slot is the live controller (main's #98 fix -- do not regress it). onReport45 only
+		// runs for a slot that just decoded a report, so the reporting slot is itself connected.
+		unsigned long now = millis();
+		int lizSlot = -1;
+		for (int s = 0; s < NSLOT; s++)
+			if (g_slot[s].used && g_connReplyMs[s] &&
+			    (now - g_connReplyMs[s]) < 1200u) {
+				lizSlot = s;
+				break;
+			}
+		if (slot == lizSlot)
+			rfLizard(&hid[0], &hid[0], 0x40, 0x41);
 	} else {
 		// body length after the id byte, clamped to the descriptor's declared size for this report id
 		// (0x42 = 53B vendor input, 0x45 = 45B input).

+ 95 - 8
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 "fault_diag.h"
 #include "fw_update.h"
@@ -146,7 +147,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)
 
@@ -475,6 +478,42 @@ void webusbInit(void)
 	// boards whose bootloader wipes retained RAM. Cheap: the callback no-ops unless loop() is already stalled.
 	tud_sof_cb_enable(true);
 }
+
+// Send the whole lizard binding table as one 0xAA frame so the panel can edit it:
+//   [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 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];
+	// 0xAA, NOT 0xA7/0xA8: main claimed 0xA7 (bond export) and 0xA8 (flight recorder), plus 0xA9 (wedge
+	// reporter) and 0xAB (fwup ack) -- 0xAA is the free device->host marker for the lizard map.
+	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 * 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)
@@ -531,8 +570,8 @@ static void webusbDrainCapture()
 void webusbPoll()
 {
 	// 160 B holds the largest command: a firmware-update data chunk 0x21 = [op][off u32][len][<=128 data]
-	// = 134 B (spans USB-FS packets; the byte-wise accumulation below reassembles it). Next-largest is the
-	// 0x0D bond-slot write (27 B); everything else is <= 9 B.
+	// = 134 B (spans USB-FS packets; the byte-wise accumulation below reassembles it). Next-largest are the
+	// 0x0D bond-slot write (27 B) and the 0x12 lizard set-binding (18 B); everything else is <= 9 B.
 	static uint8_t buf[160];
 	static uint8_t n = 0;
 	while (usb_web.available()) {
@@ -546,7 +585,7 @@ void webusbPoll()
 			if (n == 0)
 				break;
 			uint8_t op = buf[0];
-			if ((op < 0x01 || op > 0x10) &&
+			if ((op < 0x01 || op > 0x15) &&
 			    (op < 0x20 || op > 0x24)) { // resync: drop one byte
 				memmove(buf, buf + 1, --n);
 				continue;
@@ -560,14 +599,16 @@ void webusbPoll()
 					continue;
 				}
 			}
-			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x05/0x0E/0x0F/0x10 carry
-			// one value byte; 0x02 a field+value; 0x0A a 3-byte magic. Firmware update: 0x20 begin
-			// [size u32][crc32 u32], 0x21 data (6 B header + payload), 0x22/0x23/0x24 bare.
+			// Command length (fixed per opcode). 0x0D = write-one-bond-slot (27 B); 0x12 = set-one-lizard-
+			// binding (18 B); 0x05/0x0E/0x0F/0x10/0x13 carry one value byte; 0x02 a field+value; 0x0A a
+			// 3-byte magic. Firmware update: 0x20 begin [size u32][crc32 u32], 0x21 data (6 B header +
+			// payload), 0x22/0x23/0x24 bare. (0x11/0x14/0x15 are bare lizard opcodes -> default 1.)
 			uint8_t need =
 				(op == 0x0D) ? 27 :
+				(op == 0x12) ? 18 :
 				(op == 0x02) ? 3 :
 				(op == 0x03 || op == 0x05 || op == 0x0E ||
-				 op == 0x0F || op == 0x10) ?
+				 op == 0x0F || op == 0x10 || op == 0x13) ?
 					       2 :
 				(op == 0x0A) ? 4 :
 				(op == 0x20) ? 9 :
@@ -679,6 +720,52 @@ void webusbPoll()
 				faultDiagArmIntentionalReset();
 				NVIC_SystemReset();
 
+				// ---- lizard (desktop) binding map editor ----
+				// Renumbered to 0x11..0x15 on the merge with main: main already owns 0x0D (bond-slot
+				// write), 0x0E (import commit), 0x0F (stability test) and 0x10 (flight-recorder drain),
+				// 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) {
+				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) {
+				g_lizardMap.count =
+					(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) {
+				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);
+				}
+
+				// 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();
+
 				// 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
 				// is ever in flight (also what bounds the flash-stall per loop() pass to one page erase).

+ 23 - 0
docs/PROTOCOL.md

@@ -377,9 +377,32 @@ Messages:
   - `0x0B` / `0x0C`: reboot into serial DFU / UF2 bootloader
   - `0x0D <slot> <used> <24-byte rec>`: write one bond slot into RAM — see §10.1
   - `0x0E <mode>`: commit imported bonds (+ apply mode, `0xFF` = unchanged), then reboot
+  - `0x11`: get the lizard binding map (device replies `0xAA`)
+  - `0x13 <count>`: begin a lizard-map edit — set the binding count
+  - `0x12 <idx> <16-byte binding>`: set one lizard binding (see below)
+  - `0x14`: commit the edited lizard map to flash (device echoes `0xAA`)
+  - `0x15`: reset the lizard map to built-in defaults (device echoes `0xAA`)
+  - `0x20`–`0x24`: staged firmware update (begin/data/end/reboot/abort), acked with `0xAB` frames
 - Device to host:
   - `0xA5 <len> <payload>`: status blob
   - `0xA7 <len> <payload>`: bond export (§10.1)
+  - `0xA8 ...`: flight-recorder stream
+  - `0xA9 ...`: live wedge report
+  - `0xAA <count> <count×16-byte bindings>`: lizard binding map
+  - `0xAB 5 <status> <nextOff u32 LE>`: firmware-update ack
+
+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)
+```
+
+> Lizard-map commands and the `0xAA` reply were renumbered from `0x0D–0x11`/`0xA7` on the merges with
+> `main`, which had independently claimed `0x0D–0x10`, `0xA7` (bond export) and `0xA8` (flight
+> recorder).
 
 Status blob payload:
 

+ 215 - 1
docs/index.html

@@ -225,6 +225,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">
       <h2>Backup &amp; clone</h2>
       <p class="note" style="margin-top:0">Save this puck's controller pairings <em>and</em> every setting to a file, then restore them onto another puck. The other puck becomes a clone — any controller paired to this one connects to it with <strong>no re-pairing</strong>. (Also a plain backup: re-import after a factory erase to get everything back.)</p>
@@ -420,6 +433,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:case 9: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 = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV}
 function mkSelect(def, includeNone){
@@ -570,6 +623,7 @@ async function openDevice(d){
     // resume a stability test across the reset: re-arm the firmware buzz and start timing the next run
     if(stabArmed){ await send([0x0F,1]); stabStart=Date.now(); log("stability test resumed — timing next run"); }
     updateStabUI();
+    await loadLizard(); // populate the lizard-map editor from the device (global disconnect listener is set once, below)
     startPolling();
     loadReleases(false); // background, cached: feeds the update-available notice + pre-warms the update tab
     return true;
@@ -669,6 +723,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);
@@ -887,7 +944,7 @@ function setSlider(id,v){ const el=$("#"+id); if(document.activeElement!==el){ e
 
 let inflight=false;
 async function refresh(){
-  if(!dev||inflight||capturing||backupBusy||flightBusy) return; inflight=true;
+  if(!dev||inflight||capturing||backupBusy||flightBusy||lizardBusy) return; inflight=true;
   // try/finally: an exception anywhere in here (applyBlob included) must never leave inflight latched true --
   // that would silently kill all future polls and read as a fake "heartbeat lost".
   try{ await send([0x01]); const p=await readBlob(); if(p) applyBlob(p); }
@@ -1167,6 +1224,159 @@ async function startPolling(){
   }
 }
 
+// ---- 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 0x11/0x14/0x15 that
+// expects a 0xAA reply. (Opcodes/frame renumbered from 0x0D../0xA7 on the merge with main,
+// which claimed 0x0D-0x10 for bond-import/stability/flight, 0xA7 for bond export and
+// 0xA8 for the flight recorder -- 0xAA is the free device->host marker.)
+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 [0xAA][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]!==0xAA) 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([0x11]); // dump map
+    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([0x13, valid.length&0xff]); // begin edit (set count)
+    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([0x12, i, b.outType, ...od.map(x=>x&0xff), // set one binding
+        t&0xff,(t>>>8)&0xff,(t>>>16)&0xff,(t>>>24)&0xff,
+        h&0xff,(h>>>8)&0xff,(h>>>16)&0xff,(h>>>24)&0xff]);
+    }
+    await send([0x14]); // 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([0x15]); // reset to defaults
+    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;
 // ---- Firmware update: UF2 file -> staged flash over WebUSB, applied on reboot ----
@@ -1589,6 +1799,10 @@ $("#backupFile").onchange=async(e)=>{ const f=e.target.files[0]; if(f) await imp
 $("#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;
 $("#stabBtn").onclick=async()=>{
   if(!dev){ log("connect first"); return; }
   stabArmed=!stabArmed;