Просмотр исходного кода

Merge main into HID-and-Sinput

Resolves conflicts with the Original Xbox (Controller S) mode from #212,
which claimed mode slot 10: DirectInput moves to 11 and SInput to 12
(config.h, MODE_SUFFIX/MODE_NAME, panel buttons + MODE_NAMES, README,
testing guide).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jari 2 месяцев назад
Родитель
Сommit
388e728480

+ 1 - 0
CODE_MAP.md

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

+ 8 - 2
Makefile

@@ -41,6 +41,11 @@ CFG_TUD_TASK_QUEUE_SZ ?= 512
 # too small and would silently drop every frame (blank panel / stale mappings).
 CFG_TUD_VENDOR_TX_BUFSIZE ?= 256
 EXTRA_FLAGS ?=
+# TinyUSB dispatches every vendor-type control request through one global callback, which the Adafruit core
+# already defines to serve WebUSB. Original Xbox mode needs the XID requests that arrive on it.
+# Overriding the weak symbol would claim the whole hook and force a copy of Adafruit's WebUSB body,
+# so we wrap: webusb_config.cpp takes XID and passes everything else to __real_.
+OPENPUCK_LINK_FLAGS ?= -Wl,--wrap=tud_vendor_control_xfer_cb
 # {build.flags.usb} is expanded by arduino-cli (VID/PID/strings); pass it through verbatim.
 USB_EXTRA_FLAGS = -DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=$(CFG_TUD_HID) -DCFG_TUD_TASK_QUEUE_SZ=$(CFG_TUD_TASK_QUEUE_SZ) -DCFG_TUD_VENDOR_TX_BUFSIZE=$(CFG_TUD_VENDOR_TX_BUFSIZE) $(EXTRA_FLAGS)
 # When BUILD_PATH is set, --clean + path flags are injected; omitted for fast incremental dev builds.
@@ -68,7 +73,7 @@ RP_UPLOAD = arduino-cli upload -b $(FQBN) -p "$(FLASH_PORT)" ReversePuckFirmware
 ## Compile the firmware with the required USB flags baked in. Override CFG_TUD_HID / CFG_TUD_TASK_QUEUE_SZ /
 ## EXTRA_FLAGS / FQBN as make variables if needed.
 build:
-	arduino-cli compile -b $(FQBN) $(_PATH_FLAGS) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" OpenPuck
+	arduino-cli compile -b $(FQBN) $(_PATH_FLAGS) --build-property "build.extra_flags=$(USB_EXTRA_FLAGS)" --build-property "compiler.c.elf.extra_flags=$(OPENPUCK_LINK_FLAGS)" OpenPuck
 
 ## Build for the Raytac MDBT50Q-CX-40 without replacing its Open DFU bootloader.
 ## Like `build`, this does NOT run gen_version.sh -- run it yourself first if you want version provenance
@@ -79,7 +84,8 @@ build-raytac:
 	arduino-cli compile --clean -b adafruit:nrf52:mdbt50qrx \
 		--build-path build/cache/raytac \
 		--output-dir build/raytac \
-		--build-property "build.extra_flags=$(USB_EXTRA_FLAGS) -DOPK_BOARD_MDBT50Q_CX_40=1" OpenPuck
+		--build-property "build.extra_flags=$(USB_EXTRA_FLAGS) -DOPK_BOARD_MDBT50Q_CX_40=1" \
+		--build-property "compiler.c.elf.extra_flags=$(OPENPUCK_LINK_FLAGS)" OpenPuck
 
 ## Package an existing Raytac build for its factory Nordic Open DFU bootloader.
 package-raytac:

+ 12 - 14
OpenPuck/OpenPuck.ino

@@ -66,9 +66,10 @@ using namespace Adafruit_LittleFS_Namespace;
 static uint8_t g_usbCfgDesc[512];
 
 // Per-mode USB serial suffix (modes 1..MODE_MAX: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game
-// D=ds4game 3=ps3 J=dinput I=sinput).
+// D=ds4game 3=ps3 O=original-xbox J=dinput I=sinput).
+// 'C' is reserved for the CDC mode, see puck_hid.cpp
 static const char MODE_SUFFIX[] = { 'X', 'N', 'L', 'P', 'S', 'G',
-				    'Q', 'D', '3', 'J', 'I' };
+				    'Q', 'D', '3', 'O', 'J', 'I' };
 // Fixed-interface flags captured at boot so usbReenumerate (dynamic mount, no reboot) replays them.
 static bool s_dynWantWebusb = false, s_dynWantWakeMouse = false;
 
@@ -268,18 +269,15 @@ void setup()
 	usbTxBegin();
 	webusbInit(); // also drain the WebUSB status blob from the usbd task (its flush() can block loop() too)
 	hapticInit();
-	static const char *MODE_NAME[] = { "STEAM(puck)",
-					   "XBOX(xinput+mouse)",
-					   "SWITCH(horipad)",
-					   "LIZARD(puck kb/mouse)",
-					   "SWITCH(pro+gyro)",
-					   "PS5(dualsense)",
-					   "HIDGYRO(ds4+motion)",
-					   "PS5(dualsense,game/clean)",
-					   "DS4(ds4,game/clean)",
-					   "PS3(dualshock3/sixaxis)",
-					   "DINPUT(joystick+motion)",
-					   "SINPUT(sdl-native)" };
+	static const char *MODE_NAME[] = {
+		"STEAM(puck)",		 "XBOX(xinput+mouse)",
+		"SWITCH(horipad)",	 "LIZARD(puck kb/mouse)",
+		"SWITCH(pro+gyro)",	 "PS5(dualsense)",
+		"HIDGYRO(ds4+motion)",	 "PS5(dualsense,game/clean)",
+		"DS4(ds4,game/clean)",	 "PS3(dualshock3/sixaxis)",
+		"XBOX-OG(controller s)", "DINPUT(joystick+motion)",
+		"SINPUT(sdl-native)"
+	};
 	Serial.printf("# copycat up: unit=%s board=%s, mode=%s\n", g_unit,
 		      g_board,
 		      MODE_NAME[g_usbMode <= MODE_MAX ? g_usbMode : 0]);

+ 6 - 0
OpenPuck/bonds.h

@@ -16,6 +16,12 @@ struct Slot {
 	bool used;
 	uint8_t resp[63];
 	uint16_t resp_len;
+
+	// 0 = none; else the feature-01 cmd byte (0x83/0xAE/0xED/...) just relayed to this slot's controller
+	// (puck_hid.cpp handleSet, usbd task) and awaiting its real reply. Cleared by rf_link.cpp (loop/RF
+	// context) when the controller has responded to this request and overwrote resp with the response.
+	// Not persisted.
+	volatile uint8_t pendingQueryCmd;
 };
 extern Slot g_slot[NSLOT];
 

+ 25 - 8
OpenPuck/config.cpp

@@ -39,6 +39,10 @@ TypeCfg g_type[ET_COUNT] = {
 };
 uint8_t g_etype = ET_NONE;
 
+// Trackpad -> stick mapping, off for every type by default (pads keep their touch/mouse behavior).
+uint8_t g_padStickCfg[ET_COUNT][2] = {};
+uint8_t g_padStick[2] = { PS_OFF, PS_OFF };
+
 // Live mirrors of the active type (puck modes use the harmless defaults below).
 uint8_t g_abSwap = 0;
 uint8_t g_back[4] = { 5, 6, 7, 8 };
@@ -61,6 +65,7 @@ void applyActiveType()
 		g_padHaptics = 1;
 		g_rumble = 1;
 		g_ledBright = 0;
+		g_padStick[0] = g_padStick[1] = PS_OFF;
 		return;
 	}
 	const TypeCfg &t = g_type[g_etype];
@@ -71,6 +76,8 @@ void applyActiveType()
 	g_padHaptics = t.padHaptics;
 	g_rumble = t.rumble;
 	g_ledBright = t.ledBright;
+	g_padStick[0] = g_padStickCfg[g_etype][0];
+	g_padStick[1] = g_padStickCfg[g_etype][1];
 }
 // poll rate defaults to POLL_US_DEFAULT (250 Hz), matching the real Valve puck (see config.h). The
 // delivered report rate equals the poll rate (fresh IMU in every reply). Live-adjustable via console
@@ -88,12 +95,14 @@ struct Cfg {
 	// rsvd1: legacy rumble-strength slot (strength now fixed at RUMBLE_SCALE_PCT; ignored -- kept so the
 	// on-flash layout is unchanged and an existing cfg.bin still loads).
 	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the id9=0 hold enable (see
-	// haptics.h LIZKEEP_MS). landAll87: the verbatim-0x87-relay experiment toggle (haptics.h g_landAll87).
-	uint8_t rxWin10, lizKeep, landAll87;
+	// haptics.h LIZKEEP_MS). rsvd2 used to be landAll87, this is now ignored.
+	uint8_t rxWin10, lizKeep, rsvd2;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 	// TAIL (appended after CFG_MAGIC 0xCF shipped): back4+D-pad mode assignments. New tail fields go HERE, at
 	// the end, and loadCfg accepts a short file so an upgrade keeps every existing setting -- see CFG_LEN_MIN.
 	uint8_t chordDpad[4];
+	// per-type trackpad->stick mapping: [et][0] = left pad, [et][1] = right pad (PS_*)
+	uint8_t padStick[ET_COUNT][2];
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
 // Shortest cfg.bin we still accept: the layout as of CFG_MAGIC 0xCF, i.e. everything before the appended tail.
@@ -115,12 +124,16 @@ void saveCfg()
 		  0, // rsvd1 (ex rumble strength)
 		  (uint8_t)(g_rxWin / 10),
 		  g_lizKeep,
-		  g_landAll87,
+		  0, //rsvd2, used to be g_landAll87
 		  {},
 		  { g_chordDpad[0], g_chordDpad[1], g_chordDpad[2],
-		    g_chordDpad[3] } };
-	for (int i = 0; i < ET_COUNT; i++)
+		    g_chordDpad[3] },
+		  {} };
+	for (int i = 0; i < ET_COUNT; i++) {
 		c.type[i] = g_type[i];
+		c.padStick[i][0] = g_padStickCfg[i][0];
+		c.padStick[i][1] = g_padStickCfg[i][1];
+	}
 	InternalFS.remove(CFG_FILE);
 	File f(InternalFS);
 	if (f.open(CFG_FILE, FILE_O_WRITE)) {
@@ -179,6 +192,13 @@ void loadCfg()
 			for (int i = 0; i < 4; i++)
 				if (modeValid(c.chordDpad[i]))
 					g_chordDpad[i] = c.chordDpad[i];
+			// Pad->stick mapping: 0xFF (a file predating this tail field) or an out-of-range
+			// value keeps the compiled default (off).
+			for (int i = 0; i < ET_COUNT; i++)
+				for (int k = 0; k < 2; k++)
+					if (c.padStick[i][k] <= PS_MAX)
+						g_padStickCfg[i][k] =
+							c.padStick[i][k];
 			// grow a short file to the current layout on the next save
 			if (got < (int)sizeof c)
 				consume = true;
@@ -187,9 +207,6 @@ void loadCfg()
 			// through -> keep the on default)
 			if (c.lizKeep <= 1)
 				g_lizKeep = c.lizKeep;
-			// verbatim-0x87-relay experiment toggle (0/1; default off)
-			if (c.landAll87 <= 1)
-				g_landAll87 = c.landAll87;
 			// The poll RX window is now FIXED (g_rxWin is const) -- any persisted rxWin10 is ignored.
 		}
 		f.close();

+ 19 - 3
OpenPuck/config.h

@@ -41,13 +41,15 @@
 // console wants a bare Sixaxis HID, not a composite). Answers the PS3's GET_REPORT(0xF2/0xF5/0xEF/0x01)
 // enable handshake. + gyro/accel + rumble.
 #define MODE_PS3 9
+// Microsoft Original Xbox Controller S (045E:0289)
+#define MODE_XBOX_OG 10
 // Generic DirectInput joystick -- presents EVERY analog input at once (sticks, triggers, both trackpads, gyro)
 // as two DirectInput devices, for flight/space sims that bind axes through DirectInput rather than XInput.
-#define MODE_DINPUT 10
+#define MODE_DINPUT 11
 // SInput: the open SDL-native gamepad protocol (docs.handheldlegend.com/s/sinput). Sticks + analog triggers +
 // gyro/accel + BOTH trackpads + battery, all bound natively by SDL3 / Steam Input with no impersonation.
-#define MODE_SINPUT 11
-#define MODE_MAX 11
+#define MODE_SINPUT 12
+#define MODE_MAX 12
 
 // The two "game" personalities drop the wake-mouse + WebUSB interfaces so the device is a genuine single-HID PS
 // controller (some PC games -- e.g. Fortnite/UE GameInput -- refuse PS classification when extra interfaces are
@@ -80,6 +82,8 @@ static inline uint8_t etypeForMode(uint8_t m)
 {
 	switch (m) {
 	case MODE_XBOX:
+	// OG Xbox Controller S shares the Xbox layout / button config minus guide button
+	case MODE_XBOX_OG:
 		return ET_XBOX;
 	case MODE_SW_HORI:
 	case MODE_SW_PRO:
@@ -148,6 +152,16 @@ struct TypeCfg {
 	uint8_t rumble;
 };
 extern TypeCfg g_type[ET_COUNT];
+
+// Trackpad -> analog stick mapping, per emulated type: {left pad, right pad}, values PS_OFF/PS_LEFT/PS_RIGHT.
+// While the mapped pad is touched its coordinates drive that stick; releasing it re-centers the stick (the
+// physical stick still drives it whenever the pad is untouched). Kept OUTSIDE TypeCfg so the on-flash Cfg
+// layout only grows in its tail -- an existing cfg.bin still loads and keeps every other setting.
+#define PS_OFF 0
+#define PS_LEFT 1
+#define PS_RIGHT 2
+#define PS_MAX 2
+extern uint8_t g_padStickCfg[ET_COUNT][2];
 extern uint8_t
 	g_etype; // etypeForMode(g_usbMode), resolved at boot (ET_NONE for puck modes)
 
@@ -162,6 +176,8 @@ extern uint8_t
 extern uint8_t g_rumble;
 // LED brightness for the active emulated type (0 = no override, 1-100 = brightness %)
 extern uint8_t g_ledBright;
+// Live mirror of g_padStickCfg[g_etype]: {left pad, right pad} -> stick (PS_*).
+extern uint8_t g_padStick[2];
 
 // Copy g_type[g_etype] into the live mirrors above (safe defaults when g_etype == ET_NONE).
 void applyActiveType();

+ 3 - 0
OpenPuck/controllers.cpp

@@ -9,6 +9,7 @@
 #include "mode_ps3.h"
 #include "mode_dinput.h"
 #include "mode_sinput.h"
+#include "mode_xbox_og.h"
 
 IController *g_active = nullptr;
 
@@ -23,6 +24,8 @@ IController *controllerFor(uint8_t mode)
 		return &g_steamPuck;
 	case MODE_XBOX:
 		return &g_xboxCtl;
+	case MODE_XBOX_OG:
+		return &g_xboxOgCtl;
 	case MODE_SW_HORI:
 		return &g_switchHori;
 	case MODE_SW_PRO:

+ 51 - 0
OpenPuck/gamepad_util.cpp

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

+ 14 - 0
OpenPuck/gamepad_util.h

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

+ 101 - 134
OpenPuck/haptics.cpp

@@ -4,6 +4,7 @@
 #include "rf_link.h"
 #include "usb_tx.h" // usbTxBoost/Unboost -- flood-rate CDC prints share the dcd DMA claim window
 #include "puck_hid.h" // puckLizardActive() -- gate the lizard-suppression keepalive
+#include "steam_commands.h"
 
 #include "fault_diag.h" // faultDiagTrace() -- flight recorder
 // USBDevice.suspended() -> autonomous controller power-off on host sleep
@@ -26,19 +27,6 @@ uint16_t g_hapticBlockMs = HAPTIC_BLOCK_MS_DEFAULT;
 // or Steam-closed fallback) so the autonomous pad layer -- the trackpad-tick source -- is on.
 // Persisted; console 'u' toggles for A/B.
 uint8_t g_lizKeep = 1;
-// EXPERIMENT (console "L87" toggles, persisted): land ALL relayed 0x87 SET_SETTINGS via type-01 framing so
-// Steam's haptic/amp/IMU config actually reaches the controller (verbatim relay = real-puck behavior),
-// instead of the whitelist discarding everything but LED(0x2D)/id9(0x09). Hypothesis: the buzz is the
-// controller's haptic engine left MISCONFIGURED because Steam's 0x87 config is discarded; landing it should
-// configure the engine correctly and stop the buzz. Default OFF (keeps the safe whitelist) because landing
-// reg 0x30 was once blamed for freezing the gyro and 0x34/0x35 for the buzz -- but those were OpenPuck's own
-// bad hapticReinit values, so Steam's real values may be fine. Flip it on hardware to test; flip back if the
-// gyro freezes.
-uint8_t g_landAll87 = 0;
-// Land Steam's amp/haptic-config 0x87 blocks (regs 0x18/0x2E/0x34/0x35, NOT gyro 0x30) so the controller's
-// amplifier is configured and haptics play as clean ticks instead of a default-amp buzz. On by default;
-// console "AMP" toggles for A/B. See the land01 whitelist in rfConnFlushRelay.
-uint8_t g_landAmp = 1;
 // Master enable for the puck->controller haptic RELAY (Steam OUTPUT reports 0x80-0x86, incl. the trackpad
 // texture-feedback stream Steam pushes WHILE you drag). Each relayed frame is an extra TX that precedes the
 // E3 poll and steals its reply window, and the controller must stop to process it -- both can depress the
@@ -63,7 +51,8 @@ void hapticSendShutdown(uint8_t slot)
 	// the panel/test power-off button.
 	faultDiagTrace(FR_OFF, slot);
 	for (uint8_t i = 0; i < HAPTIC_SHUTDOWN_SHOTS; i++)
-		relayEnqueue(0x9F, OFF, sizeof OFF, slot);
+		relayEnqueue(IBEX_CMD_TURN_OFF_CONTROLLER, OFF, sizeof OFF,
+			     false, slot);
 	// Tell the puck presentation layer to show this slot (or all, for 0xFF broadcast) cleanly DISCONNECTED to
 	// Steam and hold it there through the controller's post-off F1 tail -- otherwise the dying replies bounce
 	// the connection state (phantom reconnect / never-removed). Covers every power-off path (Steam 0x9F, the
@@ -71,12 +60,6 @@ void hapticSendShutdown(uint8_t slot)
 	puckNotePowerOff(slot);
 }
 
-// millis of last 0x82 haptic OUTPUT relayed (Steam mode)
-static unsigned long g_haptic82Ms = 0;
-
-// a non-zero 0x82 haptic is currently active (awaiting host stop)
-static bool g_haptic82On = false;
-
 // millis of last translated host rumble (0x80), per-slot (4 XInput interfaces each have their own stream)
 static unsigned long g_rumble80Ms[NSLOT] = { 0 };
 
@@ -93,7 +76,11 @@ static bool g_rumble80On[NSLOT] = { false, false, false, false };
 // per slot (rfConnFlushRelay on that slot's poll turn). Producers serialize under PRIMASK.
 struct RelayMsg {
 	uint8_t rid, len;
+	bool isHaptic;
 	uint8_t data[RELAY_MAXP];
+	// set by the caller at relayEnqueue() time (see haptics.h) -- true iff a real controller reply is
+	// wanted for this specific message, i.e. whether rfConnFlushRelay appends the query-reply trailer.
+	bool expectReply;
 };
 // deep enough to hold a full Steam settings/LED transaction burst without loss
 #define RELAY_QLEN 32
@@ -119,7 +106,7 @@ bool relayPending()
 	return g_rqHead[cur] != g_rqTail[cur];
 }
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
-		  uint8_t slot)
+		  bool isHaptic, uint8_t slot, bool expectReply)
 {
 	if (plen > RELAY_MAXP)
 		plen = RELAY_MAXP;
@@ -138,13 +125,13 @@ bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 			g_rqTail[s] = rqNext(g_rqTail[s]);
 		g_rq[s][h].rid = rid;
 		g_rq[s][h].len = plen;
+		g_rq[s][h].expectReply = expectReply;
+		g_rq[s][h].isHaptic = isHaptic;
 		if (plen)
 			memcpy(g_rq[s][h].data, payload, plen);
 		g_rqHead[s] = nx;
 	}
-	// Track last-haptic time for the Steam-mode quiet timeout (marks the 0x82 stream inactive after silence).
-	if (rid == 0x82 || rid == 0x80)
-		g_haptic82Ms = millis();
+
 	__set_PRIMASK(pm);
 	faultDiagTrace(FR_RELAY, (uint16_t)((slot << 8) | rid));
 	return true;
@@ -256,16 +243,6 @@ bool hapticRelaySlotOk(int slot)
 {
 	return slot >= 0 && slot < NSLOT && hapticLinkUp(slot);
 }
-static bool haptic82PayloadOn(const uint8_t *p, uint16_t n)
-{
-	if (n < 3)
-		return false;
-	for (uint16_t i = 2; i < n; i++)
-		if (p[i])
-			// observed form is [01 01 gain], but treat any trailing non-zero as active
-			return true;
-	return false;
-}
 static void hapticCancelPendingOn(int slot)
 {
 	// void queued ON entries (stale haptics / rumble across a reconnect). Per-slot: only the reconnected
@@ -309,15 +286,7 @@ static void hapticCancelPendingOn(int slot)
 	}
 	__set_PRIMASK(pm);
 }
-void haptic82HostReport(const uint8_t *p, uint16_t n)
-{
-	if (n < 3)
-		return;
-	g_haptic82Ms = millis();
-	// Track on/off only. Do NOT synthesize a stop burst: Steam's own stop is forwarded verbatim, and each extra
-	// 0x82 is a discrete pad click -- the spurious end-of-movement "click"/buzz the real puck never produces.
-	g_haptic82On = haptic82PayloadOn(p, n);
-}
+
 bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 {
 	if (slot >= NSLOT)
@@ -371,7 +340,7 @@ bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot)
 	uint8_t reps = stopping ? RUMBLE_STOP_REPS : 1;
 	bool queued = false;
 	for (uint8_t i = 0; i < reps; i++)
-		if (relayEnqueue(0x80, p, sizeof p, slot))
+		if (relayEnqueue(0x80, p, sizeof p, true, slot))
 			queued = true;
 	if (!queued)
 		return false;
@@ -402,7 +371,7 @@ void hapticStabTask()
 		for (uint8_t s = 0; s < NSLOT; s++)
 			if (g_slot[s].used && g_connReplyMs[s] &&
 			    (unsigned long)(now - g_connReplyMs[s]) < 300u)
-				relayEnqueue(0x82, p, 3, s);
+				relayEnqueue(0x82, p, 3, true, s);
 	};
 	if (lastOn == 0 || (uint32_t)(now - lastOn) >= 10000u) {
 		lastOn = now;
@@ -422,10 +391,10 @@ void rfConnQueueHapticRelay()
 	static const uint8_t HAP_ON[3] = { 0x01, 0x01, 0xF7 };
 	static const uint8_t HAP_OFF[3] = { 0x01, 0x01, 0x00 };
 	if (g_testHaptic) {
-		if (relayEnqueue(0x82, HAP_ON, 3, 0xFF))
+		if (relayEnqueue(0x82, HAP_ON, 3, true, 0xFF))
 			g_testHaptic--;
 	} else if (g_hapticStop && !g_xbox) {
-		if (relayEnqueue(0x82, HAP_OFF, 3, 0xFF))
+		if (relayEnqueue(0x82, HAP_OFF, 3, true, 0xFF))
 			g_hapticStop--;
 	}
 }
@@ -471,37 +440,15 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 			// On-air sub-TLV framing. CONFIRMED from real puck<->controller sniffs: a command LANDS on
 			// the controller only with the type-01 + inner-len form E3 [2+rl][01][rid][innerlen][data];
 			// the legacy form E3 [1+rl][05][rid][data] makes the controller DISCARD any 0x87+ command.
-			//
-			// Whitelist type-01 to exactly three commands: LED brightness (0x87 reg 0x2D), power-off
-			// (0x9F), and the STANDALONE lizard-suppression keepalive (0x87 [09 00 00] -- rl==3 so a
-			// multi-register block that merely STARTS with 0x09 can't smuggle other registers into
-			// landing). Setting index 9 = digital-mappings/lizard-active (ibex FUN_0001f554 decomp);
-			// the real puck lands id9=0 every 3s -- without it the controller's revert timer fires,
-			// re-enables autonomous mode and resets ALL settings to defaults (audible amp pop +
-			// autonomous touchpad ticks = the spurious mid-session buzz). Other 0x87 writes (e.g. reg
-			// 0x30 IMU enable, 0x34/0x35 haptic amplitude) must stay on legacy form -- landing 0x30
-			// freezes the gyro; landing 0x34/0x35 causes the connect buzz.
-			// Amp/haptic-config registers (0x18/0x2E/0x34/0x35): LAND these so the controller's amplifier is
-			// configured the way Steam intends -> haptics (0x82 ticks, connect cue) play as clean ticks
-			// instead of the default-amp BUZZ we get when the config is discarded. Steam sends these grouped
-			// in 0x87 blocks that START with one of those regs and do NOT contain 0x30 (the gyro reg, which
-			// must NOT land -- it freezes the gyro), so gating on data[0] lands the amp block without 0x30.
-			// (g_landAmp, console "AMP"; on by default. The old "landing 0x34/0x35 buzzes" note was really the
-			// 0x81 storm, now removed.)
-			bool ampReg = (m.data[0] == 0x18 || m.data[0] == 0x2E ||
-				       m.data[0] == 0x34 || m.data[0] == 0x35);
-			bool land01 =
-				(m.rid == 0x9F) ||
-				(m.rid == 0x87 && rl >= 1 &&
-				 m.data[0] == 0x2D) ||
-				(m.rid == 0x87 && rl == 3 &&
-				 m.data[0] == 0x09) ||
-				(g_landAmp && m.rid == 0x87 && rl >= 1 &&
-				 ampReg) ||
-				// experiment: land EVERY 0x87 verbatim (real-puck relay) when enabled
-				(g_landAll87 && m.rid == 0x87);
-			uint8_t p[5 + RELAY_MAXP], plen;
-			if (land01) {
+
+			// Same shape as the `[len][tag][value]` TLV grammar the F1 REPLY side
+			// already uses (tags 0x02/0x04/0x06, docs/PROTOCOL.md sec 7.3): read as len=1, tag=3,
+			// value=0. If the caller decides they want an answer, we add the queryTrailer.
+			// Should check later if there's a way to somehow determine which types need the trailer and which ones don't.
+			bool queryTrailer = m.expectReply;
+			uint8_t p[5 + RELAY_MAXP + 3], plen;
+			if (!m.isHaptic) {
+				// Non-haptics use type 01
 				p[0] = g_relayOp;
 				p[1] = (uint8_t)(2 + rl);
 				p[2] = 0x01;
@@ -509,7 +456,14 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				p[4] = rl;
 				memcpy(p + 5, m.data, rl);
 				plen = (uint8_t)(5 + rl);
+				if (queryTrailer && plen + 3 <= sizeof p) {
+					p[plen + 0] = 0x01;
+					p[plen + 1] = 0x03;
+					p[plen + 2] = 0x00;
+					plen = (uint8_t)(plen + 3);
+				}
 			} else {
+				// Haptics use type 05
 				p[0] = g_relayOp;
 				p[1] = (uint8_t)(1 + rl);
 				p[2] = 0x05;
@@ -528,7 +482,8 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				usbTxBoost();
 				Serial.printf("# TX t=%lu slot%d %s rid=%02X:",
 					      (unsigned long)millis(), cur,
-					      land01 ? "L01" : "l05", m.rid);
+					      m.isHaptic ? "L05" : "L01",
+					      m.rid);
 				for (uint8_t i = 0; i < rl && i < 8; i++)
 					Serial.printf(" %02X", m.data[i]);
 				Serial.println();
@@ -556,67 +511,70 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 	return have; // true = a relay frame went out this cycle (its reply is harvested as input, above)
 }
 
-// Haptic-subsystem re-init: the captured sequence Steam sends when it (re)takes control (0x81 reset + 0x87
-// register writes). Brightness (reg 0x2D) is omitted to avoid stomping the LED. The 0x87 frames go out on
-// legacy framing so the controller discards them; the three 0x81 frames are the effective reset. slot=0xFF
-// broadcasts to all connected controllers (the re-init is settings-only and harmless on healthy controllers).
-// One-shot SET_SETTINGS (0x87) write of the controller's GLOBAL trackpad-haptics enable. The controller plays
-// touchpad haptic sequences autonomously (haptics-sequencer-touchpad) gated by settings/haptics/enabled; in the
-// emulated (non-Steam) modes the host never sends SC haptic reports, so the only way to silence the pad buzz is
-// to write this setting. Wire format [id][val u16 LE] -- confirmed from the controller's SET_SETTINGS handler
-// (decomp FUN_0001f61c; id 0x30 == settings/sensors/imu/mode is the cross-check anchor in the same id space).
-// The exact id for settings/haptics/enabled needs a one-time `scmd labels` HW read (candidates from the captured
-// reinit: 0x18 / 0x2e / 0x34 / 0x35). Until it is set, SETTING_HAPTICS_ENABLED stays 0xFF and this is inert.
-#define SETTING_HAPTICS_ENABLED 0xFF
-void hapticSetPadEnabled(uint8_t slot, bool on)
-{
-#if SETTING_HAPTICS_ENABLED != 0xFF
-	uint8_t pl[3] = { SETTING_HAPTICS_ENABLED, (uint8_t)(on ? 1 : 0), 0 };
-	relayEnqueue(0x87, pl, 3, slot);
-#else
-	(void)slot;
-	(void)on;
-#endif
-}
 void hapticReinit(uint8_t slot)
 {
-	static const uint8_t H30[] = { 0x30, 0x00, 0x00, 0x07, 0x07,
-				       0x00, 0x08, 0x07, 0x00, 0x31,
-				       0x02, 0x00, 0x52, 0x03, 0x00 };
-	static const uint8_t H18[] = { 0x18, 0x00, 0x00, 0x2e, 0x00,
-				       0x00, 0x34, 0xff, 0xff, 0x35,
-				       0xff, 0xff, 0x34, 0xff, 0xff };
-	static const uint8_t H35[] = { 0x35, 0xff, 0xff, 0x2e, 0x00, 0x00 };
+	// TODO: This function is only ever called from WebUSB in debug mode
+	// when the user clicks the "Clear stuck buzz" button.
+	// I believe with MR 230 the haptics issues should be gone,
+	// so this can probably be removed?
+
+	// clang-format off
+	static const uint8_t haptic_reset_data_1[] = { 
+			SETTING_IMU_MODE, 0x00, 0x00, 
+			SETTING_LEFT_TRACKPAD_MODE, 0x07, 0x00, 
+			SETTING_RIGHT_TRACKPAD_MODE, 0x07, 0x00, 
+			SETTING_WIRELESS_PACKET_VERSION, 0x02, 0x00, 
+			SETTING_COUNT, 0x03, 0x00 };
+	
+	static const uint8_t haptic_reset_data_2[] = { 
+			SETTING_SMOOTH_ABSOLUTE_MOUSE, 0x00, 0x00, 
+			SETTING_ENABLE_RAW_JOYSTICK, 0x00, 0x00, 
+			SETTING_LEFT_TRACKPAD_CLICK_PRESSURE, 0xff, 0xff, 
+			SETTING_RIGHT_TRACKPAD_CLICK_PRESSURE, 0xff, 0xff, 
+			SETTING_LEFT_TRACKPAD_CLICK_PRESSURE, 0xff, 0xff };
+
+	static const uint8_t haptic_reset_data_3[] = { 
+			SETTING_RIGHT_TRACKPAD_CLICK_PRESSURE, 0xff, 0xff, 
+			SETTING_ENABLE_RAW_JOYSTICK, 0x00, 0x00 };
+
+	// clang-format on
+
 	static const uint8_t T81A[] = {
 		0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
 	};
 	static const uint8_t T81B[] = {
 		0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
 	};
+
 	// reset action (FUN_0001f554) -- Steam sends this first
-	relayEnqueue(0x81, nullptr, 0, slot);
-	relayEnqueue(0x87, H30, sizeof H30, slot);
+	relayEnqueue(0x81, nullptr, 0, true, slot);
+
+	// This is probably split into three commands because you can only update
+	// five parameters in one batch.
+	relayEnqueue(IBEX_CMD_SET_SETTINGS_VALUES, haptic_reset_data_1,
+		     sizeof haptic_reset_data_1, false, slot);
+	relayEnqueue(IBEX_CMD_SET_SETTINGS_VALUES, haptic_reset_data_2,
+		     sizeof haptic_reset_data_2, false, slot);
+	relayEnqueue(IBEX_CMD_SET_SETTINGS_VALUES, haptic_reset_data_3,
+		     sizeof haptic_reset_data_3, false, slot);
+
+	// explicitly trigger an empty HAPTIC_PULSE: the part that clears a latch
+	relayEnqueue(0x81, T81A, sizeof T81A, true, slot);
+	relayEnqueue(0x81, T81B, sizeof T81B, true, slot);
 
-	// haptic config (enabled/amplifier/gain): the part that clears a latch
-	relayEnqueue(0x87, H18, sizeof H18, slot);
-	relayEnqueue(0x87, H35, sizeof H35, slot);
-	relayEnqueue(0x81, T81A, sizeof T81A, slot);
-	relayEnqueue(0x81, T81B, sizeof T81B, slot);
-	// Re-apply the active emulated type's trackpad-haptics preference last (after the haptic-config writes
-	// above, which would otherwise re-enable it). Default-on types send "enable"; Switch (padHaptics=0)
-	// disables. Inert until the setting id is captured.
-	hapticSetPadEnabled(slot, g_padHaptics != 0);
 	// Apply the configured LED brightness for the active emulated type. Steam sets the
 	// brightness each session; emulated modes never do, so the controller comes up at
 	// full brightness. 0 = no override (preserve controller default).
+	// TODO: This has nothing to do with haptics, why is this here?
 	if (g_etype < ET_COUNT && g_ledBright > 0) {
-		uint8_t pl[3] = { 0x2D, g_ledBright, 0x00 };
-		relayEnqueue(0x87, pl, sizeof pl, slot);
+		uint8_t pl[3] = { SETTING_LED_USER_BRIGHTNESS, g_ledBright,
+				  0x00 };
+		relayEnqueue(IBEX_CMD_SET_SETTINGS_VALUES, pl, sizeof pl, false,
+			     slot);
 	}
 }
 void hapticInit()
 {
-	g_haptic82On = false;
 	g_hapticStop = 0;
 	for (int s = 0; s < NSLOT; s++) {
 		g_rqHead[s] = g_rqTail[s] = 0;
@@ -635,7 +593,6 @@ void hapticOnReconnect(int slot)
 		return;
 	// post-connect haptic block is permanently disabled -- relay haptics immediately on (re)connect
 	g_hapticBlockUntil[slot] = 0;
-	g_haptic82On = false;
 	g_rumble80On[slot] = false;
 	g_rumble80Ms[slot] = 0;
 	// Scrub haptics queued before the link came up (stale across the reconnect) -- this slot only.
@@ -669,10 +626,18 @@ void hapticTask()
 	if (g_lizKeep) {
 		static unsigned long lastKeep[NSLOT] = { 0 };
 		static bool landedAuto[NSLOT] = { false };
-		static const uint8_t K0[3] = { 0x09, 0x00, 0x00 };
-		static const uint8_t K1[3] = { 0x09, 0x01, 0x00 };
-		bool wantAuto = modeIsPuck(g_usbMode) ? puckLizardActive() :
-							(g_padHaptics != 0);
+		static const uint8_t DATA_LIZARD_OFF[3] = { SETTING_LIZARD_MODE,
+							    0x00, 0x00 };
+		static const uint8_t DATA_LIZARD_ON[3] = { SETTING_LIZARD_MODE,
+							   0x01, 0x00 };
+
+		if (modeIsPuck(g_usbMode)) {
+			// We're in puck mode. Leave the haptics to Steam.
+			return;
+		}
+
+		// We're in emulated controller mode.
+		bool wantAuto = (g_padHaptics != 0);
 		for (int s = 0; s < NSLOT; s++) {
 			if (!g_slot[s].used || !hapticLinkUp(s)) {
 				// re-land id9 on the next (re)connect: a fresh controller defaults to
@@ -684,8 +649,11 @@ void hapticTask()
 			if (wantAuto) {
 				if (!landedAuto[s]) {
 					landedAuto[s] = true;
-					relayEnqueue(0x87, K1, sizeof K1,
-						     (uint8_t)s);
+					relayEnqueue(
+						IBEX_CMD_SET_SETTINGS_VALUES,
+						DATA_LIZARD_ON,
+						sizeof DATA_LIZARD_ON, false,
+						(uint8_t)s);
 				}
 			} else {
 				landedAuto[s] = false;
@@ -694,7 +662,10 @@ void hapticTask()
 					    LIZKEEP_MS)
 					continue;
 				lastKeep[s] = millis();
-				relayEnqueue(0x87, K0, sizeof K0, (uint8_t)s);
+				relayEnqueue(IBEX_CMD_SET_SETTINGS_VALUES,
+					     DATA_LIZARD_OFF,
+					     sizeof DATA_LIZARD_OFF, false,
+					     (uint8_t)s);
 			}
 		}
 	}
@@ -749,10 +720,6 @@ void hapticTask()
 		suspArmed = false; // fire once per suspend
 	}
 	wasSusp = susp;
-	// Steam-mode quiet timeout: mark 0x82 stream inactive. No synthesized stop -- Steam forwards its own.
-	if (!g_xbox && g_haptic82On &&
-	    millis() - g_haptic82Ms > HAPTIC_QUIET_MS)
-		g_haptic82On = false;
 	// Per-slot stuck-rumble watchdog: force zero after 2.5s without a refresh.
 	for (int s = 0; s < NSLOT; s++) {
 		if (g_rumble80On[s] && millis() - g_rumble80Ms[s] > 2500u)

+ 5 - 22
OpenPuck/haptics.h

@@ -18,25 +18,13 @@
 #include "config.h" // OPK_LOG
 #include "bonds.h" // NSLOT
 
-// after this much host silence, consider the current 0x82 haptic stream inactive
-#define HAPTIC_QUIET_MS 300u
 // Post-(re)connect haptic block default. While armed, ALL Steam haptic relays to that slot are dropped: a
 // just-powered-on controller's haptic engine isn't ready, and feeding it haptics in that window leaves it in a
 // degraded/latched state (slow/stuck/missing haptics until a re-init). Runtime-adjustable (g_hapticBlockMs) and
 // toggleable (g_hapticBlockOn) from the WebUSB panel; this is the boot default.
 #define HAPTIC_BLOCK_MS_DEFAULT 10000u
-// 0x82-zero relays per stop event (sent at poll cadence -- not loop rate)
-#define HAPTIC_STOP_BURST 4u
 // max relayed payload bytes per entry: RF frame = [E3][len][05][rid][payload] and MAXLEN=64 -> 60
 #define RELAY_MAXP 60u
-// Proactive post-(re)connect haptic re-init: this many shots, this far apart, starting ~200ms after the link
-// comes up -- covers ~0.2s..3s so the brief controller-side connect buzz gets reset before it can sustain.
-#define HAPTIC_REINIT_SHOTS 8u
-#define HAPTIC_REINIT_GAP_MS 350u
-// After haptic activity, if it's been idle this long, fire one clear-re-init -- kills a latch that engaged
-// during use (a buzz that starts seconds after connect and won't self-clear). Long enough not to fire between
-// rapid in-game haptics; short enough to clear a stuck buzz soon after the user pauses.
-#define HAPTIC_CLEAR_IDLE_MS 1200u
 // Controller power-off: hapticSendShutdown() relays Steam's confirmed "turn off controller" command (feature-0x01
 // cmd 0x9F, payload "off!" -- captured from the real puck). Sent as a small burst because the RF relay is NO-ACK.
 #define HAPTIC_SHUTDOWN_SHOTS 3u
@@ -47,8 +35,12 @@
 // ---- relay queue (written by puck_hid.cpp, mode_*.cpp, serial_console.cpp; drained by rf_link.cpp) ----
 // Enqueue one host->controller report. `slot` = bond slot (0..NSLOT-1) or 0xFF to broadcast to every
 // connected controller (used by hapticSendShutdown / hapticReinit / test haptics). ISR-safe (PRIMASK).
+// `expectReply`: true iff the CALLER is relaying this specifically because it wants the controller's real
+// answer back (a query like GET_ATTRIBUTES/GET_STRING_ATTRIBUTE/READ_SETTING -- see puck_hid.cpp
+// handleSet's `relayQuery`), as opposed to a fire-and-forget action (haptics, settings writes, power-off)
+// that nothing will ever wait on a reply for. rfConnFlushRelay uses this.
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
-		  uint8_t slot = 0xFF);
+		  bool isHaptic, uint8_t slot = 0xFF, bool expectReply = false);
 
 // id9=0 hold (MODE_STEAM only): land the controller's SET_SETTINGS index 9 (digital-mappings/lizard-active)
 // at 0, once per LIZKEEP_MS per connected slot, like the real puck. This holds the controller's autonomous
@@ -59,12 +51,6 @@ bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 #define LIZKEEP_MS 2000u
 extern uint8_t
 	g_lizKeep; // 1 = hold on (default, persisted); console 'u' toggles for A/B
-// Experiment: land ALL relayed 0x87 SET_SETTINGS verbatim (real-puck relay) instead of the discard-whitelist.
-// Default 0 (whitelist). Console "L87" toggles; persisted. See haptics.cpp for the buzz hypothesis it tests.
-extern uint8_t g_landAll87;
-// Land Steam's amp/haptic-config 0x87 (regs 0x18/0x2E/0x34/0x35, not gyro 0x30) so haptics play as clean
-// ticks not a default-amp buzz. On by default; console "AMP" toggles.
-extern uint8_t g_landAmp;
 // Master enable for the puck->controller haptic relay (Steam 0x80-0x86 rumble/pad-feedback). Console "HR"
 // toggles it to isolate the drag-smoothness cost of relaying Steam's trackpad haptics. See haptics.cpp.
 extern bool g_hapticRelay;
@@ -120,7 +106,6 @@ static inline bool hapLogPull(uint32_t *, uint8_t *, uint8_t *, uint8_t *,
 bool hapticLinkUp(int slot = -1);
 bool haptic82Blocked(int slot = -1);
 bool hapticRelaySlotOk(int slot);
-void haptic82HostReport(const uint8_t *p, uint16_t n);
 // queue a Steam/Triton 0x80 rumble frame. `slot` = bond slot of the originating controller (0..NSLOT-1);
 // defaults to 0 for the legacy single-controller callers. Per-slot so each connected controller can have its
 // own active rumble stream when the host presents multiple gamepads (e.g. 4 XInput devices).
@@ -158,5 +143,3 @@ void hapticReinit(uint8_t slot = 0xFF);
 // controller out of the degraded/latched haptic state. Reliable -- independent of hapticTask's link heuristic.
 // Per-slot: only the slot that just reconnected is blocked, the others keep relaying.
 void hapticOnReconnect(int slot);
-// Write the controller's global trackpad-haptics enable setting (per active emulated type). slot 0xFF = broadcast.
-void hapticSetPadEnabled(uint8_t slot, bool on);

+ 9 - 4
OpenPuck/identity.cpp

@@ -7,11 +7,16 @@ char g_unit[16];
 char g_board[16];
 char g_usbSerial[18];
 
-const uint8_t ATTR83[25] = { // 0x83 attributes (product 0x1304 = Proteus puck)
-	0x01, 0x04, 0x13, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00,
-	0x00, 0x0A, 0xF2, 0xF9, 0xD2, 0x68, 0x04, 0x53, 0xD0,
-	0x18, 0x6A, 0x09, 0x47, 0x00, 0x00, 0x00
+// clang-format off
+const uint8_t ATTR83[25] = { // 0x83 attributes
+	// 1 byte tag, 4 byte little-endian value.
+	0x01,  0x04, 0x13, 0x00, 0x00, 		// product_id = 0x1304
+	0x02,  0x00, 0x00, 0x00, 0x00, 		// capabilities = 0 (bitfield)
+	0x0A,  0xF2, 0xF9, 0xD2, 0x68,      // boot_build_timestamp = 0x68D2F9F2
+	0x04,  0x59, 0x83, 0x62, 0x6A,      // build_timestamp = 0x6A628359 todo: keep updated
+	0x09,  0x47, 0x00, 0x00, 0x00       // hw_id = 0x47
 };
+// clang-format on
 
 void genSerial()
 {

+ 17 - 10
OpenPuck/mode_hidgyro.cpp

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

+ 7 - 4
OpenPuck/mode_ps3.cpp

@@ -284,10 +284,12 @@ static void ds3Build(uint8_t slot, uint8_t out[48])
 	out[3] = (b & TB_STEAM) ? 0x01 : 0;
 
 	// out[5..8] (rd[6..9]): LX LY RX RY, center 0x80
-	out[5] = swStick(g_in[slot].lx, false);
-	out[6] = swStick(g_in[slot].ly, true);
-	out[7] = swStick(g_in[slot].rx, false);
-	out[8] = swStick(g_in[slot].ry, true);
+	int16_t lx, ly, rx, ry;
+	slotSticks(slot, &lx, &ly, &rx, &ry);
+	out[5] = swStick(lx, false);
+	out[6] = swStick(ly, true);
+	out[7] = swStick(rx, false);
+	out[8] = swStick(ry, true);
 
 	// out[13..24] (rd[14..25]): analog pressures, order Up Right Down Left L2 R2 L1 R1 Tri Cir Cross Square
 	out[13] = (b & TB_DUP) ? 0xFF : 0;
@@ -332,6 +334,7 @@ void Ps3Controller::usbIdentity()
 {
 	// Genuine Sixaxis / DualShock 3 identity. The PS3 recognises the pad by exactly this VID/PID.
 	USBDevice.setID(0x054C, 0x0268);
+	USBDevice.setVersion(0x0200);
 	USBDevice.setDeviceVersion(0x0100);
 	USBDevice.setManufacturerDescriptor("Sony");
 	USBDevice.setProductDescriptor("PLAYSTATION(R)3 Controller");

+ 17 - 9
OpenPuck/mode_ps5.cpp

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

+ 7 - 5
OpenPuck/mode_switch_hori.cpp

@@ -169,11 +169,12 @@ static void switchBuildHoripad(uint8_t slot, uint8_t out[8])
 	out[0] = btn & 0xFF;
 	out[1] = btn >> 8;
 	out[2] = hat;
-	out[3] = swStick(g_in[slot].lx, false);
-	out[4] = swStick(g_in[slot].ly,
-			 true); // HID Y is down-positive -> invert
-	out[5] = swStick(g_in[slot].rx, false);
-	out[6] = swStick(g_in[slot].ry, true);
+	int16_t lx, ly, rx, ry;
+	slotSticks(slot, &lx, &ly, &rx, &ry);
+	out[3] = swStick(lx, false);
+	out[4] = swStick(ly, true); // HID Y is down-positive -> invert
+	out[5] = swStick(rx, false);
+	out[6] = swStick(ry, true);
 	out[7] = 0;
 }
 
@@ -190,6 +191,7 @@ uint8_t SwitchHoriController::maxSlots() const
 void SwitchHoriController::usbIdentity()
 {
 	USBDevice.setID(0x0F0D, 0x0092);
+	USBDevice.setVersion(0x0200);
 	USBDevice.setDeviceVersion(0x0210);
 	USBDevice.setManufacturerDescriptor("HORI CO.,LTD.");
 	USBDevice.setProductDescriptor("POKKEN CONTROLLER");

+ 5 - 2
OpenPuck/mode_switch_pro.cpp

@@ -481,8 +481,10 @@ static void jcInputPrefix(uint8_t slot, uint8_t *out)
 	out[2] = (uint8_t)(jc);
 	out[3] = (uint8_t)(jc >> 8);
 	out[4] = (uint8_t)(jc >> 16);
-	jcPackStick(out + 5, g_in[bond].lx, g_in[bond].ly);
-	jcPackStick(out + 8, g_in[bond].rx, g_in[bond].ry);
+	int16_t lx, ly, rx, ry;
+	slotSticks(bond, &lx, &ly, &rx, &ry);
+	jcPackStick(out + 5, lx, ly);
+	jcPackStick(out + 8, rx, ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.
 	out[11] = 0x09;
 }
@@ -912,6 +914,7 @@ uint8_t SwitchProController::maxSlots() const
 void SwitchProController::usbIdentity()
 {
 	USBDevice.setID(0x057E, 0x2009);
+	USBDevice.setVersion(0x0200);
 	USBDevice.setDeviceVersion(0x0220);
 	USBDevice.setManufacturerDescriptor("Nintendo Co., Ltd.");
 	USBDevice.setProductDescriptor("Pro Controller");

+ 804 - 0
OpenPuck/mode_xbox_og.cpp

@@ -0,0 +1,804 @@
+#include "mode_xbox_og.h"
+#include "triton.h"
+#include "gamepad_util.h"
+#include "config.h"
+#include "haptics.h"
+#include "bonds.h"
+#include "usb_tx.h"
+#include "usb_app_drivers.h"
+#include <Adafruit_TinyUSB.h>
+#include <Arduino.h>
+#include <string.h>
+
+#define XBOX_OG_DESC_LEN (9 + 7 + 7)
+#define XBOX_OG_PACKET_SIZE 32
+#define XBOX_OG_CONN_MS 1200u
+
+enum {
+	XBOX_OG_DUP = 0x01,
+	XBOX_OG_DDOWN = 0x02,
+	XBOX_OG_DLEFT = 0x04,
+	XBOX_OG_DRIGHT = 0x08,
+	XBOX_OG_START = 0x10,
+	XBOX_OG_BACK = 0x20,
+	XBOX_OG_L3 = 0x40,
+	XBOX_OG_R3 = 0x80,
+};
+
+#pragma pack(push, 1)
+struct XboxOgInputReport {
+	uint8_t reserved;
+	uint8_t length;
+	uint8_t buttons;
+	uint8_t reserved2;
+	uint8_t a;
+	uint8_t b;
+	uint8_t x;
+	uint8_t y;
+	uint8_t black;
+	uint8_t white;
+	uint8_t left_trigger;
+	uint8_t right_trigger;
+	int16_t left_x;
+	int16_t left_y;
+	int16_t right_x;
+	int16_t right_y;
+};
+
+struct XboxOgOutputReport {
+	uint8_t reserved;
+	uint8_t length;
+	uint16_t left_motor;
+	uint16_t right_motor;
+};
+#pragma pack(pop)
+
+static const uint8_t XID_DESCRIPTOR[] = {
+	0x10, 0x42, 0x00, 0x01, 0x01, 0x02, 0x14, 0x06,
+	0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+static const uint8_t XID_CAPABILITIES_IN[] = {
+	0x00, 0x14, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+	0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+static const uint8_t XID_CAPABILITIES_OUT[] = {
+	0x00, 0x06, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+
+static_assert(sizeof(XboxOgInputReport) == 20,
+	      "Original Xbox input report must be 20 bytes");
+static_assert(sizeof(XboxOgOutputReport) == 6,
+	      "Original Xbox output report must be 6 bytes");
+static_assert(sizeof(XID_DESCRIPTOR) == 16, "XID descriptor must be 16 bytes");
+static_assert(sizeof(XID_CAPABILITIES_IN) == 20,
+	      "XID input capabilities must be 20 bytes");
+static_assert(sizeof(XID_CAPABILITIES_OUT) == 6,
+	      "XID output capabilities must be 6 bytes");
+
+struct XboxOgSlot {
+	uint8_t interface_number;
+	uint8_t endpoint_in;
+	uint8_t endpoint_out;
+	// The loop owns bond selection and is its only reader -- it snapshots
+	// bond_slot when relaying rumble; lifecycle callbacks only clear it.
+	volatile int8_t bond_slot;
+	// TinyUSB callbacks publish endpoint readiness from the usbd task; the
+	// loop owns pending input state.
+	volatile bool in_use;
+	volatile bool tx_pending;
+	// TinyUSB ignores xfer_cb's return value, so the loop drain must remember
+	// and recover a failed runtime OUT rearm.
+	volatile bool output_rearm_pending;
+
+	// The loop publishes complete reports under PRIMASK; USB only snapshots it.
+	XboxOgInputReport latest_report;
+	// These four are owned by the USB peripheral, not by a lock, so the
+	// rules below hold only while the endpoints are live -- bus reset and
+	// deinit clear them wholesale. See xboxOgClearSlot().
+	// EP0 IN owns this snapshot until the control transfer completes.
+	uint8_t control_in_buffer[sizeof(XboxOgInputReport)];
+	// The loop may replace this only while the interrupt IN endpoint is idle.
+	CFG_TUD_MEM_ALIGN uint8_t tx_buffer[XBOX_OG_PACKET_SIZE];
+	// Interrupt OUT DMA owns this buffer between arm and completion.
+	CFG_TUD_MEM_ALIGN uint8_t output_buffer[XBOX_OG_PACKET_SIZE];
+	// EP0 OUT owns this buffer from SET_REPORT setup through its data stage.
+	uint8_t control_out_buffer[sizeof(XboxOgOutputReport)];
+
+	// USB output callbacks publish this mailbox; the loop consumes it and
+	// relays to whichever slot is currently bonded when it does.
+	volatile uint16_t rumble_low;
+	volatile uint16_t rumble_high;
+	volatile bool rumble_pending;
+};
+
+static XboxOgSlot g_xboxOgSlot;
+static volatile uint32_t g_xboxOgGeneration;
+static unsigned long g_xboxOgNeutralMs;
+
+static void xboxOgNeutralReport(XboxOgInputReport &report)
+{
+	memset(&report, 0, sizeof report);
+	report.length = sizeof report;
+}
+
+// Wholesale clear, DMA buffers included. Every caller runs at a point where
+// no transfer is armed against them: mode setup runs before attach, init /
+// deinit / bus reset arrive after the peripheral has dropped the endpoints,
+// and xboxOgOpen clears before it arms the OUT endpoint (or after closing
+// both on the failure path).
+static void xboxOgClearSlot(void)
+{
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	g_xboxOgGeneration++;
+	memset(&g_xboxOgSlot, 0, sizeof g_xboxOgSlot);
+	g_xboxOgSlot.bond_slot = -1;
+	xboxOgNeutralReport(g_xboxOgSlot.latest_report);
+	__set_PRIMASK(primask);
+}
+
+static void xboxOgSnapshotReport(uint8_t *destination)
+{
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	memcpy(destination, &g_xboxOgSlot.latest_report,
+	       sizeof g_xboxOgSlot.latest_report);
+	__set_PRIMASK(primask);
+}
+
+static void xboxOgPublishReport(const XboxOgInputReport &report)
+{
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	memcpy(&g_xboxOgSlot.latest_report, &report, sizeof report);
+	g_xboxOgSlot.tx_pending = true;
+	__set_PRIMASK(primask);
+}
+
+static bool xboxOgPublishBondReport(const XboxOgInputReport &report, int slot,
+				    uint32_t generation)
+{
+	uint32_t primask = __get_PRIMASK();
+	bool published = false;
+
+	__disable_irq();
+	if (g_xboxOgGeneration == generation &&
+	    g_xboxOgSlot.bond_slot == slot) {
+		memcpy(&g_xboxOgSlot.latest_report, &report, sizeof report);
+		g_xboxOgSlot.tx_pending = true;
+		published = true;
+	}
+	__set_PRIMASK(primask);
+	return published;
+}
+
+static bool xboxOgApplyRumble(XboxOgSlot &slot, const uint8_t *data,
+			      uint16_t length)
+{
+	if (!data || length < sizeof(XboxOgOutputReport) || data[0] != 0x00 ||
+	    data[1] != sizeof(XboxOgOutputReport))
+		return false;
+
+	uint16_t low = (uint16_t)data[2] | ((uint16_t)data[3] << 8);
+	uint16_t high = (uint16_t)data[4] | ((uint16_t)data[5] << 8);
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	slot.rumble_low = low;
+	slot.rumble_high = high;
+	slot.rumble_pending = true;
+	__set_PRIMASK(primask);
+	return true;
+}
+
+static void xboxOgRetryOutputRearm(void)
+{
+	XboxOgSlot &slot = g_xboxOgSlot;
+	uint32_t generation = g_xboxOgGeneration;
+	uint8_t endpoint = slot.endpoint_out;
+
+	if (!tud_mounted() || !slot.in_use || !slot.output_rearm_pending ||
+	    endpoint == 0 || usbd_edpt_busy(0, endpoint) ||
+	    !usbd_edpt_claim(0, endpoint))
+		return;
+	if (usbd_edpt_xfer(0, endpoint, slot.output_buffer,
+			   sizeof slot.output_buffer)) {
+		uint32_t primask = __get_PRIMASK();
+
+		__disable_irq();
+		if (g_xboxOgGeneration == generation && slot.in_use &&
+		    slot.endpoint_out == endpoint)
+			slot.output_rearm_pending = false;
+		__set_PRIMASK(primask);
+	} else {
+		usbd_edpt_release(0, endpoint);
+	}
+}
+
+static void xboxOgTxDrain(void)
+{
+	XboxOgSlot &slot = g_xboxOgSlot;
+
+	xboxOgRetryOutputRearm();
+
+	if (!tud_mounted() || !slot.in_use || !slot.tx_pending ||
+	    slot.endpoint_in == 0 || usbd_edpt_busy(0, slot.endpoint_in))
+		return;
+	if (!usbd_edpt_claim(0, slot.endpoint_in))
+		return;
+
+	xboxOgSnapshotReport(slot.tx_buffer);
+	if (usbd_edpt_xfer(0, slot.endpoint_in, slot.tx_buffer,
+			   sizeof(XboxOgInputReport))) {
+		slot.tx_pending = false;
+	} else {
+		usbd_edpt_release(0, slot.endpoint_in);
+	}
+}
+
+// The console asks for these three before it accepts the controller: 0x06
+// with wValue 0x4200 is the XID descriptor, 0x01 with 0x0100/0x0200 the
+// input and output capabilities. Stalling them makes it refuse the pad.
+static bool xboxOgVendorRequest(const tusb_control_request_t *request)
+{
+	if (!g_xboxOgSlot.in_use || request->bmRequestType != 0xC1 ||
+	    request->wIndex != g_xboxOgSlot.interface_number)
+		return false;
+
+	if (request->bRequest == 0x06 && request->wValue == 0x4200)
+		return true;
+	if (request->bRequest != 0x01)
+		return false;
+	return request->wValue == 0x0100 || request->wValue == 0x0200;
+}
+
+bool xboxOgVendorControlXfer(uint8_t rhport, uint8_t stage,
+			     const tusb_control_request_t *request)
+{
+	if (!request || !xboxOgVendorRequest(request))
+		return false;
+	if (stage != CONTROL_STAGE_SETUP)
+		return true;
+
+	if (request->bRequest == 0x06)
+		return tud_control_xfer(rhport, request,
+					const_cast<uint8_t *>(XID_DESCRIPTOR),
+					sizeof XID_DESCRIPTOR);
+	if (request->wValue == 0x0100)
+		return tud_control_xfer(
+			rhport, request,
+			const_cast<uint8_t *>(XID_CAPABILITIES_IN),
+			sizeof XID_CAPABILITIES_IN);
+	return tud_control_xfer(rhport, request,
+				const_cast<uint8_t *>(XID_CAPABILITIES_OUT),
+				sizeof XID_CAPABILITIES_OUT);
+}
+
+// Controller S also carries input and rumble over EP0, in parallel with the
+// interrupt endpoints: GET_REPORT (0xA1/0x01, wValue 0x0100) and SET_REPORT
+// (0x21/0x09, wValue 0x0200).
+static bool xboxOgClassControlXfer(uint8_t rhport, uint8_t stage,
+				   const tusb_control_request_t *request)
+{
+	if (!request || !g_xboxOgSlot.in_use ||
+	    request->wIndex != g_xboxOgSlot.interface_number)
+		return false;
+
+	if (request->bmRequestType == 0xA1 && request->bRequest == 0x01 &&
+	    request->wValue == 0x0100) {
+		if (stage == CONTROL_STAGE_SETUP) {
+			xboxOgSnapshotReport(g_xboxOgSlot.control_in_buffer);
+			return tud_control_xfer(
+				rhport, request, g_xboxOgSlot.control_in_buffer,
+				sizeof g_xboxOgSlot.control_in_buffer);
+		}
+		return true;
+	}
+
+	if (request->bmRequestType != 0x21 || request->bRequest != 0x09 ||
+	    request->wValue != 0x0200 ||
+	    request->wLength != sizeof(XboxOgOutputReport))
+		return false;
+
+	if (stage == CONTROL_STAGE_SETUP) {
+		memset(g_xboxOgSlot.control_out_buffer, 0,
+		       sizeof g_xboxOgSlot.control_out_buffer);
+		return tud_control_xfer(rhport, request,
+					g_xboxOgSlot.control_out_buffer,
+					sizeof g_xboxOgSlot.control_out_buffer);
+	}
+	if (stage == CONTROL_STAGE_DATA)
+		return xboxOgApplyRumble(
+			g_xboxOgSlot, g_xboxOgSlot.control_out_buffer,
+			sizeof g_xboxOgSlot.control_out_buffer);
+	return true;
+}
+
+static void xboxOgInit(void)
+{
+	xboxOgClearSlot();
+}
+
+static bool xboxOgDeinit(void)
+{
+	xboxOgClearSlot();
+	return true;
+}
+
+static void xboxOgReset(uint8_t rhport)
+{
+	(void)rhport;
+	xboxOgClearSlot();
+}
+
+static bool xboxOgEndpointValid(const tusb_desc_endpoint_t *endpoint)
+{
+	return endpoint->bLength == sizeof(tusb_desc_endpoint_t) &&
+	       endpoint->bDescriptorType == TUSB_DESC_ENDPOINT &&
+	       endpoint->bmAttributes.xfer == TUSB_XFER_INTERRUPT &&
+	       endpoint->bmAttributes.sync == 0 &&
+	       endpoint->bmAttributes.usage == 0 &&
+	       (endpoint->bEndpointAddress & 0x70) == 0 &&
+	       tu_edpt_number(endpoint->bEndpointAddress) != 0 &&
+	       tu_le16toh(endpoint->wMaxPacketSize) == XBOX_OG_PACKET_SIZE &&
+	       endpoint->bInterval == 4;
+}
+
+static uint16_t xboxOgOpen(uint8_t rhport,
+			   const tusb_desc_interface_t *interface,
+			   uint16_t max_length)
+{
+	if (!interface || g_xboxOgSlot.in_use ||
+	    max_length < sizeof(tusb_desc_interface_t) ||
+	    interface->bLength != sizeof(tusb_desc_interface_t) ||
+	    interface->bDescriptorType != TUSB_DESC_INTERFACE ||
+	    interface->bAlternateSetting != 0 || interface->iInterface != 0 ||
+	    interface->bNumEndpoints != 2 ||
+	    interface->bInterfaceClass != 0x58 ||
+	    interface->bInterfaceSubClass != 0x42 ||
+	    interface->bInterfaceProtocol != 0x00)
+		return 0;
+
+	const uint8_t *start = reinterpret_cast<const uint8_t *>(interface);
+	const uint8_t *cursor = start + interface->bLength;
+	const uint8_t *end = start + max_length;
+	const tusb_desc_endpoint_t *endpoint_in = nullptr;
+	const tusb_desc_endpoint_t *endpoint_out = nullptr;
+	uint8_t endpoint_count = 0;
+
+	while (cursor < end) {
+		uint16_t remaining = (uint16_t)(end - cursor);
+
+		if (remaining < 2 || cursor[0] < 2 || cursor[0] > remaining)
+			return 0;
+		if (cursor[1] == TUSB_DESC_INTERFACE) {
+			if (cursor[0] < sizeof(tusb_desc_interface_t))
+				return 0;
+			break;
+		}
+		if (cursor[1] == TUSB_DESC_INTERFACE_ASSOCIATION) {
+			if (cursor[0] < sizeof(tusb_desc_interface_assoc_t))
+				return 0;
+			break;
+		}
+		if (cursor[1] == TUSB_DESC_ENDPOINT) {
+			if (cursor[0] < sizeof(tusb_desc_endpoint_t))
+				return 0;
+			const tusb_desc_endpoint_t *endpoint =
+				reinterpret_cast<const tusb_desc_endpoint_t *>(
+					cursor);
+
+			if (!xboxOgEndpointValid(endpoint) ||
+			    ++endpoint_count > interface->bNumEndpoints)
+				return 0;
+			if (tu_edpt_dir(endpoint->bEndpointAddress) ==
+			    TUSB_DIR_IN) {
+				if (endpoint_in)
+					return 0;
+				endpoint_in = endpoint;
+			} else {
+				if (endpoint_out)
+					return 0;
+				endpoint_out = endpoint;
+			}
+		}
+		cursor += cursor[0];
+	}
+
+	if (endpoint_count != 2 || !endpoint_in || !endpoint_out)
+		return 0;
+	if (!usbd_edpt_open(rhport, endpoint_in))
+		return 0;
+	if (!usbd_edpt_open(rhport, endpoint_out)) {
+		usbd_edpt_close(rhport, endpoint_in->bEndpointAddress);
+		return 0;
+	}
+
+	xboxOgClearSlot();
+	g_xboxOgSlot.interface_number = interface->bInterfaceNumber;
+	g_xboxOgSlot.endpoint_in = endpoint_in->bEndpointAddress;
+	g_xboxOgSlot.endpoint_out = endpoint_out->bEndpointAddress;
+	g_xboxOgSlot.in_use = true;
+	if (!usbd_edpt_xfer(rhport, g_xboxOgSlot.endpoint_out,
+			    g_xboxOgSlot.output_buffer,
+			    sizeof g_xboxOgSlot.output_buffer)) {
+		usbd_edpt_close(rhport, g_xboxOgSlot.endpoint_in);
+		usbd_edpt_close(rhport, g_xboxOgSlot.endpoint_out);
+		xboxOgClearSlot();
+		return 0;
+	}
+
+	return (uint16_t)(cursor - start);
+}
+
+static bool xboxOgXfer(uint8_t rhport, uint8_t endpoint, xfer_result_t result,
+		       uint32_t transferred)
+{
+	if (!g_xboxOgSlot.in_use)
+		return false;
+	if (endpoint == g_xboxOgSlot.endpoint_in)
+		return true;
+	if (endpoint != g_xboxOgSlot.endpoint_out)
+		return false;
+
+	if (result == XFER_RESULT_SUCCESS)
+		xboxOgApplyRumble(g_xboxOgSlot, g_xboxOgSlot.output_buffer,
+				  (uint16_t)transferred);
+	if (!g_xboxOgSlot.in_use)
+		return true;
+	if (!usbd_edpt_xfer(rhport, g_xboxOgSlot.endpoint_out,
+			    g_xboxOgSlot.output_buffer,
+			    sizeof g_xboxOgSlot.output_buffer)) {
+		uint32_t primask = __get_PRIMASK();
+
+		__disable_irq();
+		if (g_xboxOgSlot.in_use &&
+		    endpoint == g_xboxOgSlot.endpoint_out)
+			g_xboxOgSlot.output_rearm_pending = true;
+		__set_PRIMASK(primask);
+	}
+	return true;
+}
+
+static const usbd_class_driver_t g_xboxOgDriver = {
+#if CFG_TUSB_DEBUG >= 2
+	.name = "XID",
+#endif
+	.init = xboxOgInit,
+	.deinit = xboxOgDeinit,
+	.reset = xboxOgReset,
+	.open = xboxOgOpen,
+	.control_xfer_cb = xboxOgClassControlXfer,
+	.xfer_cb = xboxOgXfer,
+	.sof = nullptr,
+};
+
+const usbd_class_driver_t *xboxOgClassDriver(void)
+{
+	return &g_xboxOgDriver;
+}
+
+class Adafruit_USBD_XboxOg : public Adafruit_USBD_Interface {
+    public:
+	uint16_t getInterfaceDescriptor(uint8_t, uint8_t *buffer,
+					uint16_t buffer_size) override
+	{
+		if (!buffer)
+			return XBOX_OG_DESC_LEN;
+		if (buffer_size < XBOX_OG_DESC_LEN)
+			return 0;
+
+		uint8_t interface_number = TinyUSBDevice.allocInterface(1);
+		uint8_t endpoint_in = TinyUSBDevice.allocEndpoint(TUSB_DIR_IN);
+		uint8_t endpoint_out =
+			TinyUSBDevice.allocEndpoint(TUSB_DIR_OUT);
+		const uint8_t descriptor[XBOX_OG_DESC_LEN] = {
+			9,
+			TUSB_DESC_INTERFACE,
+			interface_number,
+			0,
+			2,
+			0x58,
+			0x42,
+			0x00,
+			0,
+			7,
+			TUSB_DESC_ENDPOINT,
+			endpoint_in,
+			TUSB_XFER_INTERRUPT,
+			U16_TO_U8S_LE(XBOX_OG_PACKET_SIZE),
+			4,
+			7,
+			TUSB_DESC_ENDPOINT,
+			endpoint_out,
+			TUSB_XFER_INTERRUPT,
+			U16_TO_U8S_LE(XBOX_OG_PACKET_SIZE),
+			4,
+		};
+
+		memcpy(buffer, descriptor, sizeof descriptor);
+		return sizeof descriptor;
+	}
+
+	bool begin()
+	{
+		return TinyUSBDevice.addInterface(*this);
+	}
+};
+
+static Adafruit_USBD_XboxOg g_xboxOgInterface;
+
+XboxOgController g_xboxOgCtl;
+
+enum XboxOgRemapTarget : uint8_t {
+	XBOX_OG_TARGET_NONE,
+	XBOX_OG_TARGET_A,
+	XBOX_OG_TARGET_B,
+	XBOX_OG_TARGET_X,
+	XBOX_OG_TARGET_Y,
+	XBOX_OG_TARGET_WHITE,
+	XBOX_OG_TARGET_BLACK,
+	XBOX_OG_TARGET_L3,
+	XBOX_OG_TARGET_R3,
+	XBOX_OG_TARGET_BACK,
+	XBOX_OG_TARGET_START,
+	XBOX_OG_TARGET_DUP,
+	XBOX_OG_TARGET_DDOWN,
+	XBOX_OG_TARGET_DLEFT,
+	XBOX_OG_TARGET_DRIGHT,
+};
+
+// Remaps target Controller S outputs independently of the RF button masks.
+// Codes come from the shared remap code space: 5/6 (LB/RB) drive White/Black,
+// 9/10 are semantic Back/Start, and 11 (Guide) has no Controller S target.
+static constexpr XboxOgRemapTarget XBOX_OG_REMAP[] = {
+	XBOX_OG_TARGET_NONE,   XBOX_OG_TARGET_A,     XBOX_OG_TARGET_B,
+	XBOX_OG_TARGET_X,      XBOX_OG_TARGET_Y,     XBOX_OG_TARGET_WHITE,
+	XBOX_OG_TARGET_BLACK,  XBOX_OG_TARGET_L3,    XBOX_OG_TARGET_R3,
+	XBOX_OG_TARGET_BACK,   XBOX_OG_TARGET_START, XBOX_OG_TARGET_NONE,
+	XBOX_OG_TARGET_DUP,    XBOX_OG_TARGET_DDOWN, XBOX_OG_TARGET_DLEFT,
+	XBOX_OG_TARGET_DRIGHT,
+};
+
+static_assert(sizeof XBOX_OG_REMAP / sizeof XBOX_OG_REMAP[0] == 16,
+	      "Xbox remap table must cover codes 0 through 15");
+
+static void xboxOgApplyRemap(XboxOgInputReport &report, uint8_t code)
+{
+	uint32_t mapped = tritonFromCode(code);
+
+	// LT/RT are codes 19/20, outside the table -- these two must stay above
+	// the bounds check or a paddle mapped to a trigger does nothing.
+	if (mapped & TB_L2)
+		report.left_trigger = 0xFF;
+	if (mapped & TB_R2)
+		report.right_trigger = 0xFF;
+	if (code >= sizeof XBOX_OG_REMAP / sizeof XBOX_OG_REMAP[0])
+		return;
+
+	switch (XBOX_OG_REMAP[code]) {
+	case XBOX_OG_TARGET_A:
+		report.a = 0xFF;
+		break;
+	case XBOX_OG_TARGET_B:
+		report.b = 0xFF;
+		break;
+	case XBOX_OG_TARGET_X:
+		report.x = 0xFF;
+		break;
+	case XBOX_OG_TARGET_Y:
+		report.y = 0xFF;
+		break;
+	case XBOX_OG_TARGET_WHITE:
+		report.white = 0xFF;
+		break;
+	case XBOX_OG_TARGET_BLACK:
+		report.black = 0xFF;
+		break;
+	case XBOX_OG_TARGET_L3:
+		report.buttons |= XBOX_OG_L3;
+		break;
+	case XBOX_OG_TARGET_R3:
+		report.buttons |= XBOX_OG_R3;
+		break;
+	case XBOX_OG_TARGET_BACK:
+		report.buttons |= XBOX_OG_BACK;
+		break;
+	case XBOX_OG_TARGET_START:
+		report.buttons |= XBOX_OG_START;
+		break;
+	case XBOX_OG_TARGET_DUP:
+		report.buttons |= XBOX_OG_DUP;
+		break;
+	case XBOX_OG_TARGET_DDOWN:
+		report.buttons |= XBOX_OG_DDOWN;
+		break;
+	case XBOX_OG_TARGET_DLEFT:
+		report.buttons |= XBOX_OG_DLEFT;
+		break;
+	case XBOX_OG_TARGET_DRIGHT:
+		report.buttons |= XBOX_OG_DRIGHT;
+		break;
+	case XBOX_OG_TARGET_NONE:
+	default:
+		break;
+	}
+}
+
+static void xboxOgBuildReport(XboxOgInputReport &report, const uint8_t *raw)
+{
+	uint32_t buttons = btnsOf(raw);
+
+	xboxOgNeutralReport(report);
+	if (buttons & TB_DUP)
+		report.buttons |= XBOX_OG_DUP;
+	if (buttons & TB_DDN)
+		report.buttons |= XBOX_OG_DDOWN;
+	if (buttons & TB_DLF)
+		report.buttons |= XBOX_OG_DLEFT;
+	if (buttons & TB_DRT)
+		report.buttons |= XBOX_OG_DRIGHT;
+	// The bit names are the reverse of the physical labels: the left
+	// button (View) arrives as TB_MENU and the right one as TB_VIEW.
+	// Back is the left button on an Original Xbox, so this is positional.
+	if (buttons & TB_VIEW)
+		report.buttons |= XBOX_OG_START;
+	if (buttons & TB_MENU)
+		report.buttons |= XBOX_OG_BACK;
+	if (buttons & TB_L3)
+		report.buttons |= XBOX_OG_L3;
+	if (buttons & TB_R3)
+		report.buttons |= XBOX_OG_R3;
+
+	// A/B/X/Y and White/Black are analog pressure bytes, so a digital press
+	// writes full scale; the shoulders have no button bit at all.
+	if (buttons & TB_A)
+		(g_abSwap ? report.b : report.a) = 0xFF;
+	if (buttons & TB_B)
+		(g_abSwap ? report.a : report.b) = 0xFF;
+	if (buttons & TB_X)
+		(g_abSwap ? report.y : report.x) = 0xFF;
+	if (buttons & TB_Y)
+		(g_abSwap ? report.x : report.y) = 0xFF;
+	if (buttons & TB_LB)
+		report.white = 0xFF;
+	if (buttons & TB_RB)
+		report.black = 0xFF;
+
+	report.left_trigger = trigU8(u16off(raw, 4));
+	report.right_trigger = trigU8(u16off(raw, 6));
+	if (buttons & TB_L2)
+		report.left_trigger = 0xFF;
+	if (buttons & TB_R2)
+		report.right_trigger = 0xFF;
+
+	const uint32_t paddle_buttons[] = { TB_L4, TB_R4, TB_L5, TB_R5 };
+
+	for (int i = 0; i < 4; i++)
+		if (buttons & paddle_buttons[i])
+			xboxOgApplyRemap(report, g_back[i]);
+	if ((buttons & TB_QAM) && g_qamMap)
+		xboxOgApplyRemap(report, g_qamMap);
+
+	// Raw-report offsets, not slotSticks(): like mode_xinput this mode decodes 0x45 in place and never
+	// touches g_in. 16/18 = left pad X/Y, 22/24 = right pad X/Y.
+	int16_t lx = (int16_t)s16off(raw, 8), ly = (int16_t)s16off(raw, 10),
+		rx = (int16_t)s16off(raw, 12), ry = (int16_t)s16off(raw, 14);
+	padStickBlend(buttons, (int16_t)s16off(raw, 16),
+		      (int16_t)s16off(raw, 18), (int16_t)s16off(raw, 22),
+		      (int16_t)s16off(raw, 24), &lx, &ly, &rx, &ry);
+	le16(reinterpret_cast<uint8_t *>(&report.left_x), lx);
+	le16(reinterpret_cast<uint8_t *>(&report.left_y), ly);
+	le16(reinterpret_cast<uint8_t *>(&report.right_x), rx);
+	le16(reinterpret_cast<uint8_t *>(&report.right_y), ry);
+}
+
+static bool xboxOgBondRecent(int slot, unsigned long now)
+{
+	return slot >= 0 && slot < NSLOT && g_slot[slot].used &&
+	       g_connReplyMs[slot] != 0 &&
+	       (unsigned long)(now - g_connReplyMs[slot]) < XBOX_OG_CONN_MS;
+}
+
+static void xboxOgUpdateBond(void)
+{
+	XboxOgSlot &state = g_xboxOgSlot;
+	unsigned long now = millis();
+	int current = state.bond_slot;
+
+	if (xboxOgBondRecent(current, now))
+		return;
+
+	if (current >= 0) {
+		XboxOgInputReport neutral;
+
+		state.bond_slot = -1;
+		xboxOgNeutralReport(neutral);
+		xboxOgPublishReport(neutral);
+		g_xboxOgNeutralMs = now;
+	}
+	for (int slot = 0; slot < NSLOT; slot++)
+		if (xboxOgBondRecent(slot, now)) {
+			state.bond_slot = (int8_t)slot;
+			break;
+		}
+}
+
+// Fire-and-forget: no stop is tracked or sent here. The shared relay in
+// haptics.cpp (burst, reconnect scrub, per-slot watchdog) stops the motor.
+static void xboxOgHandleRumble(void)
+{
+	XboxOgSlot &state = g_xboxOgSlot;
+	uint16_t low, high;
+	bool pending;
+	int8_t target;
+	uint32_t primask = __get_PRIMASK();
+
+	__disable_irq();
+	pending = state.rumble_pending;
+	low = state.rumble_low;
+	high = state.rumble_high;
+	target = state.bond_slot;
+	state.rumble_pending = false;
+	__set_PRIMASK(primask);
+
+	if (!pending || target < 0 || !g_rumble)
+		return;
+	hapticSteamRumble(low, high, (uint8_t)target);
+}
+
+void XboxOgController::begin()
+{
+	xboxOgClearSlot();
+	g_xboxOgNeutralMs = 0;
+	usbIdentity();
+	USBDevice.setConfigurationMaxPower(100);
+	g_xboxOgInterface.begin();
+	usbTxRegisterDrain(xboxOgTxDrain);
+}
+
+void XboxOgController::onReport45(int slot, const uint8_t *rep, bool fresh,
+				  uint8_t bodyTlen)
+{
+	(void)fresh;
+	if (!rep || slot < 0 || slot >= NSLOT || bodyTlen < 18 ||
+	    (rep[0] != 0x45 && rep[0] != 0x42) ||
+	    g_xboxOgSlot.bond_slot != slot)
+		return;
+
+	uint32_t generation = g_xboxOgGeneration;
+	XboxOgInputReport report;
+
+	xboxOgBuildReport(report, rep);
+	xboxOgPublishBondReport(report, slot, generation);
+}
+
+void XboxOgController::task()
+{
+	xboxOgUpdateBond();
+	xboxOgHandleRumble();
+
+	if (g_xboxOgSlot.bond_slot >= 0 ||
+	    (unsigned long)(millis() - g_xboxOgNeutralMs) < USB_STREAM_MS)
+		return;
+
+	XboxOgInputReport neutral;
+
+	g_xboxOgNeutralMs = millis();
+	xboxOgNeutralReport(neutral);
+	xboxOgPublishReport(neutral);
+}
+
+void XboxOgController::usbIdentity()
+{
+	USBDevice.setID(0x045E, 0x0289);
+	USBDevice.setDeviceVersion(0x0121);
+	// A real Controller S sends no string descriptors (iManufacturer /
+	// iProduct / iSerial = 0); hosts name it from usb.ids. TinyUSB pins
+	// those indices non-zero, so mirror what usb.ids shows for 045E:0289.
+	USBDevice.setManufacturerDescriptor("Microsoft Corp.");
+	USBDevice.setProductDescriptor("Xbox Controller S");
+}

+ 15 - 0
OpenPuck/mode_xbox_og.h

@@ -0,0 +1,15 @@
+// mode_xbox_og.h -- Original Xbox Controller S XID personality.
+#pragma once
+
+#include "controllers.h"
+
+class XboxOgController : public IController {
+    public:
+	void begin() override;
+	void onReport45(int slot, const uint8_t *rep, bool fresh,
+			uint8_t bodyTlen) override;
+	void task() override;
+	void usbIdentity() override;
+};
+
+extern XboxOgController g_xboxOgCtl;

+ 14 - 11
OpenPuck/mode_xinput.cpp

@@ -17,13 +17,10 @@
 #include "bonds.h"
 #include "usb_mount.h"
 #include "usb_tx.h"
+#include "usb_app_drivers.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
 #include <string.h>
-// custom class-driver API for the XInput interface
-extern "C" {
-#include "device/usbd_pvt.h"
-}
 
 XboxController g_xboxCtl;
 
@@ -193,9 +190,8 @@ static const usbd_class_driver_t g_xiDriver = {
 	.xfer_cb = xi_xfer,
 	.sof = NULL
 };
-extern "C" const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *count)
+const usbd_class_driver_t *xinputClassDriver(void)
 {
-	*count = 1;
 	return &g_xiDriver;
 }
 
@@ -400,10 +396,14 @@ static void rfXboxGamepad(uint8_t slot, const uint8_t *r)
 		else if (bc[i] == 20)
 			rt = 0xFF;
 	}
-	xinputSend(slot, btn, lt, rt, (int16_t)s16off(r, 8),
-		   (int16_t)s16off(r, 10), // L stick X/Y
-		   (int16_t)s16off(r, 12),
-		   (int16_t)s16off(r, 14)); // R stick X/Y
+	// Raw-report offsets, not slotSticks(): this mode already decodes 0x45 in place and never
+	// touches g_in. 16/18 = left pad X/Y, 22/24 = right pad X/Y (same pair rfXboxMouse reads).
+	int16_t lx = (int16_t)s16off(r, 8), ly = (int16_t)s16off(r, 10),
+		rx = (int16_t)s16off(r, 12), ry = (int16_t)s16off(r, 14);
+	padStickBlend(b, (int16_t)s16off(r, 16), (int16_t)s16off(r, 18),
+		      (int16_t)s16off(r, 22), (int16_t)s16off(r, 24), &lx, &ly,
+		      &rx, &ry);
+	xinputSend(slot, btn, lt, rt, lx, ly, rx, ry);
 }
 // Right pad -> mouse on a second HID-mouse interface alongside the XInput gamepad. Same glide model as Lizard's
 // right pad; RPad click = left button, LPad click = right. SINGLE shared mouse: the desktop can only consume
@@ -415,7 +415,9 @@ static void rfXboxMouse(const uint8_t *r)
 	static bool prt = false;
 	static float vx = 0, vy = 0, rmx = 0, rmy = 0;
 	static uint8_t pmb = 0;
-	bool rtouch = b & TB_RPADT;
+	// The right pad drives a stick now, so it must not also glide the mouse -- treat it as untouched and let
+	// the residual velocity decay away instead of stopping dead.
+	bool rtouch = (g_padStick[1] == PS_OFF) && (b & TB_RPADT);
 	int rx = s16off(r, 22), ry = s16off(r, 24);
 	if (rtouch) {
 		if (prt) {
@@ -479,6 +481,7 @@ void XboxController::usbIdentity()
 {
 	// 045E:028E -> Windows xusb / SDL / Linux xpad all bind it
 	USBDevice.setID(0x045E, 0x028E);
+	USBDevice.setVersion(0x0200);
 	USBDevice.setDeviceVersion(0x0120);
 	USBDevice.setManufacturerDescriptor("Microsoft");
 	USBDevice.setProductDescriptor("Controller");

+ 191 - 202
OpenPuck/puck_hid.cpp

@@ -7,7 +7,9 @@
 #include "rf_link.h"
 #include "triton.h"
 #include "mode_lizard.h"
+#include "steam_commands.h"
 #include "wake_hid.h"
+#include "build_info.h"
 #include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
@@ -123,18 +125,6 @@ static const uint8_t PUCK_LIZARD_HID_DESC[] = {
 
 static Adafruit_USBD_HID hid[NSLOT];
 
-// Drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS in Steam mode (console "S81" toggles; on by default). It's
-// the confirmed amp-clicker in Steam's per-connect config and OpenPuck doesn't need it (own input translation
-// + id9=0 keepalive). Reversible from the console if it regresses.
-bool g_drop81 = true;
-
-// Per-slot shadow of the controller's SET_SETTINGS_VALUES array (id-indexed u16, ids 0..0x52). Steam writes
-// it with 0x87 and READS IT BACK with 0x89 to verify its config "took"; on the real puck the controller
-// answers 0x89 from this array. OpenPuck can't relay-and-return over the NO-ACK RF link, so it maintains the
-// shadow here and answers 0x89 from it -- WITHOUT this, Steam's verify never matches and it re-clears (0x81)
-// + re-writes (0x87) forever = the connect config storm that buzzes the amp and floods the usbd task.
-static uint16_t g_setShadow[NSLOT][0x53];
-
 // ---- feature-command capture (diagnostic) ---------------------------------------------------------------
 // Log the USB feature command channel (Steam's SET/GET) to serial to see the connect handshake -- WITHOUT a
 // Serial.printf on the fragile 800B usbd task (that path can blow the stack -> LOCKUP; it's why production
@@ -242,8 +232,14 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 	// Steam OUTPUT reports 0x80-0x89. The haptic/actuator reports (0x80-0x86) are relayed to the controller,
 	// and ONLY when they arrive on the CONNECTED slot's interface: we have one controller but expose 4 puck
 	// slots, and a report aimed at a DIFFERENT slot made the controller buzz at random (the slot gate below
-	// fixes that). Must NOT clamp to 0x82-only: ping/grip/test haptics ride other report IDs (0x85/0x86). The
-	// 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and reach the controller via the
+	// fixes that). Must NOT clamp to 0x82-only -- Steam drives the actuators through the WHOLE Triton OUTPUT
+	// report space (ValveTritonOutReportMessageIDs, SDL steam/controller_structs.h; the ids match this
+	// interface's descriptor payload sizes exactly):
+	//   0x80 HAPTIC_RUMBLE(9)  0x81 HAPTIC_PULSE(7)   0x82 HAPTIC_COMMAND(3)
+	//   0x83 HAPTIC_LFO_TONE(9) 0x84 HAPTIC_LOG_SWEEP(8) 0x85 HAPTIC_SCRIPT(3)  0x86 (3, unnamed)
+	// These ids are NOT the feature-0x01 command ids (controller_constants.h) -- same numbers, different
+	// meanings -- so a rule written for one channel must never be applied to the other.
+	// The 63-byte settings/config reports 0x87/0x88/0x89 are NOT haptics and reach the controller via the
 	// feature-0x01 passthrough path instead.
 	if (type == HID_REPORT_TYPE_OUTPUT) {
 		if (rid >= 0x80 && rid <= 0x89) {
@@ -259,25 +255,25 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		// exact condition under which Steam loops the same haptic command (-> connect/wake buzz loop).
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
-		// Drop the OUTPUT-report form of 0x81 too (Steam's haptic-reset action, a 7-byte report id 0x81 with
-		// payloads 00../01..). Same amp-clicker as the feature-0x01 0x81; g_drop81 only covered the feature
-		// path, so this OUTPUT form was still leaking through and clicking. OpenPuck doesn't need it.
-		bool dropOut81 =
-			(g_drop81 && g_usbMode == MODE_STEAM && rid == 0x81);
-		if (g_hapticRelay && rid >= 0x80 && rid <= 0x86 && !dropOut81 &&
-		    n >= 1 && hapticRelaySlotOk(slot) && !lizardActive() &&
-		    !muted) {
+		// NOTE: report id 0x81 is NOT dropped here, unlike the feature-0x01 cmd 0x81. The two 0x81s live in
+		// DIFFERENT id spaces (SDL steam/controller_structs.h vs controller_constants.h):
+		//   OUTPUT  rid 0x81 = ID_OUT_REPORT_HAPTIC_PULSE, MsgHapticPulse {u8 side; u16 on_us; u16 off_us;
+		//                      u16 repeat_count} = the 7-byte "00.."/"01.." (side=left/right) reports Steam
+		//                      fires for trackpad CLICK feedback ("Regular Press"), the trigger FULL-PULL
+		//                      click and GripSense cues.
+		//   FEATURE cmd 0x81 = ID_CLEAR_DIGITAL_MAPPINGS (the mapping-engine reset, dropped below).
+		// Dropping the OUTPUT form as if it were the same command is what made those haptics missing
+		// (issues #163 / #166): "Soft Press" survived because it rides 0x82 ID_OUT_REPORT_HAPTIC_COMMAND,
+		// while every pulse-based click was thrown away. Pulses are self-terminating (repeat_count in the
+		// payload), so there is no latch to strand and no stop frame to lose.
+		if (g_hapticRelay && rid >= 0x80 && rid <= 0x86 && n >= 1 &&
+		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
 			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
 					     (uint8_t)(n > RELAY_MAXP ?
 							       RELAY_MAXP :
 							       n),
-					     (uint8_t)slot);
-				// Track on/off from what was actually RELAYED (= the controller's believed state): a
-				// BLOCKED stop must leave "on" set (controller may be latched -> reconnect stop-burst),
-				// a blocked ON must not set it (nothing reached the controller -> no spurious clicks).
-				if (rid == 0x82)
-					haptic82HostReport(b, n);
+					     true, (uint8_t)slot);
 			}
 		}
 
@@ -313,52 +309,78 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 	// command flood that (theory) overflows it lands in the post-mortem trail rather than being lost with USB.
 	faultDiagTrace(FR_SET, (uint16_t)((rid << 8) | cmd));
 
-	// settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
-	if (cmd >= 0x80 && cmd <= 0x89)
-		hostStampAlive();
+	// Disable lizard if requested by host.
+	if (cmd == IBEX_CMD_SET_SETTINGS_VALUES) {
+		// Host sent a settings write. Check if this is a write with the lizard suppression tag.
+		for (int off = 0; off <= len; off += 3) {
+			uint8_t settings_key = pl[off];
+			uint16_t settings_val = pl[off + 1] + 256 * pl[off + 2];
+			if (settings_key == SETTING_LIZARD_MODE &&
+			    settings_val == 0) {
+				hostStampAlive();
+				break;
+			}
+		}
+	}
+
 	// Controller power-off: Steam's "turn off controller" is feature-0x01 frame 9F 04 6F 66 66 21 ("off!"),
 	// confirmed from a real puck capture. The feature-0x01 relay below forwards it once; hapticSendShutdown
 	// bursts it for NO-ACK reliability. Slot-targeted: the command arrived on THIS controller's interface,
 	// so only this controller powers off (broadcasting killed all connected controllers at once).
-	if (rid == 1 && cmd == 0x9F)
+
+	// TODO: Why do we spam this? Doesn't the controller respond with a TAG4 upon command receival?
+	if (rid == 1 && cmd == IBEX_CMD_TURN_OFF_CONTROLLER)
 		hapticSendShutdown((uint8_t)slot);
 
 	// report 0x01 = raw passthrough -> queue for RF relay to the controller
 	if (rid == 1 && n >= 2) {
 		// (feature-1 commands -- haptics, LED, 0x87 settings, 0x9F power-off -- are captured by the
 		// general feature-SET hapLogAdd above.)
-		bool haptic82 = (cmd == 0x82 && len <= pln);
 
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 
-		// Steam-mode: DROP the relayed 0x81 CLEAR_DIGITAL_MAPPINGS (g_drop81, console "S81"). It EXECUTES on
-		// the controller (rid<0x87, legacy framing) and each one re-arms the haptic amp (non-idempotent, ibex
-		// func_0x0001bbf0) = the connect click/buzz; Steam sends ~13 in its per-connect config. OpenPuck does
-		// its OWN input translation and holds mappings cleared via the id9=0 keepalive, so it does NOT need the
-		// controller's mapping engine -> Steam's 0x81 is pure downside here. The manual "Clear stuck buzz"
-		// (hapticReinit) sends its own 0x81 via relayEnqueue directly, so that cure path is unaffected.
-		bool drop =
-			(g_drop81 && g_usbMode == MODE_STEAM && cmd == 0x81);
-		// Do NOT relay commands OpenPuck answers LOCALLY (identity/bond/settings READS). Relaying them to the
-		// controller is pointless (their reply can't come back over the NO-ACK RF link -- we answer from the
-		// handleSet/handleGet switch), and 0x83 GET_ATTRIBUTES in particular is < 0x87 so it EXECUTES on the
-		// controller -- captured as the remaining periodic CLICK, fired every time Steam re-polls identity
-		// (0x83/0xAE on all 4 interfaces). Only actuator/config commands (0x80-0x82/0x84-0x88 haptics+config,
-		// 0x9F power) should reach the controller.
-		bool localAnswer = (cmd == 0x83 || cmd == 0x89 || cmd == 0xAE ||
-				    cmd == 0xA2 || cmd == 0xA3 || cmd == 0xAD ||
-				    cmd == 0xB4 || cmd == 0xED || cmd == 0xA4);
-		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop).
-		// HR toggle (g_hapticRelay): when off, suppress the actuator/haptic range (0x80-0x86) -- the
-		// trackpad texture-feedback stream Steam pushes while dragging -- to isolate its cost on drag
-		// smoothness. Config (0x87/0x88) and power-off (0x9F) still relay so nothing else regresses.
-		bool hapticCmd = (cmd >= 0x80 && cmd <= 0x86);
-		bool relayOk = hapticRelaySlotOk(slot) && !drop &&
-			       !localAnswer &&
-			       !(haptic82 && (lizardActive() || muted)) &&
-			       !(hapticCmd && !g_hapticRelay);
-		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
+		// Reports in relayQuery will be forwarded to the controller if rid==1 and may be answered locally
+		// if it makes sense and rid==2.
+
+		// Reports in localAnswer will always be answered by the puck and not be forwarded.
+
+		// Forwarding uses the type-01 framing PLUS a trailing `01 03 00` (rfConnFlushRelay's `queryTrailer`,
+		// haptics.cpp -- without it the controller only ACKs and never answers), the controller replies --
+		// on a LATER poll -- with a tag-4 TLV carrying `[echoed cmd][len][payload]`, decoded in rf_link.cpp's
+		// F1 walk and written into this slot's `resp` (pendingQueryCmd, bonds.h).
+
+		// If rid==1:
+		// ID in relayQuery: Sent to controller, expect a response.
+		// ID in localAnswer: Not sent to controller, response generated by Puck.
+		// ID in neither: Sent to controller, no response expected.
+
+		// clang-format off
+
+		bool relayQuery =
+			(cmd == IBEX_CMD_GET_DIGITAL_MAPPINGS ||    // 0x82
+			 cmd == IBEX_CMD_GET_ATTRIBUTES_VALUES ||   // 0x83
+			 cmd == IBEX_CMD_GET_SETTINGS_VALUES ||     // 0x89
+			 cmd == IBEX_CMD_GET_SETTINGS_MAXS ||       // 0x8B
+			 cmd == IBEX_CMD_GET_SETTINGS_DEFAULTS ||   // 0x8C
+			 cmd == IBEX_CMD_GET_DEVICE_INFO ||         // 0xA1
+			 cmd == IBEX_CMD_GET_STRING_ATTRIBUTE ||    // 0xAE
+			 cmd == IBEX_CMD_GET_CHIPID ||              // 0xBA
+			 cmd == IBEX_CMD_TRITON_READ_SETTING ||     // 0xED
+			 cmd == IBEX_CMD_GET_SYSTEM_INFO);          // 0xF2
+
+		bool localAnswer =
+			(cmd == IBEX_CMD_TRITON_A2_OBSERVED_PAIRING_RECORD || // 0xA2
+			 cmd == IBEX_CMD_TRITON_A3_BOND_EVENT_OR_STATUS ||    // 0xA3
+			 cmd == IBEX_CMD_ENABLE_PAIRING ||                    // 0xAD
+			 cmd == IBEX_CMD_DONGLE_GET_WIRELESS_STATE ||         // 0xB4
+			 cmd == 0xA4); // Not sure what that's for?
+
+		// clang-format on
+
+		bool queryArmed = false;
+		bool relayOk = hapticRelaySlotOk(slot) && !localAnswer;
+		if (relayOk) {
 			// Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's
 			// multi-register 0x87 settings blocks (LED brightness) and calibration writes exceed the old
 			// 18B cap, and the chopped frames were why those settings never landed on the controller.
@@ -369,11 +391,26 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 					"# RELAY TRUNC cmd=%02X len=%u>%u\n",
 					cmd, len, (unsigned)RELAY_MAXP);
 #endif
-			relayEnqueue(cmd, pl, rl, (uint8_t)slot);
+			relayEnqueue(cmd, pl, rl, false, (uint8_t)slot,
+				     relayQuery);
 
-			// track from RELAYED frames only (see the OUTPUT path)
-			if (haptic82)
-				haptic82HostReport(pl, len);
+			if (relayQuery && slot >= 0 && slot < NSLOT) {
+				g_slot[slot].pendingQueryCmd = cmd;
+				queryArmed = true;
+			}
+		}
+		// No real controller answer is EVER coming for this SET: it wasn't a tracked query
+		// (haptics/0x87/0x9F/localAnswer/...), or it was one but couldn't relay right now (link down,
+		// blocked). `resp` must still be made to reflect CMD, not left holding whatever unrelated
+		// command's data happened to be cached there from an earlier request -- otherwise a later
+		// GET(rid=1), seeing pendingQueryCmd==0, would silently hand Steam stale/wrong data instead of
+		// a defined answer. Same echo shape the old always-run `default:` switch case used.
+		if (!queryArmed) {
+			S.resp[0] = cmd;
+			S.resp[1] = len;
+			if (pln)
+				memcpy(S.resp + 2, pl, pln > 60 ? 60 : pln);
+			S.resp_len = 63;
 		}
 	}
 #if OPK_LOG
@@ -387,64 +424,60 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		Serial.println();
 	}
 #endif
-	memset(S.resp, 0, sizeof S.resp);
-	S.resp_len = 0;
+	if (rid == 2) {
+		memset(S.resp, 0, sizeof S.resp);
+		S.resp_len = 0;
+	} else {
+		// If we forwarded the packet to the controller, do NOT clear the buffer.
+		// The controller will respond and put its data into the buffer.
+		return;
+		// rid == 1 will be handled by the controller, no need to respond here.
+	}
 	switch (cmd) {
-	case 0x83:
-		S.resp[0] = 0x83;
+	case IBEX_CMD_GET_ATTRIBUTES_VALUES: // 0x83
+		S.resp[0] = IBEX_CMD_GET_ATTRIBUTES_VALUES;
 		S.resp[1] = sizeof ATTR83;
 		memcpy(S.resp + 2, ATTR83, sizeof ATTR83);
-		// Report-id 1 = an attributes query RELAYED to the bonded CONTROLLER; it must report the CONTROLLER's
-		// product id (0x1302), NOT the puck/dongle's (0x1304) -- else Steam sees "a controller with a dongle's
-		// id" (HANDOFF.md) and drops to the LEGACY 0x81 CLEAR_DIGITAL_MAPPINGS init path, whose rapid re-arm
-		// storm is the connect buzz; with the correct id Steam uses the modern quiet 0x87 id9=0 path. Report-id
-		// 2 stays the puck (0x1304). ATTR83 stores product as u32 LE at offset 1, so only the low byte flips
-		// (0x04 -> 0x02); everything else in the blob is identical.
-		if (rid == 1)
-			S.resp[2 + 1] =
-				0x02; // product 0x1304 -> 0x1302 (controller)
 		S.resp_len = 63;
 		break;
-	case 0xAE: {
+	case IBEX_CMD_GET_STRING_ATTRIBUTE: { // 0xAE
 		uint8_t idx = pln > 0 ? pl[0] : 1;
-		// Report-id 1 = string attributes of the bonded CONTROLLER, not the puck. Steam matches the connected
-		// controller to its bond by SERIAL: it reads the controller's serial here (rid 1) and compares to the
-		// 16-byte serial in the bond record it reads via 0xA3. OpenPuck was returning the PUCK's serial (g_unit,
-		// "FXB..."), which never matches any bond's controller serial ("FXA...") -> Steam can't associate the
-		// controller with a puck -> "paired to" list is EMPTY -> Steam treats it as unconfigured and re-runs the
-		// 0x81/0x87 config storm every connect (the buzz). So on rid 1, answer with the CONTROLLER's serial from
-		// this interface's bond record (rec[8..24], the same 16-byte serial 0xA3 returns). Captured: Steam
-		// hammered this read (AE x39 on rid1) exactly because the identity never matched. rid 2 = puck (unchanged).
-		// idx 0/1/4 = board/unit/alt serial (real controller returns the SAME serial for 0 and 4; the clone
-		// was returning "NA" for idx 4, which failed Steam's read -> retry).
-		S.resp[0] = 0xAE;
-		S.resp[1] = 0x14;
+		// Report-id 1 = string attributes of the bonded CONTROLLER, not the puck. Not handled here, this request
+		// will have been forwarded to the controller by earlier code.
+		S.resp[0] = IBEX_CMD_GET_STRING_ATTRIBUTE;
+		S.resp[1] = 0x14; // todo: is this correct?
 		S.resp[2] = idx;
 		memset(S.resp + 3, 0, 60);
-		if (rid == 1 && slot >= 0 && slot < NSLOT &&
-		    g_slot[slot].used && (idx == 0 || idx == 1 || idx == 4)) {
-			// THIS slot's paired-controller serial, straight from its bond record (rec[8..24], the same
-			// 16-byte serial 0xA3 returns) -- per device, nothing hardcoded. Copied without a stack temp
-			// (this runs on the fragile 800B usbd task; every byte off the stack helps under a Steam
-			// re-enumeration burst).
-			memcpy(S.resp + 3, g_slot[slot].rec + 8, 16);
-		} else {
-			// rid 2 = the puck's own board/unit serials (device-derived). Any other idx -> "NA".
-			const char *s = (rid == 1)	       ? "NA" :
-					(idx == 0 || idx == 4) ? g_board :
-					(idx == 1)	       ? g_unit :
-								 "NA";
-			memcpy(S.resp + 3, s, strlen(s));
+		// Any other idx -> "NA".
+		const char *s = (idx == 0 || idx == 4) ? g_board :
+				(idx == 1)	       ? g_unit :
+				(idx == 3)	       ? "OpenPuck " :
+							 "NA";
+		memcpy(S.resp + 3, s, strlen(s));
+		if (idx == 3) {
+			// The official puck has a 12-character GIT commit hash of the firmware in here.
+			// Since I doubt any official process is ever going to parse / use this,
+			// looks like the perfect place to put the OpenPuck version number.
+			char *off = (char *)(S.resp + 3 + strlen(s));
+			memcpy(off, OPK_BUILD_VERSION,
+			       strlen(OPK_BUILD_VERSION));
+			off += strlen(OPK_BUILD_VERSION);
+			memcpy(off, " ", 1);
+			memcpy(off + 1, OPK_GIT_HASH, strlen(OPK_GIT_HASH));
+			if (OPK_GIT_DIRTY != 0) {
+				off += 1 + strlen(OPK_GIT_HASH);
+				memcpy(off, "-dirty", 6);
+			}
 		}
 		S.resp_len = 63;
 		break;
 	}
 
 	// connection/version state per slot: value 0x02 = controller connected, 0x01 = not
-	case 0xB4:
+	case IBEX_CMD_DONGLE_GET_WIRELESS_STATE: // 0xB4
 		// SDL Triton polls this on init; treat like Steam contact so we forward 0x45
 		hostStampAlive();
-		S.resp[0] = 0xB4;
+		S.resp[0] = IBEX_CMD_DONGLE_GET_WIRELESS_STATE;
 		S.resp[1] = 0x01;
 		// Report disconnected during the post-power-off hold so B4 agrees with the 0x79 disconnect (they used
 		// different windows -- 500ms here vs the 300ms conn/DOWN edge -- so right after a power-off Steam's B4
@@ -456,16 +489,17 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				    0x01;
 		S.resp_len = 63;
 		break;
-	case 0xAD:
+	case IBEX_CMD_ENABLE_PAIRING: // 0xAD
+		// TODO: Check if this should be relayed?
 		g_pairing = (pln > 0 && pl[0] != 0);
 #if OPK_LOG
 		Serial.printf("# pairing %s\n", g_pairing ? "ON" : "off");
 #endif
-		S.resp[0] = 0xAD;
+		S.resp[0] = IBEX_CMD_ENABLE_PAIRING;
 		S.resp[1] = 0;
 		S.resp_len = 63;
 		break;
-	case 0xA2: // write/clear THIS interface's slot
+	case IBEX_CMD_TRITON_A2_OBSERVED_PAIRING_RECORD: // 0xA2: write/clear THIS interface's slot
 		if (len >= 24 && pln >= 24) {
 			if (recEmpty(pl)) {
 				S.used = false;
@@ -480,98 +514,18 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 				      recEmpty(pl) ? "cleared" : "bonded");
 #endif
 		}
-		S.resp[0] = 0xA2;
+		S.resp[0] = IBEX_CMD_TRITON_A2_OBSERVED_PAIRING_RECORD;
 		S.resp[1] = 0;
 		S.resp_len = 63;
 		break;
-	case 0xA3: // read THIS interface's slot
-		S.resp[0] = 0xA3;
+	case IBEX_CMD_TRITON_A3_BOND_EVENT_OR_STATUS: // 0xA3: read THIS interface's slot
+		S.resp[0] = IBEX_CMD_TRITON_A3_BOND_EVENT_OR_STATUS;
 		S.resp[1] = 0x18;
 		memset(S.resp + 2, 0, 24);
 		if (S.used)
 			memcpy(S.resp + 2, S.rec, 24);
 		S.resp_len = 63;
 		break;
-	case 0x87:
-		// SET_SETTINGS_VALUES: payload = N x [id][val16 LE]. Shadow each into g_setShadow[slot] so the 0x89
-		// read-back below matches what Steam wrote -- this is THE fix that stops Steam's endless config-verify
-		// retry (the 0x81/0x87 storm that buzzes the amp). (Still relayed to the controller by the feature-1
-		// path above; this just maintains the dongle-side shadow the real controller would return on 0x89.)
-		// ACK with [0x87][0], not the default payload echo (a clean success reply, like the real dongle).
-		if (slot >= 0 && slot < NSLOT)
-			for (uint16_t i = 0; i + 2 < pln; i += 3) {
-				uint8_t id = pl[i];
-				if (id < 0x53)
-					g_setShadow[slot][id] =
-						(uint16_t)(pl[i + 1] |
-							   (pl[i + 2] << 8));
-			}
-		S.resp[0] = 0x87;
-		S.resp[1] = 0;
-		S.resp_len = 63;
-		break;
-	case 0x89: {
-		// GET_SETTINGS_VALUES: Steam reads back setting `id` (payload[0]) from the id-indexed array. Answer
-		// from the shadow 0x87 populated: [0x89][3][id][val16 LE]. (Steam didn't use 0x89 in the captured
-		// session -- it provisions via 0xED below -- but this is the correct real-dongle behavior; harmless.)
-		uint8_t id = (pln > 0) ? pl[0] : 0;
-		uint16_t v = (slot >= 0 && slot < NSLOT && id < 0x53) ?
-				     g_setShadow[slot][id] :
-				     0;
-		S.resp[0] = 0x89;
-		S.resp[1] = 3;
-		S.resp[2] = id;
-		S.resp[3] = (uint8_t)v;
-		S.resp[4] = (uint8_t)(v >> 8);
-		S.resp_len = 63;
-		break;
-	}
-	case 0xED: {
-		// GET-SETTING-BY-PATH (keyed). Steam SETs the path string, then GETs the value; the REAL controller
-		// returns the setting's value, but OpenPuck was ECHOING the path -> Steam sees the controller as
-		// un-provisioned/un-bonded and re-runs its full LEGACY config every connect (0x81 CLEAR_DIGITAL_MAPPINGS
-		// = the amp clicks/buzz; captured: 0xED reads of "esb/bond"/"user/wireless_transport" that got echoed).
-		// Answer the paths Steam checks at connect so it treats the controller as provisioned + bonded:
-		//   esb/bond          -> this slot's 24-byte bond record (the controller's record of ITS puck)
-		//   esb/bond_2        -> a 2nd-puck bond; OpenPuck bonds one puck per slot -> absent (empty)
-		//   user/wireless_transport -> 1 byte = the ACTIVE (connected) slot's transport code (see below)
-		// Anything else -> empty ([0xED][0]) rather than a garbage path echo.
-		const char *p = (const char *)pl;
-		uint16_t pl_n = pln;
-		auto pathIs = [&](const char *k) -> bool {
-			uint16_t kl = (uint16_t)strlen(k);
-			return pl_n >= kl && memcmp(p, k, kl) == 0 &&
-			       (pl_n == kl || p[kl] == 0);
-		};
-		S.resp[0] = 0xED;
-		if (slot >= 0 && slot < NSLOT && g_slot[slot].used &&
-		    pathIs("esb/bond")) {
-			S.resp[1] = 0x18;
-			memcpy(S.resp + 2, g_slot[slot].rec, 24);
-		} else if (pathIs("user/wireless_transport")) {
-			// Steam reads this (a CONTROLLER setting, relayed rid1) to mark which bond slot is ACTIVE in the
-			// pairing list, mapping active_slot = value XOR 2 (HANDOFF: slot0->0x02, slot1->0x03, slot2->0x00,
-			// slot3->0x01). It's a single DONGLE-WIDE value = the currently-connected slot, NOT this
-			// interface's own index (returning the raw index made Steam mark slot^2 active -> the live puck
-			// showed "Inactive"). Report the connected slot's code so Steam marks the live puck active.
-			int act = 0;
-			unsigned long best = 0, now = millis();
-			for (int s2 = 0; s2 < NSLOT; s2++)
-				if (g_slot[s2].used && g_connReplyMs[s2] &&
-				    (now - g_connReplyMs[s2]) < 1200u &&
-				    (best == 0 || g_connReplyMs[s2] > best)) {
-					best = g_connReplyMs[s2];
-					act = s2;
-				}
-			S.resp[1] = 1;
-			S.resp[2] = (uint8_t)(act ^ 2);
-		} else {
-			S.resp[1] =
-				0; // absent/empty -- valid "no value" reply, not a path echo
-		}
-		S.resp_len = 63;
-		break;
-	}
 	default:
 		S.resp[0] = cmd;
 		S.resp[1] = len;
@@ -584,7 +538,6 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
 			  uint8_t *buf, uint16_t reqlen)
 {
-	(void)rid;
 	if (type != HID_REPORT_TYPE_FEATURE)
 		return 0;
 
@@ -596,6 +549,37 @@ static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
 	// heartbeat ~every 3s, and 0x82 haptics), which stamp g_steamAliveMs on the handleSet paths. So drop the
 	// GET-based stamp entirely; a read alone no longer suppresses lizard.
 	Slot &S = g_slot[slot];
+
+	// A feature-01 query (0x83/0xAE/0xED, relayed for real when rid==1 -- see handleSet's `relayQuery`)
+	// is still waiting on the controller's actual reply: `resp` only holds the local placeholder for it
+	// right now (pendingQueryCmd, bonds.h; cleared by rf_link.cpp when the real answer lands).
+	// STALL this GET so the host retries shortly after (-EPIPE), like the real Puck does.
+	//
+	// Returning plain 0 does NOT stall here: this device's reports are all NUMBERED (report id 1/2), and
+	// TinyUSB's callback (adafruit/Adafruit_TinyUSB_Arduino, src/class/hid/hid_device.c)
+	// auto-prepends that report id byte into the reply BEFORE calling this callback --
+	//   uint16_t xferlen = 0;
+	//   if (report_id != HID_REPORT_TYPE_INVALID && req_len > 1) { *report_buf++ = report_id; xferlen++; }
+	//   xferlen += tud_hid_get_report_cb(...);   // <- our return value lands here
+	//   TU_ASSERT(xferlen > 0);                  // stalls the control transfer iff this is false
+	// -- so xferlen is already 1 before we're even asked, and adding our 0 leaves it at 1: the assert
+	// never fires and TinyUSB happily ships that lone prepended report-id byte as a "successful" 1-byte
+	// reply.
+	// This bug has been reported to upstream at https://github.com/hathach/tinyusb/issues/3814
+	//
+	// `xferlen` is uint16_t, so returning (uint16_t)-1 (0xFFFF) makes `1 + 0xFFFF` wrap to exactly 0
+	// -- landing on the SAME TU_ASSERT(xferlen > 0) failure a genuinely-empty callback would have hit,
+
+	// The additional check for rid is necessary so if the controller never replies, a new query for a
+	// puck feature can get the puck un-stuck.
+
+	// TODO: This is a very, very, very ugly solution and may break with library updates. Can we find a cleaner one?
+	if (rid == 1 && S.pendingQueryCmd != 0 && reqlen > 1) {
+		return 0xFFFF;
+	}
+
+	// rf_link.cpp will set pendingQueryCmd to 0 once the controller answered.
+
 	uint16_t n = S.resp_len ? S.resp_len : 63;
 	if (n > reqlen)
 		n = reqlen;
@@ -669,15 +653,20 @@ static setcb_t SETCB[NSLOT] = { setcb0, setcb1, setcb2, setcb3 };
 void SteamPuckController::begin()
 {
 	USBDevice.setID(0x28DE, 0x1304);
-	// Distinct bcdDevice so Windows keys a FRESH usbflags entry (cache is VID:PID:bcdDevice) and actually runs
-	// MS OS 2.0 / WinUSB binding for the WebUSB vendor interface, instead of reusing a stale "no WinUSB" entry
-	// tied to the real Steam Controller (28DE:1304, no WebUSB interface). The normal (wake-mouse) and one-shot
-	// debug (CDC) boots present DIFFERENT interface sets, so they need DIFFERENT bcdDevice values or Windows
-	// serves one's cached descriptor for the other across a reboot.
-	USBDevice.setDeviceVersion(
-		g_debugCdcThisBoot ?
-			0x0212 :
-			(g_usbMode == MODE_LIZARD ? 0x0213 : 0x0211));
+	USBDevice.setVersion(0x0201); // bcdUSB 2.01
+	USBDevice.setDeviceVersion(2);
+
+	// Distinct serial number so Windows keys a FRESH usbflags entry.
+	if (g_debugCdcThisBoot) {
+		snprintf(g_usbSerial, sizeof g_usbSerial, "%sC", g_unit);
+		USBDevice.setSerialDescriptor(g_usbSerial);
+	} else if (g_usbMode == MODE_LIZARD) {
+		snprintf(g_usbSerial, sizeof g_usbSerial, "%sL", g_unit);
+		USBDevice.setSerialDescriptor(g_usbSerial);
+	} else {
+		USBDevice.setSerialDescriptor(g_unit);
+	}
+
 	USBDevice.setManufacturerDescriptor("Valve Software");
 	USBDevice.setProductDescriptor("Steam Controller Puck");
 	const uint8_t *desc = (g_usbMode == MODE_LIZARD) ?

+ 0 - 4
OpenPuck/puck_hid.h

@@ -34,10 +34,6 @@ bool puckLizardActive();
 extern bool g_cmdCapture;
 void puckCmdLogDrain(void);
 
-// Drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS in Steam mode (the amp-clicker in Steam's per-connect
-// config; OpenPuck doesn't need it). On by default; console "S81" toggles for A/B.
-extern bool g_drop81;
-
 // Note that a controller power-off (0x9F "off!") was just relayed to `slot` (or ALL used slots for the 0xFF
 // broadcast). The puck then presents that slot as cleanly DISCONNECTED to Steam and HOLDS it there through the
 // controller's messy shutdown -- a real controller keeps streaming F1 for up to ~1s after the off command, and

+ 43 - 0
OpenPuck/rf_link.cpp

@@ -691,7 +691,50 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 									     i]);
 							Serial.println();
 						}
+					} else if ((ttype == 2 || ttype == 4) &&
+						   tlen >= 1 &&
+						   (size_t)(idx + 2) + tlen <=
+							   sizeof(rfrx)) {
+						// tag 0x02 ("control/status field") and tag 0x04 ("bulk data blob") --
+						// docs/PROTOCOL.md sec 7.3. CONFIRMED from a real puck<->controller capture
+						// (2026-08-10): tag-2 (`00 00 00 00`) is an immediate "request received" ack
+						// for a landed feature-01 query; tag-4, arriving on a LATER poll, carries the
+						// query's real answer as `[echoed report_id][len][payload]`. This is the reply
+						// channel for the feature-01 queries (0x83/0xAE/0xED) puck_hid.cpp now relays
+						// for real when rid==1 -- route it into whichever slot is waiting on exactly
+						// this cmd (pendingQueryCmd, bonds.h), so a stray/late/mismatched tag-4 can't
+						// clobber a slot that has moved on to a different query.
+						const uint8_t *rec =
+							&rfrx[idx + 2];
+						if (ttype == 4 && tlen >= 2 &&
+						    rec[0] != 0 &&
+						    (uint16_t)(2 + rec[1]) <=
+							    tlen &&
+						    rec[1] <= 61 &&
+						    g_curSlot >= 0 &&
+						    g_curSlot < NSLOT &&
+						    g_slot[g_curSlot].pendingQueryCmd ==
+							    rec[0]) {
+							// `resp`/`resp_len` are also written from handleSet (switch(cmd)) and
+							// read from handleGet -- both on the usbd task. Match the PRIMASK-guard
+							// pattern the rest of this file uses for usbd<->loop shared state
+							// (relayEnqueue/hapLogAdd/fcPush) so a GET_FEATURE can't observe a torn
+							// write mid-memcpy.
+							Slot &S =
+								g_slot[g_curSlot];
+							uint32_t pm =
+								__get_PRIMASK();
+							__disable_irq();
+							S.resp[0] = rec[0];
+							S.resp[1] = rec[1];
+							memcpy(S.resp + 2,
+							       rec + 2, rec[1]);
+							S.resp_len = 63;
+							S.pendingQueryCmd = 0;
+							__set_PRIMASK(pm);
+						}
 					}
+
 					idx += tlen + 2;
 				}
 				// mode-switch chord (back4 + face/dpad): A=always Steam; B/X/Y=configurable (g_chordBtn[]);

+ 2 - 22
OpenPuck/serial_console.cpp

@@ -35,12 +35,6 @@ void serialConsolePoll()
 				delay(40);
 				faultDiagArmIntentionalReset();
 				NVIC_SystemReset();
-			} else if (!strcmp(line, "AMP")) {
-				// A/B: land Steam's amp/haptic 0x87 config (0x18/0x2E/0x34/0x35) so haptics play as ticks
-				g_landAmp = !g_landAmp;
-				Serial.printf(
-					"# land amp/haptic 0x87 config %s\n",
-					g_landAmp ? "ON" : "off");
 			} else if (!strcmp(line, "CD")) {
 				// A/B: content dedup on the Steam input forward (drop reports whose body minus the
 				// free-running counter is unchanged) -- caps delivered rate at the controller's real
@@ -77,12 +71,6 @@ void serialConsolePoll()
 				Serial.printf(
 					"# haptic relay (Steam 0x80-0x86) %s\n",
 					g_hapticRelay ? "ON" : "off");
-			} else if (!strcmp(line, "S81")) {
-				// A/B: drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS (the connect amp-clicker)
-				g_drop81 = !g_drop81;
-				Serial.printf(
-					"# drop relayed 0x81 (Steam mode) %s\n",
-					g_drop81 ? "ON" : "off");
 			} else if (!strcmp(line, "FC")) {
 				// feature-command capture: log Steam's USB SET/GET commands to serial + suppress I45
 				g_cmdCapture = !g_cmdCapture;
@@ -90,14 +78,6 @@ void serialConsolePoll()
 					"# feature-cmd capture %s (I45 %s)\n",
 					g_cmdCapture ? "ON" : "off",
 					g_cmdCapture ? "suppressed" : "on");
-			} else if (!strcmp(line, "L87")) {
-				// EXPERIMENT: land all relayed 0x87 config verbatim (real-puck relay) vs the discard-whitelist.
-				// Exact-match string (single letters are all taken; 'l' alone = rfListenStart).
-				g_landAll87 = !g_landAll87;
-				saveCfg();
-				Serial.printf(
-					"# land-all-0x87 (verbatim 0x87 relay) %s\n",
-					g_landAll87 ? "ON" : "off");
 			} else if (!strcmp(line, "FR")) {
 				// re-dump the flight recorder trail captured before the last watchdog hang (also printed
 				// automatically at boot, but CDC may not be attached yet then -- this reprints on demand).
@@ -174,7 +154,7 @@ void serialConsolePoll()
 					g_lizKeep ? "ON" : "off");
 			}
 
-			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean 9=ps3(dualshock3)
+			// switch USB mode: 0=steam 1=xbox 2=hori 3=lizard 4=swpro 5=ps5 6=hidgyro 7=ps5-game/clean 8=ds4-game/clean 9=ps3(dualshock3) 10=original xbox
 			else if (line[0] == 'x') {
 				uint8_t m = strtoul(line + 1, 0, 10);
 				if (modeValid(m)) {
@@ -353,7 +333,7 @@ void serialConsolePoll()
 				uint8_t pl[3] = { id, (uint8_t)(val & 0xFF),
 						  (uint8_t)(val >> 8) };
 				// console-injected writes go to all connected controllers (no specific slot in scope)
-				relayEnqueue(0x87, pl, 3, 0xFF);
+				relayEnqueue(0x87, pl, 3, false, 0xFF);
 				Serial.printf(
 					"# queued SET-SETTINGS id=0x%02X val=%u (relay 0x87) — watch new=/s\n",
 					id, val);

+ 86 - 0
OpenPuck/steam_commands.h

@@ -0,0 +1,86 @@
+#pragma once
+
+// Feature report IDs
+// See SDL src/joystick/hidapi/steam/controller_constants.h
+// enum FeatureReportMessageIDs
+
+#define IBEX_CMD_SET_DIGITAL_MAPPINGS 0x80
+#define IBEX_CMD_CLEAR_DIGITAL_MAPPINGS 0x81
+#define IBEX_CMD_GET_DIGITAL_MAPPINGS 0x82
+#define IBEX_CMD_GET_ATTRIBUTES_VALUES 0x83
+#define IBEX_CMD_GET_ATTRIBUTE_LABEL 0x84
+#define IBEX_CMD_SET_DEFAULT_DIGITAL_MAPPINGS 0x85
+#define IBEX_CMD_FACTORY_RESET 0x86
+#define IBEX_CMD_SET_SETTINGS_VALUES 0x87
+#define IBEX_CMD_CLEAR_SETTINGS_VALUES 0x88
+#define IBEX_CMD_GET_SETTINGS_VALUES 0x89
+#define IBEX_CMD_GET_SETTING_LABEL 0x8A
+#define IBEX_CMD_GET_SETTINGS_MAXS 0x8B
+#define IBEX_CMD_GET_SETTINGS_DEFAULTS 0x8C
+#define IBEX_CMD_SET_CONTROLLER_MODE 0x8D
+#define IBEX_CMD_LOAD_DEFAULT_SETTINGS 0x8E
+#define IBEX_CMD_TRIGGER_HAPTIC_PULSE 0x8F
+#define IBEX_CMD_REBOOT_TO_BOOTLOADER 0x90
+#define IBEX_CMD_FIRMWARE_UPDATE_REBOOT 0x95
+#define IBEX_CMD_TURN_OFF_CONTROLLER 0x9F
+#define IBEX_CMD_GET_DEVICE_INFO 0xA1
+#define IBEX_CMD_TRITON_A2_OBSERVED_PAIRING_RECORD 0xA2
+#define IBEX_CMD_WRITE_CALIBRATION_DATA 0xA2
+#define IBEX_CMD_TRITON_A3_BOND_EVENT_OR_STATUS 0xA3
+#define IBEX_CMD_CALIBRATE_TRACKPADS 0xA7
+#define IBEX_CMD_SET_SERIAL_NUMBER 0xA9
+#define IBEX_CMD_GET_TRACKPAD_CALIBRATION 0xAA
+#define IBEX_CMD_GET_TRACKPAD_FACTORY_CALIBRATION 0xAB
+#define IBEX_CMD_GET_TRACKPAD_RAW_DATA 0xAC
+#define IBEX_CMD_ENABLE_PAIRING 0xAD
+#define IBEX_CMD_GET_STRING_ATTRIBUTE 0xAE
+#define IBEX_CMD_RADIO_ERASE_RECORDS 0xAF
+#define IBEX_CMD_RADIO_WRITE_RECORD 0xB0
+#define IBEX_CMD_SET_DONGLE_SETTING 0xB1
+#define IBEX_CMD_DONGLE_DISCONNECT_DEVICE 0xB2
+#define IBEX_CMD_DONGLE_COMMIT_DEVICE 0xB3
+#define IBEX_CMD_DONGLE_GET_WIRELESS_STATE 0xB4
+#define IBEX_CMD_CALIBRATE_GYRO 0xB5
+#define IBEX_CMD_PLAY_AUDIO 0xB6
+#define IBEX_CMD_AUDIO_UPDATE_START 0xB7
+#define IBEX_CMD_AUDIO_UPDATE_DATA 0xB8
+#define IBEX_CMD_AUDIO_UPDATE_COMPLETE 0xB9
+#define IBEX_CMD_GET_CHIPID 0xBA
+#define IBEX_CMD_GET_BATTERY_DATA 0xBE
+#define IBEX_CMD_CALIBRATE_JOYSTICK 0xBF
+#define IBEX_CMD_CALIBRATE_ANALOG_TRIGGERS 0xC0
+#define IBEX_CMD_SET_AUDIO_MAPPING 0xC1
+#define IBEX_CMD_CHECK_GYRO_FW_LOAD 0xC2
+#define IBEX_CMD_CALIBRATE_ANALOG 0xC3
+#define IBEX_CMD_DONGLE_GET_CONNECTED_SLOTS 0xC4
+#define IBEX_CMD_SET_LED_COLOR 0xC5 // ??
+#define IBEX_CMD_RESET_IMU 0xCE
+#define IBEX_CMD_CALIBRATE_TRACKPAD_STICK 0xD8
+#define IBEX_CMD_GET_USER_STORE 0xDB
+#define IBEX_CMD_SET_USER_STORE 0xDC
+#define IBEX_CMD_SET_TRACKPAD_SIDE 0xE2
+#define IBEX_CMD_GET_LED_COLOR 0xE9
+#define IBEX_CMD_TRIGGER_HAPTIC_CMD 0xEA // Deck only
+#define IBEX_CMD_TRIGGER_RUMBLE_CMD 0xEB // Deck only
+#define IBEX_CMD_TRITON_READ_SETTING 0xED
+#define IBEX_CMD_TRITON_STAGE_SETTING 0xEE // write
+#define IBEX_CMD_TRITON_COMMIT_SETTING 0xEF // save
+#define IBEX_CMD_TRITON_DELETE_SETTING 0xF0
+#define IBEX_CMD_GET_SYSTEM_INFO 0xF2
+#define IBEX_CMD_WRITE_PROVISIONING 0xFE
+
+// Controller setting IDs
+// See SDL src/joystick/hidapi/steam/controller_constants.h
+// enum ControllerSettings
+
+#define SETTING_LEFT_TRACKPAD_MODE 7
+#define SETTING_RIGHT_TRACKPAD_MODE 8
+#define SETTING_LIZARD_MODE 9
+#define SETTING_SMOOTH_ABSOLUTE_MOUSE 24 // 0x18
+#define SETTING_LED_USER_BRIGHTNESS 45 // 0x2d
+#define SETTING_ENABLE_RAW_JOYSTICK 46 // 0x2e
+#define SETTING_IMU_MODE 48 // 0x30
+#define SETTING_WIRELESS_PACKET_VERSION 49 // 0x31
+#define SETTING_LEFT_TRACKPAD_CLICK_PRESSURE 52 // 0x34
+#define SETTING_RIGHT_TRACKPAD_CLICK_PRESSURE 53 // 0x35
+#define SETTING_COUNT 82 // 0x52 - todo: is this really correct?

+ 12 - 0
OpenPuck/usb_app_drivers.cpp

@@ -0,0 +1,12 @@
+#include "usb_app_drivers.h"
+
+extern "C" const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *count)
+{
+	static const usbd_class_driver_t drivers[] = {
+		*xinputClassDriver(),
+		*xboxOgClassDriver(),
+	};
+
+	*count = sizeof(drivers) / sizeof(drivers[0]);
+	return drivers;
+}

+ 9 - 0
OpenPuck/usb_app_drivers.h

@@ -0,0 +1,9 @@
+// usb_app_drivers.h -- internal registry for custom TinyUSB class drivers.
+#pragma once
+
+extern "C" {
+#include "device/usbd_pvt.h"
+}
+
+const usbd_class_driver_t *xinputClassDriver(void);
+const usbd_class_driver_t *xboxOgClassDriver(void);

+ 1 - 0
OpenPuck/usb_tx.h

@@ -46,6 +46,7 @@ void usbTxUnboost(void);
 // Register a callback to run every USB frame ON THE usbd TASK (from tud_sof_cb, right after usbTxDrain()).
 // This is how senders that DON'T go through Adafruit_USBD_HID get their transmits off the loop task too:
 //   - mode_xinput flushes its raw custom-class IN endpoints;
+//   - mode_xbox_og flushes its raw custom-class IN endpoint;
 //   - webusb_config sends the status blob (its usb_web.flush() can otherwise block the loop on a full queue).
 // Call once at setup() (before usbTxBegin()). Safe no-op if usb isn't up yet. Capacity is small + fixed.
 typedef void (*usbTxDrainFn)(void);

+ 55 - 7
OpenPuck/webusb_config.cpp

@@ -17,6 +17,26 @@
 
 Adafruit_USBD_WebUSB usb_web;
 
+// Vendor-type control requests and the vendor interface's class requests both land on one global TinyUSB
+// callback, which the Adafruit core defines for WebUSB. Linker wrapping (-Wl,--wrap, set in the Makefile)
+// gives MODE_XBOX_OG first refusal on the XID requests without copying that router; whatever it declines, and
+// every other mode, must still reach __real_ -- that answers WebUSB's 0x22, whose connected flag gates
+// the panel sends.
+bool xboxOgVendorControlXfer(uint8_t rhport, uint8_t stage,
+			     const tusb_control_request_t *request);
+extern "C" bool
+__real_tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
+				  const tusb_control_request_t *request);
+extern "C" bool
+__wrap_tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
+				  const tusb_control_request_t *request)
+{
+	if (g_usbMode == MODE_XBOX_OG &&
+	    xboxOgVendorControlXfer(rhport, stage, request))
+		return true;
+	return __real_tud_vendor_control_xfer_cb(rhport, stage, request);
+}
+
 // Panel-blob send is deferred to the usbd task: webusbPoll() (loop) just sets this, and webusbSofDrain()
 // (registered with usb_tx, runs from tud_sof_cb on the usbd task) does the actual write+flush. usb_web.flush()
 // goes through the same blocking osd_queue_send path as HID sends, so doing it from loop() could stall the
@@ -77,7 +97,8 @@ static bool boardCommand(uint8_t op)
 //                [v14/v17: p[181] landAll87 (verbatim-0x87-relay experiment toggle)]
 //                [v18: p[182..185] chordDpad left/up/right/down (back4+D-pad mode assignments)]
 //                [v19: p[186] swGyroLegacy (Switch Pro gyro mapping: 0 = corrected, 1 = legacy/pre-#189)]
-#define WB_PAYLEN 185
+//                [v20: p[187..194] per-type trackpad->stick map, 4x2B {left pad, right pad} (PS_OFF/LEFT/RIGHT)]
+#define WB_PAYLEN 193
 // The blob send is drop-on-full (never blocks loop), so the vendor TX FIFO MUST be able to hold a whole blob
 // -- otherwise tud_vendor_write_available() never reaches the frame size and EVERY frame is dropped (blank
 // panel / stale mappings). The Makefile sets -DCFG_TUD_VENDOR_TX_BUFSIZE=256; guard it here so a build without
@@ -102,7 +123,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (19 = +Switch Pro legacy-gyro select (field 38, blob p[186]); the rumble-strength,
+	// protocol version (20 = +per-type trackpad->stick mapping (fields 80..87, blob p[187..194]);
+	// 19 = +Switch Pro legacy-gyro select (field 38, blob p[186]); the rumble-strength,
 	// Switch report-rate and Switch gyro-scale settings (fields 22/23/24, blob p[53..55]) are GONE -- those
 	// bytes now read 0; 18 = +configurable back4+D-pad chords (fields 34..37, blob p[182..185]);
 	// 17 = per-type rumble field (TypeCfg k=8), per-type stride 8->9; 16 = +configurable
@@ -111,7 +133,7 @@ static void webusbSendBlob()
 	// unknown op; 15 = +staged firmware-update ops 0x20..0x24; 14 = +landAll87 toggle; 13 = +per-slot link
 	// stats; 12 = +relay rate + clock fingerprint; 11 = +reset cause; 10 = +ledBright per type; 9 = +per-type
 	// cfg; 8 = +per-slot link status; 7 = +raw accel; 6 = +swPro120/gyroScale)
-	p[2] = 19;
+	p[2] = 20;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -277,7 +299,7 @@ static void webusbSendBlob()
 		q[8] = g_slotRelayps[s];
 	}
 	// v14/v17: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
-	p[181] = g_landAll87;
+	p[181] = 0; // used to be g_landAll87
 	// v18: back4+D-pad mode assignments (panel renders these as selects next to the B/X/Y ones)
 	p[182] = g_chordDpad[CHD_LEFT];
 	p[183] = g_chordDpad[CHD_UP];
@@ -285,6 +307,11 @@ static void webusbSendBlob()
 	p[185] = g_chordDpad[CHD_DOWN];
 	// v19: Switch Pro gyro mapping (0 = corrected/default, 1 = legacy pre-#189 raw axes)
 	p[186] = g_swGyroLegacy;
+	// v20: per-type trackpad->stick mapping, {left pad, right pad} per emulated type
+	for (int et = 0; et < ET_COUNT; et++) {
+		p[187 + et * 2] = g_padStickCfg[et][0];
+		p[188 + et * 2] = g_padStickCfg[et][1];
+	}
 	// CRITICAL: usb_web.write() SPINS (`while (remain && _connected) yield();`) until the IN FIFO drains or the
 	// panel disconnects. If the panel holds the WebUSB interface open but stops reading its IN endpoint -- a
 	// backgrounded tab, or the host briefly not servicing transferIn under load -- the FIFO never empties and
@@ -986,6 +1013,28 @@ void webusbPoll()
 					}
 					break;
 
+				// 80..87: per-type trackpad->stick mapping, field = 80 + et*2 + pad
+				// (pad 0 = left, 1 = right; value PS_OFF/PS_LEFT/PS_RIGHT).
+				// Must stay clear of the per-type cfg range 40..40+ET_COUNT*9-1
+				// (40..75), which is claimed before this switch runs.
+				case 80:
+				case 81:
+				case 82:
+				case 83:
+				case 84:
+				case 85:
+				case 86:
+				case 87: {
+					uint8_t et = (uint8_t)((f - 80) / 2),
+						pad = (uint8_t)((f - 80) % 2);
+					if (et < ET_COUNT && v <= PS_MAX) {
+						g_padStickCfg[et][pad] = v;
+						if (et == g_etype)
+							applyActiveType();
+					}
+					break;
+				}
+
 				// Switch Pro gyro mapping: 0 = corrected (default), 1 = legacy
 				// (pre-#189 raw axes, no sensitivity trim). Protocol v19.
 				case 38:
@@ -1000,10 +1049,9 @@ void webusbPoll()
 					// (field 25, poll RX window, removed -- g_rxWin is now FIXED/not configurable)
 					// (fields 27/28, post-connect haptic block, removed -- permanently disabled)
 
-				// EXPERIMENT: land ALL relayed 0x87 config verbatim (real-puck relay)
-				// instead of the discard-whitelist. Persisted; blob p[181] reflects state.
+				// Used to be experimental g_landAll87. Persisted; blob p[181] reflects state. Now ignored.
 				case 29:
-					g_landAll87 = v ? 1 : 0;
+					// g_landAll87 = v ? 1 : 0;
 					break;
 				}
 				if (persist)

+ 7 - 3
README.md

@@ -1,5 +1,8 @@
 # OpenPuck
-Opensource firmware for NRF52840 Pro Micro that copycats the Steam Controller 2 Puck and allows emulation of Xbox, Switch, and PS3/4/5 controllers and also includes an independant lizard mode (which can work on UAC prompts/task manager/etc). The Switch and PS3 modes have been verified to work on real consoles and Switch, PS4/5 modes have gyro (and touchpad where available) hooked in. Back 4 buttons are mappable for all emulated modes.
+
+[![BuyMeACoffee](https://raw.githubusercontent.com/pachadotdev/buymeacoffee-badges/main/bmc-yellow.svg)](https://github.com/safijari/openpuck/discussions/211)
+
+OpenPuck is an opensource firmware for NRF52840 Pro Micro that copycats the Steam Controller 2 Puck and allows emulation of Xbox, Original Xbox, Switch, and PS3/4/5 controllers and also includes an independant lizard mode (which can work on UAC prompts/task manager/etc). The Switch, PS3, and Original Xbox modes have been verified to work on real consoles and Switch, PS4/5 modes have gyro (and touchpad where available) hooked in. Back 4 buttons are mappable for all emulated modes.
 
 > [!WARNING]
 > Every part of this project _HEAVILY_ used LLMs*
@@ -37,8 +40,9 @@ Similarly you can hold all 4 back buttons and press Y to switch (teehee) over to
 | WebUSB panel → mode 5 | DualSense + Gyro + Trackpad | PC only |
 | WebUSB panel → mode 6 | DS4/HIDGYRO + Gyro + Trackpad | PC only |
 | WebUSB panel → mode 9 | PS3 DualShock 3 / Sixaxis | Enumerates on a real PS3 (+ gyro/haptics) |
-| WebUSB panel → mode 10 | DirectInput (flight/space sims) | Every axis at once, as two DirectInput joysticks |
-| WebUSB panel → mode 11 | SInput (SDL-native) | Sticks + analog triggers + gyro + both trackpads + battery |
+| WebUSB panel → mode 10 | Original Xbox Controller S | Enumerates on a real Original Xbox |
+| WebUSB panel → mode 11 | DirectInput (flight/space sims) | Every axis at once, as two DirectInput joysticks |
+| WebUSB panel → mode 12 | SInput (SDL-native) | Sticks + analog triggers + gyro + both trackpads + battery |
 
 **DirectInput mode** exists because Steam Input funnels everything through XInput, so only a handful of the
 controller's analog inputs can be live at once — a problem for flight and space sims, which bind axes through

+ 431 - 125
ReversePuck/scpair.py

@@ -1,11 +1,17 @@
 #!/usr/bin/env python3
 """scpair.py -- Linux hidraw steamless pairing for the Steam Controller 2 puck + (emulated) controller.
 
+Original code by safijari
+Steam Machine, slot cloning and wireless controller support, firmware version query by Leseratte10.
+
 The Linux equivalent of pairtui/scmd.c (which is macOS/IOKit). Talks the Valve feature-report command
 channel on the usagePage-0x01 control interface over /dev/hidraw* (HIDIOCSFEATURE / HIDIOCGFEATURE).
 Used to pair on the Steam Deck itself when BOTH the puck (PID 1304) and the ReversePuck controller
 (PID 1302) are plugged into the Deck -- or run it on any Linux box.
 
+This code is not limited to the ReversePuck, it can also be used to manage, reset, pair and clone
+normal pairings between a Puck (or OpenPuck) and a Steam Controller.
+
 Pairing flow (protocol/USB_COMMANDS.md, == pairtui.pair_full):
   1. fresh 8-byte key (r1,r2)
   2. puck   slot N  (rid 2):  0xA2 [r1][r2][controller_serial 16]
@@ -52,23 +58,21 @@ HIDIOCGRDESCSIZE = _IOR('H', 0x01, 4)
 HIDIOCGRDESC = _IOR('H', 0x02, 4 + 4096)
 
 VALVE_VID = 0x28DE
-PUCK_PID = 0x1304
+PUCK_PIDS = (0x1304, 0x1305)
 CTRL_PIDS = (0x1302, 0x1301, 0x1303, 0x1205)
-FEAT_LEN = 64  # Valve feature reports are exactly 64 bytes
 
 
 def _sysfs_ids(node):
-    """Return (vid, pid, serial) for a /dev/hidrawN node, or (None, None, None)."""
+    """Return (vid, pid) for a /dev/hidrawN node, or (None, None)."""
     name = os.path.basename(node)
     try:
         # /sys/class/hidraw/hidrawN/device/uevent has HID_ID=0003:000028DE:00001304
         ue = open("/sys/class/hidraw/%s/device/uevent" % name).read()
     except Exception:
-        return (None, None, None)
+        return (None, None)
     
     vid = None
     pid = None 
-    serial = None
 
     for line in ue.splitlines():
         if line.startswith("HID_ID="):
@@ -76,12 +80,12 @@ def _sysfs_ids(node):
             try:
                 vid = int(parts[1], 16) & 0xFFFF
                 pid = int(parts[2], 16) & 0xFFFF
+                return (vid, pid)
             except:
                 pass
-        if line.startswith("HID_UNIQ="):
-            serial = line.split('=')[1]
+
+    return (None, None)
     
-    return (vid, pid, serial)
 
 
 def _first_usage_page(fd):
@@ -109,12 +113,11 @@ def _first_usage_page(fd):
 
 
 class HidDev:
-    def __init__(self, node, vid, pid, serial, vendor_str = "Valve"):
+    def __init__(self, node, vid, pid, info_str = "Valve"):
         self.node = node
         self.vid = vid
         self.pid = pid
-        self.serial = serial
-        self.vendor_str = vendor_str
+        self.info_str = info_str
         self.fd = os.open(node, os.O_RDWR)
         self.usage_page = _first_usage_page(self.fd)
 
@@ -124,33 +127,115 @@ class HidDev:
         except Exception:
             pass
 
+    def get_bcd_version(self):
+        """
+        Relevant for Pucks. Both the standalone Puck and the receiver in the Steam Machine
+        use bcdVersion 2, but Steam also supports Pucks with other versions that sometimes 
+        behave differently.
+        """
+        dxi = self.node.replace('/dev/', '')
+        base = Path(f"/sys/class/hidraw/{dxi}/device")
+        dev_dir = base.resolve().parent.parent
+        bcdDevice = int((dev_dir / "bcdDevice").read_text().strip(), 16)
+        return bcdDevice
+
+
     def set_feature(self, rid, cmd, payload=b""):
-        buf = bytearray(FEAT_LEN)
+        buf = bytearray(64)
         buf[0] = rid
         buf[1] = cmd
         buf[2] = len(payload)
         buf[3:3 + len(payload)] = bytes(payload)
-        fcntl.ioctl(self.fd, _IOC_SF(FEAT_LEN), buf, True)
+        fcntl.ioctl(self.fd, _IOC_SF(64), buf, True)
 
-    def get_feature(self, rid):
-        buf = bytearray(FEAT_LEN)
+    def get_feature(self, rid, max_len=64):
+        buf = bytearray(max_len)
         buf[0] = rid
-        fcntl.ioctl(self.fd, _IOC_GF(FEAT_LEN), buf, True)
+        try: 
+            fcntl.ioctl(self.fd, _IOC_GF(max_len), buf, True)
+        except: 
+            # wireless connections sometimes take two attempts ...
+            time.sleep(0.05)
+            fcntl.ioctl(self.fd, _IOC_GF(max_len), buf, True)
         return bytes(buf)
 
-    def read_serial(self, rid):
-        """0xAE idx 1 -> unit serial string."""
-        try:
-            self.set_feature(rid, 0xAE, bytes([0x01]))
-            r = self.get_feature(rid)
-            # reply [rid][AE][len][idx][serial...]
-            return r[4:4 + 16].split(b"\x00")[0].decode("latin1", "replace")
-        except Exception:
-            return ""
+    def read_attribute_values(self, rid, cmd):
+        self.set_feature(rid, cmd)
+        data = self.get_feature(rid, 256)   # Feature report with attributes can be longer than 64.
+
+        data = data[3:]
+        attribute_count = len(data) // 5
+        padding_len = len(data) - (attribute_count * 5)
+
+        fstr = '<' + 'BL' * attribute_count + 'B' * padding_len
+        unpacked = struct.unpack(fstr, data)
+
+        attribute_list = {}
+        for i in range(attribute_count):
+            tag = unpacked[2*i]
+            val = unpacked[2*i + 1]
+            attribute_list[tag] = val
+
+        return attribute_list
+
+    def read_firmware_version(self, force_esb = False):
+        # When sent to a Puck, force_esb=False will return the puck's firmware, 
+        # while force_esb=True will return the connected controller's firmware.
+
+        if self.pid in CTRL_PIDS or force_esb:
+            attrs = self.read_attribute_values(1, 0x83)
+
+        elif self.pid in PUCK_PIDS:
+            if self.get_bcd_version() == 2:
+                attrs = self.read_attribute_values(2, 0x83)
+            else: 
+                # I wonder which official 
+                # Valve device uses a BCD other than 2. Maybe a prototype?
+                attrs = self.read_attribute_values(1, 0xa6)
+
+        return attrs[4]
+
+    def read_serial(self, force_esb = False, index = 1):
+        # When sent to a Puck, force_esb=False will return the puck's serial, 
+        # while force_esb=True will return the connected controller's serial.
+
+        # Index:
+        # 0: MXA123123123A ??
+        # 1: FXB9912345678 (serial number on sticker)
+        # 2: NA
+        # 3: fec01234c8af git firmware commit hash
+        # 4: MXA123123123A
+
+        r = None
+
+        if self.pid in CTRL_PIDS or force_esb:
+            self.set_feature(1, 0xae, bytes([index]))
+            r = self.get_feature(1)
+              
+        elif self.pid in PUCK_PIDS:
+            if self.get_bcd_version() == 2:
+                # 2 = Valve Puck or Machine, 211-213 OpenPuck? Unclear why.
+                self.set_feature(2, 0xae, bytes([index]))
+                r = self.get_feature(2)
+            else: 
+                # I wonder which official Valve device uses 
+                # a BCD other than 2. Maybe a prototype?
+                self.set_feature(1, 0xa4, bytes([index]))
+                r = self.get_feature(1)
+
+        if r is None: 
+            return None
+        
+        return r[4:].split(b"\x00")[0].decode("latin1", "replace")
+            
+       
 
 def nodesort(nodeobj):
     """Sorts the node path properly (numerically)."""
 
+    # TODO: This doesn't seem to be 100% reliable, if you plug in two Pucks at the exact same time
+    # the interface order might get messed up.
+
     if isinstance(nodeobj, HidDev):
         return nodesort(nodeobj.node)
 
@@ -159,10 +244,15 @@ def nodesort(nodeobj):
 
 
 def enumerate_devices():
-    """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]} (control interfaces only)."""
+    """Return dict role -> list[HidDev]: {'puck':[...], 'ctrl':[...]}."""
     out = {"puck": [], "ctrl": []}
+
+    last_serial = None
+    current_ctrl_iface = None
+    current_esb_ifaces = []
+
     for node in sorted(glob.glob("/dev/hidraw*"), key=nodesort):
-        vid, pid, serial = _sysfs_ids(node)
+        vid, pid = _sysfs_ids(node)
         if vid != VALVE_VID:
             continue
 
@@ -180,34 +270,69 @@ def enumerate_devices():
             # If these don't match, this is not a Puck slot, but some other random HID interface.
             continue
 
-        # If we're here, this is a Puck. Let's detect and display if it's a Valve Puck or an OpenPuck.
+        # If we're here, this is a Puck. Let's detect and display its type.
         # OpenPuck can be detected by the OpenPuck mouse wake interface. 
 
         devnode = iface_dir.parent
-        device_vendor = "Valve"
+        device_vendor = "Valve Puck"
 
-        for subnode in glob.glob(str(devnode) + "/" + devnode.name + ":*/interface"):
-            txt = Path(subnode).read_text().strip()
-            if "OpenPuck" in txt:
-                device_vendor = "OpenPuck"
+        if pid == 0x1305:
+            device_vendor = "Valve Steam Machine"
+        else:
+            for subnode in glob.glob(str(devnode) + "/" + devnode.name + ":*/interface"):
+                txt = Path(subnode).read_text().strip()
+                if "OpenPuck" in txt:
+                    device_vendor = "OpenPuck"
 
         try:
-            dev = HidDev(node, vid, pid, serial, device_vendor)
+            dev = HidDev(node, vid, pid, device_vendor)
         except PermissionError:
             print("permission denied on %s (run with sudo or install the udev rule)" % node, file=sys.stderr)
             continue
         except Exception:
             continue
-        # control interface only (usagePage 0x01); skip the vendor 0xFF00 diagnostic interface
-        if dev.usage_page not in (0x01, None):
-            dev.close()
+
+        if pid in CTRL_PIDS:
+            out["ctrl"].append(dev)
             continue
-        if pid == PUCK_PID:
-            out["puck"].append(dev)
+
+        serial = dev.read_serial()
+
+        if pid in PUCK_PIDS and last_serial is not None and last_serial != serial:
+            # OpenCode has no control interface.          
+            current_puck = {
+                "control": current_ctrl_iface,
+                "esb": current_esb_ifaces
+            }
+
+            out["puck"].append(current_puck)
+            current_esb_ifaces = []
+            current_ctrl_iface = None
+
+        last_serial = serial
+
+
+        if pid in PUCK_PIDS and dev.usage_page in (0x01, None):
+            current_esb_ifaces.append(dev)
+        elif pid in PUCK_PIDS:
+            current_ctrl_iface = dev
         elif pid in CTRL_PIDS:
             out["ctrl"].append(dev)
         else:
             dev.close()
+
+
+    if len(current_esb_ifaces) > 0:
+        # Add puck interfaces from last loop
+        # OpenCode has no control interface.          
+
+        current_puck = {
+            "control": current_ctrl_iface,
+            "esb": current_esb_ifaces
+        }
+
+        out["puck"].append(current_puck)
+
     return out
 
 
@@ -219,38 +344,52 @@ def _ser16(serial):
 def read_slots(puck_list):
     """Read all bond slots from all attached pucks (each puck control interface == one slot)."""
     pucks = []
-    slots = []
 
-    last_dev = None
-    idx = 0
-    for dev in sorted(puck_list, key=nodesort):
-        if last_dev is not None and dev.serial != last_dev.serial:
+    for puck in puck_list:
+
+        esb_devices = puck['esb']
+
+        slots = []
+        idx = 0
+
+        for esb in esb_devices:
+            try:
+
+                esb.set_feature(2, 0xA3)
+                r = esb.get_feature(2)  # [02 A3 18 <8 uuid><16 serial>]
+                rec = r[3:3 + 24]
+                used = any(rec[0:8])
+                serial = rec[8:24].split(b"\x00")[0].decode("latin1", "replace") if used else ""
+                key = rec[:8].hex()
+    
+                # Check if a controller is currently connected to this slot:
+                esb.set_feature(2, 0xB4)
+                r = esb.get_feature(2)
+                active = (r[0] == 0x02 and r[1] == 0xb4 and r[3] == 2)
+    
+                slots.append({"idx": idx, "dev": esb, "used": used, "active": active, "serial": serial, "rec": rec})
+            except Exception as ex:
+                slots.append({"idx": idx, "dev": esb, "used": False, "active": False, "serial": "", "rec": b"", "err": str(ex)})
+
+            idx = idx + 1
+            
+        if esb is not None:
             # This is a new puck, append the old puck and all its slots to the list.
-            pucks.append({"serial": last_dev.serial, "slots": slots, "vendor": last_dev.vendor_str})
-            slots = []
-            idx = 0
-        try:
-            dev.set_feature(2, 0xA3)
-            r = dev.get_feature(2)  # [02 A3 18 <8 uuid><16 serial>]
-            rec = r[3:3 + 24]
-            used = any(rec[0:8])
-            serial = rec[8:24].split(b"\x00")[0].decode("latin1", "replace") if used else ""
-            slots.append({"idx": idx, "dev": dev, "used": used, "serial": serial, "rec": rec})
-        except Exception as ex:
-            slots.append({"idx": idx, "dev": dev, "used": False, "serial": "", "rec": b"", "err": str(ex)})
+            pucks.append({"serial": esb.read_serial(), 
+                        "firmware_ts": esb.read_firmware_version(),
+                        "firmware_commit": esb.read_serial(False, 3),
+                        "slots": slots, 
+                        "info_str": esb.info_str})
         
-        last_dev = dev
-        idx = idx + 1
-
-    pucks.append({"serial": dev.serial, "slots": slots, "vendor": dev.vendor_str})
     return pucks
 
 
 def get_puck_slot_for_writing(puck_serial=None, slot=None):
     devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=nodesort)
+    pucks = devs["puck"]
+
     if not pucks:
-        raise SystemExit("no puck (28DE:1304) control interface found")
+        raise SystemExit("no puck (28DE:1304/1305) control interface found")
 
     puck_objects = read_slots(pucks)
     if puck_serial is None and len(puck_objects) > 1:
@@ -286,18 +425,49 @@ def get_puck_slot_for_writing(puck_serial=None, slot=None):
     
 
 
-def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
+def pair(puck_serial=None, puck_slot=None, controller_serial=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
     devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=nodesort)
+    puck_devs = devs["puck"]
+    pucks = []
+
+    for puck in puck_devs:
+        pucks.append(puck["esb"])
+
     ctrls = sorted(devs["ctrl"], key=nodesort)
     if not pucks:
-        raise SystemExit("no puck (28DE:1304) control interface found")
-    if not ctrls:
-        raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
-    ctrl = ctrls[0]
+        raise SystemExit("no puck (28DE:1304/1305) control interface found")
+    
+    ctrl = None
+    if controller_serial is not None:
+        # Check controllers connected through USB
+        for c in ctrls:
+            if c.read_serial() == controller_serial:
+                print(f"Found controller {controller_serial} connected over USB")
+                ctrl = c
+
+        if ctrl is None: 
+            # Check controllers connected wirelessly
+            for puck in pucks:
+                for slot in puck:
+                    try: 
+                        if slot.read_serial(True) == controller_serial:
+                            print(f"Found controller {controller_serial} connected wirelessly")
+                            ctrl = puck
+                    except BrokenPipeError:
+                        pass
+    else: 
+        # Controller serial is none. Assume the user wants to use the 1st wired controller. 
+        if not ctrls:
+            raise SystemExit("no controller (28DE:1302) control interface found - please plug in either a Steam Controller or the ReversePuck controller")
+           
+        ctrl = ctrls[0]
+
+    if ctrl is None:
+        raise SystemExit(f"Controller with serial {controller_serial} not found.")
+
 
     if controller_name is None: 
-        ctrl_serial_str = ctrl.read_serial(1) or "FXA000000000"
+        ctrl_serial_str = ctrl.read_serial(True)
     else: 
         ctrl_serial_str = controller_name[:15]
 
@@ -321,7 +491,7 @@ def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, co
     # Controller name gets written to Puck
 
     print("Puck: %s slot %d controller %s" % (puck_serial, slot_obj['idx'], ctrl_serial_str))
-    print("Controller: %s slot %d puck %s" % (ctrl.read_serial(1), controller_slot, puck_serial_str))
+    print("Controller: %s slot %d puck %s" % (ctrl.read_serial(True), controller_slot, puck_serial_str))
 
     # puck slot: 0xA2 [r1 r2][ctrl serial]
     slot_obj['dev'].set_feature(2, 0xA2, r + _ser16(ctrl_serial_str))
@@ -352,23 +522,49 @@ def write_puck_slot(puck_serial=None, puck_slot=None, ctrl_name=None, pkey=None)
           (puck_serial, slot_obj['idx'], ctrl_name, pkey.hex()))
         # puck slot: 0xA2 [r1 r2][ctrl serial]
 
-        print(slot_obj['dev'].node)
-
         slot_obj['dev'].set_feature(2, 0xA2, pkey + _ser16(ctrl_name))
 
     print("done")
 
-def write_controller_slot(slot=0, puck_serial=None, pkey=None):
+def write_controller_slot(controller_serial=None, slot=0, puck_serial=None, pkey=None):
+
     devs = enumerate_devices()
-    ctrls = sorted(devs["ctrl"], key=nodesort)
-    if not ctrls:
-        raise SystemExit("no controller (28DE:1302) control interface found — plug the ReversePuck controller in")
-    
-    if slot not in [0, 1]:
-        raise SystemExit("invalid controller slot %d" % (slot))
+    puck_devs = devs["puck"]
+    pucks = []
+
+    for puck in puck_devs:
+        pucks.append(puck["esb"])
+
+    ctrls = sorted(devs["ctrl"], key=nodesort) 
+    ctrl = None
+    if controller_serial is not None:
+        # Check controllers connected through USB
+        for c in ctrls:
+            if c.read_serial() == controller_serial:
+                print(f"Found controller {controller_serial} connected over USB")
+                ctrl = c
+
+        if ctrl is None: 
+            # Check controllers connected wirelessly
+            for puck in pucks:
+                for slot in puck:
+                    print(slot)
+                    try: 
+                        if slot.read_serial(True) == controller_serial:
+                            print(f"Found controller {controller_serial} connected wirelessly")
+                            ctrl = puck
+                    except BrokenPipeError:
+                        pass
+    else: 
+        # Controller serial is none. Assume the user wants to use the 1st wired controller. 
+        if not ctrls:
+            raise SystemExit("no controller (28DE:1302) control interface found - please plug in a Steam Controller")
+           
+        ctrl = ctrls[0]
 
+    if ctrl is None:
+        raise SystemExit(f"Controller with serial {controller_serial} not found.")
 
-    ctrl = ctrls[0]
 
     if pkey is None: 
         r = os.urandom(8)
@@ -399,75 +595,160 @@ def write_controller_slot(slot=0, puck_serial=None, pkey=None):
         ctrl.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
         print("paired. controller rebooting into wireless — move the puck to your host.")
 
-def get_controller_pairings():
-    devs = enumerate_devices()
-    ctrls = sorted(devs["ctrl"], key=nodesort)
-
-    if not ctrls:
-        return []
+def get_single_controller_pairings(controller_dev):
 
-    controllers = []
+    slots = []
+    i = 0
+    for bond_str in [b"esb/bond\x00", b"esb/bond_2\x00"]:
+        controller_dev.set_feature(1, 0xED, bond_str)
+        bond = controller_dev.get_feature(1)
+
+        if bond[0] == 0x01 and bond[1] == 0xed and bond[2] > 0:
+            bond_len = bond[2]
+            bond_key = bond[3:3+8]
+            bond_name = bond[11:11+(bond_len-8)]
+            slots.append({"idx": i, "used": True, "key": bond_key.hex(), "serial": bond_name.decode("latin1", "replace").split('\x00')[0]})
+        else: 
+            slots.append({"idx": i, "used": False})
+
+        i = i + 1
+
+    return {"serial": controller_dev.read_serial(True), 
+            "firmware_ts": controller_dev.read_firmware_version(True), 
+            "firmware_commit": controller_dev.read_serial(True, 3), 
+            "slots": slots}
+
+
+def cmd_reboot(device_serial: str, reboot_type: int):
+    """
+        type:
+        These types don't correspond to anything in the protocol, these are just flags used for this function.
+        
+        1: reboot (controller only)
+        2: reboot to bootloader (puck or controller)
+        3: reboot to wireless connection (controller only)
+        4: power off (controller only)
+    
+    """
 
-    for c in ctrls:
-        slots = []
-        i = 0
+    devs = enumerate_devices()
 
-        for bond_str in [b"esb/bond\x00", b"esb/bond_2\x00"]:
-            c.set_feature(1, 0xED, bond_str)
-            bond = c.get_feature(1)
+    puck_devs = sorted(devs["puck"], key=nodesort)
 
-            if bond[0] == 0x01 and bond[1] == 0xed and bond[2] > 0:
-                bond_len = bond[2]
-                bond_key = bond[3:3+8]
-                bond_name = bond[11:11+(bond_len-8)]
-                slots.append({"idx": i, "used": True, "key": bond_key.hex(), "serial": bond_name.decode("latin1", "replace")})
-            else: 
-                slots.append({"idx": i, "used": False})
+    ctrls = sorted(devs["ctrl"], key=nodesort)
 
-            i = i + 1
 
-        controllers.append({"serial": c.read_serial(1), "slots": slots})
+    # Check if we want to reboot a Puck
+    for p in puck_devs: 
+        pc = p['control']
+        if pc.read_serial() == device_serial:
+            if reboot_type == 2:
+                print(f"Found puck with serial {device_serial}, rebooting to bootloader...")
+                if pc.get_bcd_version() == 2:
+                    pc.set_feature(2, 0x90)
+                else: 
+                    pc.set_feature(1, 0x90)
+                return True
+            else:
+                print(f"Found puck with serial {device_serial}, but pucks can only reboot to bootloader. Skipping.")
+                print(f"If that's what you want, run again with --bootloader.")
+
+    # Check if we want to reboot a controller that's wireless.
+    found_puck_dev = None
+    ps = read_slots(puck_devs)
+    for puck in ps:
+        slots = puck['slots']
+
+        for s in slots: 
+            if s["active"]:
+                if s['dev'].read_serial(True) == device_serial:
+                    found_puck_dev = s['dev']
+                    break
+
+    # Check if we want to reboot a controller that's wired.
+    if found_puck_dev is None:
+        for c in ctrls: 
+            if c.read_serial() == device_serial:
+                found_puck_dev = c
+                break
 
-    return controllers
+    if found_puck_dev is not None:
+        if reboot_type == 1: 
+            print(f"Found controller with serial {device_serial}, rebooting...")
+            found_puck_dev.set_feature(1, 0x95)
+        elif reboot_type == 2:
+            print(f"Found controller with serial {device_serial}, rebooting to bootloader...")
+            found_puck_dev.set_feature(1, 0x90)
+        elif reboot_type == 3:
+            print(f"Found controller with serial {device_serial}, rebooting to wireless...")
+            found_puck_dev.set_feature(1, 0x95, bytes([0x52, 0xAF, 0x27, 0xA4]))
+        elif reboot_type == 4:
+            print(f"Found controller with serial {device_serial}, powering off...")
+            found_puck_dev.set_feature(1, 0x9F, b'off!')
+        return True
 
 
 def cmd_list():
     devs = enumerate_devices()
-    pucks = sorted(devs["puck"], key=nodesort)
-    ctrls = sorted(devs["ctrl"], key=nodesort)
-
-    if ctrls:
-        cs = get_controller_pairings()
-
-        for c in cs:
-            print(f"Controller: {c['serial']}")
-
-            for s in c['slots']:
-                if s['used']:
-                    print("  slot %d: %s (key %s)" % (s["idx"], s["serial"], s["key"]))
-                else:
-                    print("  slot %d: Unused" % (s["idx"]))
-    else:
-        print("No controller connected.")
-
+    pucks = devs["puck"]
+    ctrls = devs["ctrl"]
 
+    ctrls_esb = []
 
     if pucks:
         ps = read_slots(pucks)
         for puck in ps:
             slots = puck['slots']
 
-            print(f"Puck: {puck['serial']} ({puck['vendor']})")
+            firmware_str = hex(puck['firmware_ts']).replace('0x', '').upper() + " (" + puck['firmware_commit']  + ")"
+
+            print(f"Puck: {puck['serial']} ({puck['info_str']}), firmware {firmware_str}")
 
             for s in slots: 
+                suffix = ""
+                if s["active"]:
+                    conn_serial = s['dev'].read_serial(True)
+                    suffix = f", connected to controller " + conn_serial
+                    ctrls_esb.append(s['dev'])
+
                 if s["used"]:
-                    print("  slot %d: %s (key %s)" % (s["idx"], s["serial"], s["rec"][:8].hex()))
+                    print("  slot %d: %s (key %s)%s" % (s["idx"], s["serial"], s["rec"][:8].hex(), suffix))
                 else: 
                     print("  slot %d: Empty" % (s["idx"]))
+
+            print()
+
     else:
         print("No Puck connected.")
 
 
+    if ctrls or ctrls_esb:
+        ctype = "wired"
+        for c in [*ctrls, None, *ctrls_esb]:
+            if c is None: 
+                ctype = "wireless"
+                continue
+
+            cdata = get_single_controller_pairings(c)
+
+            firmware_str = hex(cdata['firmware_ts']).replace('0x', '').upper() + " (" + cdata['firmware_commit']  + ")"
+        
+            print(f"Controller ({ctype}): {cdata['serial']}, firmware {firmware_str}")
+
+            for s in cdata['slots']:
+                if s['used']:
+                    print("  slot %d: %s (key %s)" % (s["idx"], s["serial"], s["key"]))
+                else:
+                    print("  slot %d: Empty" % (s["idx"]))
+
+            print()
+
+    else:
+        print("No controller connected.")
+
+
+
+
 def steam_key_check(key):
     try: 
         key = bytes.fromhex(key)
@@ -482,10 +763,12 @@ def main():
     sub = ap.add_subparsers(dest="cmd", required=True)
     sub.add_parser("list")
     p = sub.add_parser("pair")
-    #def pair(puck_serial=None, puck_slot=None, controller_slot=0, puck_name=None, controller_name=None, pkey=None):
+    #def pair(puck_serial=None, puck_slot=None, controller_serial=None, controller_slot=0, 
+    #           puck_name=None, controller_name=None, pkey=None)
     p.add_argument("--puck-serial", type=str, default=None)
     p.add_argument("--puck-slot", type=int, default=None)
     p.add_argument("--puck-name", type=str, default=None)
+    p.add_argument("--controller-serial", type=str, default=None)
     p.add_argument("--controller-slot", type=int, default=0)
     p.add_argument("--controller-name", type=str, default=None)
     p.add_argument("--key", type=steam_key_check, default=None)
@@ -499,22 +782,45 @@ def main():
     s.add_argument("--key", type=steam_key_check, default=None)
 
     c = sub.add_parser("write-controller")
-    #def write_controller_slot(slot=0, puck_serial=None, pkey=None):
+    #def write_controller_slot(controller_serial=None, slot=0, puck_serial=None, pkey=None):
+    c.add_argument("--controller-serial", type=str, default=None)
     c.add_argument("--controller-slot", type=int, default=None)
     c.add_argument("--puck-name", type=str, default=None)
     c.add_argument("--key", type=steam_key_check, default=None)
 
 
+    rb = sub.add_parser("reboot")
+    #def cmd_reboot(device_serial: str, reboot_type: int):
+    rb.add_argument("--serial", type=str, required=True)
+
+    reboot_group = rb.add_mutually_exclusive_group()        # no parameters: normal reboot, type 1
+    reboot_group.add_argument("--bootloader", action='store_true') # type 2
+    reboot_group.add_argument("--wireless", action='store_true') # type 3
+    reboot_group.add_argument("--poweroff", action='store_true') # type 4
+
+
+
     
     args = ap.parse_args()
     if args.cmd == "list":
         cmd_list()
     elif args.cmd == "pair":
-        pair(args.puck_serial, args.puck_slot, args.controller_slot, args.puck_name, args.controller_name, args.key)
+        pair(args.puck_serial, args.puck_slot, args.controller_serial, 
+                args.controller_slot, args.puck_name, args.controller_name, args.key)
     elif args.cmd == "write-puck":
         write_puck_slot(args.puck_serial, args.puck_slot, args.controller_name, args.key)
     elif args.cmd == "write-controller":
         write_controller_slot(args.controller_slot, args.puck_name, args.key)
+    elif args.cmd == "reboot":
+        reboot_type = 1
+        if args.bootloader: 
+            reboot_type = 2
+        if args.wireless: 
+            reboot_type = 3
+        if args.poweroff: 
+            reboot_type = 4
+
+        cmd_reboot(args.serial, reboot_type)
 
 
 

+ 1 - 1
ReversePuckFirmware/ctrl_link.cpp

@@ -334,7 +334,7 @@ static uint8_t rxOnce(const uint8_t *base, uint8_t prefix, uint8_t ch,
 					    rfrx[4] == 0x05) {
 						// Relayed Steam OUTPUT (SET): E3 [tlvlen][05][rid][data]. The puck
 						// forwards Steam's rumble/haptic/LED writes this way (legacy type-05
-						// form). Hand rumble (0x80) / haptic pulses (0x82/0x8F) to the Deck so
+						// form). Hand rumble (0x80) / haptic pulses (0x81/0x82/0x8F) to the Deck so
 						// it buzzes; relays are NO_ACK so we DON'T reply.
 						uint8_t rl =
 							(rfrx[3] >= 1) ?

+ 9 - 0
ReversePuckFirmware/deck_input.cpp

@@ -182,6 +182,15 @@ void deckForwardHaptic(uint8_t rid, const uint8_t *d, uint8_t n)
 			right = (uint16_t)(d[6] | (d[7] << 8));
 			rgain = d[8];
 		}
+	} else if (rid == 0x81 && n >= 3) {
+		// OUTPUT report 0x81 = HAPTIC_PULSE [side][on_us u16][off_us u16][repeat u16] -- Steam's trackpad
+		// CLICK and trigger full-pull cues. (0x81 in the FEATURE command space is CLEAR_DIGITAL_MAPPINGS,
+		// a different thing entirely; this path only ever sees relayed OUTPUT reports.) The Deck has no
+		// per-pad clicker, so synthesize a short mid-strength buzz on both motors.
+		if (d[1] || d[2]) { // on_us != 0
+			intensity = 0x6000;
+			left = right = 0x6000;
+		}
 	} else if (rid == 0x82 || rid == 0x8F) {
 		// discrete haptic click/pulse (data ~ [01 01 F7]; trailing byte F7=on, 00=off). The Deck has no
 		// per-pad clicker, so synthesize a short mid-strength buzz on both motors.

+ 2 - 1
ReversePuckFirmware/deck_input.h

@@ -34,7 +34,8 @@ void deckHapticTask();
 void deckText(const char *s);
 
 // Forward a relayed Steam haptic/rumble to the Deck as a HAPTIC frame (the app plays it as Deck rumble).
-// rid = the relayed report id (0x80 SDL Triton rumble, 0x82/0x8F haptic pulse); d/n = its raw payload.
+// rid = the relayed report id (0x80 Triton rumble, 0x81 HAPTIC_PULSE, 0x82 HAPTIC_COMMAND, 0x8F legacy
+// feature pulse); d/n = its raw payload.
 void deckForwardHaptic(uint8_t rid, const uint8_t *d, uint8_t n);
 
 // Is the app currently forwarding (user tapped a puck)? When false, ctrl_link sends a neutral report.

+ 1 - 1
docs/BUILD_AND_DEPLOY.md

@@ -70,7 +70,7 @@ make build FQBN=adafruit:nrf52:somethingelse          # a different nRF52840 boa
 `#error`s without them (so a forgotten flag fails loudly instead of shipping a broken/deadlock-prone image):
 
 ```bash
-arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=6 -DCFG_TUD_TASK_QUEUE_SZ=512 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" OpenPuck
+arduino-cli compile -b adafruit:nrf52:feather52840 --build-property "build.extra_flags=-DNRF52840_XXAA {build.flags.usb} -DCFG_TUD_HID=6 -DCFG_TUD_TASK_QUEUE_SZ=512 -DCFG_TUD_VENDOR_TX_BUFSIZE=256" --build-property "compiler.c.elf.extra_flags=-Wl,--wrap=tud_vendor_control_xfer_cb" OpenPuck
 ```
 
 ### 4a. Raytac MDBT50Q-CX-40

+ 53 - 24
docs/PROTOCOL.md

@@ -46,14 +46,17 @@ Feature payload framing is:
 [cmd][len][payload...]
 ```
 
+
 Implemented commands:
 
-- `0x83`: attribute block
-- `0xAE`: string attribute
-- `0xB4`: connection state for the interface's slot
-- `0xAD`: pairing mode enable/disable
-- `0xA2`: write or clear the interface's 24-byte bond slot
-- `0xA3`: read the interface's 24-byte bond slot
+- `0x83`: `GET_ATTRIBUTES_VALUES`
+- `0xAE`: `GET_STRING_ATTRIBUTE`
+- `0xB4`: `DONGLE_GET_WIRELESS_STATE` (connection state for the interface's slot)
+- `0xAD`: `ENABLE_PAIRING` (pairing mode enable/disable)
+- `0xA2`: `TRITON_A2_OBSERVED_PAIRING_RECORD` (write or clear the interface's 24-byte bond slot)
+- `0xA3`: `TRITON_A3_BOND_EVENT_OR_STATUS` (read the interface's 24-byte bond slot)
+
+For a full list of commands the controller supports, check steam_commands.h
 
 #### `0xB4` response
 
@@ -82,31 +85,17 @@ When Steam writes feature report `0x01`, OpenPuck forwards it over RF to the con
 containing a sub-TLV. There are two on-air forms:
 
 ```text
-legacy   [E3][1+len][0x05][report_id][data...]              (no inner-len)
-landing  [E3][2+len][0x01][report_id][innerlen][data...]    (type 01, KEEPS inner-len; controller ACTS on it)
+haptic   [E3][1+len][0x05][report_id][data...]             (type 05, no inner-len)
+normal  [E3][2+len][0x01][report_id][innerlen][data...]    (type 01, KEEPS inner-len)
 ```
 
-A command only **lands** on the controller in the `landing` form (type `0x01` — the same type byte the GET poll
-uses, `E3 02 01 45 <param>`; the `report_id` selects the action, `innerlen` is the report's own `[len]`). In the
-`legacy` form the controller discards any `0x87+` command (it reads the first data byte as the length).
-
-**OpenPuck sends the `legacy` form for everything EXCEPT a tight whitelist** — only LED brightness (`0x87` whose
-first register byte is `0x2D`) and controller power-off (`0x9F`) use the `landing` form:
 
 ```text
 haptic on    E3 04 05 82 01 01 F7              (report 0x82, legacy)
-brightness   E3 05 01 87 03 2D <val> 00        (report 0x87 reg 0x2D, LANDING -- whitelisted)
-power-off    E3 06 01 9F 04 6F 66 66 21        (report 0x9F "off!", LANDING -- whitelisted)
-Steam 0x87 cfg  E3 .. 05 87 ..                 (haptic/IMU config: legacy -> DISCARDED, by design)
+brightness   E3 05 01 87 03 2D <val> 00        (report 0x87 reg 0x2D, LANDING)
+power-off    E3 06 01 9F 04 6F 66 66 21        (report 0x9F "off!", LANDING)
 ```
 
-**Why the whitelist is tight, not "land everything `0x87+`":** Steam continuously writes its own `0x87`
-passthrough during normal play — the haptic-config block including reg `0x30` (IMU/subsystem enable) and
-`0x34/0x35` (haptic amplitude). If those *land*, reg `0x30` **freezes the controller's gyro stream** and
-`0x34/0x35` drive the **connect-time buzz**. The long-working build never landed any `0x87`, so neither happened.
-Landing the whole class regresses both (stuck gyro + buzz); landing only the brightness register and power-off
-adds those two features while leaving Steam's config writes discarded exactly as before.
-
 The relay carries the command's declared length, up to 60 bytes — the most one RF frame fits. Relays are staged
 in a small ring (not a single buffer): the USB SET callbacks run in ISR context and Steam sends
 settings/calibration as back-to-back bursts, so a single pending slot both drops reports and can be torn
@@ -347,6 +336,25 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
   feature `0x01` passthrough). Restricting this to `0x82` alone silently dropped the ping/grip/test
   haptics, which use other report IDs.
 
+#### ⚠️ Two different `0x8x` id spaces — never share a rule between them
+
+Steam drives the actuators through the Triton **OUTPUT report** space, which is a *different* id space
+from the feature-`0x01` **command** space even though the numbers overlap. Ground truth: SDL
+`src/joystick/hidapi/steam/controller_structs.h` (`ValveTritonOutReportMessageIDs`) and
+`controller_constants.h`. The payload sizes match this firmware's report descriptor exactly:
+
+| id | OUTPUT report (haptics/actuators) | payload | feature `0x01` command |
+|----|-----------------------------------|---------|------------------------|
+| `0x80` | `HAPTIC_RUMBLE` `[type][intensity u16][{speed u16,gain}L][{…}R]` | 9 | `SET_DIGITAL_MAPPINGS` |
+| `0x81` | `HAPTIC_PULSE` `[side][on_us u16][off_us u16][repeat u16]` | 7 | `CLEAR_DIGITAL_MAPPINGS` |
+| `0x82` | `HAPTIC_COMMAND` `[side][command][gain_db]` | 3 | `GET_DIGITAL_MAPPINGS` |
+| `0x83` | `HAPTIC_LFO_TONE` `[side][gain_db][freq u16][dur u16][lfo_freq u16][lfo_depth]` | 9 | `GET_ATTRIBUTES_VALUES` |
+| `0x84` | `HAPTIC_LOG_SWEEP` `[side][gain_db][dur u16][start u16][end u16]` | 8 | `GET_ATTRIBUTE_LABEL` |
+| `0x85` | `HAPTIC_SCRIPT` `[side][script_id][gain_db]` | 3 | `SET_DEFAULT_DIGITAL_MAPPINGS` |
+| `0x86` | (unnamed) | 3 | `FACTORY_RESET` |
+| `0x87`+ | 63-byte settings/config | 63 | `SET_SETTINGS_VALUES` … |
+
+
 ### 9.2 Xbox mode
 
 - VID:PID `045E:028E`, clean device (no CDC/WebUSB)
@@ -359,6 +367,18 @@ does a `detach -> rebuild -> attach` so the host re-reads the descriptor cleanly
 - Single HID interface with the canonical HORIPAD descriptor (interrupt IN + OUT endpoints), accepted by
   a real Switch console with no handshake; an 8-byte report is streamed at ~250 Hz
 
+### 9.4 Original Xbox mode
+
+- VID:PID `045E:0289` (Microsoft Controller S). Composite: the XID interface plus the wake mouse and
+  the WebUSB panel, so the config panel stays reachable on a PC
+- Proprietary XID interface (class `0x58`, subclass `0x42`, protocol `0x00`) with interrupt IN +
+  OUT endpoints; 20-byte input reports, rumble applied from the OUT endpoint
+- The console refuses the controller until three `0xC1` vendor control requests are answered:
+  `bRequest 0x06` / `wValue 0x4200` (XID descriptor), and `bRequest 0x01` with `wValue 0x0100` /
+  `0x0200` (input and output capabilities)
+- Also answers the XID report requests the protocol carries on EP0, in parallel with the interrupt
+  endpoints: `GET_REPORT` (`0xA1 0x01`, `wValue 0x0100`) and `SET_REPORT` (`0x21 0x09`, `wValue 0x0200`)
+
 ## 10. WebUSB control channel
 
 The WebUSB vendor interface is present only in Steam mode (Xbox/Switch are clean controllers with no
@@ -439,6 +459,15 @@ Status blob payload:
 [22] persist-mode flag
 ```
 
+Later fields are appended (the version byte says how far the payload goes). From version 20 the tail carries
+the per-emulated-type trackpad-to-stick mapping at payload bytes 187..194 — two bytes per type
+(`{left pad, right pad}`), each `0` off / `1` left stick / `2` right stick. Set with
+`0x02 <80 + type*2 + pad> <value>`. Fields 40..75 are the per-type config block, so the mapping starts at 80.
+A mapped pad **blends** with its stick: while the pad is touched each axis reports whichever of the two
+sources is deflected further from center (signed); an untouched pad contributes nothing and the physical
+stick passes straight through. A mapped pad also stops reporting as a touchpad contact / mouse.
+
+
 ### 10.1 Backup / clone (bond export & import)
 
 A puck's whole portable identity is its four 24-byte bond records (`[proteus_uuid 4][ibex_uuid 4][serial 16]`,

+ 2 - 2
docs/TESTING_GUIDE.md

@@ -13,7 +13,7 @@
 - Is battery level shown and correct?
 - Is the polling rate and response rate the same? Is it around 250hz?
 
-### Testing the DirectInput mode (mode 10)
+### Testing the DirectInput mode (mode 11)
 
 Windows has a built-in DirectInput tester: run `joy.cpl` (or Settings → Bluetooth & devices → Devices → More
 devices → *Set up USB game controllers*). **Two** entries should appear — that's the point of the mode; one
@@ -30,7 +30,7 @@ Things to confirm:
 DirectInput is Windows-only. On Linux the same descriptor shows up through evdev (`evtest`, `jstest`), but how
 the two collections are split is kernel-dependent — use SInput mode there.
 
-### Testing the SInput mode (mode 11)
+### Testing the SInput mode (mode 12)
 
 SInput needs a host with SDL's SInput driver (SDL 3.4+ / a current Steam client). Practical checks:
 

+ 39 - 16
docs/index.html

@@ -163,10 +163,11 @@
         <button data-mode="5" class="modebtn">PS5 DualSense</button>
         <button data-mode="6" class="modebtn">HID gyro (DS4)</button>
         <button data-mode="9" class="modebtn">PS3 (DualShock 3)</button>
-        <button data-mode="10" class="modebtn">DirectInput (sims)</button>
-        <button data-mode="11" class="modebtn">SInput (SDL native)</button>
+        <button data-mode="10" class="modebtn">Original Xbox</button>
+        <button data-mode="11" class="modebtn">DirectInput (sims)</button>
+        <button data-mode="12" class="modebtn">SInput (SDL native)</button>
       </div>
-      <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only.</p>
+      <p class="note">Switching reboots the copycat and re-enumerates USB (~2 s). WebUSB works in every mode, including Steam/Lizard with Steam running (Chrome claims the vendor interface; Steam keeps the HID slots). Switch HORIPAD + WebUSB is PC-only. Original Xbox emulates one Microsoft Controller S and uses the Xbox mapping tab; LB/RB become White/Black. It's for a real Original Xbox console — a PC needs a driver for the original Xbox controller to see it as a gamepad.</p>
       <p class="note"><b>DirectInput</b> is for flight/space sims: every analog input is live at once, split over two joysticks (#1 sticks + triggers + 26 buttons, #2 trackpads + gyro). Trackpad axes hold their last position and re-centre on a pad click. <b>SInput</b> is the open SDL-native protocol — SDL3 / Steam Input read sticks, both analog triggers, gyro, both trackpads and battery from it natively, with rumble back from the host.</p>
       <div class="row"><label>Persist last mode</label>
         <button id="persistMode">off</button>
@@ -242,9 +243,6 @@
       <div class="row" style="margin:10px 0 0"><button id="ctlrOff">Turn off controller</button>
         <span class="note" style="flex:1">Fires the controller power-off (0x9F "off!", x3) — the same path Steam's "turn off controller" and host-suspend use. Watch the link status to see it disconnect.</span>
       </div>
-      <div class="row debugonly" style="margin:10px 0 0"><button id="landAll87">off</button>
-        <span class="note" style="flex:1"><strong>Land all 0x87 (experiment).</strong> Relay Steam's full haptic/settings config to the controller verbatim (like the real puck) instead of discarding it. Testing whether the connect buzz is caused by the controller's haptic engine being left misconfigured. If the <em>gyro freezes</em> in Steam, turn this back off.</span>
-      </div>
       <div class="row debugonly" style="margin:10px 0 0;border-top:1px solid #272d3d;padding-top:14px"><button id="stabBtn">Test stability</button>
         <span class="v" id="stabStatus" style="flex:0 0 auto;min-width:120px">—</span>
         <span class="note" style="flex:1">Buzzes the controllers every 10 s (keeps them awake) and times how long the puck stays up. On a reset it records the uptime and shows it by <em>Last reset</em>; it auto-resumes after the puck reconnects. The count is kept in this tab (a page refresh clears it).</span>
@@ -391,7 +389,7 @@
 </div>
 
 <script>
-const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)","DirectInput (sims)","SInput (SDL native)"];
+const MODE_NAMES = ["Steam (puck)","Xbox 360","Switch (HORIPAD)","Lizard (always)","Switch Pro + gyro","PS5 DualSense","HID gyro (DS4)","PS5 (game/clean)","DS4 (game/clean)","PS3 (DualShock 3)","Original Xbox","DirectInput (sims)","SInput (SDL native)"];
 const CHORD_FIELD = [17, 18, 19];          // back4 + B/X/Y
 const CHORD_DPAD_FIELD = [34, 35, 36, 37]; // back4 + D-pad left/up/right/down (firmware protocol >= 18)
 const CHORD_DPAD_DEF = [9, 8, 7, 2];       // firmware defaults: PS3, DS4 game, PS5 game, Switch HORIPAD
@@ -551,7 +549,7 @@ const TYPE_DEFS = [
   {key:"DS5", name:"DS5", labels:{1:"Cross",2:"Circle",3:"Square",4:"Triangle",5:"L1",6:"R1",7:"L3",8:"R3",9:"Create",10:"Options",11:"PS",19:"L2",20:"R2",...DPAD,16:"Touchpad Click",17:"Mute"}},
 ];
 // Which emulated type a USB mode belongs to (mirror of firmware etypeForMode); -1 = puck mode (no type).
-function etypeForMode(m){ switch(m){case 1:return 0; case 2:case 4:return 1; case 6:case 8:case 9:return 2; case 5:case 7:return 3; default:return -1;} }
+function etypeForMode(m){ switch(m){case 1:case 10: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
@@ -594,7 +592,13 @@ let lizardBindings = [];   // [{outType, od:[7], trig, hold}]
 let lizardBusy = false;
 let lizardLoaded = false;  // one-shot: the lizard map is fetched lazily, only once the connected puck proves it speaks v16+
 const BACK_LABELS = ["L4 (back upper-left)","R4 (back upper-right)","L5 (back lower-left)","R5 (back lower-right)"];
-const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV, rumble}
+const typeEls = []; // one entry per TYPE_DEFS: {sec, tab, activeDot, back[], qam, abSwap, pad, led, ledV, rumble, padStick[]}
+// Trackpad -> joystick mapping (firmware PS_OFF/PS_LEFT/PS_RIGHT). Field id = PAD_STICK_FIELD0 + et*2 + pad
+// (pad 0 = left trackpad, 1 = right). While mapped and touched the pad drives that stick; on release the
+// stick re-centers, and an untouched mapped pad leaves the physical stick in control.
+const PAD_STICK_FIELD0 = 80;
+const PAD_STICK_OPTS = [[0,"Off (touchpad)"],[1,"Left stick"],[2,"Right stick"]];
+const PAD_STICK_LABELS = ["Left trackpad \u2192 stick","Right trackpad \u2192 stick"];
 function mkSelect(def, includeNone){
   const sel=document.createElement("select");
   if(includeNone){ const o=document.createElement("option"); o.value=0; o.textContent="— none —"; sel.appendChild(o); }
@@ -622,7 +626,7 @@ function setTab(et){
     tab.onclick=()=>setTab(et); bar.appendChild(tab);
     // panel
     const sec=document.createElement("div"); sec.style.cssText="background:#0e1017;border:1px solid #232a3a;border-radius:8px;padding:12px 14px;display:none";
-    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null,rumble:null};
+    const rec={sec,tab,activeDot:tab.querySelector(".active-dot"),back:[],qam:null,abSwap:null,pad:null,led:null,ledV:null,rumble:null,padStick:[]};
     // A/B swap + trackpad haptics + rumble toggles
     const tog=document.createElement("div"); tog.className="row";
     tog.innerHTML='<label>A/B + X/Y swap</label>';
@@ -660,6 +664,16 @@ function setTab(et){
       sl.addEventListener("change",()=>setField(40+et*9+7, +sl.value));
       sec.appendChild(row);
       rec.led=sl; rec.ledV=vspan; }
+    // trackpad -> joystick mapping (one select per pad)
+    for(let pad=0; pad<2; pad++){
+      const row=document.createElement("div"); row.className="row";
+      const lab=document.createElement("label"); lab.textContent=PAD_STICK_LABELS[pad]; row.appendChild(lab);
+      const sel=document.createElement("select");
+      for(const [v,lbl] of PAD_STICK_OPTS){ const o=document.createElement("option"); o.value=v; o.textContent=lbl; sel.appendChild(o); }
+      row.appendChild(sel); sec.appendChild(row);
+      sel.addEventListener("change",()=>setField(PAD_STICK_FIELD0+et*2+pad, +sel.value));
+      rec.padStick.push(sel);
+    }
     host.appendChild(sec); typeEls.push(rec);
   });
   setTab(0);
@@ -1046,6 +1060,8 @@ function applyBlob(p){
     if (RR_FAULT.has(code) && !window._flightAuto){ window._flightAuto=true; window._autoFlight=true; }
   }
 
+  // per-type trackpad->stick mapping (blob v20, firmware p[187..194] -> JS p[185..192])
+  const padStickCap=(p[0]>=20 && p.length>192);
   const gitDirty=(p.length>38?p[38]:0); let buildId="";
   for(let i=39;i<51 && i<p.length && p[i];i++) buildId+=String.fromCharCode(p[i]);
   $("#stBuild").innerHTML = buildId
@@ -1057,8 +1073,6 @@ function applyBlob(p){
   checkUpdateNotice(); // needs BOTH the installed version (this blob) and relCache (fetched on connect)
   setSlider("mDiv",mDiv); setSlider("mFric",mFric);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
-  // land-all-0x87 experiment toggle (blob v14/v17, firmware p[181] -> JS p[179])
-  { const la=(p.length>179?p[179]:0); $("#landAll87").textContent = la?"on":"off"; $("#landAll87").classList.toggle("active",!!la); }
   // per-type button config (blob v10/v17): 4 types x 9 bytes. readBlob() strips the 2-byte [0xA5][len] header, so
   // the firmware's buffer index 75 lands at JS index 73 here.
   if(p.length>=73+TYPE_DEFS.length*9){
@@ -1072,6 +1086,11 @@ function applyBlob(p){
       rec.pad.textContent=pad?"on":"off"; rec.pad.classList.toggle("active",!!pad);
       rec.rumble.textContent=rum?"on":"off"; rec.rumble.classList.toggle("active",!!rum);
       if(document.activeElement!==rec.led){ rec.led.value=led; rec.ledV.textContent=(led===0)?"Auto":led+"%"; }
+      rec.padStick.forEach((sel,pad)=>{
+        // hidden entirely on firmware too old to speak the mapping, so the panel never shows a dead control
+        sel.parentNode.classList.toggle("hide", !padStickCap);
+        if(padStickCap && document.activeElement!==sel) sel.value=p[185+et*2+pad];
+      });
       // dot marks the type matching the current puck mode
       rec.activeDot.style.display = (et===activeEt) ? "" : "none";
     });
@@ -1183,7 +1202,10 @@ function buildBackup(p, bp){
     bonds.push({slot:s, used:!!((mask>>s)&1), rec:hexEnc(rec)}); }
   const types=[];
   for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*9;
-    types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7], rumble:p[q+8]}); }
+    const t={back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7], rumble:p[q+8]};
+    // pad->stick mapping only exists from protocol v20; omit on older pucks so a restore can't replay defaults
+    if(p[0]>=21 && p.length>192) t.padStick=[p[185+et*2],p[186+et*2]];
+    types.push(t); }
   const cfg={ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]], types };
   // D-pad chords only exist from protocol v18, the Switch gyro mapping from v19; omit the keys entirely on
   // older pucks so a restore doesn't replay defaults over a newer puck's real settings. (Backups from before
@@ -1242,7 +1264,8 @@ async function importBackup(file){
   const typeN=Array.isArray(c.types)?Math.min(c.types.length,4):0;
   const lizardN=(Array.isArray(c.lizardMap) && lizardCapable())?Math.min(c.lizardMap.length,LZ_MAX):0;
   const baseN=6 + (Array.isArray(c.chordD)?4:0) + (c.swGyroLegacy!==undefined?1:0);
-  const total=baseN + typeN*9 + lizardN + 4 + 1;
+  const padStickN=Array.isArray(c.types)?c.types.slice(0,typeN).filter(t=>Array.isArray(t.padStick)).length*2:0;
+  const total=baseN + typeN*9 + padStickN + lizardN + 4 + 1;
   let step=0, stage="";
   const tick=()=>modalStage(stage, Math.min(99,Math.floor(step*100/total)));
   try{
@@ -1278,7 +1301,8 @@ async function importBackup(file){
         for(let k=0;k<4;k++) await sf(40+et*9+k, t.back[k]);
         await sf(40+et*9+4, t.qam); await sf(40+et*9+5, t.abSwap?1:0);
         await sf(40+et*9+6, t.pad?1:0); await sf(40+et*9+7, t.led);
-        await sf(40+et*9+8, t.rumble!==undefined?t.rumble:1); }
+        await sf(40+et*9+8, t.rumble!==undefined?t.rumble:1);
+        if(Array.isArray(t.padStick)){ await sf(PAD_STICK_FIELD0+et*2, t.padStick[0]); await sf(PAD_STICK_FIELD0+et*2+1, t.padStick[1]); } }
       log("settings replayed ("+(baseN+typeN*9)+" fields)");
     } else { log("settings replayed ("+baseN+" fields — no per-type config in this backup)"); }
     // restore the lizard map if the backup includes it (version 2+) and the puck speaks v16+.
@@ -2184,7 +2208,6 @@ $("#wipeBoard").onclick=async()=>{
   log("FULL BOARD WIPE sent — the board is erasing firmware + all data (~15–20 s), then reboots as a blank UF2 drive. Flash OpenPuck (.uf2) to restore it.");
 };
 $("#persistMode").onclick=()=>{ const on=$("#persistMode").classList.contains("active"); setField(16, on?0:1); };
-$("#landAll87").onclick=async()=>{ const on=$("#landAll87").classList.contains("active"); await setField(29, on?0:1); log("land-all-0x87 "+(on?"disabled":"enabled")); };
 // Connection tuning: post-connect haptic block on (field 27) + seconds (field 28). (poll RX window is fixed.)
 for(const id of ["mDiv","mFric"]){
   const field={mDiv:1,mFric:2}[id];