Explorar el Código

Triton and SDL input protocol synchronization (#271)

Co-authored-by: ixelyth <314675972+ixelyth@users.noreply.github.com>
Edgar hace 1 mes
padre
commit
29f4d1e8b8

+ 57 - 0
OpenPuck/gamepad_util.cpp

@@ -168,6 +168,63 @@ void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch, uint16_t lx,
 		touchPackPoint(pts, 0, true, rx, ry);
 	}
 }
+
+struct TouchContactState {
+	bool down;
+	uint8_t id;
+	uint8_t source;
+	uint16_t x;
+	uint16_t y;
+};
+
+static TouchContactState g_touchState[NSLOT][2] = {};
+
+static void touchPackStatefulPoint(uint8_t stateSlot, uint8_t *base, int finger,
+				   bool touch, uint8_t source, uint16_t x,
+				   uint16_t y)
+{
+	if (stateSlot >= NSLOT || finger < 0 || finger > 1) {
+		touchPackPoint(base, finger, touch, x, y);
+		return;
+	}
+	TouchContactState &st = g_touchState[stateSlot][finger];
+	if (touch) {
+		if (!st.down || st.source != source) {
+			st.id = (uint8_t)((st.id + 1u) & 0x7Fu);
+			st.source = source;
+		}
+		st.down = true;
+		st.x = x;
+		st.y = y;
+	} else if (st.down) {
+		st.down = false;
+	}
+	uint8_t *f = base + finger * 4;
+	f[0] = (uint8_t)(st.id | (st.down ? 0x00u : 0x80u));
+	f[1] = (uint8_t)(st.x & 0xFF);
+	f[2] = (uint8_t)(((st.x >> 8) & 0x0F) | ((st.y & 0x0F) << 4));
+	f[3] = (uint8_t)((st.y >> 4) & 0xFF);
+}
+
+void touchPackPadsStateful(uint8_t stateSlot, uint8_t *pts, bool lTouch,
+			   bool rTouch, uint16_t lx, uint16_t ly, uint16_t rx,
+			   uint16_t ry)
+{
+	if (lTouch && rTouch) {
+		touchPackStatefulPoint(stateSlot, pts, 0, true, 1, lx, ly);
+		touchPackStatefulPoint(stateSlot, pts, 1, true, 2, rx, ry);
+	} else if (lTouch) {
+		touchPackStatefulPoint(stateSlot, pts, 0, true, 1, lx, ly);
+		touchPackStatefulPoint(stateSlot, pts, 1, false, 0, 0, 0);
+	} else if (rTouch) {
+		touchPackStatefulPoint(stateSlot, pts, 0, true, 2, rx, ry);
+		touchPackStatefulPoint(stateSlot, pts, 1, false, 0, 0, 0);
+	} else {
+		touchPackStatefulPoint(stateSlot, pts, 0, false, 0, 0, 0);
+		touchPackStatefulPoint(stateSlot, pts, 1, false, 0, 0, 0);
+	}
+}
+
 void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy,
 		      int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
 		      uint16_t *rx, uint16_t *ry)

+ 3 - 0
OpenPuck/gamepad_util.h

@@ -45,6 +45,9 @@ void touchPackPoint(uint8_t *base, int finger, bool touch, uint16_t x,
 // DualSense / DS4: many hosts only honor contact slot 0 -- put a lone pad in slot 0, both pads in 0+1.
 void touchPackPads(uint8_t *pts, bool lTouch, bool rTouch, uint16_t lx,
 		   uint16_t ly, uint16_t rx, uint16_t ry);
+void touchPackPadsStateful(uint8_t stateSlot, uint8_t *pts, bool lTouch,
+			   bool rTouch, uint16_t lx, uint16_t ly, uint16_t rx,
+			   uint16_t ry);
 void steamPadsToTouch(uint32_t b, uint16_t touchH, int16_t lpx, int16_t lpy,
 		      int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
 		      uint16_t *rx, uint16_t *ry);

+ 4 - 3
OpenPuck/mode_hidgyro.cpp

@@ -182,11 +182,12 @@ static void hidGyroBuild(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 		static uint8_t tstamp[NSLOT] = { 0 };
 		out[32] = 1;
 		out[33] = tstamp[usbSlot]++;
-		touchPackPads(out + 34, lTouch, rTouch, tlx, tly, trx, trry);
+		touchPackPadsStateful(slot, out + 34, lTouch, rTouch, tlx, tly,
+				      trx, trry);
 	} else {
 		out[32] = 0;
-		touchPackPads(
-			out + 34, false, false, 0, 0, 0,
+		touchPackPadsStateful(
+			slot, out + 34, false, false, 0, 0, 0,
 			0); // contact 0x80 -- memset(0) reads as touch @0,0
 	}
 }

+ 8 - 1
OpenPuck/mode_ps5.cpp

@@ -199,11 +199,18 @@ static void ps5Build(uint8_t usbSlot, uint8_t slot, uint8_t out[63])
 	out[24] = g_in[slot].ay >> 8;
 	out[25] = g_in[slot].az & 0xFF;
 	out[26] = g_in[slot].az >> 8;
+	// SDL treats the normal DualSense 32-bit sensor timestamp as ~1/3 us ticks.
+	uint32_t ps5SensorTimestamp = g_in[slot].imuTimestampUs * 3u;
+	out[27] = (uint8_t)(ps5SensorTimestamp & 0xFF);
+	out[28] = (uint8_t)((ps5SensorTimestamp >> 8) & 0xFF);
+	out[29] = (uint8_t)((ps5SensorTimestamp >> 16) & 0xFF);
+	out[30] = (uint8_t)((ps5SensorTimestamp >> 24) & 0xFF);
 	uint16_t tlx, tly, trx, trry;
 	steamPadsToTouch(b, PS5_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
 			 g_in[slot].rpx, g_in[slot].rpy, &tlx, &tly, &trx,
 			 &trry);
-	touchPackPads(out + 32, lTouch, rTouch, tlx, tly, trx, trry);
+	touchPackPadsStateful(slot, out + 32, lTouch, rTouch, tlx, tly, trx,
+			      trry);
 	out[52] = PS5_STATUS_USB;
 	PsImuFrame imu = psImuFromSteam(g_in[slot]);
 	le16(out + 15, imu.gx);

+ 41 - 38
OpenPuck/puck_hid.cpp

@@ -68,20 +68,22 @@ static const uint8_t PUCK_HID_DESC[] = {
 	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
 	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
 	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
-	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
-	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
-	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
-	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
-	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
-	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
-	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
-	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
-	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
-	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
-	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
-	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
-	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
-	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0
+	0x02, 0x85, 0x47, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D,
+	0x09, 0x47, 0x81, 0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x09, 0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91,
+	0x02, 0x85, 0x83, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x83, 0x91, 0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x08, 0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91,
+	0x02, 0x85, 0x87, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x87, 0x91, 0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01,
+	0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01,
+	0xB1, 0x02, 0xC0
 };
 
 // Pure MODE_LIZARD only: same puck/lizard descriptor plus a tiny Consumer Control report for media keys.
@@ -104,23 +106,24 @@ static const uint8_t PUCK_LIZARD_HID_DESC[] = {
 	0x08, 0x95, 0x0E, 0x09, 0x43, 0x81, 0x02, 0x85, 0x7B, 0x15, 0x00, 0x26,
 	0xFF, 0x00, 0x75, 0x08, 0x95, 0x0C, 0x09, 0x7B, 0x81, 0x02, 0x85, 0x45,
 	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D, 0x09, 0x45, 0x81,
-	0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
-	0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
-	0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82, 0x15, 0x00, 0x26,
-	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91, 0x02, 0x85, 0x83,
-	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09, 0x09, 0x83, 0x91,
-	0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x08,
-	0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
-	0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86, 0x15, 0x00, 0x26,
-	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91, 0x02, 0x85, 0x87,
-	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x87, 0x91,
-	0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
-	0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
-	0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01, 0x95, 0x3F, 0x09,
-	0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0xC0,
-	0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x03, 0x15, 0x00, 0x25, 0x01,
-	0x09, 0xE9, 0x09, 0xEA, 0x75, 0x01, 0x95, 0x02, 0x81, 0x02, 0x75, 0x06,
-	0x95, 0x01, 0x81, 0x01, 0xC0
+	0x02, 0x85, 0x47, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x2D,
+	0x09, 0x47, 0x81, 0x02, 0x85, 0x80, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x09, 0x09, 0x80, 0x91, 0x02, 0x85, 0x81, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x07, 0x09, 0x81, 0x91, 0x02, 0x85, 0x82,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x82, 0x91,
+	0x02, 0x85, 0x83, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x09,
+	0x09, 0x83, 0x91, 0x02, 0x85, 0x84, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x08, 0x09, 0x84, 0x91, 0x02, 0x85, 0x85, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x85, 0x91, 0x02, 0x85, 0x86,
+	0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x03, 0x09, 0x86, 0x91,
+	0x02, 0x85, 0x87, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F,
+	0x09, 0x87, 0x91, 0x02, 0x85, 0x89, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75,
+	0x08, 0x95, 0x3F, 0x09, 0x89, 0x91, 0x02, 0x85, 0x88, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x3F, 0x09, 0x88, 0x91, 0x02, 0x85, 0x01,
+	0x95, 0x3F, 0x09, 0x01, 0xB1, 0x02, 0x85, 0x02, 0x95, 0x3F, 0x09, 0x01,
+	0xB1, 0x02, 0xC0, 0x05, 0x0C, 0x09, 0x01, 0xA1, 0x01, 0x85, 0x03, 0x15,
+	0x00, 0x25, 0x01, 0x09, 0xE9, 0x09, 0xEA, 0x75, 0x01, 0x95, 0x02, 0x81,
+	0x02, 0x75, 0x06, 0x95, 0x01, 0x81, 0x01, 0xC0
 };
 
 static Adafruit_USBD_HID hid[NSLOT];
@@ -728,8 +731,8 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 		return;
 	if (!g_slot[slot].used)
 		return;
-	// Report id: 0x45 = legacy main input; 0x42 = new-firmware (SC2 beta ~2026-07) main input (53B). Forward
-	// whichever the controller actually sent, verbatim, under its own id -- both are declared in PUCK_HID_DESC
+	// Report ids: 0x45 = legacy state, 0x42 = extended state, 0x47 = SDL timestamped state. Forward
+	// whichever the controller actually sent, verbatim, under its own id -- all are declared in PUCK_HID_DESC
 	// and this is exactly what the real puck does. rep[0] is the id byte (rep points at it in the F1 TLV).
 	const uint8_t rid = rep[0];
 	if (lizardActive()) {
@@ -755,7 +758,7 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 			rfLizard(&hid[0], &hid[0], 0x40, 0x41);
 	} else {
 		// body length after the id byte, clamped to the descriptor's declared size for this report id
-		// (0x42 = 53B vendor input, 0x45 = 45B input).
+		// (0x42 = 53B vendor input; 0x45/0x47 = 45B input).
 		uint8_t maxb = (rid == 0x42) ? 53 : 45;
 		uint8_t blen = bodyTlen - 1;
 		if (blen > maxb)
@@ -790,10 +793,10 @@ void SteamPuckController::onReport45(int slot, const uint8_t *rep, bool fresh,
 				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)
+		if (send) {
+			// Steam/SDL Triton: 0x45 legacy / 0x42 extended / 0x47 timestamped.
+			usbTxHid(&hid[slot], rid, rep + 1, blen);
+		}
 	}
 }
 

+ 88 - 16
OpenPuck/rf_link.cpp

@@ -135,6 +135,7 @@ static unsigned long g_stMs = 0;
 // Per-slot dedupe seq + per-slot new-report counter (the real puck sends 0x45 per controller; merging all
 // slots into a single sequence makes one controller "swallow" the other's frame).
 static uint8_t g_lastSeq[NSLOT] = { 0 };
+static uint8_t g_lastInputRid[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 };
@@ -439,20 +440,29 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 					// ([46..47]=0x7FFF const, [48..53]=0) and sets two extra always-on status bits (28/29) that no
 					// mode reads. So both decode through this ONE path unchanged; rep[0] carries the id downstream
 					// (Steam forwards it verbatim under the right id/length in onReport45).
-					if (ttype == 6 && tlen >= 28 &&
+					if (ttype == 6 &&
+					    (((tlen >= 28) &&
+					      (rfrx[idx + 2] == 0x45 ||
+					       rfrx[idx + 2] == 0x42)) ||
+					     (tlen == 46 &&
+					      rfrx[idx + 2] ==
+						      OPK_TRITON_REPORT_STATE_TIMESTAMP)) &&
 					    (size_t)(idx + 2) + tlen <=
-						    sizeof(rfrx) &&
-					    (rfrx[idx + 2] == 0x45 ||
-					     rfrx[idx + 2] == 0x42)) {
+						    sizeof(rfrx)) {
 						// report 0x45/0x42: [id][seq][buttons u32]...
 						const uint8_t *rep =
 							&rfrx[idx + 2];
 						bool fresh =
-							(rep[1] !=
-							 g_lastSeq[g_curSlot]);
-						// genuine new report vs stale poll-repeat
+							(rep[0] !=
+								 g_lastInputRid
+									 [g_curSlot] ||
+							 rep[1] !=
+								 g_lastSeq[g_curSlot]);
+						// genuine new report vs stale poll-repeat; a report-id transition is also fresh
 						if (fresh) {
 							g_stNew[g_curSlot]++;
+							g_lastInputRid[g_curSlot] =
+								rep[0];
 							g_lastSeq[g_curSlot] =
 								rep[1];
 						}
@@ -576,18 +586,44 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						// for the Switch digital-trigger threshold
 						g_in[g_curSlot].rt =
 							trigU8(u16off(rep, 6));
-						g_in[g_curSlot].lpx =
-							(int16_t)s16off(rep,
+						// Timestamped report 0x47 inserts unTrackpadTimestamp before the pad coordinates.
+						if (rep[0] ==
+						    OPK_TRITON_REPORT_STATE_TIMESTAMP) {
+							g_in[g_curSlot].lpx =
+								(int16_t)s16off(
+									rep,
+									18);
+							g_in[g_curSlot].lpy =
+								(int16_t)s16off(
+									rep,
+									20);
+							g_in[g_curSlot].rpx =
+								(int16_t)s16off(
+									rep,
+									24);
+							g_in[g_curSlot].rpy =
+								(int16_t)s16off(
+									rep,
+									26);
+						} else {
+							g_in[g_curSlot].lpx =
+								(int16_t)s16off(
+									rep,
 									16);
-						g_in[g_curSlot].lpy =
-							(int16_t)s16off(rep,
+							g_in[g_curSlot].lpy =
+								(int16_t)s16off(
+									rep,
 									18);
-						g_in[g_curSlot].rpx =
-							(int16_t)s16off(rep,
+							g_in[g_curSlot].rpx =
+								(int16_t)s16off(
+									rep,
 									22);
-						g_in[g_curSlot].rpy =
-							(int16_t)s16off(rep,
+							g_in[g_curSlot].rpy =
+								(int16_t)s16off(
+									rep,
 									24);
+						}
+
 						// IMU lives at report bytes 0x22..0x2D (rep[34..45]). Decode it ONLY when a FULL 46-byte report was
 						// actually received -- bounded by `end` (the received length), NOT sizeof(rfrx). The outer gate is
 						// tlen>=28 (enough for buttons/sticks/pads, which end at rep[27]), so a short 0x45 (button-only, or
@@ -595,7 +631,40 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 						// past the received data and clobber g_in's gyro/accel. On a short frame, hold the last good IMU.
 						if (tlen >= 46 &&
 						    (size_t)(idx + 2) + 46 <=
-							    (size_t)end)
+							    (size_t)end) {
+							// SDL 0x47 uses a wrapping 16-bit IMU clock in 32-us units; legacy
+							// 0x42/0x45 retain the 32-bit microsecond timestamp at rep[30..33].
+							if (rep[0] ==
+							    OPK_TRITON_REPORT_STATE_TIMESTAMP) {
+								uint16_t tick47 =
+									(uint16_t)rep
+										[32] |
+									((uint16_t)rep
+										 [33]
+									 << 8);
+								g_in[g_curSlot]
+									.imuTimestampUs = tritonTimestamp47Us(
+									(uint8_t)
+										g_curSlot,
+									tick47);
+							} else {
+								tritonTimestamp47Reset(
+									(uint8_t)
+										g_curSlot);
+								g_in[g_curSlot]
+									.imuTimestampUs =
+									(uint32_t)rep
+										[30] |
+									((uint32_t)rep
+										 [31]
+									 << 8) |
+									((uint32_t)rep
+										 [32]
+									 << 16) |
+									((uint32_t)rep
+										 [33]
+									 << 24);
+							}
 							imuFrom45(
 								rep,
 								&g_in[g_curSlot]
@@ -610,6 +679,7 @@ uint8_t rfConnTx(uint8_t ch, uint8_t s1, const uint8_t *payload, uint8_t plen,
 									 .gy,
 								&g_in[g_curSlot]
 									 .gz);
+						}
 						// Mode-switch chord (all 4 back + face/dpad): don't leak the press to the host. g_in[g_curSlot].buttons stays
 						// intact so the chord detector still fires; per-mode builders mask the same bits while back-4 held.
 						if ((bb & CHORD_BACK4) ==
@@ -1065,6 +1135,8 @@ void rfLinkTask()
 				  (millis() - g_connReplyMs[s] < 300u);
 			if (wasUp[s] && !up) {
 				memset(&g_in[s], 0, sizeof g_in[s]);
+				tritonTimestamp47Reset((uint8_t)s);
+				g_lastInputRid[s] = 0;
 				if (g_active)
 					g_active->onReport45(s, neutral45, true,
 							     sizeof neutral45);

+ 32 - 0
OpenPuck/triton.cpp

@@ -2,6 +2,38 @@
 
 PuckInput g_in[NSLOT];
 
+// Triton report 0x47 carries a 16-bit IMU clock in 32-us units.  Unwrap it
+// per RF bond slot so translated modes receive the same monotonic microsecond
+// source clock shared with 0x42/0x45 reports.
+static uint16_t g_triton47LastTick[NSLOT] = {};
+static uint32_t g_triton47TimestampUs[NSLOT] = {};
+static uint8_t g_triton47TimestampValid[NSLOT] = {};
+
+uint32_t tritonTimestamp47Us(uint8_t slot, uint16_t timestamp32us)
+{
+	if (slot >= NSLOT)
+		return (uint32_t)timestamp32us * 32u;
+	if (!g_triton47TimestampValid[slot]) {
+		g_triton47TimestampValid[slot] = 1;
+		g_triton47LastTick[slot] = timestamp32us;
+		g_triton47TimestampUs[slot] = (uint32_t)timestamp32us * 32u;
+		return g_triton47TimestampUs[slot];
+	}
+	uint16_t delta = (uint16_t)(timestamp32us - g_triton47LastTick[slot]);
+	g_triton47LastTick[slot] = timestamp32us;
+	g_triton47TimestampUs[slot] += (uint32_t)delta * 32u;
+	return g_triton47TimestampUs[slot];
+}
+
+void tritonTimestamp47Reset(uint8_t slot)
+{
+	if (slot >= NSLOT)
+		return;
+	g_triton47LastTick[slot] = 0;
+	g_triton47TimestampUs[slot] = 0;
+	g_triton47TimestampValid[slot] = 0;
+}
+
 void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 	       int16_t *gx, int16_t *gy, int16_t *gz)
 {

+ 9 - 0
OpenPuck/triton.h

@@ -7,6 +7,8 @@
 #pragma once
 #include <stdint.h>
 
+#define OPK_TRITON_REPORT_STATE_TIMESTAMP 0x47u
+
 // ---- Triton button masks (the 32-bit field at rep[2..5]) ----
 #define TB_A 0x1u
 #define TB_B 0x2u
@@ -79,6 +81,10 @@ static inline uint32_t btnsOf(const uint8_t *r)
 // report 0x45 IMU offsets (PROTOCOL.md §8): accel @0x22, gyro @0x28 from report start.
 void imuFrom45(const uint8_t *r, int16_t *ax, int16_t *ay, int16_t *az,
 	       int16_t *gx, int16_t *gy, int16_t *gz);
+// Convert Triton report 0x47's wrapping 16-bit 32-us IMU clock to the
+// same monotonic microsecond domain used by PuckInput::imuTimestampUs.
+uint32_t tritonTimestamp47Us(uint8_t slot, uint16_t timestamp32us);
+void tritonTimestamp47Reset(uint8_t slot);
 
 // ---- shared decoded input (filled by rf_link.cpp once per fresh report 0x45, read by every mode) ----
 // One PuckInput per bond slot: each controller in a multi-slot puck has its own decoded input. The stream
@@ -92,6 +98,9 @@ struct PuckInput {
 	int16_t lpx, lpy, rpx, rpy; // left / right trackpad coords (int16)
 	int16_t ax, ay, az; // accelerometer
 	int16_t gx, gy, gz; // gyroscope
+
+	// Triton 0x42/0x45 IMU source clock, microseconds
+	uint32_t imuTimestampUs;
 };
 #include "bonds.h" // NSLOT
 extern PuckInput g_in[NSLOT];