Преглед на файлове

Improve polling rate and make trackpad mouse smooth (#122)

Copilot преди 3 месеца
родител
ревизия
3f03cc4901
променени са 9 файла, в които са добавени 157 реда и са изтрити 34 реда
  1. 5 3
      OpenPuck/config.cpp
  2. 10 3
      OpenPuck/config.h
  3. 21 3
      OpenPuck/haptics.cpp
  4. 6 2
      OpenPuck/haptics.h
  5. 44 5
      OpenPuck/puck_hid.cpp
  6. 4 0
      OpenPuck/puck_hid.h
  7. 29 16
      OpenPuck/rf_link.cpp
  8. 3 2
      OpenPuck/rf_link.h
  9. 35 0
      OpenPuck/serial_console.cpp

+ 5 - 3
OpenPuck/config.cpp

@@ -65,9 +65,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 (haptic-block bytes repurposed); bump so old flash format is discarded ->

+ 10 - 3
OpenPuck/config.h

@@ -156,7 +156,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
@@ -164,8 +169,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.

+ 21 - 3
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 };
 
@@ -500,12 +507,23 @@ 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.
+			// 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,
-				 80); // one relay per poll cycle
+				 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;

+ 44 - 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,33 @@ 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

+ 35 - 0
OpenPuck/serial_console.cpp

@@ -40,6 +40,41 @@ 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;