Quellcode durchsuchen

Implement ESB forwarding, fix haptics/command overlap (#230)

* Test

* Test

* Test

* more dang tests

* test

* Cleanup

* Stall req if not ready

* Stupid library bugs

* More fuckery

* Cleanup

* Cleanup, remove identity spoofing, remove bond spoofing

* Drop custom 0xED code as this is now forwarded

* Cleanup comments, drop get/set settings code

* Cleanup, force-enable g_landAll87

* Reformat ATTR83

* Query trailer for 87

* more debugging

* library workaround

* workaround?

* No relay data for set_settings

* Update

* set setting no response

* More testing

* lenght logger

* More debugging

* Fix typo

* Fix typo

* More debugging

* memset only when needed

* more bugs

* remove cmd lock

* Cleanup part 1

* More cleanup

* Remove unnecessary g_landAll87 flag

* Fix clang-format

* Increase emulated firmware version

* Guard against stale data when forwarding fails

* Simplify land01

* Update PROTOCOL.md

* add isHaptic distinction for form selection

* Complete relayQuery list

* Update docs

* Remove drop81, tighten lizard keepalive, add defines for commands

* More haptics cleanup, add header file for command types

* Replace more hardcoded values with defined constants

* Test with correct bcd version

* Update scpair to remove bcd workaround

* Remove outdated comment

* Formatting

* Remove more unneeded haptics variables I missed

* Remove unused haptic definitions
Florian Bach vor 2 Monaten
Ursprung
Commit
b52e045a3d

+ 1 - 0
OpenPuck/OpenPuck.ino

@@ -66,6 +66,7 @@ using namespace Adafruit_LittleFS_Namespace;
 static uint8_t g_usbCfgDesc[512];
 
 // Per-mode USB serial suffix (modes 1..9: X=xbox N=hori L=lizard P=swpro S=ps5 G=hidgyro Q=ps5game D=ds4game 3=ps3).
+// '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'
 };

+ 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];
 

+ 3 - 6
OpenPuck/config.cpp

@@ -95,8 +95,8 @@ 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.
@@ -124,7 +124,7 @@ 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] },
@@ -207,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();

+ 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()
 {

+ 1 - 0
OpenPuck/mode_hidgyro.cpp

@@ -208,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");

+ 1 - 0
OpenPuck/mode_ps3.cpp

@@ -334,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");

+ 1 - 0
OpenPuck/mode_ps5.cpp

@@ -220,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");

+ 1 - 0
OpenPuck/mode_switch_hori.cpp

@@ -191,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");

+ 1 - 0
OpenPuck/mode_switch_pro.cpp

@@ -914,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");

+ 1 - 0
OpenPuck/mode_xinput.cpp

@@ -485,6 +485,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");

+ 168 - 211
OpenPuck/puck_hid.cpp

@@ -7,6 +7,7 @@
 #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"
@@ -124,20 +125,6 @@ static const uint8_t PUCK_LIZARD_HID_DESC[] = {
 
 static Adafruit_USBD_HID hid[NSLOT];
 
-// Drop Steam's relayed FEATURE cmd 0x81 = ID_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.
-// FEATURE-CHANNEL ONLY: the OUTPUT report with id 0x81 is ID_OUT_REPORT_HAPTIC_PULSE, an unrelated command
-// (see handleSet) -- dropping that one costs the trackpad-click / trigger-full-pull haptics (#163, #166).
-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
@@ -286,12 +273,7 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 					     (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);
 			}
 		}
 
@@ -327,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.
@@ -383,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
@@ -401,71 +424,49 @@ 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).
-			// TODO: This should not be read from the bond record and instead queried from the controller, see #227.
-			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 :
-					(idx == 3)	       ? "OpenPuck " :
-								 "NA";
-			memcpy(S.resp + 3, s, strlen(s));
-			if (rid == 2 && 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);
-				}
+		// 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;
@@ -473,10 +474,10 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 	}
 
 	// 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
@@ -488,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;
@@ -512,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;
@@ -616,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;
 
@@ -628,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;
@@ -701,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[]);

+ 1 - 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,13 +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 FEATURE cmd 0x81 CLEAR_DIGITAL_MAPPINGS (the connect
-				// amp-clicker). Does NOT touch OUTPUT report 0x81 = HAPTIC_PULSE (always relayed).
-				g_drop81 = !g_drop81;
-				Serial.printf(
-					"# drop relayed feature 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;
@@ -91,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).
@@ -354,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?

+ 3 - 4
OpenPuck/webusb_config.cpp

@@ -279,7 +279,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];
@@ -1029,10 +1029,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)

+ 2 - 5
ReversePuck/scpair.py

@@ -186,8 +186,7 @@ class HidDev:
             attrs = self.read_attribute_values(1, 0x83)
 
         elif self.pid in PUCK_PIDS:
-            if self.get_bcd_version() in [2, 0x212, 0x213, 0x211]:       
-                # 2 = Valve Puck or Machine, 211-213 OpenPuck? Unclear why.
+            if self.get_bcd_version() == 2:
                 attrs = self.read_attribute_values(2, 0x83)
             else: 
                 # I wonder which official 
@@ -214,7 +213,7 @@ class HidDev:
             r = self.get_feature(1)
               
         elif self.pid in PUCK_PIDS:
-            if self.get_bcd_version() in [2, 0x212, 0x213, 0x211]:       
+            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)
@@ -523,8 +522,6 @@ 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")

+ 13 - 32
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
@@ -365,14 +354,6 @@ from the feature-`0x01` **command** space even though the numbers overlap. Groun
 | `0x86` | (unnamed) | 3 | `FACTORY_RESET` |
 | `0x87`+ | 63-byte settings/config | 63 | `SET_SETTINGS_VALUES` … |
 
-`0x81` is the trap: the feature-channel `0x81` (`CLEAR_DIGITAL_MAPPINGS`) is dropped in Steam mode on
-purpose (`g_drop81`, console `S81` — it is the connect-time amp-clicker and OpenPuck does its own input
-translation), but the OUTPUT-report `0x81` is `HAPTIC_PULSE` — the left/right pulse Steam fires for
-trackpad **click** feedback ("Regular Press"), the **trigger full-pull** click and GripSense cues.
-Extending the feature-channel drop to the OUTPUT report is what made those haptics missing
-(issues #163 / #166) while "Soft Press" kept working, since that one rides `0x82 HAPTIC_COMMAND`.
-Pulses carry their own `repeat_count`, so they are self-terminating: there is no latch to strand and no
-stop frame that can be lost (unlike the `0x82` on/off pair or `0x80` rumble).
 
 ### 9.2 Xbox mode
 

+ 0 - 6
docs/index.html

@@ -239,9 +239,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>
@@ -1070,8 +1067,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){
@@ -2207,7 +2202,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];