Bläddra i källkod

Merge branch 'main' into copilot/add-disable-rumble-option

Jari 3 månader sedan
förälder
incheckning
5e1d221a75

+ 5 - 3
OpenPuck/config.cpp

@@ -69,9 +69,11 @@ void applyActiveType()
 // rumble strength % (200 = double); adjustable from the WebUSB panel
 uint8_t g_rumbleScale = 200;
 
-// poll rate is fixed. Faster than the controller can refresh wastes airtime; slower adds latency. Any rate
-// persisted by an older build is ignored and overwritten with the default on boot (see loadCfg).
-const uint32_t g_pollUs = POLL_US_DEFAULT;
+// poll rate defaults to POLL_US_DEFAULT (250 Hz), matching the real Valve puck (see config.h). The
+// delivered report rate equals the poll rate (fresh IMU in every reply). Live-adjustable via console
+// "PR<hz>" for on-HW sweeps; session-only, so any rate persisted by an older build is ignored and boot
+// always starts at the default (see loadCfg).
+uint32_t g_pollUs = POLL_US_DEFAULT;
 
 #define CFG_FILE "/cfg.bin"
 // Struct layout/semantics changed (TypeCfg gained rumble byte); bump so old flash format is discarded ->

+ 10 - 3
OpenPuck/config.h

@@ -160,7 +160,12 @@ extern uint8_t g_swGyroScale10;
 // persist g_swProRate + g_swGyroScale10 to their flash file
 void swProSaveCfg();
 
-// 250 Hz -- matches SC2 input report rate (1000000/250 = 4000 us)
+// RF poll cadence: 250 Hz (4000 us) -- matches the real Valve puck's rate. The controller does NOT stream
+// on its own; the puck POLLS (short frame) and the controller auto-ACKs one input frame per poll (RE air
+// capture, nrf-sniffer HANDOFF.md), and it stuffs fresh IMU into every report, so the DELIVERED report
+// rate simply equals the poll rate. The real puck polls ~230-250 Hz, so we match it here. (The genuine
+// smoothness bug was never the rate -- it was onReport45 dropping ~1/3 of captured reports via a bogus
+// hid.ready() gate; see puck_hid.cpp. Poll rate is live-tunable via console "PR<hz>" for sweeps.)
 #define POLL_US_DEFAULT 4000u
 // host-side HID stream cadence for translated modes (~250 Hz)
 #define USB_STREAM_MS 4u
@@ -168,8 +173,10 @@ void swProSaveCfg();
 // (host idle power-management) resumes in <1s and must not trigger a self-inflicted power-off -> the
 // resulting disconnect/reconnect churn looked like random controller drops. Real host sleep persists.
 #define SUSPEND_OFF_MS 4000u
-// RF poll cadence (us). FIXED -- not configurable (see loadCfg).
-extern const uint32_t g_pollUs;
+// RF poll cadence (us). Defaults to POLL_US_DEFAULT; live-adjustable via the console "PR<hz>" command
+// (session-only -- NOT persisted; loadCfg always forces the default on boot) so the sweet spot can be
+// swept on hardware while watching the delivered report rate.
+extern uint32_t g_pollUs;
 
 // loop-timing diagnostics (defined in OpenPuck.ino) -- surfaced in the WebUSB status blob to find what caps
 // the poll rate: avg loop period, slowest section index, and that section's avg us/iteration.

+ 23 - 4
OpenPuck/haptics.cpp

@@ -39,6 +39,13 @@ uint8_t g_landAll87 = 0;
 // 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
+// input rate exactly during a drag. On by default; console "HR" toggles it so the drag-smoothness cost of
+// haptics can be isolated on hardware (drag with it OFF vs ON). OFF only affects Steam-driven rumble/pad
+// feedback; it does NOT touch settings/config (0x87) or power-off (0x9F) relays.
+bool g_hapticRelay = true;
 // Per-slot reconnect block. 0 = idle; non-zero = drop haptics aimed at this slot until millis() catches up.
 unsigned long g_hapticBlockUntil[NSLOT] = { 0 };
 
@@ -503,12 +510,24 @@ bool rfConnFlushRelay(uint8_t ch, uint8_t s1)
 			}
 			// 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.
-			rfConnTx(ch, s1, p, plen,
-				 80); // one relay per poll cycle
+			// dedup never treats the GET as a retransmit of this relay.
+			//
+			// HARVEST the relay's reply as INPUT. Like any frame we send, the controller auto-ACKs a
+			// relay with its current input in the ACK payload (~90us later, per the RE poll->ACK
+			// capture). The old 80us window closed BEFORE that reply arrived, so every relay's input was
+			// discarded -- and yet HW A/B showed the delivered input rate RISING when haptics relay
+			// (an extra frame/cycle during a trackpad drag), because each frame makes the controller
+			// refresh its ACK payload and the following E3 poll caught the fresher value. Reading the
+			// reply directly turns each relay into a SECOND input sample per cycle: rfConnTx runs the
+			// full F1 decode (seq-dedup guards double-forward), so a drag streaming haptics now collects
+			// ~2x the samples, closing the gap to the real puck. A present reply returns early (~90us);
+			// only a genuine no-reply pays the bounded 400us window, so airtime stays in budget.
+			rfConnTx(
+				ch, s1, p, plen,
+				400); // one relay per poll cycle -- reply harvested as input
 		}
 	}
-	return have; // true = a relay frame went out this cycle (steals a reply window from the E3 poll)
+	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

+ 6 - 2
OpenPuck/haptics.h

@@ -62,6 +62,9 @@ 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;
 
 // 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.
@@ -121,8 +124,9 @@ void haptic82HostReport(const uint8_t *p, uint16_t n);
 bool hapticSteamRumble(uint16_t lowFreq, uint16_t highFreq, uint8_t slot = 0);
 
 // queue + flush the pending host/test/stop relay inside the poll cadence (called from rf_link).
-// rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it (rf_link cycles the shared
-// PID counter), so the controller doesn't dedup the GET as a retransmit of the relay.
+// rfConnFlushRelay's s1 must carry a PID distinct from the GET poll that follows it. g_relayPid
+// is initialised 2 ahead of g_pollPid and both increment once per cycle, so the 2-bit PIDs stay
+// 2 apart (mod 4) forever and never collide — keeping the controller from deduplicating the GET.
 // Stability test: when g_stabTest (WebUSB cmd 0x0F), buzz all controllers every 10s to keep them awake for an
 // unattended uptime-until-hang measurement. hapticStabTask() is called from loop().
 extern bool g_stabTest;

+ 33 - 7
OpenPuck/mode_switch_pro.cpp

@@ -4,6 +4,7 @@
 #include "config.h"
 #include "haptics.h"
 #include "bonds.h"
+#include "rf_link.h"
 #include "usb_mount.h"
 #include "usb_tx.h"
 #include <Adafruit_TinyUSB.h>
@@ -222,6 +223,31 @@ static void jcPackStick(uint8_t s[3], int16_t x, int16_t y)
 	s[1] = (uint8_t)(((Y & 0x0F) << 4) | ((X >> 8) & 0x0F));
 	s[2] = (uint8_t)((Y >> 4) & 0xFF);
 }
+
+// The Switch battery/connection byte (bat_con) is NOT a 0..8 value in the high nibble. Per hid-nintendo it is:
+//   bits[7:5] = battery capacity (0=empty .. 4=full), bit4 = charging, bit0 = host_powered (USB-powered).
+// A genuine pad therefore only ever emits the EVEN high-nibble levels 8/6/4/2/0 (full/medium/low/critical/empty)
+// -- the odd bit (bit4) is reserved for the charging flag. The old code packed a 0..8 value straight into the
+// high nibble, so any ODD level (1,3,5,7) set bit4 and the console showed the pad as "charging". Here we round
+// the percentage onto the genuine 5-level scale and return the ready-to-place EVEN nibble {0,2,4,6,8}.
+static uint8_t jcBatteryNibble(uint8_t bond)
+{
+	if (bond >= NSLOT)
+		return 0;
+	uint8_t pct = g_battery[bond];
+	uint8_t cap; // genuine 0..4 capacity
+	if (pct >= 70)
+		cap = 4; // full
+	else if (pct >= 50)
+		cap = 3; // medium
+	else if (pct >= 30)
+		cap = 2; // low
+	else if (pct >= 10)
+		cap = 1; // critical
+	else
+		cap = 0; // empty
+	return (uint8_t)(cap << 1); // -> 0,2,4,6,8 (even; leaves bit4 clear)
+}
 // Standard input-report prefix [0..11] (timer, battery/conn, 3 button bytes, both packed sticks, vibrator),
 // shared by the streamed 0x30 report and the 0x21 subcommand-reply reports the host reads during init.
 static void jcInputPrefix(uint8_t slot, uint8_t *out)
@@ -284,16 +310,16 @@ static void jcInputPrefix(uint8_t slot, uint8_t *out)
 		jc |= codeToJc(g_qamMap, fA, fB, fX, fY);
 	out[0] = g_jcTimer[slot]++;
 
-	// battery full+charging (hi nibble 0x9), connection_info=1 (lo nibble): a wired/charging Pro Controller.
-	// A real Switch reads this to show the pad as connected; Steam/hid-nintendo accept it too.
-	out[1] = 0x91;
+	// bat_con byte: [7:5]=capacity, bit4=charging, bit0=host_powered (see jcBatteryNibble). The controllers are
+	// wireless (battery powered) from the console's view, so host_powered=0 -- setting it pins the pad the console
+	// treats as the wired/primary device in the charging state (the first-enumerated pad showing "charging, 100%").
+	// The charging flag reflects the controller's REAL EChargeState (2=charging); wireless pads report discharging
+	// (1) so it stays clear, but a pad genuinely on a charger will show the bolt correctly.
+	uint8_t chg = (bond < NSLOT && g_batteryState[bond] == 2) ? 0x10 : 0x00;
+	out[1] = (uint8_t)((jcBatteryNibble(bond) << 4) | chg);
 	out[2] = (uint8_t)(jc);
 	out[3] = (uint8_t)(jc >> 8);
 	out[4] = (uint8_t)(jc >> 16);
-
-	// charging_grip bit (button "common" byte, bit7): genuine Pro Controller always sets it on USB; real
-	// Switch uses it to recognise a wired controller. Not a button, so hid-nintendo ignores it.
-	out[3] |= 0x80;
 	jcPackStick(out + 5, g_in[bond].lx, g_in[bond].ly);
 	jcPackStick(out + 8, g_in[bond].rx, g_in[bond].ry);
 	// rumble_input_report echo: genuine pad emits 0x09..0x0C; some Switch firmware expects this nonzero.

+ 45 - 5
OpenPuck/puck_hid.cpp

@@ -14,6 +14,13 @@
 #include <string.h>
 
 uint8_t g_fwdNewOnly = 1;
+// Content dedup for the Steam input forward: only forward a 0x45/0x42 report when its body EXCLUDING the
+// counter byte differs from the last one forwarded on that slot. The controller's counter (rep[1]) FREE-RUNS
+// -- it advances on every reply even with static input -- so the seq-based g_fwdNewOnly can't suppress true
+// repeats, and a poll rate above the controller's ~250 Hz sample rate forwards identical inputs with only the
+// counter bumped (host tester reads >250/s for a <=250 Hz controller). Content dedup is what the real puck
+// effectively does. On by default; console "CD" toggles for A/B.
+uint8_t g_fwdContentDedup = 1;
 SteamPuckController g_steamPuck;
 
 // Cloned puck HID report descriptor: mouse(0x40)+keyboard(0x41)+vendor(FF00) with the 63-byte FEATURE
@@ -231,8 +238,9 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		// 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 (g_hapticRelay && rid >= 0x80 && rid <= 0x86 && !dropOut81 &&
+		    n >= 1 && hapticRelaySlotOk(slot) && !lizardActive() &&
+		    !muted) {
 			if (!haptic82Blocked(slot)) {
 				relayEnqueue(rid, b,
 					     (uint8_t)(n > RELAY_MAXP ?
@@ -315,10 +323,15 @@ static void handleSet(int slot, uint8_t rid, hid_report_type_t type,
 		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)
+		// 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));
+			       !(haptic82 && (lizardActive() || muted)) &&
+			       !(hapticCmd && !g_hapticRelay);
 		if (relayOk && (!haptic82 || !haptic82Blocked(slot))) {
 			// 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
@@ -713,7 +726,34 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 		// forward the puck's raw pad coords untouched (Steam does its own interpolation/smoothing). Forward
 		// only FRESH reports: the real puck dedupes, so stale repeats make Steam's velocity/smoothing
 		// stair-step. g_fwdNewOnly toggles for A/B.
-		if ((fresh || !g_fwdNewOnly) && hid[slot].ready())
+		//
+		// Do NOT gate on hid[slot].ready() here: usbTxHid enqueues into a ring buffer (drop-oldest)
+		// that exists precisely to hold a report while the endpoint is briefly busy -- usbTxDrain
+		// sends it the instant the host next polls. Gating on ready() at enqueue time DROPPED every
+		// fresh report that landed while the endpoint was busy (~1 ms after each send), which at
+		// 300+ captured reports/s silently discarded ~a third of them (measured: RF new/s ~313 but
+		// host delivered ~180). Always enqueue; the ring paces delivery to the host without loss.
+		bool send = (fresh || !g_fwdNewOnly);
+		// Content dedup: the counter byte (rep[1]) free-runs, so drop a report whose body AFTER the
+		// counter is byte-identical to the last one forwarded on this slot -- a true repeat (same
+		// physical input, bumped counter), not a new sample. Caps delivery at the controller's real
+		// distinct-report rate instead of the (higher) poll rate. See g_fwdContentDedup above.
+		if (send && g_fwdContentDedup && blen >= 2) {
+			static uint8_t lastBody[NSLOT][53];
+			static uint8_t lastBodyLen[NSLOT] = { 0 };
+			uint8_t clen =
+				(uint8_t)(blen - 1); // bytes after the counter
+			if (clen > sizeof lastBody[0])
+				clen = sizeof lastBody[0];
+			if (lastBodyLen[slot] == clen &&
+			    memcmp(lastBody[slot], rep + 2, clen) == 0)
+				send = false; // identical input, only the counter moved
+			else {
+				lastBodyLen[slot] = clen;
+				memcpy(lastBody[slot], rep + 2, clen);
+			}
+		}
+		if (send)
 			usbTxHid(
 				&hid[slot], rid, rep + 1,
 				blen); // Steam/SDL Triton: input report 0x45 (old) / 0x42 (new fw)

+ 4 - 0
OpenPuck/puck_hid.h

@@ -17,6 +17,10 @@
 // Forward report 0x45 only when the seq advanced (dedupe like the real puck); sending stale
 // repeats makes Steam's trackpad smoothing stair-step. (WebUSB field 14)
 extern uint8_t g_fwdNewOnly;
+// Content dedup: forward a 0x45/0x42 report only when its body EXCLUDING the free-running counter byte
+// changed, so the delivered rate tracks the controller's real distinct-report rate (not the poll rate).
+// On by default; console "CD" toggles for A/B. (see puck_hid.cpp for the why)
+extern uint8_t g_fwdContentDedup;
 
 // True while we are presenting the desktop keyboard/mouse instead of forwarding the gamepad to Steam
 // (MODE_LIZARD always, or MODE_STEAM once Steam's heartbeat has stopped). Exposed so the haptic layer can

+ 29 - 16
OpenPuck/rf_link.cpp

@@ -42,12 +42,12 @@ bool g_e7announce =
 bool g_e1keepalive = true;
 
 bool g_connVerbose = false;
-// poll RX-window (us): the poll BUSY-WAITS up to this long for the controller's reply, so it is the dominant
-// per-poll loop cost and directly sets the poll rate -- one slot needs (g_rxWin + overhead) < g_pollUs(4000)
-// to sustain 250 Hz. 1200 is the proven 250 Hz value; it was briefly raised to 2000 (issue-72, delayed-reply
-// tolerance) which dropped the rate to ~220. FIXED + not configurable (like g_pollUs): there is no good
-// reason to raise it in the field, and doing so silently halves the poll rate. Any persisted/old value is
-// ignored.
+// poll RX-window (us): the poll BUSY-WAITS up to this long for the controller's reply. The reply returns
+// EARLY (EVENTS_END) on a successful poll, so this window is only paid IN FULL on a genuine no-reply --
+// one slot needs (g_rxWin + overhead) < g_pollUs(4000) so a no-reply poll still fits inside the 250 Hz
+// cycle. 1200 is the proven value; it was briefly raised to 2000 (issue-72, delayed-reply tolerance) which
+// dropped the rate. FIXED + not configurable (like g_pollUs): there is no good reason to raise it in the
+// field, and doing so silently starves the poll cycle. Any persisted/old value is ignored.
 const uint32_t g_rxWin = 1200;
 unsigned long g_connCooldown = 0;
 
@@ -108,7 +108,11 @@ int g_curSlot = -1;
 // +4 per cycle = 0 mod 4, so each slot's GET PID is constant => the controller never dequeues => ~60 new/s
 // instead of ~400. Each slot's counter increments once per poll-of-that-slot so it cycles 0,1,2,3 cleanly.
 static uint8_t g_pollPid[NSLOT] = {};
-static uint8_t g_relayPid[NSLOT] = {};
+// Relay PID is offset by 2 from poll PID so the two never share the same
+// 2-bit PID value in the same cycle. Both counters advance once per cycle;
+// starting 2 apart keeps them 2 apart (mod 4) forever, preventing the
+// controller from deduplicating the E3 GET as a retransmit of the relay.
+static uint8_t g_relayPid[NSLOT] = { 2, 2, 2, 2 };
 // All link statistics are PER SLOT: each controller's polls/replies/errors are counted (and reported --
 // serial stat line, WebUSB blob v13) against that controller only. The old scalar counters merged every
 // slot into one number, so the panel couldn't tell "controller B is drowning" from "everything is slow".
@@ -133,7 +137,8 @@ static uint8_t g_lastSeq[NSLOT] = { 0 };
 static uint32_t g_stNew[NSLOT] = {};
 static uint32_t g_stCrc[NSLOT] = {}, g_stNoRx[NSLOT] = {};
 static uint32_t g_chF1[3] = { 0, 0, 0 };
-// Cycle gate: fires once per g_pollUs; each fire polls every warm slot so all run at ~250 Hz.
+// Cycle gate: fires once per g_pollUs; each fire polls every warm slot so all run at ~250 Hz (oversampling
+// the controller's ~270 Hz report generation so no fresh trackpad sample is dropped -- see config.h).
 static uint32_t g_lastPollUs = 0;
 static uint32_t g_connRx = 0;
 static unsigned long g_lastSessBeacon = 0, g_lastDisc = 0;
@@ -196,17 +201,21 @@ static void rfHostFrameOnce(int slot, bool discovery)
 	NRF_RADIO->TASKS_TXEN = 1;
 	RWAIT_DISABLED();
 	NRF_RADIO->EVENTS_DISABLED = 0;
+
+	// Session keepalive: the controller answers E3 polls, not beacons.
+	// No reply arrives here; radio is already disabled from the TX
+	// END_DISABLE short, so skip the RX window entirely.
+	if (!discovery)
+		return;
+
+	// Discovery/pairing beacons listen for the controller's response.
 	NRF_RADIO->PACKETPTR = (uint32_t)rfrx;
 	rfrx[0] = 0;
 	NRF_RADIO->SHORTS = RADIO_SHORTS_READY_START_Msk;
 	NRF_RADIO->EVENTS_END = 0;
 	NRF_RADIO->TASKS_RXEN = 1;
-	// Discovery/pairing beacons listen for the controller's response (matters for RE/pairing); the connected
-	// session keepalive expects NO response (the controller answers E3 polls, not the beacon), so don't burn
-	// 800us of dead air per frame -- that was the bulk of the idle poll-rate deficit (40 beacons/s x slots).
-	uint16_t bwin = discovery ? 800u : 150u;
 	uint32_t t0 = micros();
-	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < bwin) {
+	while (!NRF_RADIO->EVENTS_END && (micros() - t0) < 800u) {
 	}
 	if (NRF_RADIO->EVENTS_END) {
 		// any reception = controller answered our frame
@@ -907,6 +916,13 @@ static void rfConnStep()
 
 void rfLinkTask()
 {
+	// Poll before beacons: the cycle gate must fire as close to its
+	// 4 ms deadline as possible; beacon TX (up to 3.6 ms for 4 slots)
+	// runs after so it never delays the current poll.
+	if (g_connOn && millis() - g_connCooldown > 2500) {
+		rfConnStep();
+	} // connected-mode: poll controller, read input
+
 	// Host-frame beacon: sent continuously, INCLUDING while connected. The controller uses the periodic E1 (the
 	// real puck's per-hop-cycle announce) to stay synced and keep answering polls at full rate; suppressing it
 	// drops the reply rate from ~210/s to ~38/s. Paused only during the post-disconnect cooldown so a controller
@@ -937,9 +953,6 @@ void rfLinkTask()
 				rfHostFrameOnce(s, true);
 		}
 	}
-	if (g_connOn && millis() - g_connCooldown > 2500) {
-		rfConnStep();
-	} // connected-mode: poll controller, read input
 
 	// Release stale input on a per-slot link-drop edge. g_in[s] is refreshed ONLY
 	// by the 0x45 decode on a fresh reply, so once a controller goes silent the

+ 3 - 2
OpenPuck/rf_link.h

@@ -43,8 +43,9 @@ extern bool
 
 // full multi-line debug dump vs compact "I45 <hex>" stream
 extern bool g_connVerbose;
-// poll RX-window (us): caps poll rate (~1e6/rxWin). FIXED, not configurable (like g_pollUs) -- raising it
-// drops the poll rate, so it's pinned at the 250 Hz value rather than exposed as a footgun knob.
+// poll RX-window (us): the full window is paid only on a no-reply poll (a reply returns early), so it must
+// stay < g_pollUs to fit inside the 250 Hz cycle. FIXED, not configurable (like g_pollUs) -- raising it
+// starves the poll cycle, so it's pinned rather than exposed as a footgun knob.
 extern const uint32_t g_rxWin;
 
 // set on 0xF2 disconnect; pauses beacon+poll so a powering-off controller can sleep

+ 36 - 0
OpenPuck/serial_console.cpp

@@ -40,6 +40,42 @@ void serialConsolePoll()
 				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
+				// distinct-report rate instead of the poll rate.
+				g_fwdContentDedup = !g_fwdContentDedup;
+				Serial.printf("# content dedup %s\n",
+					      g_fwdContentDedup ? "ON" : "off");
+			} else if (!strcmp(line, "SS")) {
+				// on-demand smoothness snapshot: print the last-second cached rates in ONE line, NOW,
+				// instead of waiting for the guarded 1 Hz "# stat" (which the I45/FC flood starves during
+				// a drag). new/s = the FRESH-delivered rate (what Steam receives); noRx = RF packet loss.
+				Serial.printf(
+					"# SS pollrate=%luHz polls=%u/s F1=%u/s new=%u/s crc=%u noRx=%u slot%d\n",
+					(unsigned long)(1000000UL / g_pollUs),
+					g_pollsps, g_f1ps, g_newps, g_crcps,
+					g_norxps, g_curSlot);
+			} else if (!strncmp(line, "PR", 2) &&
+				   (line[2] >= '0' && line[2] <= '9')) {
+				// live poll-rate sweep: "PR<hz>" sets the RF poll cadence (session-only, not persisted).
+				// Clamped 100..1000 Hz. Watch the delivered report rate / feel to find the sweet spot.
+				long hz = atol(line + 2);
+				if (hz < 100)
+					hz = 100;
+				else if (hz > 1000)
+					hz = 1000;
+				g_pollUs = (uint32_t)(1000000L / hz);
+				Serial.printf(
+					"# poll rate -> %ld Hz (%lu us)\n", hz,
+					(unsigned long)g_pollUs);
+			} else if (!strcmp(line, "HR")) {
+				// A/B: disable the puck->controller haptic relay (Steam 0x80-0x86 rumble/pad feedback)
+				// to isolate whether relaying Steam's trackpad texture haptics degrades drag smoothness.
+				g_hapticRelay = !g_hapticRelay;
+				Serial.printf(
+					"# haptic relay (Steam 0x80-0x86) %s\n",
+					g_hapticRelay ? "ON" : "off");
 			} else if (!strcmp(line, "S81")) {
 				// A/B: drop Steam's relayed 0x81 CLEAR_DIGITAL_MAPPINGS (the connect amp-clicker)
 				g_drop81 = !g_drop81;

+ 1 - 0
README.md

@@ -70,6 +70,7 @@ You can copy configurations between OpenPucks using the export/import card in th
 
 # 3D Printed Cases
 - [jaki-gh](https://github.com/jaki-gh) has contributed a 3D printable housing with OpenPuck written on it alongside a Steam logo. You can find that [here](https://www.thingiverse.com/thing:7371668).
+- [BOT-Yanni](https://www.reddit.com/user/BOT-Yanni/) designed a slim case with OpenPuck written on it and a cute little glyph of the Steam Controller. You can find that [here](https://www.thingiverse.com/thing:7379316).
 - [StonnedModder](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case) built a case meant to accomodate a USB C to USB A adapter which you can find [here](https://www.printables.com/model/1760684-openpuck-promicro-nrf52840-case).
 - Another plain case for these pro micros can be found [here](https://www.printables.com/model/1285346-pro-micro-nicenano-nrf52840-dongle-case/collections).
 

+ 61 - 14
docs/index.html

@@ -984,9 +984,15 @@ function buildBackup(p, bp){
   const types=[];
   for(let et=0; et<TYPE_DEFS.length; et++){ const q=73+et*9;
     types.push({back:[p[q],p[q+1],p[q+2],p[q+3]], qam:p[q+4], abSwap:p[q+5], pad:p[q+6], led:p[q+7], rumble:p[q+8]}); }
-  return { magic:"openpuck-backup", version:1, bonds,
-    config:{ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]],
-             rumble:p[51], swProRate:p[52], swGyro10:p[53], types } };
+  const cfg={ mode:p[1], mDiv:p[2], mFric:p[3], persistMode:p[22], chord:[p[23],p[24],p[25]],
+              rumble:p[51], swProRate:p[52], swGyro10:p[53], types };
+  // version 2: include the lizard map so it survives a backup/restore cycle.
+  // Guard on !lizardBusy to avoid a race with the one-shot lazy load (lizardExchange sets
+  // lizardBusy while the initial 0x11 fetch is in flight); if the load is still pending the
+  // map snapshot would be empty, which would incorrectly clear bindings on restore.
+  if(lizardLoaded && lizardCapable() && !lizardBusy)
+    cfg.lizardMap=lizardBindings.map(b=>({outType:b.outType,od:b.od.slice(),trig:b.trig>>>0,hold:b.hold>>>0}));
+  return { magic:"openpuck-backup", version:(cfg.lizardMap?2:1), bonds, config:cfg };
 }
 function downloadBackup(obj){
   const ts=new Date().toISOString().slice(0,19).replace(/[:T]/g,"-");
@@ -1022,33 +1028,69 @@ async function importBackup(file){
   if(!confirm("Restore this backup onto the CONNECTED puck (serial "+(dev.serialNumber||"?")+")?\n\nMake sure this is the TARGET puck, not the one you exported from — the panel only talks to the puck you last connected.\n\nThis OVERWRITES its "+bondedN+" controller pairing(s) and ALL settings, then reboots it.\n\nResult: any controller paired to the original puck will connect to this one with no re-pairing.")) return;
   log("importing onto puck "+(dev.serialNumber||"?"));
   backupBusy=true; await waitIdle();
+  // Blocking progress modal (same surface the firmware updater uses): covers the page so nothing else can be
+  // driven while the import replays every setting onto the puck, then closes on Done / on error.
+  modalOpen("Restore backup");
+  const c=obj.config;
+  // Pre-count every write so the bar tracks real progress: settings (9 base fields), per-type config
+  // (9 each), the lizard bindings, 4 bond slots, and the final commit.
+  const typeN=Array.isArray(c.types)?Math.min(c.types.length,4):0;
+  const lizardN=(Array.isArray(c.lizardMap) && lizardCapable())?Math.min(c.lizardMap.length,LZ_MAX):0;
+  const total=9 + typeN*9 + lizardN + 4 + 1;
+  let step=0, stage="";
+  const tick=()=>modalStage(stage, Math.min(99,Math.floor(step*100/total)));
   try{
     // Capability probe: the connected puck MUST run firmware new enough to answer 0x09 with a 0xA7 frame,
     // otherwise it will silently ignore the 0x0D bond-import below. This is the #1 import failure: the panel
     // is connected to a puck still on old firmware. Catch it loudly instead of "nothing happened".
+    modalStage("Checking puck firmware", null);
     await send([0x09]);
     let probe=null; for(let t=0;t<8 && !probe;t++) probe=await readFrame(0xA7, 2+4*24);
     if(!probe){
       log("import ABORTED — the connected puck does not support import (old firmware).");
-      alert("This puck's firmware is too old to import a backup.\n\nFlash it with the latest OpenPuck build (the same one that produced the export), reconnect, then import again.");
+      modalDone(false, "This puck's firmware is too old to import a backup. Flash it with the latest "
+        +"OpenPuck build (the same one that produced the export), reconnect, then import again.", "Import failed");
       return;
     }
-    const c=obj.config;
     // Every step is a write-then-read transaction (same shape as the proven 0x02 sliders): the firmware acks
     // each with a status blob, which we read back to keep the OUT pipe flowing and naturally pace the writes.
-    const w=async(bytes)=>{ await send(bytes); await readBlob(); };
+    const w=async(bytes)=>{ await send(bytes); await readBlob(); step++; tick(); };
     const sf=async(f,v)=>{ await w([0x02, f, (v|0)&0xff]); };
     // replay every setting (each persists; none reboot). mode is applied by the 0x0E commit below.
+    stage="Restoring settings"; tick();
     await sf(1,c.mDiv); await sf(2,c.mFric); await sf(22,c.rumble); await sf(16,c.persistMode?1:0);
     await sf(17,c.chord[0]); await sf(18,c.chord[1]); await sf(19,c.chord[2]);
     await sf(23,c.swProRate); await sf(24,c.swGyro10);
-    for(let et=0; et<c.types.length && et<4; et++){ const t=c.types[et];
-      for(let k=0;k<4;k++) await sf(40+et*9+k, t.back[k]);
-      await sf(40+et*9+4, t.qam); await sf(40+et*9+5, t.abSwap?1:0);
-      await sf(40+et*9+6, t.pad?1:0); await sf(40+et*9+7, t.led);
-      await sf(40+et*9+8, t.rumble!==undefined?t.rumble:1); }
-    log("settings replayed ("+(9+c.types.length*9)+" fields)");
+    // c.types is absent in some very old backups created before per-type paddle/haptic config
+    // was added to the panel export; guard so a missing field never crashes the import mid-flight.
+    if(typeN){
+      for(let et=0; et<typeN; et++){ const t=c.types[et];
+        for(let k=0;k<4;k++) await sf(40+et*9+k, t.back[k]);
+        await sf(40+et*9+4, t.qam); await sf(40+et*9+5, t.abSwap?1:0);
+        await sf(40+et*9+6, t.pad?1:0); await sf(40+et*9+7, t.led);
+        await sf(40+et*9+8, t.rumble!==undefined?t.rumble:1); }
+      log("settings replayed ("+(9+typeN*9)+" fields)");
+    } else { log("settings replayed (9 fields — no per-type config in this backup)"); }
+    // restore the lizard map if the backup includes it (version 2+) and the puck speaks v16+.
+    // pre-lizard-map backups simply lack c.lizardMap; skip silently so they still import cleanly.
+    if(lizardN){
+      stage="Restoring button map"; tick();
+      const map=c.lizardMap.slice(0,LZ_MAX);
+      await send([0x13, map.length&0xff]); // begin edit (set count)
+      for(let i=0;i<map.length;i++){
+        const b=map[i], od=(b.od||[]).slice(0,7); while(od.length<7) od.push(0);
+        const t=(b.trig||0)>>>0, h=(b.hold||0)>>>0;
+        await send([0x12, i, (b.outType||0)&0xff, ...od.map(x=>(x||0)&0xff),
+          t&0xff,(t>>>8)&0xff,(t>>>16)&0xff,(t>>>24)&0xff,
+          h&0xff,(h>>>8)&0xff,(h>>>16)&0xff,(h>>>24)&0xff]);
+        step++; tick();
+      }
+      await send([0x14]); // commit + persist to flash
+      await readLizard(); // drain the 0xAA echo so the pipe is clean for bond writes
+      log("lizard map restored — "+map.length+" bindings");
+    }
     // write each bond slot with its own small command [0x0D][slot][used][24-byte rec]
+    stage="Restoring controller pairings"; tick();
     let n=0;
     for(let s=0;s<4;s++){
       const b=obj.bonds.find(x=>x.slot===s) || {used:false, rec:""};
@@ -1059,8 +1101,13 @@ async function importBackup(file){
     }
     log("wrote "+n+" bond slot(s) — committing + rebooting");
     // commit: persist bonds, apply mode, reboot (no ack — the device reboots)
+    stage="Committing + rebooting"; step++; tick();
     await send([0x0E, (c.mode!==undefined?c.mode:0xFF)&0xff]);
     log("import sent — puck is cloning and rebooting. Reconnect after it returns.");
+    modalDone(true, "Backup restored — the puck is cloning and rebooting. Reconnect after it returns.");
+  } catch(e){
+    log("import FAILED — "+e.message);
+    modalDone(false, e.message+" — the import stopped partway; reconnect and retry.", "Import failed");
   } finally { backupBusy=false; }
 }
 async function setField(field,value){
@@ -1543,12 +1590,12 @@ function modalOpen(title){
   $("#updModal").classList.remove("hide");
   modalStage("Preparing…",null);
 }
-function modalDone(ok,msg){
+function modalDone(ok,msg,failTitle){
   const f=$("#updFill");
   f.classList.remove("pulse");
   f.style.width="100%";
   f.classList.toggle("bad",!ok);
-  $("#updStage").textContent = ok ? "Done" : "Update failed";
+  $("#updStage").textContent = ok ? "Done" : (failTitle||"Update failed");
   $("#updPct").textContent="";
   $("#updDetail").textContent=msg;
   $("#updClose").classList.remove("hide");