ソースを参照

The buzzing is finally gone

Jari 3 ヶ月 前
親
コミット
b34a3ed1a5

+ 3 - 0
OpenPuck/OpenPuck.ino

@@ -29,6 +29,7 @@ using namespace Adafruit_LittleFS_Namespace;
 #include "controllers.h"
 #include "haptics.h"
 #include "rf_link.h"
+#include "puck_hid.h" // puckCmdLogDrain()
 #include "rf_diag.h"
 #include "webusb_config.h"
 #include "serial_console.h"
@@ -336,6 +337,7 @@ void loop()
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
+	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
 	loops++;
 	if (millis() - secMs >= 1000) {
 		g_loopPeriodUs = loops ? (uint16_t)(1000000UL / loops) : 0;
@@ -375,5 +377,6 @@ void loop()
 	usbMountTask(); // dynamic mount/unmount of connected controllers (no-op unless enabled)
 	faultDiagSetStage(8);
 	usbTxPump(); // drain queued device->host reports HERE, in loop -- never off-loop (jitters the RF poll)
+	puckCmdLogDrain(); // print captured USB feature commands (diagnostic; no-op unless g_cmdCapture)
 #endif
 }

+ 7 - 5
OpenPuck/config.cpp

@@ -76,10 +76,9 @@ const uint32_t g_pollUs = POLL_US_DEFAULT;
 struct Cfg {
 	uint8_t magic, mode, mDiv, mFric, rsvd0, pollU100, persistMode,
 		bootMode, chordBtn[3], rumbleScale;
-	// rxWin10: legacy RF tunable slot (window now fixed; ignored). lizKeep: the lizard-suppression
-	// keepalive enable (see haptics.h LIZKEEP_MS) -- repurposed from the dead post-connect haptic-block
-	// bytes. rsvd1: the block's old duration byte, free for the next tunable.
-	uint8_t rxWin10, lizKeep, rsvd1;
+	// 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;
 	TypeCfg type[ET_COUNT]; // per-emulated-type back/qam/abSwap/padHaptics
 }; // rsvd0 = ex-padSmooth, now the one-shot debug-CDC arm
 
@@ -97,7 +96,7 @@ void saveCfg()
 		  g_rumbleScale,
 		  (uint8_t)(g_rxWin / 10),
 		  g_lizKeep,
-		  0,
+		  g_landAll87,
 		  {} };
 	for (int i = 0; i < ET_COUNT; i++)
 		c.type[i] = g_type[i];
@@ -156,6 +155,9 @@ 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();

+ 62 - 18
OpenPuck/haptics.cpp

@@ -19,9 +19,23 @@ volatile uint8_t g_hapticStop = 0;
 // Off by default -- opt in from the WebUSB panel if a controller's haptics come up degraded after connect.
 uint8_t g_hapticBlockOn = 0;
 uint16_t g_hapticBlockMs = HAPTIC_BLOCK_MS_DEFAULT;
-// Lizard-suppression keepalive (see hapticTask): OFF by default (it silences autonomous touchpad haptics;
-// Steam sends its own id9=0 anyway). Persisted; console 'u' toggles for A/B.
-uint8_t g_lizKeep = 0;
+// id9=0 hold in MODE_STEAM (see hapticTask): ON by default -- keeps the controller's autonomous haptic engine
+// off so it can't latch into the deep-inside buzz. Scoped to MODE_STEAM (pure MODE_LIZARD keeps its ticks).
+// 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;
 // Per-slot reconnect block. 0 = idle; non-zero = drop haptics aimed at this slot until millis() catches up.
 unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 
@@ -416,11 +430,25 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 			// 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);
+				(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) {
 				p[0] = g_relayOp;
@@ -439,6 +467,18 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 				plen = (uint8_t)(4 + rl);
 			}
 			hapLogAdd(0xFE, m.rid, m.data, rl);
+			// Lifecycle log (CDC debug boot): EXACTLY what we TX to the controller, so a capture shows
+			// whether OpenPuck (or Steam in the background) pokes the controller right before a buzz
+			// latches -- the piece the I45 stream (controller->us) can't show. Low rate outside a haptic
+			// burst; guarded so it never blocks the loop. cur = target slot.
+			if (Serial.availableForWrite() > 60) {
+				Serial.printf("# TX t=%lu slot%d %s rid=%02X:",
+					      (unsigned long)millis(), cur,
+					      land01 ? "L01" : "l05", m.rid);
+				for (uint8_t i = 0; i < rl && i < 8; i++)
+					Serial.printf(" %02X", m.data[i]);
+				Serial.println();
+			}
 			// slot was already consumed under the critical section above.
 			// s1 carries a PID distinct from the GET poll (caller cycles it) so the controller's ESB
 			// dedup never treats the GET as a retransmit of this relay. 80us RX: relay is NO-ACK.
@@ -544,20 +584,18 @@ void hapticOnReconnect(int slot)
 }
 void hapticTask()
 {
-	// Lizard-suppression keepalive (OFF by default; 'u' toggles for A/B): land SET_SETTINGS index 9 = 0 on
-	// each connected controller every LIZKEEP_MS, the way Steam does through the real puck (sniff1.json:
-	// id9=0 every 3.000s). Holding settings[9]=0 keeps the controller's revert timer (ibex FUN_0004265c)
-	// from re-enabling autonomous mode + resetting all settings to defaults (the amp-pop / spurious buzz).
-	// BUT settings[9]=0 disables the controller's whole pad-input layer (ibex FUN_0001bd80 early-returns when
-	// id9==0), and the autonomous TOUCHPAD HAPTIC tick is generated by that layer -- so holding id9=0 kills
-	// the pad ticks (confirmed on HW). There is NO id9 setting that keeps ticks while suppressing lizard;
-	// id9 gates the entire pad layer. So NEVER send it while lizard is active -- pure MODE_LIZARD and
-	// Steam-closed both want those ticks; leaving id9 alone (default 1) keeps them for free. In Steam-driven
-	// mode Steam sends its own id9=0 (now landed by the whitelist), so this synthesized copy is redundant
-	// there; off by default, it's the experiment behind 'u'. NOTE the framing: this uses 0x87 (SET_SETTINGS),
-	// which is idempotent -- a repeated same-value id9=0 is change-guarded in the controller and does NOT
-	// re-fire the click-causing 0x81 side effects, so holding via 0x87 is silent where a looped 0x81 was not.
-	if (g_lizKeep && !puckLizardActive()) {
+	// id9=0 hold in MODE_STEAM: land SET_SETTINGS index 9 = 0 on each connected controller every LIZKEEP_MS,
+	// the way the real puck does (sniff1.json: id9=0 landed ~every few s). This holds the controller in
+	// gamepad mode with its AUTONOMOUS mapping/haptic engine OFF -- which is what stops the controller's own
+	// touchpad-haptic engine from latching into the "deep-inside" buzz seen after repeated reconnects (that
+	// buzz is controller-internal: capture-for-haptics.txt showed the controller in autonomous mode, rid=40/
+	// 41, with OpenPuck relaying no haptics at all -- so only holding id9=0 quiets it). Framing note: 0x87
+	// (SET_SETTINGS) is idempotent -- a repeated same-value id9=0 is change-guarded in the controller, so it
+	// does NOT re-fire the click-causing 0x81 side effects; holding via 0x87 is silent where a looped 0x81
+	// was not. It DOES disable the controller's autonomous touchpad ticks (id9 gates the whole pad layer),
+	// which is fine in Steam mode (Steam owns haptics) -- hence scoped to MODE_STEAM ONLY. Pure MODE_LIZARD
+	// deliberately leaves id9 alone so its autonomous ticks keep working (and it's the mode with no buzz).
+	if (g_lizKeep && g_usbMode == MODE_STEAM) {
 		static unsigned long lastKeep[NSLOT] = { 0 };
 		static const uint8_t KA[3] = { 0x09, 0x00, 0x00 };
 		for (int s = 0; s < NSLOT; s++) {
@@ -584,6 +622,12 @@ void hapticTask()
 		if (!up && wasHapticLinkUp[s]) {
 			uint8_t mk = 0;
 			hapLogAdd(0xFD, 0xEE, &mk, 1);
+			// Lifecycle log (CDC debug boot): the 300ms link-up watchdog saw this slot go silent. Pairs
+			// with the CONNECT/RECONNECT lines from rf_link so a session of cycles shows the full
+			// down->up cadence -- how often the link actually drops (churn) vs stays up.
+			if (Serial.availableForWrite() > 50)
+				Serial.printf("# LC t=%lu slot%d link DOWN\n",
+					      (unsigned long)millis(), s);
 		}
 		wasHapticLinkUp[s] = up;
 	}

+ 13 - 6
OpenPuck/haptics.h

@@ -47,14 +47,21 @@
 bool relayEnqueue(uint8_t rid, const uint8_t *payload, uint8_t plen,
 		  uint8_t slot = 0xFF);
 
-// Lizard-suppression keepalive: hold the controller's SET_SETTINGS index 9 (digital-mappings/lizard-active)
-// at 0, once per LIZKEEP_MS per connected slot, exactly like the real puck (Steam lands id9=0 every 3.000s,
-// sniff1.json). Without the hold, the controller's revert timer (ibex FUN_0004265c) re-enables autonomous
-// mode and resets ALL settings to defaults -> amp pop + autonomous touchpad ticks = spurious buzz with zero
-// host/RF traffic. 2s cadence = comfortable margin under the (unmeasured, SC1-era ~5s) revert timeout.
+// 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
+// mapping/haptic engine OFF so it can't latch into the deep-inside buzz seen after repeated reconnects
+// (capture-for-haptics.txt: the buzz is controller-internal; OpenPuck relays no haptics in that state). It
+// also disables the controller's autonomous touchpad ticks (id9 gates the whole pad layer) -- fine in Steam
+// mode (Steam owns haptics), so it is scoped to MODE_STEAM; pure MODE_LIZARD is left alone to keep its ticks.
 #define LIZKEEP_MS 2000u
 extern uint8_t
-	g_lizKeep; // 1 = keepalive on (default, persisted); console 'u' toggles for A/B
+	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;
 
 // Post-connect haptic block (persisted, panel-controlled): when g_hapticBlockOn, Steam haptics are dropped for
 // g_hapticBlockMs after a (re)connect so the controller's haptic engine settles before the first real haptic.

+ 217 - 18
OpenPuck/puck_hid.cpp

@@ -92,6 +92,69 @@ 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
+// avoids printf here). handleSet/handleGet (usbd task) push a compact record under PRIMASK; puckCmdLogDrain()
+// prints them from loop() context. When g_cmdCapture is on, the high-rate I45 input stream is also suppressed
+// (rf_link) so the command sequence is readable. Toggle from the console with "FC".
+bool g_cmdCapture = true;
+struct FCmdRec {
+	uint8_t dir; // 0 = SET (host->puck write), 1 = GET (host read)
+	uint8_t iface; // HID interface index = bond slot the command hit
+	uint8_t rid, cmd, len, b[10];
+};
+static FCmdRec g_fc[32];
+static volatile uint8_t g_fcHead, g_fcTail;
+static void fcPush(uint8_t dir, int iface, uint8_t rid, uint8_t cmd,
+		   uint8_t len, const uint8_t *b, uint8_t n)
+{
+	if (!g_cmdCapture)
+		return;
+	uint32_t pm = __get_PRIMASK();
+	__disable_irq();
+	uint8_t h = g_fcHead, nx = (uint8_t)((h + 1) & 31);
+	if (nx !=
+	    g_fcTail) { // drop-newest when full (loop drains fast); never block the usbd task
+		g_fc[h].dir = dir;
+		g_fc[h].iface = (uint8_t)iface;
+		g_fc[h].rid = rid;
+		g_fc[h].cmd = cmd;
+		g_fc[h].len = len;
+		for (uint8_t i = 0; i < 10; i++)
+			g_fc[h].b[i] = (i < n) ? b[i] : 0;
+		g_fcHead = nx;
+	}
+	__set_PRIMASK(pm);
+}
+void puckCmdLogDrain(void)
+{
+	while (g_fcHead != g_fcTail) {
+		if (Serial.availableForWrite() < 70)
+			break; // don't stall loop on CDC backpressure; resume next iteration
+		FCmdRec r = g_fc[g_fcTail];
+		g_fcTail = (uint8_t)((g_fcTail + 1) & 31);
+		Serial.printf("# FC %s if=%u rid=%u cmd=%02X len=%u:",
+			      r.dir ? "GET" : "SET", r.iface, r.rid, r.cmd,
+			      r.len);
+		for (uint8_t i = 0; i < 10; i++)
+			Serial.printf(" %02X", r.b[i]);
+		Serial.println();
+	}
+}
+
 // ===================== seamless LIZARD decision =====================
 // Steam, while running, re-sends settings report 0x87 (lizard-off) every ~3s as a heartbeat (captured on HW),
 // and ANY OUTPUT report likewise stamps g_steamAliveMs. When the heartbeat stops we fall back to lizard, so the
@@ -158,15 +221,12 @@ 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;
-		// NEVER relay output report 0x81 (= CLEAR DIGITAL MAPPINGS, ibex FUN_0001f554) to the controller.
-		// It is NOT a haptic despite sitting in the 0x80-0x86 range; it is non-idempotent (each one re-arms a
-		// hardware block -> an audible actuator "deep-inside" click) and OpenPuck does its OWN input
-		// translation, so it never needs the controller's internal mapping engine. Steam sends CLEAR DIGITAL
-		// MAPPINGS as part of its per-controller CONNECT init (this is why the click is seen only in Steam
-		// mode, only with Steam open, and only at connect). sniff1.json was captured mid-session (established
-		// link, no connect handshake), so it never showed the connect init -- absence of 0x81 there is not
-		// evidence Steam never sends it. Dropping the relay is safe (Steam still gets 0x45) and quiets connect.
-		if (rid >= 0x80 && rid <= 0x86 && rid != 0x81 && n >= 1 &&
+		// 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 (rid >= 0x80 && rid <= 0x86 && !dropOut81 && n >= 1 &&
 		    hapticRelaySlotOk(slot) && !lizardActive() && !muted) {
 			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
@@ -208,6 +268,8 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 	// Capture EVERY feature SET (the whole cmd channel: 0x83 attr, 0xAE strings, 0xB4 conn, 0xA2/A3 bond, the
 	// feature-1 relay, AND any host battery query) to the WebUSB ring so the panel's capture view shows it.
 	hapLogAdd((uint8_t)slot, cmd, b, n);
+	// ...and to the serial feature-command capture (diagnostic; drained in loop, see puckCmdLogDrain).
+	fcPush(0, slot, rid, cmd, len, pl, pln > 10 ? 10 : (uint8_t)pln);
 
 	// settings/haptic/LED report (incl. 0x87 lizard-off heartbeat, SDL Triton lizard-disable)
 	if (cmd >= 0x80 && cmd <= 0x89)
@@ -228,10 +290,26 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		bool muted = g_resumeMs &&
 			     millis() - g_resumeMs < POST_RESUME_MUTE_MS;
 
-		// never push haptics while presenting lizard (Steam isn't reading 0x45 -> would buzz-loop); and NEVER
-		// relay 0x81 CLEAR DIGITAL MAPPINGS (same reason as the OUTPUT path above -- non-idempotent actuator
-		// click, unused by OpenPuck's own translation, and the source of the Steam-mode connect clicks).
-		bool relayOk = hapticRelaySlotOk(slot) && cmd != 0x81 &&
+		// 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)
+		bool relayOk = hapticRelaySlotOk(slot) && !drop &&
+			       !localAnswer &&
 			       !(haptic82 && (lizardActive() || muted));
 		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
 			// Relay the DECLARED length (up to the 60B RF frame ceiling), not a truncation: Steam's
@@ -269,13 +347,45 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		S.resp[0] = 0x83;
 		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: {
 		uint8_t idx = pln > 0 ? pl[0] : 1;
-		const char *s = (idx == 0) ? g_board :
-				(idx == 1) ? g_unit :
-					     "NA";
+		// 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). idx 3 = the fixed BT-MAC-style
+		// string the real controller reports.
+		char ctrl[17];
+		const char *s;
+		if (rid == 1 && slot >= 0 && slot < NSLOT &&
+		    g_slot[slot].used) {
+			memcpy(ctrl, g_slot[slot].rec + 8, 16);
+			ctrl[16] = 0;
+			s = (idx == 0 || idx == 1 || idx == 4) ? ctrl :
+			    (idx == 3) ? "7054257d2da7" :
+					 "NA";
+		} else {
+			s = (idx == 0 || idx == 4) ? g_board :
+			    (idx == 1)		   ? g_unit :
+			    (idx == 3)		   ? "7054257d2da7" :
+						     "NA";
+		}
 		S.resp[0] = 0xAE;
 		S.resp[1] = 0x14;
 		S.resp[2] = idx;
@@ -334,6 +444,86 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 			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;
@@ -368,13 +558,22 @@ static uint16_t handleGet(int slot, uint8_t rid, hid_report_type_t type,
 	// answer it with g_battery in handleSet). De-duped by report id so the high-rate polling doesn't flood the
 	// ring/console -- a freshly-requested id is logged once, then again only after 1s.
 	{
-		static uint8_t lastRid = 0xFF;
+		// Dedup by (rid, interface, response-cmd) so each interface's GET is captured (not collapsed to one
+		// line across all 4 slots) -- the per-interface bond/identity responses are exactly what we need to
+		// see -- while still not flooding on high-rate same-report polling.
+		static uint8_t lastRid = 0xFF, lastSlot = 0xFF, lastCmd = 0xFF;
 		static unsigned long lastMs = 0;
-		if (rid != lastRid || millis() - lastMs > 1000) {
+		if (rid != lastRid || (uint8_t)slot != lastSlot ||
+		    S.resp[0] != lastCmd || millis() - lastMs > 1000) {
 			lastRid = rid;
+			lastSlot = (uint8_t)slot;
+			lastCmd = S.resp[0];
 			lastMs = millis();
 			// ring marker 0xFC = "host feature GET" (panel renders it as "GET rid=.."); payload = what we returned
 			hapLogAdd(0xFC, rid, S.resp, n);
+			// serial feature-command capture: what Steam READ on this interface + our answer
+			fcPush(1, slot, rid, S.resp[0], (uint8_t)n, S.resp + 2,
+			       10);
 #if OPK_LOG
 			// Diagnostic build only -- no Serial.printf on the 800B usbd-task stack in production (LOCKUP
 			// mitigation). The GET is captured to the OPK_LOG ring above for the WebUSB panel.

+ 10 - 0
OpenPuck/puck_hid.h

@@ -24,6 +24,16 @@ extern uint8_t g_fwdNewOnly;
 // NOT run while lizard is active, or it disables the controller's autonomous touchpad haptic ticks.
 bool puckLizardActive();
 
+// Feature-command capture (diagnostic): when g_cmdCapture is on, Steam's USB feature SET/GET commands are
+// logged to serial (# FC lines) from loop context and the high-rate I45 input stream is suppressed, so the
+// connect handshake is readable. puckCmdLogDrain() must be called once per loop(). Console "FC" toggles it.
+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;
+
 class SteamPuckController : public IController {
     public:
 	void begin() override;

+ 62 - 4
OpenPuck/rf_link.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "triton.h"
 #include "haptics.h"
+#include "puck_hid.h" // g_cmdCapture (suppress I45 during feature-command capture)
 #include "controllers.h"
 #include "status_led.h"
 #include "fault_diag.h"
@@ -76,6 +77,14 @@ uint16_t g_rfStallRecover = 0;
 // not merely blipping. RF_RECOVER_MS rate-limits the recovery so it can't thrash while a stalled link re-syncs.
 #define RF_STALL_MS 2500u
 #define RF_RECOVER_MS 2000u
+// A genuinely WEDGED radio recovers within a power-cycle or two; a controller that is simply OFF / out of
+// range never comes back no matter how many times we power-cycle. So after RF_STALL_GIVEUP consecutive
+// recoveries that restored NO link, treat it as "controller absent" and stop hammering: back the power-cycle
+// off to RF_STALL_BACKOFF_MS (a slow safety-net kick). Discovery beacons keep running throughout, so a
+// returning controller still reconnects normally without the power-cycle -- and the aggressive 2s cadence was
+// both wasteful and able to disrupt a controller mid-reconnect. The counter resets the moment any slot replies.
+#define RF_STALL_GIVEUP 3u
+#define RF_STALL_BACKOFF_MS 30000u
 // measured avg us between GET-poll fires (vs intended g_pollUs)
 uint16_t g_pollPeriodUs = 0;
 static uint32_t g_pollDtSum = 0;
@@ -305,8 +314,31 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				// (e.g. a power-cycled controller that reconnects without us cleanly seeing the link drop).
 				if (g_connReplyMs[s] == 0 ||
 				    (uint32_t)(millis() - g_connReplyMs[s]) >
-					    1500u)
+					    1500u) {
+					// Lifecycle log (CDC debug boot): distinguish a first-ever connect from a reconnect and
+					// print the silent gap -- lets a long session of connect/disconnect cycles be diffed to
+					// see whether each cycle re-establishes (churn / boot-haptic click) vs stays linked.
+					if (Serial.availableForWrite() > 50) {
+						uint32_t gap =
+							g_connReplyMs[s] ?
+								(uint32_t)(millis() -
+									   g_connReplyMs
+										   [s]) :
+								0;
+						Serial.printf(
+							"# LC t=%lu slot%d %s gap=%lums rtype=%02X cd=%lums\n",
+							(unsigned long)millis(),
+							s,
+							g_connReplyMs[s] ?
+								"RECONNECT" :
+								"CONNECT",
+							(unsigned long)gap,
+							rtype,
+							(unsigned long)(millis() -
+									g_connCooldown));
+					}
 					hapticOnReconnect(s);
+				}
 				g_connRx++;
 				// link alive -> loop() suppresses the redundant E1 beacon
 				g_connReplyMs[s] = millis();
@@ -338,6 +370,17 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						others++;
 				if (others == 0)
 					g_connCooldown = millis();
+				// Lifecycle log: the controller sent F2 (disconnect/power-off). Note whether it armed the
+				// 2.5s cooldown (which pauses ALL beacon+poll -> the controller can lose the session and
+				// reboot on the next reconnect = a boot-haptic click). Prime suspect for the connect buzz.
+				if (Serial.availableForWrite() > 50)
+					Serial.printf(
+						"# LC t=%lu slot%d F2 DISCONNECT others=%d%s\n",
+						(unsigned long)millis(),
+						g_curSlot, others,
+						others == 0 ?
+							" -> cooldown 2.5s (beacon+poll paused)" :
+							"");
 			}
 			// F3 = controller status/version reply (reply to E7 handshake, byte[6]=version)
 			if (rtype == 0xF3) {
@@ -678,6 +721,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 				// compact stream for rf_controller_ui.py -- NON-BLOCKING: skip if CDC TX is backed up (a blocking
 				// Serial.print stalls the RF+USB loop -> jaggy input). One line/frame using the last record.
 				if (lastRep && !g_connVerbose &&
+				    !g_cmdCapture &&
 				    Serial.availableForWrite() > 110 &&
 				    millis() - g_lastStream >= 4) {
 					g_lastStream = millis();
@@ -897,6 +941,7 @@ void rfLinkTask()
 	// cannot thrash. g_rfStallRecover is surfaced to the panel so the wedge -- and its recovery -- is observable.
 	if (g_connOn && g_curSlot >= 0 && millis() - g_connCooldown > 2500) {
 		static unsigned long lastRecoverMs = 0;
+		static uint8_t consecStall = 0;
 		unsigned long nowMs2 = millis();
 		bool anyEverConnected = false, allStalled = true;
 		for (int s = 0; s < NSLOT; s++) {
@@ -906,10 +951,20 @@ void rfLinkTask()
 			if ((uint32_t)(nowMs2 - g_connReplyMs[s]) < RF_STALL_MS)
 				allStalled = false;
 		}
+		// Any slot alive -> a recovery worked (or we were never stalled): reset the give-up counter.
+		if (!allStalled)
+			consecStall = 0;
+		// Back off once we've power-cycled RF_STALL_GIVEUP times with nothing coming back (controller absent,
+		// not a wedged radio): slow the retry to RF_STALL_BACKOFF_MS instead of hammering every RF_RECOVER_MS.
+		uint32_t interval = (consecStall < RF_STALL_GIVEUP) ?
+					    RF_RECOVER_MS :
+					    RF_STALL_BACKOFF_MS;
 		if (anyEverConnected && allStalled &&
-		    (uint32_t)(nowMs2 - lastRecoverMs) > RF_RECOVER_MS) {
+		    (uint32_t)(nowMs2 - lastRecoverMs) > interval) {
 			lastRecoverMs = nowMs2;
 			g_rfStallRecover++;
+			if (consecStall < 255)
+				consecStall++;
 			NRF_RADIO->POWER = 0;
 			NRF_RADIO->POWER = 1;
 			g_connCooldown = 0;
@@ -917,8 +972,11 @@ void rfLinkTask()
 			g_connStep = 0;
 			if (Serial.availableForWrite() > 60)
 				Serial.printf(
-					"# RF STALL (#%u) -> radio power-cycle + reconnect\n",
-					g_rfStallRecover);
+					"# RF STALL (#%u consec=%u%s) -> radio power-cycle + reconnect\n",
+					g_rfStallRecover, consecStall,
+					consecStall >= RF_STALL_GIVEUP ?
+						" BACKOFF" :
+						"");
 		}
 	}
 

+ 30 - 2
OpenPuck/serial_console.cpp

@@ -5,6 +5,7 @@
 #include "haptics.h"
 #include "bonds.h"
 #include "config.h"
+#include "puck_hid.h" // g_cmdCapture (feature-command capture toggle)
 #include "fault_diag.h"
 #include <Adafruit_TinyUSB.h>
 #include <Arduino.h>
@@ -33,6 +34,33 @@ 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, "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;
+				Serial.printf(
+					"# 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 (line[0] == 'l')
 				rfListenStart();
 			else if (line[0] == 'B') {
@@ -91,11 +119,11 @@ void serialConsolePoll()
 				NRF_RADIO->TASKS_DISABLE = 1;
 				Serial.println("# RF off");
 			} else if (line[0] == 'u') {
-				// lizard-suppression keepalive A/B toggle (persisted; see haptics.h LIZKEEP_MS)
+				// id9=0 hold (MODE_STEAM only) A/B toggle (persisted; see haptics.h LIZKEEP_MS)
 				g_lizKeep = !g_lizKeep;
 				saveCfg();
 				Serial.printf(
-					"# lizard keepalive (id9=0 / %ums) %s\n",
+					"# id9=0 hold (Steam mode, /%ums) %s\n",
 					(unsigned)LIZKEEP_MS,
 					g_lizKeep ? "ON" : "off");
 			}

+ 12 - 3
OpenPuck/webusb_config.cpp

@@ -38,7 +38,8 @@ static volatile bool g_bondExportRequest = false;
 //                [v12: relayps(lo,hi)][clkLfSrc][clkHfSrc][usPerMs(lo,hi)][hangStage][curStage][stallMs/40][ringFault(lo,hi)][hangPC u32][hangLR u32][usbdStackFree(lo,hi)][loopStackFree(lo,hi)]
 //                [v13: per-slot link stats, 4x9B from p[141]: {pollsps u16, f1ps u16, newps u16, crc/s u8,
 //                 noRx/s u8, relay/s u8} -- each controller's own rates (the v4 aggregates are their sums)]
-#define WB_PAYLEN 175
+//                [v14: p[177] landAll87 (verbatim-0x87-relay experiment toggle)]
+#define WB_PAYLEN 176
 // 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
@@ -59,8 +60,8 @@ static void webusbSendBlob()
 	p[0] = 0xA5;
 	p[1] = WB_PAYLEN;
 
-	// protocol version (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] = 13;
+	// protocol version (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] = 14;
 	p[3] = g_usbMode;
 	p[4] = (uint8_t)g_mDiv;
 	p[5] = (uint8_t)g_mFric;
@@ -222,6 +223,8 @@ static void webusbSendBlob()
 		q[7] = g_slotNoRxps[s];
 		q[8] = g_slotRelayps[s];
 	}
+	// v14: verbatim-0x87-relay experiment toggle (panel reflects + toggles it)
+	p[177] = g_landAll87;
 	// 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
@@ -610,6 +613,12 @@ 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[177] reflects state.
+				case 29:
+					g_landAll87 = v ? 1 : 0;
+					break;
 				}
 				if (persist)
 					saveCfg();

+ 6 - 0
docs/index.html

@@ -146,6 +146,9 @@
       <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" 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" 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>
@@ -652,6 +655,8 @@ function applyBlob(p){
     : "—";
   setSlider("mDiv",mDiv); setSlider("mFric",mFric); setSlider("rumble",rumbleScale);
   $("#persistMode").textContent = persistMode? "on":"off"; $("#persistMode").classList.toggle("active",!!persistMode);
+  // land-all-0x87 experiment toggle (blob v14, firmware p[177] -> JS p[175])
+  { const la=(p.length>175?p[175]:0); $("#landAll87").textContent = la?"on":"off"; $("#landAll87").classList.toggle("active",!!la); }
   // per-type button config (blob v10): 4 types x 8 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*8){
@@ -905,6 +910,7 @@ $("#factoryErase").onclick=async()=>{
   log("FACTORY ERASE sent — copycat is reformatting and rebooting to defaults. Re-pair the controller, then reconnect.");
 };
 $("#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","rumble"]){
   const field={mDiv:1,mFric:2,rumble:22}[id];