Procházet zdrojové kódy

PicoPuck: SC2 BLE haptic fixes + on-device haptic bisector

- valve_feature_write now BLE-segments writes to the SC2 report char
  (100F6C34): each GATT write is [header][<=18], header=0x80|seq|(0x40
  if last); keepalive/IMU blobs prefixed 0xC0. Raw writes were silently
  dropped by the SC2 (Steam Controller BLE segmentation, per SDL).
- puck_rumble: match SDL's triton rumble report exactly (type=0,
  intensity=0, magnitude on left/right.speed) instead of type=0x04.
- pplog: RAM diagnostic log ring surfaced in the 0xAD WebUSB frame
  (stdio is UART-only); dumps every discovered Valve characteristic
  (dedicated non-rolling buffer) + each 0x80-0x86 actuator write.
- Haptic-target bisector: opcode 0x1A + panel "Test rumble" button
  fires a strong rumble at each writable char x framing combo so the
  haptic characteristic/framing can be found on hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Jari před 2 měsíci
rodič
revize
bad8aa744e

+ 1 - 0
PicoPuck/CMakeLists.txt

@@ -56,6 +56,7 @@ target_sources(picopuck PRIVATE
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/mode_switch_pro.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/puck/xinput.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/sys/settings.c
+    ${CMAKE_CURRENT_SOURCE_DIR}/src/sys/pplog.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_control_stub.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_host.c
     ${CMAKE_CURRENT_SOURCE_DIR}/src/bt/bt_valve.c

+ 218 - 31
PicoPuck/src/bt/bt_valve.c

@@ -8,6 +8,7 @@
 #include "puck/personality.h"
 #include "puck/triton.h"
 #include "config/picopuck_config.h"
+#include "sys/pplog.h"
 
 #include <stdio.h>
 #include <string.h>
@@ -27,12 +28,22 @@ static const uint8_t VALVE_REPORT[16] = { 0x10, 0x0F, 0x6C, 0x34, 0x17, 0x35,
 					  0x43, 0x13, 0xB4, 0x02, 0x38, 0x56,
 					  0x71, 0x31, 0xE5, 0xF3 };
 
+// Every write to the SC2's report characteristic (100F6C34) is BLE-segmented:
+// each GATT write is [header][<=18 payload], header = 0x80(DATA) | seq(0-2) |
+// 0x40(LAST). A report that fits one segment is prefixed 0xC0. This is the Steam
+// Controller BLE framing (SDL SetFeatureReport); WITHOUT it the SC2 silently
+// drops every write — keepalive, settings, and haptics all no-op.
+#define BLE_SEG_DATA 0x80
+#define BLE_SEG_LAST 0x40
+#define BLE_SEG_PAYLOAD 18
+
 // The SC2 re-enables lizard (keyboard/mouse) mode on a ~3 s watchdog, so resend
-// lizard-off well inside that. {0x87 ID_SET_SETTINGS, 0x03 len, 0x09 id, u16 0}.
+// lizard-off well inside that. {0x87 ID_SET_SETTINGS, 0x03 len, 0x09 id, u16 0},
+// prefixed with the single-segment header 0xC0.
 #define VALVE_KEEPALIVE_MS 2000u
-static uint8_t s_lizard_off[5] = { 0x87, 0x03, 0x09, 0x00, 0x00 };
+static uint8_t s_lizard_off[6] = { 0xC0, 0x87, 0x03, 0x09, 0x00, 0x00 };
 // IMU on: SEND_RAW_ACCEL(0x08) | SEND_RAW_GYRO(0x10) = 0x18 on setting id 0x30.
-static uint8_t s_imu_on[5] = { 0x87, 0x03, 0x30, 0x18, 0x00 };
+static uint8_t s_imu_on[6] = { 0xC0, 0x87, 0x03, 0x30, 0x18, 0x00 };
 
 typedef enum {
 	V_IDLE = 0,
@@ -42,8 +53,8 @@ typedef enum {
 	V_READY,
 } valve_state_t;
 
-#define VALVE_WQ 8       // pending host→controller writes
-#define VALVE_WMAX 62    // max report bytes per write
+#define VALVE_WQ 8 // pending host→controller writes
+#define VALVE_WMAX 62 // max report bytes per write
 
 typedef struct {
 	valve_state_t state;
@@ -51,17 +62,42 @@ typedef struct {
 	hci_con_handle_t handle;
 	gatt_client_service_t service;
 	gatt_client_characteristic_t input_char;
-	uint8_t report_id;              // 0x45 or 0x47
-	uint16_t report_value_handle;   // 100F6C34 write target
+	uint8_t report_id; // 0x45 or 0x47
+	uint16_t report_value_handle; // 100F6C34 write target
 	gatt_client_notification_t notify;
 	uint32_t last_keepalive_ms;
 	bool imu_enabled;
 
+	// Every WRITABLE characteristic discovered in the Valve service (handle +
+	// last two UUID bytes), for the haptic-target bisector. 100F6C34 is the
+	// feature/report char; the extras (6CBC/BD/BE…) are candidate output/haptic
+	// targets whose correct char+framing we don't yet know.
+	struct {
+		uint16_t handle;
+		uint8_t uuid2, uuid3;
+	} wr[8];
+	uint8_t wr_n;
+
+	// One-shot haptic-bisector test write (target an arbitrary char/handle with a
+	// chosen framing). Drained with top priority in valve_periodic.
+	uint16_t test_handle;
+	uint8_t test_buf[24];
+	uint8_t test_len;
+	bool test_pending;
+
 	// Relayed feature/output reports awaiting a free GATT slot (one write in
 	// flight at a time). Drop-oldest when full so a stall can't wedge input.
 	uint8_t wq[VALVE_WQ][VALVE_WMAX];
 	uint8_t wq_len[VALVE_WQ];
+	uint16_t wq_handle[VALVE_WQ]; // per-entry target characteristic
 	uint8_t wq_head, wq_tail, wq_count;
+
+	// Dedicated Triton output-report characteristics (haptics/LED): one writable
+	// char per report id, keyed by [report_id - 0x80]. SDL derives the id from the
+	// last UUID byte: id = last_byte - 0x35, so rumble 0x80 is 100F6CB5. These are
+	// the only valid targets for output reports; the feature/report char 100F6C34
+	// is for 0x87 settings writes. Sending haptics to 100F6C34 is silently dropped.
+	uint16_t out_handle[10];
 } valve_t;
 
 static valve_t s_valve[PP_NSLOT];
@@ -85,7 +121,8 @@ static valve_t *by_handle(hci_con_handle_t h)
 static void valve_reset(valve_t *v)
 {
 	if (v->state == V_READY)
-		gatt_client_stop_listening_for_characteristic_value_updates(&v->notify);
+		gatt_client_stop_listening_for_characteristic_value_updates(
+			&v->notify);
 	memset(v, 0, sizeof(*v));
 	v->handle = HCI_CON_HANDLE_INVALID;
 }
@@ -130,12 +167,13 @@ static void notify_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 	puck_present_raw(v->slot, rep, (uint8_t)(len + 1));
 }
 
-static void write_cb(uint8_t type, uint16_t channel, uint8_t *packet, uint16_t size)
+static void write_cb(uint8_t type, uint16_t channel, uint8_t *packet,
+		     uint16_t size)
 {
 	(void)type;
 	(void)channel;
 	(void)packet;
-	(void)size;  // completion only; ignore
+	(void)size; // completion only; ignore
 }
 
 static void gatt_handler(uint8_t type, uint16_t channel, uint8_t *packet,
@@ -152,11 +190,27 @@ static void gatt_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 
 	switch (hci_event_packet_get_type(packet)) {
 	case GATT_EVENT_SERVICE_QUERY_RESULT:
-		gatt_event_service_query_result_get_service(packet, &v->service);
+		gatt_event_service_query_result_get_service(packet,
+							    &v->service);
 		break;
 	case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: {
 		gatt_client_characteristic_t ch;
-		gatt_event_characteristic_query_result_get_characteristic(packet, &ch);
+		gatt_event_characteristic_query_result_get_characteristic(
+			packet, &ch);
+		// Dump EVERY characteristic in the Valve service (not just the three we
+		// recognise) so a real SC2 connection reveals whether haptics have their
+		// own output char we're currently ignoring. props: 0x04=write-no-resp,
+		// 0x08=write, 0x10=notify. Mirror it into the WebUSB log ring so it shows
+		// up in the panel even with no UART adapter attached.
+		{
+			char lb[48];
+			snprintf(lb, sizeof(lb),
+				 "char %02X%02X%02X%02X h=%04X p=%02X\n",
+				 ch.uuid128[0], ch.uuid128[1], ch.uuid128[2],
+				 ch.uuid128[3], ch.value_handle, ch.properties);
+			printf("[sc2] %s", lb);
+			pplog_chars(lb);
+		}
 		if (memcmp(ch.uuid128, VALVE_INPUT_45, 16) == 0) {
 			v->input_char = ch;
 			v->report_id = 0x45;
@@ -167,10 +221,26 @@ static void gatt_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 		} else if (memcmp(ch.uuid128, VALVE_REPORT, 16) == 0) {
 			v->report_value_handle = ch.value_handle;
 		}
+		// Record every writable char (props 0x08 write | 0x04 write-no-resp) as a
+		// haptic-target candidate for the bisector.
+		if ((ch.properties & 0x0C) && v->wr_n < 8) {
+			v->wr[v->wr_n].handle = ch.value_handle;
+			v->wr[v->wr_n].uuid2 = ch.uuid128[2];
+			v->wr[v->wr_n].uuid3 = ch.uuid128[3];
+			v->wr_n++;
+		}
+		// A Valve writable char whose last UUID byte is 0xB5..0xBE is a dedicated
+		// output (haptic/LED) char for report id = byte-0x35 (rumble 0x80 →
+		// 100F6CB5). Remember its handle so valve_feature_write routes
+		// 0x80-0x89 there instead of the feature/report char 100F6C34.
+		if ((ch.properties & 0x0C) && ch.uuid128[3] >= 0xB5 &&
+		    ch.uuid128[3] <= 0xBE)
+			v->out_handle[ch.uuid128[3] - 0xB5] = ch.value_handle;
 		break;
 	}
 	case GATT_EVENT_QUERY_COMPLETE: {
-		uint8_t status = gatt_event_query_complete_get_att_status(packet);
+		uint8_t status =
+			gatt_event_query_complete_get_att_status(packet);
 		if (status != ATT_ERROR_SUCCESS) {
 			printf("[sc2] GATT query failed state=%d status=0x%02X\n",
 			       v->state, status);
@@ -184,10 +254,14 @@ static void gatt_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 				break;
 			}
 			v->state = V_DISC_CHARS;
+			v->wr_n = 0;
+			// fresh characteristic dump per discovery
+			pplog_chars_reset();
 			gatt_client_discover_characteristics_for_service(
 				gatt_handler, v->handle, &v->service);
 		} else if (v->state == V_DISC_CHARS) {
-			if (v->report_id == 0 || v->input_char.value_handle == 0) {
+			if (v->report_id == 0 ||
+			    v->input_char.value_handle == 0) {
 				printf("[sc2] no Valve input characteristic\n");
 				valve_reset(v);
 				break;
@@ -202,7 +276,8 @@ static void gatt_handler(uint8_t type, uint16_t channel, uint8_t *packet,
 				&v->input_char);
 			v->state = V_READY;
 			v->last_keepalive_ms = now_ms();
-			printf("[sc2] ready (report id 0x%02X)\n", v->report_id);
+			printf("[sc2] ready (report id 0x%02X)\n",
+			       v->report_id);
 		}
 		break;
 	}
@@ -232,16 +307,27 @@ void valve_periodic(void)
 		if (v->state != V_READY || v->report_value_handle == 0)
 			continue;
 		if (!gatt_client_is_ready(v->handle))
-			continue;  // one GATT op at a time
+			continue; // one GATT op at a time
+
+		// Haptic bisector test write — highest priority, one-shot.
+		if (v->test_pending) {
+			v->test_pending = false;
+			gatt_client_write_value_of_characteristic(
+				write_cb, v->handle, v->test_handle,
+				v->test_len, v->test_buf);
+			g_valve_writes++;
+			continue;
+		}
 
 		// Relayed host writes (haptics/rumble/config) take priority for latency.
 		if (v->wq_count) {
 			uint8_t *rep = v->wq[v->wq_tail];
 			uint8_t rl = v->wq_len[v->wq_tail];
+			uint16_t h = v->wq_handle[v->wq_tail];
 			v->wq_tail = (uint8_t)((v->wq_tail + 1) % VALVE_WQ);
 			v->wq_count--;
 			gatt_client_write_value_of_characteristic(
-				write_cb, v->handle, v->report_value_handle, rl, rep);
+				write_cb, v->handle, h, rl, rep);
 			g_valve_writes++;
 			continue;
 		}
@@ -265,26 +351,127 @@ void valve_periodic(void)
 	}
 }
 
-// Queue the HID report [report_id][body] verbatim for the SC2's report
-// characteristic (drop-oldest if the queue is full). Drained in valve_periodic.
-void valve_feature_write(int slot, uint8_t report_id, const uint8_t *body, uint16_t len)
+// Haptic-target bisector: fire a strong test rumble at one writable char with
+// one framing, selected by `variant`. variant = charIndex*3 + framing:
+//   framing 0 = raw report [0x80][body]
+//   framing 1 = single BLE segment [0xC0][0x80][body]
+//   framing 2 = SDL feature form [0x03][0xC0][0x80][body], zero-padded to 20
+// Cycle variant from the panel until the SC2 buzzes, then report which won.
+void bt_test_haptic(uint8_t variant)
 {
-	if (slot < 0 || slot >= PP_NSLOT)
-		return;
-	valve_t *v = &s_valve[slot];
-	if (v->state != V_READY || v->report_value_handle == 0)
-		return;
-	if (len > VALVE_WMAX - 1)
-		len = VALVE_WMAX - 1;
+	for (int i = 0; i < PP_NSLOT; i++) {
+		valve_t *v = &s_valve[i];
+		if (v->state != V_READY || v->wr_n == 0)
+			continue;
+		uint8_t ci = (uint8_t)((variant / 3) % v->wr_n);
+		uint8_t fr = (uint8_t)(variant % 3);
+		// [id][type=0][inten16=0][spdL16=FFFF][gainL=0][spdR16=FFFF][gainR=0]
+		static const uint8_t rep[10] = { 0x80, 0, 0,	0,    0xFF,
+						 0xFF, 0, 0xFF, 0xFF, 0 };
+		uint8_t *b = v->test_buf;
+		uint8_t n = 0;
+		if (fr == 0) {
+			memcpy(b, rep, sizeof(rep));
+			n = sizeof(rep);
+		} else if (fr == 1) {
+			b[0] = 0xC0;
+			memcpy(b + 1, rep, sizeof(rep));
+			n = 1 + sizeof(rep);
+		} else {
+			memset(b, 0, 20);
+			b[0] = 0x03;
+			b[1] = 0xC0;
+			memcpy(b + 2, rep, sizeof(rep));
+			n = 20;
+		}
+		v->test_handle = v->wr[ci].handle;
+		v->test_len = n;
+		v->test_pending = true;
+		char lb[52];
+		snprintf(lb, sizeof(lb),
+			 "TEST v=%u ch=100F6C%02X%02X h=%04X fr=%u\n", variant,
+			 v->wr[ci].uuid2, v->wr[ci].uuid3, v->test_handle, fr);
+		pplog(lb);
+	}
+}
 
+// Append one write to the per-slot queue (drop-oldest if full). The target
+// handle rides per entry so haptics and feature writes can hit different chars.
+static void vq_push(valve_t *v, uint16_t handle, const uint8_t *data,
+		    uint8_t len)
+{
 	if (v->wq_count == VALVE_WQ) {
 		v->wq_tail = (uint8_t)((v->wq_tail + 1) % VALVE_WQ);
 		v->wq_count--;
 	}
-	uint8_t *dst = v->wq[v->wq_head];
-	dst[0] = report_id;
-	memcpy(dst + 1, body, len);
-	v->wq_len[v->wq_head] = (uint8_t)(len + 1);
+	memcpy(v->wq[v->wq_head], data, len);
+	v->wq_len[v->wq_head] = len;
+	v->wq_handle[v->wq_head] = handle;
 	v->wq_head = (uint8_t)((v->wq_head + 1) % VALVE_WQ);
 	v->wq_count++;
 }
+
+// Forward the HID report [report_id][body] to an SC2. Output reports
+// (0x80-0x89, haptics/LED) go to their per-id output char (id+0x35 → rumble
+// 0x80 = 100F6CB5) as the raw body; everything else (0x87 settings, power,
+// passthrough) goes to the feature/report char 100F6C34 BLE-segmented. Drained
+// in valve_periodic, drop-oldest if the queue fills.
+void valve_feature_write(int slot, uint8_t report_id, const uint8_t *body,
+			 uint16_t len)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	valve_t *v = &s_valve[slot];
+	if (v->state != V_READY || v->report_value_handle == 0)
+		return;
+
+	// Triton output reports: dedicated char, body-only (no report-id, no 0xC0
+	// segment header — the char is per-report-id so no demux header is needed).
+	// SDL HIDDeviceBLESteamController routes them exactly this way. If discovery
+	// hasn't pinned the char yet, drop (the SC2 on this link is unusable anyway).
+	// Log actuator writes so the panel shows each one reaching the SC2 — the
+	// key datum for "is rumble firing at all?".
+	if (report_id >= 0x80 && report_id <= 0x86) {
+		char lb[40];
+		snprintf(lb, sizeof(lb), "hap %02X n=%u b=%02X %02X %02X\n",
+			 report_id, (unsigned)(len + 1), len > 0 ? body[0] : 0,
+			 len > 1 ? body[1] : 0, len > 2 ? body[2] : 0);
+		pplog(lb);
+	}
+
+	if (report_id >= 0x80 && report_id <= 0x89) {
+		uint16_t oh = v->out_handle[report_id - 0x80];
+		if (!oh || len == 0)
+			return;
+		if (len > VALVE_WMAX)
+			len = VALVE_WMAX;
+		vq_push(v, oh, body, (uint8_t)len);
+		return;
+	}
+
+	// Feature report [report_id][body], BLE-segmented to the report char 100F6C34
+	// (see BLE_SEG_*); most (settings 0x87) fit in a single 0xC0 segment.
+	uint8_t full[1 + VALVE_WMAX];
+	if (len > sizeof(full) - 1)
+		len = (uint16_t)(sizeof(full) - 1);
+	full[0] = report_id;
+	memcpy(full + 1, body, len);
+	uint16_t total = (uint16_t)(len + 1);
+
+	uint16_t off = 0;
+	uint8_t seg = 0;
+	while (off < total) {
+		uint16_t n = (uint16_t)(total - off);
+		if (n > BLE_SEG_PAYLOAD)
+			n = BLE_SEG_PAYLOAD;
+		uint8_t hdr =
+			(uint8_t)(BLE_SEG_DATA | (seg & 0x07) |
+				  ((off + n) >= total ? BLE_SEG_LAST : 0));
+		uint8_t segbuf[1 + BLE_SEG_PAYLOAD];
+		segbuf[0] = hdr;
+		memcpy(segbuf + 1, full + off, n);
+		vq_push(v, v->report_value_handle, segbuf, (uint8_t)(n + 1));
+		off = (uint16_t)(off + n);
+		seg++;
+	}
+}

+ 13 - 12
PicoPuck/src/puck/chord.c

@@ -15,12 +15,6 @@
 #include "pico/stdlib.h"
 #include "hardware/watchdog.h"
 
-// Face → target mode (A always Steam). Mirrors OpenPuck's chordBtn idea, but the
-// defaults point at PicoPuck's implemented emulated modes.
-#define CHORD_B MODE_PS5
-#define CHORD_X MODE_HIDGYRO
-#define CHORD_Y MODE_SW_HORI
-
 static void mode_switch_reboot(uint8_t mode)
 {
 	settings_set_mode(mode);
@@ -41,14 +35,21 @@ void chord_note(int slot)
 	if (((b & back4) != back4) && ((b & trig) != trig))
 		return;
 
+	// Face → target mode: A is always Steam; B/X/Y come from the configurable
+	// chord table (settings, panel-editable) — same as OpenPuck's g_chordBtn[].
+	const pp_cfg_t *c = settings();
 	uint8_t want = 0xFF;
-	if (b & TB_A) want = MODE_STEAM;
-	else if (b & TB_B) want = CHORD_B;
-	else if (b & TB_X) want = CHORD_X;
-	else if (b & TB_Y) want = CHORD_Y;
+	if (b & TB_A)
+		want = MODE_STEAM;
+	else if (b & TB_B)
+		want = c->chord[0];
+	else if (b & TB_X)
+		want = c->chord[1];
+	else if (b & TB_Y)
+		want = c->chord[2];
 
 	if (want == 0xFF || want == settings_mode())
-		return;  // guard held but no (new) target picked → pass through
+		return; // guard held but no (new) target picked → pass through
 
-	mode_switch_reboot(want);  // deliberate combo → switch immediately
+	mode_switch_reboot(want); // deliberate combo → switch immediately
 }

+ 12 - 8
PicoPuck/src/puck/relay.c

@@ -30,16 +30,20 @@ void relay_enqueue(int slot, uint8_t report_id, const uint8_t *body, uint16_t le
 
 void puck_rumble(int slot, uint16_t lo, uint16_t hi)
 {
-	uint16_t inten = (lo > hi) ? lo : hi;
+	// ID_OUT_REPORT_HAPTIC_RUMBLE (0x80) body — matches SDL's triton driver
+	// exactly: MsgHapticRumble { u8 type=0, u16 intensity=0, {u16 speed, i8
+	// gain}, {u16 speed, i8 gain} }. Magnitude rides left/right.speed (= the
+	// low/high-freq rumble); type/intensity/gain stay 0. (We previously set
+	// type=0x04 and intensity=max, which the SC2 rejects.)
 	uint8_t p[9];
-	p[0] = inten ? 0x04 : 0x00;  // HAPTIC_TYPE_RUMBLE / off
-	p[1] = (uint8_t)(inten & 0xFF);
-	p[2] = (uint8_t)(inten >> 8);
-	p[3] = (uint8_t)(lo & 0xFF);
+	p[0] = 0;                    // type
+	p[1] = 0;                    // intensity lo
+	p[2] = 0;                    // intensity hi
+	p[3] = (uint8_t)(lo & 0xFF); // left.speed
 	p[4] = (uint8_t)(lo >> 8);
-	p[5] = 0;
-	p[6] = (uint8_t)(hi & 0xFF);
+	p[5] = 0;                    // left.gain (dB)
+	p[6] = (uint8_t)(hi & 0xFF); // right.speed
 	p[7] = (uint8_t)(hi >> 8);
-	p[8] = 0;
+	p[8] = 0;                    // right.gain (dB)
 	relay_enqueue(slot, 0x80, p, 9);
 }

+ 92 - 0
PicoPuck/src/sys/pplog.c

@@ -0,0 +1,92 @@
+// pplog.c — see pplog.h.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "sys/pplog.h"
+
+#include <string.h>
+#include "pico/sync.h"
+
+#define PPLOG_CAP 1024
+
+static char s_buf[PPLOG_CAP];
+static uint16_t s_len; // valid bytes at the front of s_buf (oldest→newest)
+static spin_lock_t *s_lock;
+
+static spin_lock_t *lock(void)
+{
+	// Producers run in the BTstack/async context, the snapshot in the TinyUSB
+	// task — different call sites, so serialise with a spin lock (claimed lazily
+	// to avoid an init-order dependency).
+	if (!s_lock)
+		s_lock = spin_lock_instance(spin_lock_claim_unused(true));
+	return s_lock;
+}
+
+void pplog(const char *s)
+{
+	if (!s || !*s)
+		return;
+	uint16_t n = (uint16_t)strlen(s);
+	uint32_t sv = spin_lock_blocking(lock());
+	if (n >= PPLOG_CAP) {
+		memcpy(s_buf, s + (n - PPLOG_CAP), PPLOG_CAP);
+		s_len = PPLOG_CAP;
+	} else {
+		if ((uint32_t)s_len + n > PPLOG_CAP) {
+			uint16_t drop = (uint16_t)(s_len + n - PPLOG_CAP);
+			memmove(s_buf, s_buf + drop, (size_t)(s_len - drop));
+			s_len = (uint16_t)(s_len - drop);
+		}
+		memcpy(s_buf + s_len, s, n);
+		s_len = (uint16_t)(s_len + n);
+	}
+	spin_unlock(lock(), sv);
+}
+
+uint16_t pplog_snapshot(uint8_t *out, uint16_t max)
+{
+	uint32_t sv = spin_lock_blocking(lock());
+	uint16_t n = s_len;
+	uint16_t off = 0;
+	if (n > max) {
+		off = (uint16_t)(n - max); // keep the most-recent `max` bytes
+		n = max;
+	}
+	memcpy(out, s_buf + off, n);
+	spin_unlock(lock(), sv);
+	return n;
+}
+
+// ---- characteristic dump (append-only, cleared per discovery) --------------
+#define PPCHARS_CAP 512
+static char s_chars[PPCHARS_CAP];
+static uint16_t s_chars_len;
+
+void pplog_chars_reset(void)
+{
+	uint32_t sv = spin_lock_blocking(lock());
+	s_chars_len = 0;
+	spin_unlock(lock(), sv);
+}
+
+void pplog_chars(const char *s)
+{
+	if (!s || !*s)
+		return;
+	uint16_t n = (uint16_t)strlen(s);
+	uint32_t sv = spin_lock_blocking(lock());
+	if ((uint32_t)s_chars_len + n <= PPCHARS_CAP) {
+		memcpy(s_chars + s_chars_len, s, n);
+		s_chars_len = (uint16_t)(s_chars_len + n);
+	}
+	spin_unlock(lock(), sv);
+}
+
+uint16_t pplog_chars_snapshot(uint8_t *out, uint16_t max)
+{
+	uint32_t sv = spin_lock_blocking(lock());
+	uint16_t n = s_chars_len > max ? max : s_chars_len;
+	memcpy(out, s_chars, n);
+	spin_unlock(lock(), sv);
+	return n;
+}

+ 29 - 0
PicoPuck/src/sys/pplog.h

@@ -0,0 +1,29 @@
+// pplog.h — tiny in-RAM diagnostic log ring, surfaced over WebUSB.
+//
+// PicoPuck's stdio goes to UART only (the USB port is the puck's HID identity),
+// so when no serial adapter is attached these lines would be lost. pplog() keeps
+// the most-recent bytes in RAM and send_bt_frame() appends a snapshot to the
+// 0xAD pairing frame the panel already polls — so the log shows up in the panel.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PP_SYS_PPLOG_H
+#define PP_SYS_PPLOG_H
+
+#include <stdint.h>
+
+// Append a NUL-terminated string (include your own '\n'). Oldest bytes are
+// dropped when the ring fills. Safe to call from any context; best-effort.
+void pplog(const char *s);
+
+// Copy up to `max` most-recent bytes (chronological order) into out[]; returns
+// the number copied.
+uint16_t pplog_snapshot(uint8_t *out, uint16_t max);
+
+// A SEPARATE, non-rolling buffer for the one-shot GATT characteristic dump, so a
+// burst of haptic writes can't evict it before it's read. Reset at the start of
+// each discovery, then appended per characteristic.
+void pplog_chars_reset(void);
+void pplog_chars(const char *s);
+uint16_t pplog_chars_snapshot(uint8_t *out, uint16_t max);
+
+#endif // PP_SYS_PPLOG_H

+ 84 - 29
PicoPuck/src/sys/settings.c

@@ -16,7 +16,8 @@
 // is clear of both. (History: this used to be -3*SECTOR, which collided with the
 // Pico 2 W bond bank and wiped pairings on every mode switch / connect.)
 #define SETTINGS_OFFSET (PICO_FLASH_SIZE_BYTES - 4 * FLASH_SECTOR_SIZE)
-#define SETTINGS_MAGIC 0x50503104u  // "PP" v4 (bond[] table; moved off the bond bank)
+#define SETTINGS_MAGIC \
+	0x50503105u // "PP" v5 (chord defaults → LIZARD/XBOX/SW_PRO)
 
 typedef struct {
 	uint32_t magic;
@@ -28,23 +29,33 @@ static pp_cfg_t s_cfg;
 static void defaults(pp_cfg_t *c)
 {
 	memset(c, 0, sizeof(*c));
+	// All defaults MUST match OpenPuck's (config.cpp g_* / g_type[]) so the shared
+	// WebUSB panel shows/sets identical values on both firmwares.
 	c->mode = MODE_STEAM;
-	c->m_div = 8;
-	c->m_fric = 40;
-	c->rumble_scale = 100;
+	c->m_div = 64; // mouse sensitivity (OpenPuck g_mDiv)
+	c->m_fric = 94; // mouse friction   (OpenPuck g_mFric)
+	c->rumble_scale =
+		200; // OpenPuck g_rumbleScale (panel slider is 0..255)
 	c->persist_mode = 0;
-	c->chord[0] = MODE_PS5;    // B
-	c->chord[1] = MODE_HIDGYRO;  // X
-	c->chord[2] = MODE_SW_HORI;  // Y
+	// back4 + B/X/Y chord targets (A is always Steam). Matches OpenPuck's
+	// g_chordBtn default so the same combo switches to the same mode on both.
+	c->chord[0] = MODE_LIZARD; // B
+	c->chord[1] = MODE_XBOX; // X
+	c->chord[2] = MODE_SW_PRO; // Y
 	c->sw_pro_rate = 2;
 	c->sw_gyro10 = 10;
+	// Per-type (ET_XBOX/ET_SWITCH/ET_DS4/ET_DS5) — mirrors OpenPuck g_type[]:
+	//   back {5,6,7,8}, rumble on; Switch is Nintendo layout (ab_swap) with QAM→
+	//   Capture(18) and NO trackpad haptics; the rest default trackpad haptics on.
 	for (int et = 0; et < 4; et++) {
 		c->type[et].back[0] = 5;
 		c->type[et].back[1] = 6;
 		c->type[et].back[2] = 7;
 		c->type[et].back[3] = 8;
 		c->type[et].rumble = 1;
-		c->type[et].ab_swap = (et == 1) ? 1 : 0;  // Switch = Nintendo layout
+		c->type[et].ab_swap = (et == 1) ? 1 : 0;
+		c->type[et].qam = (et == 1) ? 18 : 0;
+		c->type[et].pad_haptics = (et == 1) ? 0 : 1;
 	}
 }
 
@@ -74,7 +85,8 @@ static void commit(void)
 	st.cfg = s_cfg;
 	uint8_t page[FLASH_PAGE_SIZE];
 	memset(page, 0xFF, sizeof(page));
-	memcpy(page, &st, sizeof(st) < sizeof(page) ? sizeof(st) : sizeof(page));
+	memcpy(page, &st,
+	       sizeof(st) < sizeof(page) ? sizeof(st) : sizeof(page));
 
 	uint32_t ints = save_and_disable_interrupts();
 	flash_range_erase(SETTINGS_OFFSET, FLASH_SECTOR_SIZE);
@@ -97,28 +109,67 @@ void settings_set_field(uint8_t field, uint8_t val)
 		int et = (field - 40) / 9, k = (field - 40) % 9;
 		pp_type_cfg_t *t = &s_cfg.type[et];
 		switch (k) {
-		case 0: case 1: case 2: case 3: t->back[k] = val; break;
-		case 4: t->qam = val; break;
-		case 5: t->ab_swap = val ? 1 : 0; break;
-		case 6: t->pad_haptics = val ? 1 : 0; break;
-		case 7: t->led_bright = val > 100 ? 100 : val; break;
-		case 8: t->rumble = val ? 1 : 0; break;
+		case 0:
+		case 1:
+		case 2:
+		case 3:
+			t->back[k] = val;
+			break;
+		case 4:
+			t->qam = val;
+			break;
+		case 5:
+			t->ab_swap = val ? 1 : 0;
+			break;
+		case 6:
+			t->pad_haptics = val ? 1 : 0;
+			break;
+		case 7:
+			t->led_bright = val > 100 ? 100 : val;
+			break;
+		case 8:
+			t->rumble = val ? 1 : 0;
+			break;
 		}
 		commit();
 		return;
 	}
 	switch (field) {
-	case 1: s_cfg.m_div = val < 4 ? 4 : val; break;
-	case 2: s_cfg.m_fric = val > 99 ? 99 : val; break;
-	case 16: s_cfg.persist_mode = val ? 1 : 0; break;
-	case 17: if (val <= MODE_MAX) s_cfg.chord[0] = val; break;
-	case 18: if (val <= MODE_MAX) s_cfg.chord[1] = val; break;
-	case 19: if (val <= MODE_MAX) s_cfg.chord[2] = val; break;
-	case 22: s_cfg.rumble_scale = val; break;
-	case 23: s_cfg.sw_pro_rate = val <= 2 ? val : 2; break;
-	case 24: s_cfg.sw_gyro10 = (val >= 5 && val <= 30) ? val : 10; break;
-	case 29: s_cfg.land_all_87 = val ? 1 : 0; break;
-	default: return;  // unknown/ignored field — don't rewrite flash
+	case 1:
+		s_cfg.m_div = val < 4 ? 4 : val;
+		break;
+	case 2:
+		s_cfg.m_fric = val > 99 ? 99 : val;
+		break;
+	case 16:
+		s_cfg.persist_mode = val ? 1 : 0;
+		break;
+	case 17:
+		if (val <= MODE_MAX)
+			s_cfg.chord[0] = val;
+		break;
+	case 18:
+		if (val <= MODE_MAX)
+			s_cfg.chord[1] = val;
+		break;
+	case 19:
+		if (val <= MODE_MAX)
+			s_cfg.chord[2] = val;
+		break;
+	case 22:
+		s_cfg.rumble_scale = val;
+		break;
+	case 23:
+		s_cfg.sw_pro_rate = val <= 2 ? val : 2;
+		break;
+	case 24:
+		s_cfg.sw_gyro10 = (val >= 5 && val <= 30) ? val : 10;
+		break;
+	case 29:
+		s_cfg.land_all_87 = val ? 1 : 0;
+		break;
+	default:
+		return; // unknown/ignored field — don't rewrite flash
 	}
 	commit();
 }
@@ -133,7 +184,8 @@ void settings_factory_reset(void)
 static int bond_find(const uint8_t addr[6])
 {
 	for (int i = 0; i < 4; i++)
-		if (s_cfg.bond[i].used && memcmp(s_cfg.bond[i].addr, addr, 6) == 0)
+		if (s_cfg.bond[i].used &&
+		    memcmp(s_cfg.bond[i].addr, addr, 6) == 0)
 			return i;
 	return -1;
 }
@@ -144,9 +196,12 @@ void settings_bond_remember(const uint8_t addr[6], uint8_t addr_type,
 	int i = bond_find(addr);
 	if (i < 0)
 		for (int k = 0; k < 4; k++)
-			if (!s_cfg.bond[k].used) { i = k; break; }
+			if (!s_cfg.bond[k].used) {
+				i = k;
+				break;
+			}
 	if (i < 0)
-		i = 0;  // table full (≤4 bonds): overwrite the first entry
+		i = 0; // table full (≤4 bonds): overwrite the first entry
 	pp_bond_meta_t *b = &s_cfg.bond[i];
 	// Skip the flash write if nothing changed (avoids a wear cycle on every
 	// reconnect, since route_connection calls this each time a pad links).

+ 27 - 3
PicoPuck/src/usb/webusb.c

@@ -15,6 +15,7 @@
 #include "puck/relay.h"
 #include "puck/personality.h"
 #include "sys/settings.h"
+#include "sys/pplog.h"
 #include "bt/bt_control.h"
 
 #include <string.h>
@@ -26,6 +27,7 @@
 // TinyUSB translation unit).
 extern volatile uint16_t g_valve_writes;
 uint8_t bt_report_count(int slot);
+void bt_test_haptic(uint8_t variant);  // haptic-target bisector (bt_valve.c)
 
 #define WB_VER 17
 #define WB_PAYLEN 180
@@ -117,12 +119,14 @@ static void send_blob(void)
 #define BT_SCAN_MAX 10
 static void send_bt_frame(void)
 {
-	uint8_t f[3 + 3 + PP_NSLOT * BT_SLOT_REC + 1 + BT_SCAN_MAX * BT_SCAN_REC + 8];
+	// Sized to the vendor TX FIFO (512 B) so the appended log tail can use
+	// whatever room the pairing payload leaves without overrunning the FIFO.
+	uint8_t f[512];
 	uint8_t *p = f;
 	*p++ = 0xAD;
 	uint8_t *plen = p; p += 2;   // 16-bit LE length, backfilled below
 	uint8_t *start = p;
-	*p++ = 1;             // frame version
+	*p++ = 2;             // frame version (2 = log tail present)
 	*p++ = PP_BOARD;
 	*p++ = bt_scan_active() ? 1 : 0;
 
@@ -182,6 +186,25 @@ static void send_bt_frame(void)
 	*p++ = flags; *p++ = hci;
 	*p++ = (uint8_t)(g_valve_writes & 0xFF); *p++ = (uint8_t)(g_valve_writes >> 8);
 
+	// Two length-prefixed log sections: [charlen][chars][loglen][haptics]. The
+	// characteristic dump goes first with priority (it's the datum we need and
+	// must not be evicted by haptic spam); the rolling haptic log fills whatever
+	// room is left. Whole frame capped at 500 B: below the 512-B FIFO AND not a
+	// multiple of the 64-B bulk packet, so the transfer always ends on a short
+	// packet (an exactly-512 frame has no terminating short packet → host stalls).
+	{
+		uint16_t used = (uint16_t)(p - f) + 4; // two length prefixes
+		uint16_t room = used < 500 ? (uint16_t)(500 - used) : 0;
+		uint8_t *clen = p; p += 2;
+		uint16_t cn = pplog_chars_snapshot(p, room);
+		p += cn; room = (uint16_t)(room - cn);
+		clen[0] = (uint8_t)(cn & 0xFF); clen[1] = (uint8_t)(cn >> 8);
+		uint8_t *llen = p; p += 2;
+		uint16_t ln = pplog_snapshot(p, room);
+		p += ln;
+		llen[0] = (uint8_t)(ln & 0xFF); llen[1] = (uint8_t)(ln >> 8);
+	}
+
 	uint16_t pl = (uint16_t)(p - start);
 	plen[0] = (uint8_t)(pl & 0xFF); plen[1] = (uint8_t)(pl >> 8);
 	send_frame(f, (uint16_t)(p - f));
@@ -197,7 +220,7 @@ static uint8_t opcode_len(uint8_t op)
 		return 1;
 	case 0x02: return 3;
 	case 0x03: case 0x05: case 0x0E: case 0x0F: case 0x10: case 0x13:
-	case 0x16:
+	case 0x16: case 0x1A:
 		return 2;
 	case 0x0A: return 4;
 	case 0x25: return 5;
@@ -241,6 +264,7 @@ static void dispatch(const uint8_t *c)
 	case 0x17: bt_pair(&c[1], c[7]); break;
 	case 0x18: bt_forget(&c[1], c[7]); break;
 	case 0x19: send_bt_frame(); break;
+	case 0x1A: bt_test_haptic(c[1]); break;  // haptic-target bisector
 	default: break;  // stubs (0x07/0x09/0x0D/0x0E/0x0F/0x10/0x11-0x15/0x20-0x25)
 	}
 }

+ 44 - 0
docs/index.html

@@ -1223,6 +1223,50 @@ function applyBtFrame(p){
   }
   $("#ppState").textContent = (scanActive ? "scanning for controllers…" : "idle") + diag;
   $("#ppScanBtn").textContent = scanActive ? "Stop" : "Scan";
+  // Device log tail (frame v2+): two length-prefixed latin1 sections —
+  // [charlen][chars] (frozen GATT characteristic dump) then [loglen][haptics]
+  // (rolling). Surfaced here because PicoPuck's stdio is UART-only.
+  const dec=a=>new TextDecoder("latin1").decode(new Uint8Array(a));
+  let off=dg+6, chars="", haps="";
+  if(p.length>=off+2){
+    const cl=p[off]|(p[off+1]<<8); off+=2;
+    if(p.length>=off+cl){ chars=dec(p.slice(off,off+cl)); off+=cl; }
+    if(p.length>=off+2){ const hl=p[off]|(p[off+1]<<8); off+=2;
+      if(p.length>=off+hl) haps=dec(p.slice(off,off+hl)); }
+    ppShowDevLog((chars?"== characteristics ==\n"+chars:"")+(haps?"== haptics ==\n"+haps:""));
+  }
+}
+// Render the device log window into #ppDevLog (created lazily under the pairing
+// card). Only touch the DOM when the text changes so the 600 ms poll doesn't
+// fight text selection / copy.
+let ppDevLogLast="";
+function ppShowDevLog(txt){
+  if(txt===ppDevLogLast) return; ppDevLogLast=txt;
+  let el=$("#ppDevLog");
+  if(!el){
+    const host=$("#ppState")?.closest(".card")||document.body;
+    const wrap=document.createElement("div"); wrap.style.cssText="margin-top:10px";
+    wrap.innerHTML='<div class="row" style="align-items:center;gap:8px;margin-bottom:4px">'
+      +'<strong style="font-size:12px">Device log</strong>'
+      +'<button id="ppDevLogCopy" class="btn" style="padding:2px 8px;font-size:11px">Copy</button>'
+      +'<button id="ppHapTest" class="btn" style="padding:2px 8px;font-size:11px">Test rumble ▶</button>'
+      +'<span id="ppHapV" style="font-size:11px;opacity:.7"></span></div>'
+      +'<pre id="ppDevLog" style="max-height:200px;overflow:auto;background:#111;color:#0f0;'
+      +'font:11px/1.4 monospace;padding:8px;border-radius:6px;white-space:pre-wrap;margin:0"></pre>';
+    host.appendChild(wrap);
+    el=$("#ppDevLog");
+    $("#ppDevLogCopy").onclick=()=>navigator.clipboard?.writeText($("#ppDevLog").textContent||"");
+    // Haptic-target bisector: each press fires a strong test rumble at the next
+    // (characteristic, framing) combo. Watch the SC2 — when it buzzes, the "TEST
+    // v=… ch=… fr=…" line in the log names the winning combo.
+    window.ppHapV=0;
+    $("#ppHapTest").onclick=async()=>{
+      const v=window.ppHapV++ & 0xFF;
+      $("#ppHapV").textContent="variant "+v;
+      try{ await ppBtCommand([0x1A, v]); }catch(e){ log("haptic test err: "+e.message); }
+    };
+  }
+  el.textContent=txt;
 }
 function renderPicoPuckSlots(slots){
   const el=$("#ppSlots"); if(!el) return;