瀏覽代碼

picopuck src folder

Jari 3 月之前
父節點
當前提交
735adaa7fa

+ 258 - 0
PicoPuck/src/bt/bt_hogp.c

@@ -0,0 +1,258 @@
+// bt_hogp.c — manual HID-over-GATT client (see bt_hogp.h).
+//
+// Modeled on the working Valve GATT client. One instance per slot, demuxed by
+// con_handle. Enables notifications on the HID service's input Report
+// characteristics and forwards each notification to the slot's driver.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "bt/bt_hogp.h"
+#include "bt/input_driver.h"
+#include "puck/personality.h"
+#include "puck/triton.h"
+#include "puck/slots.h"
+#include "config/picopuck_config.h"
+
+#include <stdio.h>
+#include <string.h>
+#include "pico/time.h"
+
+// From bt_host.c: which driver is bound to a slot, and the diagnostic counter.
+const input_driver_t *bt_slot_driver(int slot);
+void bt_host_note_report(int slot);
+
+#define HOGP_SVC_HID 0x1812u
+#define HOGP_CHR_REPORT 0x2A4Du
+#define HOGP_MAX_INPUTS 4  // input Report characteristics we subscribe per device
+
+typedef enum {
+	H_IDLE = 0,
+	H_DISC_SERVICE,
+	H_DISC_CHARS,
+	H_ENABLE_CCC,
+	H_READY,
+} hogp_state_t;
+
+typedef struct {
+	hogp_state_t state;
+	int slot;
+	hci_con_handle_t handle;
+	gatt_client_service_t service;
+	gatt_client_characteristic_t input[HOGP_MAX_INPUTS];
+	gatt_client_notification_t notify[HOGP_MAX_INPUTS];
+	uint8_t n_input;
+	uint8_t cccd_idx;  // which input char's CCCD we're enabling
+	gatt_client_characteristic_t output;  // first writable report char (rumble)
+	bool have_output;
+	uint32_t last_batt_ms;  // periodic Battery Service poll
+} hogp_t;
+
+#define HOGP_BATT_POLL_MS 30000u
+
+static hogp_t s_hogp[PP_NSLOT];
+
+static hogp_t *by_handle(hci_con_handle_t h)
+{
+	for (int i = 0; i < PP_NSLOT; i++)
+		if (s_hogp[i].state != H_IDLE && s_hogp[i].handle == h)
+			return &s_hogp[i];
+	return NULL;
+}
+
+static void hogp_reset(hogp_t *g)
+{
+	if (g->state == H_READY)
+		for (int i = 0; i < g->n_input; i++)
+			gatt_client_stop_listening_for_characteristic_value_updates(
+				&g->notify[i]);
+	memset(g, 0, sizeof(*g));
+	g->handle = HCI_CON_HANDLE_INVALID;
+}
+
+void hogp_disconnected(hci_con_handle_t handle)
+{
+	hogp_t *g = by_handle(handle);
+	if (g)
+		hogp_reset(g);
+}
+
+static void notify_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			   uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET ||
+	    hci_event_packet_get_type(packet) != GATT_EVENT_NOTIFICATION)
+		return;
+	hogp_t *g = by_handle(gatt_event_notification_get_handle(packet));
+	if (!g || g->state != H_READY)
+		return;
+
+	uint16_t len = gatt_event_notification_get_value_length(packet);
+	const uint8_t *val = gatt_event_notification_get_value(packet);
+	const input_driver_t *drv = bt_slot_driver(g->slot);
+	if (!drv || !drv->decode || len < 1)
+		return;
+
+	bt_host_note_report(g->slot);
+	// HOGP report-characteristic values carry no report-id byte (the id lives in
+	// the report reference). For the Xbox layout, the 14–16 byte report is the
+	// main gamepad state (report 0x01); a short one is the legacy guide (0x02).
+	uint8_t rid = (len >= 14) ? 0x01 : 0x02;
+	if (drv->decode(rid, val, len, &g_in[g->slot]))
+		puck_present_synth(g->slot);
+}
+
+static void gatt_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			 uint16_t size);
+
+static void hogp_write_next_ccc(hogp_t *g)
+{
+	// Write the CCCD (enable notifications) for input[cccd_idx].
+	gatt_client_write_client_characteristic_configuration(
+		gatt_handler, g->handle, &g->input[g->cccd_idx],
+		GATT_CLIENT_CHARACTERISTICS_CONFIGURATION_NOTIFICATION);
+}
+
+static void gatt_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+			 uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET)
+		return;
+	hogp_t *g = by_handle(gatt_event_query_complete_get_handle(packet));
+	if (!g)
+		return;
+
+	switch (hci_event_packet_get_type(packet)) {
+	case GATT_EVENT_SERVICE_QUERY_RESULT:
+		gatt_event_service_query_result_get_service(packet, &g->service);
+		break;
+	case GATT_EVENT_CHARACTERISTIC_QUERY_RESULT: {
+		gatt_client_characteristic_t ch;
+		gatt_event_characteristic_query_result_get_characteristic(packet, &ch);
+		// Input report characteristics support NOTIFY (0x10); the rumble output
+		// report supports WRITE (0x08) or WRITE-WITHOUT-RESPONSE (0x04).
+		if (ch.uuid16 == HOGP_CHR_REPORT && (ch.properties & 0x10) &&
+		    g->n_input < HOGP_MAX_INPUTS)
+			g->input[g->n_input++] = ch;
+		else if (ch.uuid16 == HOGP_CHR_REPORT && (ch.properties & 0x0C) &&
+			 !g->have_output) {
+			g->output = ch;
+			g->have_output = true;
+		}
+		break;
+	}
+	case GATT_EVENT_QUERY_COMPLETE: {
+		uint8_t status = gatt_event_query_complete_get_att_status(packet);
+		if (status != ATT_ERROR_SUCCESS) {
+			printf("[hogp] query failed state=%d status=0x%02X\n",
+			       g->state, status);
+			hogp_reset(g);
+			break;
+		}
+		if (g->state == H_DISC_SERVICE) {
+			if (g->service.start_group_handle == 0) {
+				printf("[hogp] no HID service\n");
+				hogp_reset(g);
+				break;
+			}
+			g->state = H_DISC_CHARS;
+			gatt_client_discover_characteristics_for_service(
+				gatt_handler, g->handle, &g->service);
+		} else if (g->state == H_DISC_CHARS) {
+			if (g->n_input == 0) {
+				printf("[hogp] no input report characteristic\n");
+				hogp_reset(g);
+				break;
+			}
+			g->state = H_ENABLE_CCC;
+			g->cccd_idx = 0;
+			hogp_write_next_ccc(g);
+		} else if (g->state == H_ENABLE_CCC) {
+			// One CCCD written; start listening on that char, then next.
+			gatt_client_listen_for_characteristic_value_updates(
+				&g->notify[g->cccd_idx], notify_handler, g->handle,
+				&g->input[g->cccd_idx]);
+			g->cccd_idx++;
+			if (g->cccd_idx < g->n_input) {
+				hogp_write_next_ccc(g);
+			} else {
+				g->state = H_READY;
+				printf("[hogp] ready (%d input chars, slot %d)\n",
+				       g->n_input, g->slot);
+			}
+		}
+		break;
+	}
+	default:
+		break;
+	}
+}
+
+// Battery Service (0x180F / level char 0x2A19) read completion.
+static void battery_read_handler(uint8_t type, uint16_t channel, uint8_t *packet,
+				 uint16_t size)
+{
+	(void)channel;
+	(void)size;
+	if (type != HCI_EVENT_PACKET ||
+	    hci_event_packet_get_type(packet) !=
+		    GATT_EVENT_CHARACTERISTIC_VALUE_QUERY_RESULT)
+		return;
+	hogp_t *g = by_handle(
+		gatt_event_characteristic_value_query_result_get_handle(packet));
+	if (!g)
+		return;
+	if (gatt_event_characteristic_value_query_result_get_value_length(packet) >= 1) {
+		uint8_t pct = gatt_event_characteristic_value_query_result_get_value(packet)[0];
+		if (pct > 100)
+			pct = 100;
+		g_battery[g->slot] = pct;
+		g_battery_state[g->slot] = 1;  // discharging (BAS has no charge state)
+	}
+}
+
+// Periodic Battery Service poll for connected HOGP pads. Call from the loop.
+void hogp_periodic(void)
+{
+	uint32_t now = to_ms_since_boot(get_absolute_time());
+	for (int i = 0; i < PP_NSLOT; i++) {
+		hogp_t *g = &s_hogp[i];
+		if (g->state != H_READY)
+			continue;
+		if (g->last_batt_ms && (now - g->last_batt_ms) < HOGP_BATT_POLL_MS)
+			continue;
+		if (!gatt_client_is_ready(g->handle))
+			continue;
+		g->last_batt_ms = now ? now : 1;
+		gatt_client_read_value_of_characteristics_by_uuid16(
+			battery_read_handler, g->handle, 0x0001, 0xFFFF, 0x2A19);
+	}
+}
+
+void hogp_send_output(int slot, const uint8_t *data, uint16_t len)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	hogp_t *g = &s_hogp[slot];
+	if (g->state != H_READY || !g->have_output)
+		return;
+	// Write-without-response for low-latency rumble; ignore the return (best-effort).
+	gatt_client_write_value_of_characteristic_without_response(
+		g->handle, g->output.value_handle, len, (uint8_t *)data);
+}
+
+void hogp_start(int slot, hci_con_handle_t handle)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	hogp_t *g = &s_hogp[slot];
+	memset(g, 0, sizeof(*g));
+	g->slot = slot;
+	g->handle = handle;
+	g->state = H_DISC_SERVICE;
+	printf("[hogp] discovering HID service (slot %d)\n", slot);
+	gatt_client_discover_primary_services_by_uuid16(gatt_handler, handle,
+							HOGP_SVC_HID);
+}

+ 27 - 0
PicoPuck/src/bt/bt_hogp.h

@@ -0,0 +1,27 @@
+// bt_hogp.h — manual HID-over-GATT client for standard BLE gamepads (Xbox, …).
+//
+// BTstack's hids_client auto-discovers and subscribes, but in practice it does
+// not deliver notifications from Xbox controllers (observed: bt0). joypad-os hit
+// the same wall and hand-rolled GATT discovery; our Valve GATT client (bt_valve)
+// proves that manual discovery works reliably here. This is the same pattern for
+// the standard HID service: discover 0x1812, enable the input Report
+// characteristic's notifications, and route each notification to the slot's
+// input driver.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_BT_HOGP_H
+#define PICOPUCK_BT_HOGP_H
+
+#include <stdint.h>
+#include "btstack.h"
+
+void hogp_start(int slot, hci_con_handle_t handle);
+void hogp_disconnected(hci_con_handle_t handle);
+
+// Write an output report (e.g. rumble) to the pad's HID output characteristic.
+void hogp_send_output(int slot, const uint8_t *data, uint16_t len);
+
+// Periodic Battery Service poll for connected HOGP pads. Call each loop.
+void hogp_periodic(void);
+
+#endif // PICOPUCK_BT_HOGP_H

+ 43 - 0
PicoPuck/src/config/modes.h

@@ -0,0 +1,43 @@
+// modes.h — USB presentation modes, matching OpenPuck's MODE_* values exactly.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_MODES_H
+#define PICOPUCK_MODES_H
+
+#include <stdint.h>
+#include <stdbool.h>
+
+#define MODE_STEAM 0     // Valve puck (transparent SC2 / synth for generic pads)
+#define MODE_XBOX 1      // Xbox 360 XInput
+#define MODE_SW_HORI 2   // Switch HORIPAD
+#define MODE_LIZARD 3    // Puck HID, always keyboard+mouse
+#define MODE_SW_PRO 4    // Nintendo Switch Pro Controller + gyro
+#define MODE_PS5 5       // Sony DualSense + gyro + split trackpad
+#define MODE_HIDGYRO 6   // DS4-layout generic HID gamepad + gyro
+#define MODE_PS5_GAME 7  // DualSense, clean single-HID
+#define MODE_DS4_GAME 8  // DS4, clean single-HID
+#define MODE_PS3 9       // DualShock 3 / Sixaxis
+#define MODE_MAX 9
+
+// Modes that present the Valve puck HID (Steam personality + its 4 slots).
+static inline bool mode_is_puck(uint8_t m)
+{
+	return m == MODE_STEAM || m == MODE_LIZARD;
+}
+
+// Xbox 360 XInput is a vendor USB class (not HID), served by its own custom
+// TinyUSB class driver (xinput.c) rather than the emu_mode_t HID framework.
+static inline bool mode_is_xinput(uint8_t m)
+{
+	return m == MODE_XBOX;
+}
+
+// The single-controller emulated modes present one HID gamepad built from the
+// active controller's g_in (the "clean PS" modes drop the WebUSB/wake ifaces on
+// OpenPuck; PicoPuck keeps WebUSB in every mode so the panel always works).
+static inline bool mode_is_emulated(uint8_t m)
+{
+	return !mode_is_puck(m) && m <= MODE_MAX;
+}
+
+#endif // PICOPUCK_MODES_H

+ 70 - 0
PicoPuck/src/puck/chord.c

@@ -0,0 +1,70 @@
+// chord.c — button chord → USB mode switch (see chord.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/chord.h"
+#include "puck/triton.h"
+#include "config/modes.h"
+#include "config/picopuck_config.h"
+#include "sys/settings.h"
+
+#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
+
+#define CHORD_HOLD 12  // consecutive matching reports before switching
+
+static uint8_t s_cnt[PP_NSLOT];
+static uint8_t s_want[PP_NSLOT];
+
+static void mode_switch_reboot(uint8_t mode)
+{
+	settings_set_mode(mode);
+	watchdog_reboot(0, 0, 1);
+	while (1) {
+	}
+}
+
+void chord_check(int slot, bool is_sc2)
+{
+	if (slot < 0 || slot >= PP_NSLOT)
+		return;
+	uint32_t b = g_in[slot].buttons;
+	uint32_t guard = is_sc2 ? (TB_R4 | TB_L4 | TB_R5 | TB_L5)
+				: (TB_L2 | TB_R2 | TB_LB | TB_RB);
+
+	if ((b & guard) != guard) {
+		s_cnt[slot] = 0;
+		return;
+	}
+
+	// Guard held → a face button selects the target.
+	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;
+
+	// Don't leak the chord to the host.
+	g_in[slot].buttons &= ~(guard | TB_A | TB_B | TB_X | TB_Y);
+
+	if (want == 0xFF || want == settings_mode()) {
+		s_cnt[slot] = 0;
+		return;
+	}
+	if (want != s_want[slot]) {
+		s_want[slot] = want;
+		s_cnt[slot] = 1;
+		return;
+	}
+	if (++s_cnt[slot] >= CHORD_HOLD)
+		mode_switch_reboot(want);
+}

+ 17 - 0
PicoPuck/src/puck/chord.h

@@ -0,0 +1,17 @@
+// chord.h — controller button chord → USB mode switch (persist + reboot).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_CHORD_H
+#define PICOPUCK_CHORD_H
+
+#include <stdbool.h>
+
+// Check the mode-switch chord against g_in[slot] (call after each input decode).
+// Guard: an SC2 uses the four back paddles (like OpenPuck); a generic pad uses
+// L2+R2+LB+RB (which every pad has). While the guard is held a face button picks
+// the target (A→Steam, B/X/Y→configured modes) and the guard+face bits are
+// masked out of g_in so they don't leak to the host. A stable selection reboots
+// into the new mode.
+void chord_check(int slot, bool is_sc2);
+
+#endif // PICOPUCK_CHORD_H

+ 29 - 0
PicoPuck/src/puck/emu.c

@@ -0,0 +1,29 @@
+// emu.c — emulated-controller mode registry (see emu.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/emu.h"
+#include "config/modes.h"
+#include <stddef.h>
+
+// Each emulated mode exposes an emu_mode_t from its own file.
+extern const emu_mode_t emu_ds4;          // MODE_HIDGYRO / MODE_DS4_GAME
+extern const emu_mode_t emu_ps5;          // MODE_PS5 / MODE_PS5_GAME
+extern const emu_mode_t emu_switch_hori;  // MODE_SW_HORI
+extern const emu_mode_t emu_switch_pro;   // MODE_SW_PRO
+extern const emu_mode_t emu_ps3;          // MODE_PS3
+
+// Not yet ported (needs machinery beyond the plain-HID framework):
+//   MODE_XBOX — XInput is a vendor interface class, not HID.
+const emu_mode_t *emu_mode_for(uint8_t mode)
+{
+	switch (mode) {
+	case MODE_SW_HORI: return &emu_switch_hori;
+	case MODE_SW_PRO: return &emu_switch_pro;
+	case MODE_PS5:
+	case MODE_PS5_GAME: return &emu_ps5;
+	case MODE_HIDGYRO:
+	case MODE_DS4_GAME: return &emu_ds4;
+	case MODE_PS3: return &emu_ps3;
+	default: return NULL;  // puck fallback until ported
+	}
+}

+ 49 - 0
PicoPuck/src/puck/emu.h

@@ -0,0 +1,49 @@
+// emu.h — emulated-controller mode interface (Xbox/Switch/PS5/PS3/…).
+//
+// A puck mode (Steam/Lizard) presents the Valve puck HID; every other mode
+// presents ONE HID gamepad built from the active controller's g_in. Each such
+// mode provides its USB identity, HID report descriptor, an input-report builder
+// (g_in → bytes), and an optional output-report handler (rumble/LED). This is
+// the PicoPuck analogue of OpenPuck's mode_*.cpp report builders.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_EMU_H
+#define PICOPUCK_EMU_H
+
+#include <stdint.h>
+#include <stddef.h>
+#include <stdbool.h>
+#include "puck/triton.h"
+
+// HID report types (match TinyUSB's hid_report_type_t values), so mode files
+// don't need to pull in tusb.h.
+#define PP_HID_INPUT 1
+#define PP_HID_OUTPUT 2
+#define PP_HID_FEATURE 3
+
+typedef struct emu_mode {
+	uint16_t vid, pid, bcd;
+	const char *product;
+	const uint8_t *report_desc;
+	uint16_t report_desc_len;
+
+	// Build the input report for the controller on `slot` into out[] (body,
+	// no report id). Returns body length; sets *report_id (0 = no id).
+	uint16_t (*build)(int slot, uint8_t *out, uint8_t *report_id);
+
+	// Answer a host feature/input GET_REPORT (calibration/MAC/fw). Fill buf,
+	// return length (0 = unhandled). May be NULL.
+	uint16_t (*get_report)(int slot, uint8_t report_id, uint8_t type,
+			       uint8_t *buf, uint16_t reqlen);
+
+	// Handle a host output/feature SET_REPORT (rumble/LED). May be NULL.
+	void (*set_report)(int slot, uint8_t report_id, uint8_t type,
+			   const uint8_t *buf, uint16_t len);
+
+	uint8_t poll_ms;  // report cadence (0 → every loop)
+} emu_mode_t;
+
+// The emulated mode for `mode` (MODE_* value), or NULL for puck/unknown modes.
+const emu_mode_t *emu_mode_for(uint8_t mode);
+
+#endif // PICOPUCK_EMU_H

+ 58 - 0
PicoPuck/src/puck/emu_present.c

@@ -0,0 +1,58 @@
+// emu_present.c — drive the active emulated controller from g_in (see .h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/emu_present.h"
+#include "puck/emu.h"
+#include "puck/slots.h"
+#include "usb/usb_tx.h"
+#include "config/modes.h"
+#include "sys/settings.h"
+
+#include "pico/time.h"
+
+#define EMU_STREAM_MS 4u  // ~250 Hz, matching OpenPuck's USB_STREAM_MS
+
+static uint32_t s_last_ms;
+
+// First connected slot, or 0 (present a neutral controller when nothing is on).
+static int active_slot(void)
+{
+	for (int s = 0; s < PP_NSLOT; s++)
+		if (g_slot[s].connected)
+			return s;
+	return 0;
+}
+
+void emu_present_task(void)
+{
+	const emu_mode_t *e = emu_mode_for(settings_mode());
+	if (!e)
+		return;
+	uint32_t t = to_ms_since_boot(get_absolute_time());
+	if (t - s_last_ms < EMU_STREAM_MS)
+		return;
+	s_last_ms = t;
+
+	uint8_t body[64];
+	uint8_t rid = 0;
+	uint16_t n = e->build(active_slot(), body, &rid);
+	if (n)
+		usb_tx_hid(0, rid, body, n);
+}
+
+uint16_t emu_get_report(uint8_t report_id, uint8_t type, uint8_t *buf,
+			uint16_t reqlen)
+{
+	const emu_mode_t *e = emu_mode_for(settings_mode());
+	if (e && e->get_report)
+		return e->get_report(active_slot(), report_id, type, buf, reqlen);
+	return 0;
+}
+
+void emu_set_report(uint8_t report_id, uint8_t type, const uint8_t *buf,
+		    uint16_t len)
+{
+	const emu_mode_t *e = emu_mode_for(settings_mode());
+	if (e && e->set_report)
+		e->set_report(active_slot(), report_id, type, buf, len);
+}

+ 19 - 0
PicoPuck/src/puck/emu_present.h

@@ -0,0 +1,19 @@
+// emu_present.h — drive the active emulated controller from g_in.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_EMU_PRESENT_H
+#define PICOPUCK_EMU_PRESENT_H
+
+#include <stdint.h>
+
+// Build + send the emulated controller's input report each loop (rate-limited).
+void emu_present_task(void);
+
+// HID feature/output dispatch for emulated modes (routed from the shared
+// tud_hid callbacks when not in a puck mode).
+uint16_t emu_get_report(uint8_t report_id, uint8_t type, uint8_t *buf,
+			uint16_t reqlen);
+void emu_set_report(uint8_t report_id, uint8_t type, const uint8_t *buf,
+		    uint16_t len);
+
+#endif // PICOPUCK_EMU_PRESENT_H

+ 126 - 0
PicoPuck/src/puck/gamepad_util.c

@@ -0,0 +1,126 @@
+// gamepad_util.c — shared report-building helpers (see gamepad_util.h).
+//
+// Ported from OpenPuck/gamepad_util.cpp. PicoPuck omits the configurable
+// back-paddle / QAM / A-B-swap remapping for now (chord.c already masks the
+// switch chord out of g_in), so these use the base PlayStation layout.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/gamepad_util.h"
+
+static inline void le16(uint8_t *p, int16_t v)
+{
+	p[0] = (uint8_t)(v & 0xFF);
+	p[1] = (uint8_t)((v >> 8) & 0xFF);
+}
+
+uint8_t sw_stick(int16_t v, bool invert)
+{
+	int32_t a = 0x80 + (invert ? -((int32_t)v >> 8) : ((int32_t)v >> 8));
+	if (a < 0)
+		a = 0;
+	if (a > 255)
+		a = 255;
+	return (uint8_t)a;
+}
+
+void ps_neutral_calib(uint8_t *buf)
+{
+	le16(buf + 6, 2844);  le16(buf + 8, -2844);   // gyro pitch +/-
+	le16(buf + 10, 2844); le16(buf + 12, -2844);  // gyro yaw +/-
+	le16(buf + 14, 2844); le16(buf + 16, -2844);  // gyro roll +/-
+	le16(buf + 18, 2844); le16(buf + 20, 2844);   // gyro speed +/-
+	le16(buf + 22, 8192); le16(buf + 24, -8192);  // accel X +/-
+	le16(buf + 26, 8192); le16(buf + 28, -8192);  // accel Y +/-
+	le16(buf + 30, 8192); le16(buf + 32, -8192);  // accel Z +/-
+}
+
+uint8_t ps_hat_nibble(uint32_t b)
+{
+	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF, r = b & TB_DRT;
+	if (u && r) return 1;
+	if (r && d) return 3;
+	if (d && l) return 5;
+	if (l && u) return 7;
+	if (u) return 0;
+	if (r) return 2;
+	if (d) return 4;
+	if (l) return 6;
+	return 8;
+}
+
+uint8_t ps_face_nibble(uint32_t b)
+{
+	uint8_t f = 0;
+	if (b & TB_A) f |= 0x20;  // cross
+	if (b & TB_B) f |= 0x40;  // circle
+	if (b & TB_X) f |= 0x10;  // square
+	if (b & TB_Y) f |= 0x80;  // triangle
+	return f;
+}
+
+uint8_t ps_shoulders_byte(uint32_t b, uint8_t lt, uint8_t rt)
+{
+	return ((b & TB_LB) ? 0x01 : 0) | ((b & TB_RB) ? 0x02 : 0) |
+	       ((lt > SW_TRIG_ON || (b & TB_L2)) ? 0x04 : 0) |
+	       ((rt > SW_TRIG_ON || (b & TB_R2)) ? 0x08 : 0) |
+	       ((b & TB_MENU) ? 0x10 : 0) | ((b & TB_VIEW) ? 0x20 : 0) |
+	       ((b & TB_L3) ? 0x40 : 0) | ((b & TB_R3) ? 0x80 : 0);
+}
+
+static uint16_t pad_norm_u16(int16_t v, uint16_t maxv)
+{
+	int32_t t = (int32_t)v + 32768;
+	if (t < 0) t = 0;
+	if (t > 65535) t = 65535;
+	return (uint16_t)((t * (int32_t)maxv) / 65535);
+}
+static uint16_t touch_half_x(int16_t v, bool right_half)
+{
+	uint16_t x = pad_norm_u16(v, TOUCH_PAD_W / 2 - 1);
+	return right_half ? (uint16_t)(TOUCH_PAD_W / 2 + x) : x;
+}
+static uint16_t touch_y_inv(int16_t v, uint16_t height)
+{
+	uint16_t maxy = height - 1;
+	return (uint16_t)(maxy - pad_norm_u16(v, maxy));
+}
+static void touch_pack_point(uint8_t *base, int finger, bool touch, uint16_t x,
+			     uint16_t y)
+{
+	uint8_t *f = base + finger * 4;
+	if (!touch) {
+		f[0] = 0x80; f[1] = 0; f[2] = 0; f[3] = 0;
+		return;
+	}
+	f[0] = (uint8_t)(finger & 0x7F);
+	f[1] = (uint8_t)(x & 0xFF);
+	f[2] = (uint8_t)(((x >> 8) & 0x0F) | ((y & 0x0F) << 4));
+	f[3] = (uint8_t)((y >> 4) & 0xFF);
+}
+void touch_pack_pads(uint8_t *pts, bool l_touch, bool r_touch, uint16_t lx,
+		     uint16_t ly, uint16_t rx, uint16_t ry)
+{
+	touch_pack_point(pts, 0, false, 0, 0);
+	touch_pack_point(pts, 1, false, 0, 0);
+	if (l_touch && r_touch) {
+		touch_pack_point(pts, 0, true, lx, ly);
+		touch_pack_point(pts, 1, true, rx, ry);
+	} else if (l_touch) {
+		touch_pack_point(pts, 0, true, lx, ly);
+	} else if (r_touch) {
+		touch_pack_point(pts, 0, true, rx, ry);
+	}
+}
+void steam_pads_to_touch(uint32_t b, uint16_t touch_h, int16_t lpx, int16_t lpy,
+			 int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
+			 uint16_t *rx, uint16_t *ry)
+{
+	bool lt = (b & TB_LPADT) || (b & TB_LPADC);
+	bool rt = (b & TB_RPADT) || (b & TB_RPADC);
+	*lx = touch_half_x(lpx, false);
+	*ly = touch_y_inv(lpy, touch_h);
+	*rx = touch_half_x(rpx, true);
+	*ry = touch_y_inv(rpy, touch_h);
+	if (lt && !(b & TB_LPADT)) { *lx = TOUCH_PAD_W / 4; *ly = touch_h / 2; }
+	if (rt && !(b & TB_RPADT)) { *rx = TOUCH_PAD_W / 4 * 3; *ry = touch_h / 2; }
+}

+ 27 - 0
PicoPuck/src/puck/gamepad_util.h

@@ -0,0 +1,27 @@
+// gamepad_util.h — shared report-building helpers (ported from OpenPuck).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_GAMEPAD_UTIL_H
+#define PICOPUCK_GAMEPAD_UTIL_H
+
+#include <stdint.h>
+#include <stdbool.h>
+#include "puck/triton.h"
+
+#define SW_TRIG_ON 40  // analog-trigger fraction (/255) that trips digital ZL/ZR/L2/R2
+#define TOUCH_PAD_W 1920u
+
+uint8_t sw_stick(int16_t v, bool invert);      // int16 center0 → u8 center0x80
+void ps_neutral_calib(uint8_t *buf);           // DS4/DualSense motion calib payload
+
+uint8_t ps_hat_nibble(uint32_t b);             // d-pad → 8-way hat (8 = neutral)
+uint8_t ps_face_nibble(uint32_t b);            // PlayStation face nibble
+uint8_t ps_shoulders_byte(uint32_t b, uint8_t lt, uint8_t rt);
+
+void steam_pads_to_touch(uint32_t b, uint16_t touch_h, int16_t lpx, int16_t lpy,
+			 int16_t rpx, int16_t rpy, uint16_t *lx, uint16_t *ly,
+			 uint16_t *rx, uint16_t *ry);
+void touch_pack_pads(uint8_t *pts, bool l_touch, bool r_touch, uint16_t lx,
+		     uint16_t ly, uint16_t rx, uint16_t ry);
+
+#endif // PICOPUCK_GAMEPAD_UTIL_H

+ 128 - 0
PicoPuck/src/puck/mode_ds4.c

@@ -0,0 +1,128 @@
+// mode_ds4.c — DualShock 4 / HID-gyro emulated controller (ported from OpenPuck
+// mode_hidgyro.cpp). Report id 0x01 (63B) + gyro/accel + split trackpad; feature
+// reports for motion calibration / MAC / fw; rumble on output report 0x05.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/emu.h"
+#include "puck/gamepad_util.h"
+#include "puck/relay.h"
+#include <string.h>
+
+#define DS4_TOUCH_H 942
+#define DS4_STATUS_USB 0x1B
+
+static const uint8_t GYRO_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
+	0x09, 0x32, 0x09, 0x35, 0x15, 0x00, 0x25, 0x07, 0x75, 0x04, 0x95, 0x01,
+	0x81, 0x42, 0x65, 0x00, 0x05, 0x09, 0x19, 0x01, 0x29, 0x0E, 0x15, 0x00,
+	0x25, 0x01, 0x75, 0x01, 0x95, 0x0E, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09,
+	0x20, 0x75, 0x06, 0x95, 0x01, 0x15, 0x00, 0x25, 0x7F, 0x81, 0x02, 0x05,
+	0x01, 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x75, 0x08,
+	0x95, 0x02, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x21, 0x95, 0x36, 0x81,
+	0x02, 0x85, 0x05, 0x09, 0x22, 0x95, 0x1F, 0x91, 0x02, 0x85, 0x04, 0x09,
+	0x23, 0x95, 0x24, 0xB1, 0x02, 0x85, 0x02, 0x09, 0x24, 0x95, 0x24, 0xB1,
+	0x02, 0xC0
+};
+
+static uint16_t ds4_build(int slot, uint8_t *out, uint8_t *rid)
+{
+	*rid = 0x01;
+	uint32_t b = g_in[slot].buttons;
+	bool lt_touch = (b & TB_LPADT) || (b & TB_LPADC);
+	bool rt_touch = (b & TB_RPADT) || (b & TB_RPADC);
+	memset(out, 0, 63);
+	out[0] = sw_stick(g_in[slot].lx, false);
+	out[1] = sw_stick(g_in[slot].ly, true);
+	out[2] = sw_stick(g_in[slot].rx, false);
+	out[3] = sw_stick(g_in[slot].ry, true);
+	out[4] = ps_hat_nibble(b) | ps_face_nibble(b);
+	out[5] = ps_shoulders_byte(b, g_in[slot].lt, g_in[slot].rt);
+	static uint8_t ctr;
+	out[6] = ((ctr++ & 0x0F) << 4) |
+		 ((b & (TB_TOUCH | TB_LPADC | TB_RPADC)) ? 0x02 : 0) |
+		 ((b & TB_STEAM) ? 0x01 : 0);
+	out[7] = g_in[slot].lt;
+	out[8] = g_in[slot].rt;
+	out[12] = g_in[slot].gx & 0xFF;  out[13] = g_in[slot].gx >> 8;
+	out[14] = g_in[slot].gz & 0xFF;  out[15] = g_in[slot].gz >> 8;
+	out[16] = (-g_in[slot].gy) & 0xFF; out[17] = (-g_in[slot].gy) >> 8;
+	out[18] = g_in[slot].ax & 0xFF;  out[19] = g_in[slot].ax >> 8;
+	out[20] = g_in[slot].ay & 0xFF;  out[21] = g_in[slot].ay >> 8;
+	out[22] = g_in[slot].az & 0xFF;  out[23] = g_in[slot].az >> 8;
+	out[29] = DS4_STATUS_USB;
+	if (lt_touch || rt_touch) {
+		uint16_t lx, ly, rx, ry;
+		steam_pads_to_touch(b, DS4_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+				    g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx, &ry);
+		static uint8_t tstamp;
+		out[32] = 1;
+		out[33] = tstamp++;
+		touch_pack_pads(out + 34, lt_touch, rt_touch, lx, ly, rx, ry);
+	} else {
+		out[32] = 0;
+		touch_pack_pads(out + 34, false, false, 0, 0, 0, 0);
+	}
+	return 63;
+}
+
+static uint16_t ds4_get(int slot, uint8_t rid, uint8_t type, uint8_t *buf,
+			uint16_t reqlen)
+{
+	if (type != PP_HID_FEATURE || reqlen == 0)
+		return 0;
+	memset(buf, 0, reqlen);
+	uint8_t mac[6] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55, (uint8_t)(0x50 + slot) };
+	switch (rid) {
+	case 0x02:
+		if (reqlen < 36) return 0;
+		ps_neutral_calib(buf);
+		return 36;
+	case 0x12:
+		if (reqlen < 15) return 0;
+		memcpy(buf, mac, 6);
+		return 15;
+	case 0x81:
+		if (reqlen < 6) return 0;
+		memcpy(buf, mac, 6);
+		return 6;
+	case 0xA3:
+		if (reqlen < 48) return 0;
+		buf[0] = 0x01;
+		return 48;
+	default:
+		return 0;
+	}
+}
+
+static void ds4_set(int slot, uint8_t rid, uint8_t type, const uint8_t *b,
+		    uint16_t n)
+{
+	if (type != PP_HID_OUTPUT)
+		return;
+	// OUT endpoint delivers rid=0 with the id in b[0]; control SET_REPORT splits
+	// the id into rid. Normalise to p = bytes AFTER the id (effects @ [3]/[4]).
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		if (n < 1 || b[0] != 0x05) return;
+		p = b + 1; pn = (uint16_t)(n - 1);
+	} else if (rid == 0x05) {
+		p = b; pn = n;
+	} else {
+		return;
+	}
+	if (pn < 5)
+		return;
+	puck_rumble(slot, (uint16_t)p[4] * 257u, (uint16_t)p[3] * 257u);
+}
+
+const emu_mode_t emu_ds4 = {
+	.vid = 0x054C, .pid = 0x05C4, .bcd = 0x0120,
+	.product = "Wireless Controller",
+	.report_desc = GYRO_HID_DESC,
+	.report_desc_len = sizeof(GYRO_HID_DESC),
+	.build = ds4_build,
+	.get_report = ds4_get,
+	.set_report = ds4_set,
+	.poll_ms = 4,
+};

+ 252 - 0
PicoPuck/src/puck/mode_ps3.c

@@ -0,0 +1,252 @@
+// mode_ps3.c — Sony Sixaxis / DualShock 3 (054C:0268) emulated controller,
+// ported from OpenPuck mode_ps3.cpp. The PS3 recognises the pad by VID/PID plus
+// the control-transfer feature handshake below; input then streams as report
+// 0x01. Rumble arrives as output report 0x01.
+//
+// Unlike Adafruit's HID wrapper (which force-prepends the report id to a
+// GET_REPORT response), raw TinyUSB sends our buffer verbatim — so the feature
+// reports are copied on the wire exactly as GIMX emits them (byte 0 is the
+// array's own first byte, NOT necessarily the report id), no buf[-1] trick.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/emu.h"
+#include "puck/gamepad_util.h"
+#include "puck/relay.h"
+#include <string.h>
+
+// Genuine Sixaxis / DualShock 3 HID report descriptor (148 bytes, verbatim from
+// real hardware). The console does not parse this to drive input — it matches by
+// VID/PID + handshake — but a faithful descriptor keeps host HID stacks happy.
+// Do not "tidy" it: this is the real firmware's (famously odd) descriptor.
+static const uint8_t DS3_HID_DESC[148] = {
+	0x05, 0x01, 0x09, 0x04, 0xA1, 0x01, 0xA1, 0x02, 0x85, 0x01, 0x75, 0x08,
+	0x95, 0x01, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x81, 0x03, 0x75, 0x01, 0x95,
+	0x13, 0x15, 0x00, 0x25, 0x01, 0x35, 0x00, 0x45, 0x01, 0x05, 0x09, 0x19,
+	0x01, 0x29, 0x13, 0x81, 0x02, 0x75, 0x01, 0x95, 0x0D, 0x06, 0x00, 0xFF,
+	0x81, 0x03, 0x15, 0x00, 0x26, 0xFF, 0x00, 0x05, 0x01, 0x09, 0x01, 0xA1,
+	0x00, 0x75, 0x08, 0x95, 0x04, 0x35, 0x00, 0x46, 0xFF, 0x00, 0x09, 0x30,
+	0x09, 0x31, 0x09, 0x32, 0x09, 0x35, 0x81, 0x02, 0xC0, 0x05, 0x01, 0x75,
+	0x08, 0x95, 0x27, 0x09, 0x01, 0x81, 0x02, 0x75, 0x08, 0x95, 0x30, 0x09,
+	0x01, 0x91, 0x02, 0x75, 0x08, 0x95, 0x30, 0x09, 0x01, 0xB1, 0x02, 0xC0,
+	0xA1, 0x02, 0x85, 0x02, 0x75, 0x08, 0x95, 0x30, 0x09, 0x01, 0xB1, 0x02,
+	0xC0, 0xA1, 0x02, 0x85, 0xEE, 0x75, 0x08, 0x95, 0x30, 0x09, 0x01, 0xB1,
+	0x02, 0xC0, 0xA1, 0x02, 0x85, 0xEF, 0x75, 0x08, 0x95, 0x30, 0x09, 0x01,
+	0xB1, 0x02, 0xC0, 0xC0
+};
+
+// Magic GET_REPORT(Feature) responses, verbatim from GIMX-firmwares EMUPS3 — a
+// LUFA DS3 emulator proven to enumerate on a real PS3. The enable handshake is:
+//   GET 0x01 -> GET 0xF2 -> {SET 0xEF / GET 0xEF}x2 -> GET 0xF8 -> SET out 0x01
+//   -> SET 0xF4(enable); then input report 0x01 streams (we stream regardless).
+static const uint8_t report_01[64] = {
+	0x00, 0x01, 0x04, 0x00, 0x07, 0x0c, 0x01, 0x02, 0x18, 0x18, 0x18,
+	0x18, 0x09, 0x0a, 0x10, 0x11, 0x12, 0x13, 0x00, 0x00, 0x00, 0x00,
+	0x04, 0x00, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00, 0x04, 0x04,
+	0x04, 0x04, 0x00, 0x00, 0x04, 0x00, 0x01, 0x02, 0x07, 0x00, 0x17,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t report_f2[64] = {
+	0xf2, 0xff, 0xff, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00,
+	0x03, 0x50, 0x81, 0xd8, 0x01, 0x8a, 0x13, 0x00, 0x00, 0x00, 0x00,
+	0x04, 0x00, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00, 0x04, 0x04,
+	0x04, 0x04, 0x00, 0x00, 0x04, 0x00, 0x01, 0x02, 0x07, 0x00, 0x17,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t report_f5[64] = {
+	0x01, 0x00, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xff, 0xf7, 0x00,
+	0x03, 0x50, 0x81, 0xd8, 0x01, 0x8a, 0x13, 0x00, 0x00, 0x00, 0x00,
+	0x04, 0x00, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00, 0x04, 0x04,
+	0x04, 0x04, 0x00, 0x00, 0x04, 0x00, 0x01, 0x02, 0x07, 0x00, 0x17,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t report_ef[64] = {
+	0x00, 0xef, 0x04, 0x00, 0x07, 0x03, 0x01, 0xb0, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x6b, 0x02, 0x68, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t report_f8[64] = {
+	0x00, 0x01, 0x00, 0x00, 0x07, 0x03, 0x01, 0xb0, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x6b, 0x02, 0x68, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+static const uint8_t report_f7[64] = {
+	0x01, 0x04, 0xc4, 0x02, 0xd6, 0x01, 0xee, 0xff, 0x14, 0x13, 0x01,
+	0x02, 0xc4, 0x01, 0xd6, 0x00, 0x00, 0x02, 0x02, 0x02, 0x00, 0x03,
+	0x00, 0x00, 0x02, 0x00, 0x00, 0x02, 0x62, 0x01, 0x02, 0x01, 0x5e,
+	0x00, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+
+static uint8_t g_byte6ef = 0xb0;                 // last SET 0xEF byte[6]; echoed in GET 0xEF/0xF8 byte[7]
+static uint8_t g_masterBd[6];                    // host MAC learned via SET 0xF5
+static bool g_haveMaster;
+// Controller BT MAC (OUI 00:1B:DC like the other PS modes; last byte 0x80 keeps
+// it distinct). Only relevant to BT pairing identity; read via GET 0xF2.
+static const uint8_t g_ds3Mac[6] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55, 0x80 };
+
+// Copy a 64-byte Sixaxis feature report verbatim (raw TinyUSB does not prepend
+// the id). Returns min(64, reqlen).
+static uint16_t emit_feature(uint8_t *buf, uint16_t reqlen, const uint8_t arr[64])
+{
+	uint16_t n = reqlen < 64 ? reqlen : 64;
+	memcpy(buf, arr, n);
+	return n;
+}
+
+static uint16_t ds3_get(int slot, uint8_t rid, uint8_t type, uint8_t *buf,
+			uint16_t reqlen)
+{
+	(void)slot;
+	if (type != PP_HID_FEATURE || !buf || reqlen == 0)
+		return 0;
+	uint8_t r[64];
+	switch (rid) {
+	case 0x01:
+		return emit_feature(buf, reqlen, report_01);
+	case 0xf2:  // overlay our controller MAC at [4..9]
+		memcpy(r, report_f2, 64);
+		memcpy(r + 4, g_ds3Mac, 6);
+		return emit_feature(buf, reqlen, r);
+	case 0xf5:  // overlay learned host MAC at [2..7]
+		memcpy(r, report_f5, 64);
+		if (g_haveMaster)
+			memcpy(r + 2, g_masterBd, 6);
+		return emit_feature(buf, reqlen, r);
+	case 0xef:  // echo the PS3's remembered byte at [7]
+		memcpy(r, report_ef, 64);
+		r[7] = g_byte6ef;
+		return emit_feature(buf, reqlen, r);
+	case 0xf8:
+		memcpy(r, report_f8, 64);
+		r[7] = g_byte6ef;
+		return emit_feature(buf, reqlen, r);
+	case 0xf7:
+		return emit_feature(buf, reqlen, report_f7);
+	default:
+		return 0;
+	}
+}
+
+static void ds3_set(int slot, uint8_t rid, uint8_t type, const uint8_t *b,
+		    uint16_t n)
+{
+	if (type == PP_HID_FEATURE) {
+		// 0xEF: remember byte[6] for the next GET echo. 0xF5: learn host MAC.
+		// 0xF4 (set-operational) is ACKed silently — we stream unconditionally.
+		if (rid == 0xef && n >= 7)
+			g_byte6ef = b[6];
+		else if (rid == 0xf5 && n >= 8) {
+			memcpy(g_masterBd, b + 2, 6);
+			g_haveMaster = true;
+		}
+		return;
+	}
+	if (type != PP_HID_OUTPUT)
+		return;
+	// OUT endpoint delivers rid=0 with the id in b[0]; control SET_REPORT splits
+	// the id into rid. Normalise to p = bytes AFTER the id.
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		if (n < 1 || b[0] != 0x01) return;
+		p = b + 1; pn = (uint16_t)(n - 1);
+	} else if (rid == 0x01) {
+		p = b; pn = n;
+	} else {
+		return;
+	}
+	if (pn < 5)
+		return;
+	// Output report 0x01 body: p[0]=0x00, p[1]=small dur, p[2]=small power,
+	// p[3]=large dur, p[4]=large power. Large→lowFreq, small→highFreq(on/off);
+	// 257 = 65535/255 scales 8-bit power to the 16-bit haptic range.
+	puck_rumble(slot, (uint16_t)p[4] * 257u, p[2] ? 0xFFFFu : 0u);
+}
+
+// SC2 IMU int16 (center 0) → DS3 10-bit unsigned (center 511), little-endian.
+static void ds3_imu(uint8_t *out, int16_t v)
+{
+	int32_t e = 511 + ((int32_t)v >> 6);
+	if (e < 0) e = 0;
+	if (e > 1023) e = 1023;
+	out[0] = (uint8_t)(e & 0xFF);
+	out[1] = (uint8_t)((e >> 8) & 0xFF);
+}
+
+// Build the 48-byte input payload (id 0x01 prepended by the stack → 49-byte
+// Sixaxis report). Offsets are the genuine report's rd[] minus one.
+static uint16_t ds3_build(int slot, uint8_t *out, uint8_t *rid)
+{
+	*rid = 0x01;
+	uint32_t b = g_in[slot].buttons;
+	bool l2 = (g_in[slot].lt > SW_TRIG_ON) || (b & TB_L2);
+	bool r2 = (g_in[slot].rt > SW_TRIG_ON) || (b & TB_R2);
+	memset(out, 0, 48);
+
+	// out[1]: Select L3 R3 Start Up Right Down Left. SC2 View→Start, Menu→Select
+	// (matches the physical button positions on the puck).
+	out[1] = ((b & TB_MENU) ? 0x01 : 0) | ((b & TB_L3) ? 0x02 : 0) |
+		 ((b & TB_R3) ? 0x04 : 0) | ((b & TB_VIEW) ? 0x08 : 0) |
+		 ((b & TB_DUP) ? 0x10 : 0) | ((b & TB_DRT) ? 0x20 : 0) |
+		 ((b & TB_DDN) ? 0x40 : 0) | ((b & TB_DLF) ? 0x80 : 0);
+
+	// out[2]: L2 R2 L1 R1 Triangle Circle Cross Square.
+	uint8_t tri = (b & TB_Y) ? 0x10 : 0;
+	uint8_t cir = (b & TB_B) ? 0x20 : 0;
+	uint8_t crs = (b & TB_A) ? 0x40 : 0;
+	uint8_t sqr = (b & TB_X) ? 0x80 : 0;
+	out[2] = (l2 ? 0x01 : 0) | (r2 ? 0x02 : 0) | ((b & TB_LB) ? 0x04 : 0) |
+		 ((b & TB_RB) ? 0x08 : 0) | tri | cir | crs | sqr;
+
+	out[3] = (b & TB_STEAM) ? 0x01 : 0;  // PS button
+
+	out[5] = sw_stick(g_in[slot].lx, false);
+	out[6] = sw_stick(g_in[slot].ly, true);
+	out[7] = sw_stick(g_in[slot].rx, false);
+	out[8] = sw_stick(g_in[slot].ry, true);
+
+	// out[13..24]: analog pressures Up Right Down Left L2 R2 L1 R1 Tri Cir Crs Sqr.
+	out[13] = (b & TB_DUP) ? 0xFF : 0;
+	out[14] = (b & TB_DRT) ? 0xFF : 0;
+	out[15] = (b & TB_DDN) ? 0xFF : 0;
+	out[16] = (b & TB_DLF) ? 0xFF : 0;
+	out[17] = g_in[slot].lt;
+	out[18] = g_in[slot].rt;
+	out[19] = (b & TB_LB) ? 0xFF : 0;
+	out[20] = (b & TB_RB) ? 0xFF : 0;
+	out[21] = tri ? 0xFF : 0;
+	out[22] = cir ? 0xFF : 0;
+	out[23] = crs ? 0xFF : 0;
+	out[24] = sqr ? 0xFF : 0;
+
+	out[28] = 0x00;
+	out[29] = 0x05;  // battery level: full (cosmetic over USB)
+
+	// out[40..47]: accel X, accel Z, accel Y, gyro Z — each 10-bit LE, center 511.
+	ds3_imu(out + 40, g_in[slot].ax);
+	ds3_imu(out + 42, g_in[slot].az);
+	ds3_imu(out + 44, g_in[slot].ay);
+	ds3_imu(out + 46, g_in[slot].gz);
+	return 48;
+}
+
+const emu_mode_t emu_ps3 = {
+	.vid = 0x054C, .pid = 0x0268, .bcd = 0x0100,
+	.product = "PLAYSTATION(R)3 Controller",
+	.report_desc = DS3_HID_DESC,
+	.report_desc_len = sizeof(DS3_HID_DESC),
+	.build = ds3_build,
+	.get_report = ds3_get,
+	.set_report = ds3_set,
+	.poll_ms = 1,
+};

+ 137 - 0
PicoPuck/src/puck/mode_ps5.c

@@ -0,0 +1,137 @@
+// mode_ps5.c — Sony DualSense emulated controller (ported from OpenPuck
+// mode_ps5.cpp). Report id 0x01 (63B) + gyro/accel + split trackpad; feature
+// reports 0x03/0x05/0x09/0x20; rumble on output report 0x02.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/emu.h"
+#include "puck/gamepad_util.h"
+#include "puck/relay.h"
+#include <string.h>
+
+#define PS5_TOUCH_H 1080
+#define PS5_STATUS_USB 0x1A
+
+static const uint8_t PS5_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x85, 0x01, 0x09, 0x30, 0x09, 0x31,
+	0x09, 0x32, 0x09, 0x35, 0x09, 0x33, 0x09, 0x34, 0x15, 0x00, 0x26, 0xFF,
+	0x00, 0x75, 0x08, 0x95, 0x06, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x20,
+	0x95, 0x01, 0x81, 0x02, 0x05, 0x01, 0x09, 0x39, 0x15, 0x00, 0x25, 0x07,
+	0x35, 0x00, 0x46, 0x3B, 0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81,
+	0x42, 0x65, 0x00, 0x05, 0x09, 0x19, 0x01, 0x29, 0x0F, 0x15, 0x00, 0x25,
+	0x01, 0x75, 0x01, 0x95, 0x0F, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x21,
+	0x95, 0x0D, 0x81, 0x02, 0x06, 0x00, 0xFF, 0x09, 0x22, 0x15, 0x00, 0x26,
+	0xFF, 0x00, 0x75, 0x08, 0x95, 0x34, 0x81, 0x02, 0x85, 0x02, 0x09, 0x23,
+	0x95, 0x2F, 0x91, 0x02, 0x85, 0x05, 0x09, 0x33, 0x95, 0x28, 0xB1, 0x02,
+	0x85, 0x08, 0x09, 0x34, 0x95, 0x2F, 0xB1, 0x02, 0x85, 0x09, 0x09, 0x24,
+	0x95, 0x13, 0xB1, 0x02, 0x85, 0x0A, 0x09, 0x25, 0x95, 0x1A, 0xB1, 0x02,
+	0x85, 0x20, 0x09, 0x26, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x21, 0x09, 0x27,
+	0x95, 0x04, 0xB1, 0x02, 0x85, 0x22, 0x09, 0x40, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0x80, 0x09, 0x28, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x81, 0x09, 0x29,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0x82, 0x09, 0x2A, 0x95, 0x09, 0xB1, 0x02,
+	0x85, 0x83, 0x09, 0x2B, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0x84, 0x09, 0x2C,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0x85, 0x09, 0x2D, 0x95, 0x02, 0xB1, 0x02,
+	0x85, 0xA0, 0x09, 0x2E, 0x95, 0x01, 0xB1, 0x02, 0x85, 0xE0, 0x09, 0x2F,
+	0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF0, 0x09, 0x30, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0xF1, 0x09, 0x31, 0x95, 0x3F, 0xB1, 0x02, 0x85, 0xF2, 0x09, 0x32,
+	0x95, 0x0F, 0xB1, 0x02, 0x85, 0xF4, 0x09, 0x35, 0x95, 0x3F, 0xB1, 0x02,
+	0x85, 0xF5, 0x09, 0x36, 0x95, 0x03, 0xB1, 0x02, 0xC0
+};
+
+static uint16_t ps5_build(int slot, uint8_t *out, uint8_t *rid)
+{
+	*rid = 0x01;
+	uint32_t b = g_in[slot].buttons;
+	bool lt_touch = (b & TB_LPADT) || (b & TB_LPADC);
+	bool rt_touch = (b & TB_RPADT) || (b & TB_RPADC);
+	memset(out, 0, 63);
+	out[0] = sw_stick(g_in[slot].lx, false);
+	out[1] = sw_stick(g_in[slot].ly, true);
+	out[2] = sw_stick(g_in[slot].rx, false);
+	out[3] = sw_stick(g_in[slot].ry, true);
+	out[4] = g_in[slot].lt;
+	out[5] = g_in[slot].rt;
+	static uint8_t seq;
+	out[6] = seq++;
+	out[7] = ps_hat_nibble(b) | ps_face_nibble(b);
+	out[8] = ps_shoulders_byte(b, g_in[slot].lt, g_in[slot].rt);
+	out[9] = ((b & TB_STEAM) ? 0x01 : 0) |
+		 ((b & (TB_TOUCH | TB_LPADC | TB_RPADC)) ? 0x02 : 0) |
+		 ((b & TB_MUTE) ? 0x04 : 0);
+	out[15] = g_in[slot].gx & 0xFF;  out[16] = g_in[slot].gx >> 8;
+	out[17] = g_in[slot].gz & 0xFF;  out[18] = g_in[slot].gz >> 8;
+	out[19] = (-g_in[slot].gy) & 0xFF; out[20] = (-g_in[slot].gy) >> 8;
+	out[21] = g_in[slot].ax & 0xFF;  out[22] = g_in[slot].ax >> 8;
+	out[23] = g_in[slot].ay & 0xFF;  out[24] = g_in[slot].ay >> 8;
+	out[25] = g_in[slot].az & 0xFF;  out[26] = g_in[slot].az >> 8;
+	uint16_t lx, ly, rx, ry;
+	steam_pads_to_touch(b, PS5_TOUCH_H, g_in[slot].lpx, g_in[slot].lpy,
+			    g_in[slot].rpx, g_in[slot].rpy, &lx, &ly, &rx, &ry);
+	touch_pack_pads(out + 32, lt_touch, rt_touch, lx, ly, rx, ry);
+	out[52] = PS5_STATUS_USB;
+	return 63;
+}
+
+static uint16_t ps5_get(int slot, uint8_t rid, uint8_t type, uint8_t *buf,
+			uint16_t reqlen)
+{
+	if (type != PP_HID_FEATURE || reqlen == 0)
+		return 0;
+	memset(buf, 0, reqlen);
+	uint8_t mac[6] = { 0x00, 0x1B, 0xDC, 0x4F, 0x55, (uint8_t)(0x60 + slot) };
+	switch (rid) {
+	case 0x03:
+		if (reqlen < 47) return 0;
+		buf[0] = 0x00; buf[1] = 0x28; buf[2] = 0x01; buf[3] = 0x00;
+		buf[4] = 0x0E;
+		return 47;
+	case 0x05:
+		if (reqlen < 40) return 0;
+		ps_neutral_calib(buf);
+		return 40;
+	case 0x09:
+		if (reqlen < 19) return 0;
+		memcpy(buf, mac, 6);
+		return 19;
+	case 0x20:
+		if (reqlen < 63) return 0;
+		buf[23] = 0x01;
+		buf[27] = 0x01;
+		return 63;
+	default:
+		return 0;
+	}
+}
+
+static void ps5_set(int slot, uint8_t rid, uint8_t type, const uint8_t *b,
+		    uint16_t n)
+{
+	if (type != PP_HID_OUTPUT)
+		return;
+	// OUT endpoint delivers rid=0 with the id in b[0]; control SET_REPORT splits
+	// the id into rid. Normalise to p = bytes AFTER the id.
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) {
+		if (n < 1 || b[0] != 0x02) return;
+		p = b + 1; pn = (uint16_t)(n - 1);
+	} else if (rid == 0x02) {
+		p = b; pn = n;
+	} else {
+		return;
+	}
+	if (pn < 4)
+		return;
+	// DualSense output 0x02 body: [2]=right(high) motor, [3]=left(low) motor.
+	puck_rumble(slot, (uint16_t)p[3] * 257u, (uint16_t)p[2] * 257u);
+}
+
+const emu_mode_t emu_ps5 = {
+	.vid = 0x054C, .pid = 0x0CE6, .bcd = 0x0110,
+	.product = "DualSense Wireless Controller",
+	.report_desc = PS5_HID_DESC,
+	.report_desc_len = sizeof(PS5_HID_DESC),
+	.build = ps5_build,
+	.get_report = ps5_get,
+	.set_report = ps5_set,
+	.poll_ms = 4,
+};

+ 71 - 0
PicoPuck/src/puck/mode_switch_hori.c

@@ -0,0 +1,71 @@
+// mode_switch_hori.c — Switch HORIPAD emulated controller (ported from OpenPuck).
+//
+// 8-byte report, no report id: [btn_lo][btn_hi][hat][LX][LY][RX][RY][vendor].
+// Nintendo face layout (A↔B / X↔Y swapped). No rumble/features.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/emu.h"
+#include "puck/gamepad_util.h"
+
+static const uint8_t SWITCH_HID_DESC[] = {
+	0x05, 0x01, 0x09, 0x05, 0xA1, 0x01, 0x15, 0x00, 0x25, 0x01, 0x35, 0x00,
+	0x45, 0x01, 0x75, 0x01, 0x95, 0x10,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x10, 0x81, 0x02, 0x05, 0x01, 0x25, 0x07,
+	0x46, 0x3B, 0x01, 0x75, 0x04, 0x95, 0x01, 0x65, 0x14, 0x09, 0x39, 0x81,
+	0x42,
+	0x65, 0x00, 0x95, 0x01, 0x81, 0x01,
+	0x26, 0xFF, 0x00, 0x46, 0xFF, 0x00, 0x09, 0x30, 0x09, 0x31, 0x09, 0x32,
+	0x09, 0x35, 0x75, 0x08, 0x95, 0x04, 0x81, 0x02,
+	0x06, 0x00, 0xFF, 0x09, 0x20, 0x95, 0x01, 0x81, 0x02,
+	0x0A, 0x21, 0x26, 0x95, 0x08, 0x91, 0x02, 0xC0
+};
+
+static uint16_t hori_build(int slot, uint8_t *out, uint8_t *rid)
+{
+	*rid = 0;
+	uint32_t b = g_in[slot].buttons;
+	uint16_t btn = 0;
+	// Nintendo layout (abSwap): A→B(0x02) B→A(0x04) X→Y(0x01) Y→X(0x08).
+	if (b & TB_A) btn |= 0x02;
+	if (b & TB_B) btn |= 0x04;
+	if (b & TB_X) btn |= 0x01;
+	if (b & TB_Y) btn |= 0x08;
+	if (b & TB_LB) btn |= 0x10;
+	if (b & TB_RB) btn |= 0x20;
+	if (g_in[slot].lt >= SW_TRIG_ON || (b & TB_L2)) btn |= 0x40;  // ZL
+	if (g_in[slot].rt >= SW_TRIG_ON || (b & TB_R2)) btn |= 0x80;  // ZR
+	if (b & TB_MENU) btn |= 0x100;   // Minus
+	if (b & TB_VIEW) btn |= 0x200;   // Plus
+	if (b & TB_L3) btn |= 0x400;
+	if (b & TB_R3) btn |= 0x800;
+	if (b & TB_STEAM) btn |= 0x1000; // Home
+	if (b & TB_QAM) btn |= 0x2000;   // Capture
+
+	bool u = b & TB_DUP, d = b & TB_DDN, l = b & TB_DLF, r = b & TB_DRT;
+	uint8_t hat = 8;
+	if (u && r) hat = 1; else if (r && d) hat = 3;
+	else if (d && l) hat = 5; else if (l && u) hat = 7;
+	else if (u) hat = 0; else if (r) hat = 2;
+	else if (d) hat = 4; else if (l) hat = 6;
+
+	out[0] = (uint8_t)(btn & 0xFF);
+	out[1] = (uint8_t)(btn >> 8);
+	out[2] = hat;
+	out[3] = sw_stick(g_in[slot].lx, false);
+	out[4] = sw_stick(g_in[slot].ly, true);
+	out[5] = sw_stick(g_in[slot].rx, false);
+	out[6] = sw_stick(g_in[slot].ry, true);
+	out[7] = 0;
+	return 8;
+}
+
+const emu_mode_t emu_switch_hori = {
+	.vid = 0x0F0D, .pid = 0x0092, .bcd = 0x0210,
+	.product = "POKKEN CONTROLLER",
+	.report_desc = SWITCH_HID_DESC,
+	.report_desc_len = sizeof(SWITCH_HID_DESC),
+	.build = hori_build,
+	.get_report = NULL,
+	.set_report = NULL,
+	.poll_ms = 4,
+};

+ 591 - 0
PicoPuck/src/puck/mode_switch_pro.c

@@ -0,0 +1,591 @@
+// mode_switch_pro.c — Nintendo Switch Pro Controller (057E:2009) emulated
+// controller, ported from OpenPuck mode_switch_pro.cpp. The console drives a
+// subcommand handshake over the OUT pipe (report 0x80 USB init, report 0x01
+// subcommands) and the controller replies on the INPUT pipe (0x81 / 0x21); only
+// after subcommand 0x03 selects report mode 0x30 does input stream.
+//
+// PicoPuck presents ONE emulated HID interface (the active slot), so the
+// per-slot handshake arrays of the OpenPuck original collapse to a single state
+// machine here. Replies are queued from the SET_REPORT callback and drained one
+// per input-build cycle (the emu framework has no separate task/ISR split).
+// User-calibration SPI writes are mirrored in RAM only (no flash persistence);
+// motion cal therefore re-seeds from the factory blocks after a reboot.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/emu.h"
+#include "puck/gamepad_util.h"
+#include "puck/relay.h"
+#include "puck/slots.h"
+#include "sys/settings.h"
+#include "config/modes.h"
+
+#include <string.h>
+#include <math.h>
+#include "pico/time.h"
+
+#define ET_SWITCH 1              // per-type config index (matches OpenPuck ET_SWITCH)
+#define SW_PRO_REPORT_MS 15u     // 66 Hz
+#define SW_PRO_REPORT_MS_120 8u  // ~120 Hz
+#define SW_STREAM_MS 4u          // "full" (~250 Hz), matches emu_present cadence
+#define SW_ACCEL_DIV 4           // SC2 ±2g (16384/g) → Pro ±8g (4096/g)
+#define CHORD_BACK4 (TB_L4 | TB_R4 | TB_L5 | TB_R5)
+
+static const uint8_t SWPRO_HID_DESC[] = {
+	0x05, 0x01, 0x15, 0x00, 0x09, 0x04, 0xA1, 0x01, 0x85, 0x30, 0x05, 0x01,
+	0x05, 0x09, 0x19, 0x01, 0x29, 0x0A, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01,
+	0x95, 0x0A, 0x55, 0x00, 0x65, 0x00, 0x81, 0x02, 0x05, 0x09, 0x19, 0x0B,
+	0x29, 0x0E, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81, 0x02,
+	0x75, 0x01, 0x95, 0x02, 0x81, 0x03, 0x0B, 0x01, 0x00, 0x01, 0x00, 0xA1,
+	0x00, 0x0B, 0x30, 0x00, 0x01, 0x00, 0x0B, 0x31, 0x00, 0x01, 0x00, 0x0B,
+	0x32, 0x00, 0x01, 0x00, 0x0B, 0x35, 0x00, 0x01, 0x00, 0x15, 0x00, 0x27,
+	0xFF, 0xFF, 0x00, 0x00, 0x75, 0x10, 0x95, 0x04, 0x81, 0x02, 0xC0, 0x0B,
+	0x39, 0x00, 0x01, 0x00, 0x15, 0x00, 0x25, 0x07, 0x35, 0x00, 0x46, 0x3B,
+	0x01, 0x65, 0x14, 0x75, 0x04, 0x95, 0x01, 0x81, 0x42, 0x05, 0x09, 0x19,
+	0x0F, 0x29, 0x12, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x04, 0x81,
+	0x02, 0x75, 0x08, 0x95, 0x34, 0x81, 0x03, 0x06, 0x00, 0xFF, 0x85, 0x21,
+	0x09, 0x01, 0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x81, 0x09, 0x02,
+	0x75, 0x08, 0x95, 0x3F, 0x81, 0x03, 0x85, 0x01, 0x09, 0x03, 0x75, 0x08,
+	0x95, 0x3F, 0x91, 0x83, 0x85, 0x10, 0x09, 0x04, 0x75, 0x08, 0x95, 0x3F,
+	0x91, 0x83, 0x85, 0x80, 0x09, 0x05, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83,
+	0x85, 0x82, 0x09, 0x06, 0x75, 0x08, 0x95, 0x3F, 0x91, 0x83, 0xC0
+};
+
+#define JC_BTN_Y (1u << 0)
+#define JC_BTN_X (1u << 1)
+#define JC_BTN_B (1u << 2)
+#define JC_BTN_A (1u << 3)
+#define JC_BTN_R (1u << 6)
+#define JC_BTN_ZR (1u << 7)
+#define JC_BTN_MINUS (1u << 8)
+#define JC_BTN_PLUS (1u << 9)
+#define JC_BTN_RSTICK (1u << 10)
+#define JC_BTN_LSTICK (1u << 11)
+#define JC_BTN_HOME (1u << 12)
+#define JC_BTN_CAPTURE (1u << 13)
+#define JC_BTN_DOWN (1u << 16)
+#define JC_BTN_UP (1u << 17)
+#define JC_BTN_RIGHT (1u << 18)
+#define JC_BTN_LEFT (1u << 19)
+#define JC_BTN_L (1u << 22)
+#define JC_BTN_ZL (1u << 23)
+
+static uint32_t code_to_jc(uint8_t c, uint32_t fA, uint32_t fB, uint32_t fX,
+			   uint32_t fY)
+{
+	switch (c) {
+	case 1: return fA;
+	case 2: return fB;
+	case 3: return fX;
+	case 4: return fY;
+	case 5: return JC_BTN_L;
+	case 6: return JC_BTN_R;
+	case 7: return JC_BTN_LSTICK;
+	case 8: return JC_BTN_RSTICK;
+	case 9: return JC_BTN_MINUS;
+	case 10: return JC_BTN_PLUS;
+	case 11: return JC_BTN_HOME;
+	case 18: return JC_BTN_CAPTURE;
+	case 19: return JC_BTN_ZL;
+	case 20: return JC_BTN_ZR;
+	case 12: return JC_BTN_UP;
+	case 13: return JC_BTN_DOWN;
+	case 14: return JC_BTN_LEFT;
+	case 15: return JC_BTN_RIGHT;
+	default: return 0;
+	}
+}
+
+// Single controller MAC (one presented interface). OUI 7C:BB:8A like a genuine
+// Pro Controller; the console reads it via subcommand 0x02 to identify the pad.
+static const uint8_t g_jcMac[6] = { 0x7C, 0xBB, 0x8A, 0x00, 0x00, 0x10 };
+static uint8_t g_jcTimer;
+static uint8_t g_reportMode;  // 0 until subcommand 0x03 selects 0x30
+
+// ---- HD-rumble amplitude decoder (see OpenPuck for the full protocol notes).
+enum { HDR_AMP_MIN = -256, HDR_AMP_OFF = -256 };
+static int16_t hdr_amp7(uint8_t code)
+{
+	if (code == 0) return HDR_AMP_MIN;
+	if (code < 16) return (int16_t)(8 * (int)code - 248);
+	if (code < 32) return (int16_t)(2 * (int)code - 158);
+	return (int16_t)((int)code - 127);
+}
+static int16_t hdr_amp5(uint8_t code, int16_t cur)
+{
+	if (code == 0) return HDR_AMP_OFF;
+	if (code <= 11) return (int16_t)(-16 * (int)(code - 1));
+	int step = 0;
+	if (code >= 17 && code <= 19) step = 4;
+	else if (code >= 20 && code <= 22) step = 1;
+	else if (code >= 26 && code <= 28) step = -1;
+	else if (code >= 29 && code <= 31) step = -4;
+	int v = (int)cur + step;
+	return v < HDR_AMP_MIN ? HDR_AMP_MIN : (v > 0 ? 0 : (int16_t)v);
+}
+static uint16_t g_hdr_level[257];
+static bool g_hdr_built;
+static void hdr_build_levels(void)
+{
+	if (g_hdr_built) return;
+	for (int u = HDR_AMP_MIN; u <= 0; u++) {
+		float lin = (float)u / 32.0f;
+		float amp = (lin >= -7.9375f) ? exp2f(lin) : 0.0f;
+		if (amp > 1.0f) amp = 1.0f;
+		uint32_t v = (uint32_t)(amp * 65535.0f + 0.5f);
+		g_hdr_level[u - HDR_AMP_MIN] = (v > 0xFFFF) ? 0xFFFF : (uint16_t)v;
+	}
+	g_hdr_built = true;
+}
+static int16_t g_band_lo[2], g_band_hi[2];  // running amplitudes per motor (0=L,1=R)
+static void hdr_reset(void)
+{
+	g_band_lo[0] = g_band_hi[0] = HDR_AMP_OFF;
+	g_band_lo[1] = g_band_hi[1] = HDR_AMP_OFF;
+}
+static uint8_t hdr_field(uint32_t w, uint8_t shift, uint8_t width)
+{
+	return (uint8_t)((w >> shift) & ((1u << width) - 1u));
+}
+static uint16_t hdr_decode(uint8_t motor, const uint8_t b[4])
+{
+	int16_t *lo = &g_band_lo[motor], *hi = &g_band_hi[motor];
+	uint32_t w = (uint32_t)b[0] | ((uint32_t)b[1] << 8) |
+		     ((uint32_t)b[2] << 16) | ((uint32_t)b[3] << 24);
+	uint16_t peak = 0;
+#define HDR_SAMPLE()                                          \
+	do {                                                  \
+		uint16_t la = g_hdr_level[*lo - HDR_AMP_MIN]; \
+		uint16_t ha = g_hdr_level[*hi - HDR_AMP_MIN]; \
+		uint16_t lv = la > ha ? la : ha;              \
+		if (lv > peak) peak = lv;                     \
+	} while (0)
+
+	switch (hdr_field(w, 30, 2)) {
+	case 0:
+		HDR_SAMPLE();
+		break;
+	case 1:
+		if ((w & 0xFFFFF) == 0) {
+			*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
+			HDR_SAMPLE();
+		} else if ((w & 0x3) == 0) {
+			*lo = hdr_amp7(hdr_field(w, 23, 7));
+			*hi = hdr_amp7(hdr_field(w, 9, 7));
+			HDR_SAMPLE();
+		} else {
+			bool want_hi = (w & 1) != 0;
+			bool is_freq = ((w >> 2) & 1) != 0;
+			if (!is_freq) {
+				if (want_hi) *hi = hdr_amp7(hdr_field(w, 23, 7));
+				else *lo = hdr_amp7(hdr_field(w, 23, 7));
+			}
+			HDR_SAMPLE();
+			*lo = hdr_amp5(hdr_field(w, 18, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 13, 5), *hi);
+			HDR_SAMPLE();
+			*lo = hdr_amp5(hdr_field(w, 8, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 3, 5), *hi);
+			HDR_SAMPLE();
+		}
+		break;
+	case 2:
+		if ((w & 0x3FF) == 0) {
+			*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
+			HDR_SAMPLE();
+			*lo = hdr_amp5(hdr_field(w, 15, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 10, 5), *hi);
+			HDR_SAMPLE();
+		} else {
+			if (w & 1) {
+				*hi = hdr_amp7(hdr_field(w, 23, 7));
+				*lo = hdr_amp5(hdr_field(w, 18, 5), *lo);
+			} else {
+				*lo = hdr_amp7(hdr_field(w, 23, 7));
+				*hi = hdr_amp5(hdr_field(w, 18, 5), *hi);
+			}
+			HDR_SAMPLE();
+			*lo = hdr_amp5(hdr_field(w, 13, 5), *lo);
+			*hi = hdr_amp5(hdr_field(w, 8, 5), *hi);
+			HDR_SAMPLE();
+		}
+		break;
+	case 3:
+		*lo = hdr_amp5(hdr_field(w, 25, 5), *lo);
+		*hi = hdr_amp5(hdr_field(w, 20, 5), *hi);
+		HDR_SAMPLE();
+		*lo = hdr_amp5(hdr_field(w, 15, 5), *lo);
+		*hi = hdr_amp5(hdr_field(w, 10, 5), *hi);
+		HDR_SAMPLE();
+		*lo = hdr_amp5(hdr_field(w, 5, 5), *lo);
+		*hi = hdr_amp5(hdr_field(w, 0, 5), *hi);
+		HDR_SAMPLE();
+		break;
+	}
+#undef HDR_SAMPLE
+	return peak;
+}
+static uint16_t g_jc_last_lo, g_jc_last_hi;
+static void jc_rumble(int slot, const uint8_t *p, uint16_t pn)
+{
+	if (pn < 9) return;  // [timer][left x4][right x4]
+	hdr_build_levels();
+	uint16_t lo = hdr_decode(0, p + 1), hi = hdr_decode(1, p + 5);
+	if (lo == g_jc_last_lo && hi == g_jc_last_hi)
+		return;  // Switch streams rumble every frame; relay only on change
+	g_jc_last_lo = lo;
+	g_jc_last_hi = hi;
+	puck_rumble(slot, lo, hi);
+}
+
+static int jc_stick12(int16_t v, bool inv)
+{
+	int a = 2048 + (inv ? -((int)v >> 4) : ((int)v >> 4));
+	return a < 0 ? 0 : (a > 4095 ? 4095 : a);
+}
+static void jc_pack_stick(uint8_t s[3], int16_t x, int16_t y)
+{
+	int X = jc_stick12(x, false), Y = jc_stick12(y, false);
+	s[0] = (uint8_t)(X & 0xFF);
+	s[1] = (uint8_t)(((Y & 0x0F) << 4) | ((X >> 8) & 0x0F));
+	s[2] = (uint8_t)((Y >> 4) & 0xFF);
+}
+static uint8_t jc_battery_nibble(int slot)
+{
+	uint8_t pct = g_battery[slot];
+	uint8_t cap;
+	if (pct >= 70) cap = 4;
+	else if (pct >= 50) cap = 3;
+	else if (pct >= 30) cap = 2;
+	else if (pct >= 10) cap = 1;
+	else cap = 0;
+	return (uint8_t)(cap << 1);  // even nibble {0,2,4,6,8}; leaves bit4 (charging) clear
+}
+
+// Scale a gyro axis by sw_gyro10/10, clamped to int16.
+static int16_t gscale(int16_t v)
+{
+	uint8_t g10 = settings()->sw_gyro10;
+	if (g10 == 10) return v;
+	int32_t s = (int32_t)v * (int32_t)g10 / 10;
+	if (s > 32767) s = 32767;
+	else if (s < -32768) s = -32768;
+	return (int16_t)s;
+}
+static void jc_input_prefix(int slot, uint8_t *out)
+{
+	const pp_type_cfg_t *t = &settings()->type[ET_SWITCH];
+	uint32_t b = g_in[slot].buttons;
+	bool qam = t->qam && (b & TB_QAM);
+	if ((b & CHORD_BACK4) == CHORD_BACK4)
+		b &= ~(uint32_t)(TB_A | TB_B | TB_X | TB_Y);
+	uint32_t fA = t->ab_swap ? JC_BTN_B : JC_BTN_A;
+	uint32_t fB = t->ab_swap ? JC_BTN_A : JC_BTN_B;
+	uint32_t fX = t->ab_swap ? JC_BTN_Y : JC_BTN_X;
+	uint32_t fY = t->ab_swap ? JC_BTN_X : JC_BTN_Y;
+	uint32_t jc = 0;
+	if (b & TB_Y) jc |= fY;
+	if (b & TB_B) jc |= fB;
+	if (b & TB_A) jc |= fA;
+	if (b & TB_X) jc |= fX;
+	if (b & TB_LB) jc |= JC_BTN_L;
+	if (b & TB_RB) jc |= JC_BTN_R;
+	if ((g_in[slot].lt >= SW_TRIG_ON) || (b & TB_L2)) jc |= JC_BTN_ZL;
+	if ((g_in[slot].rt >= SW_TRIG_ON) || (b & TB_R2)) jc |= JC_BTN_ZR;
+	if (b & TB_VIEW) jc |= JC_BTN_PLUS;
+	if (b & TB_MENU) jc |= JC_BTN_MINUS;
+	if (b & TB_L3) jc |= JC_BTN_LSTICK;
+	if (b & TB_R3) jc |= JC_BTN_RSTICK;
+	if (b & TB_STEAM) jc |= JC_BTN_HOME;
+	if (b & TB_DDN) jc |= JC_BTN_DOWN;
+	if (b & TB_DUP) jc |= JC_BTN_UP;
+	if (b & TB_DRT) jc |= JC_BTN_RIGHT;
+	if (b & TB_DLF) jc |= JC_BTN_LEFT;
+	if (b & TB_L4) jc |= code_to_jc(t->back[0], fA, fB, fX, fY);
+	if (b & TB_R4) jc |= code_to_jc(t->back[1], fA, fB, fX, fY);
+	if (b & TB_L5) jc |= code_to_jc(t->back[2], fA, fB, fX, fY);
+	if (b & TB_R5) jc |= code_to_jc(t->back[3], fA, fB, fX, fY);
+	if (qam) jc |= code_to_jc(t->qam, fA, fB, fX, fY);
+	out[0] = g_jcTimer++;
+	uint8_t chg = (g_battery_state[slot] == 1) ? 0x10 : 0x00;
+	out[1] = (uint8_t)((jc_battery_nibble(slot) << 4) | chg);
+	out[2] = (uint8_t)(jc);
+	out[3] = (uint8_t)(jc >> 8);
+	out[4] = (uint8_t)(jc >> 16);
+	jc_pack_stick(out + 5, g_in[slot].lx, g_in[slot].ly);
+	jc_pack_stick(out + 8, g_in[slot].rx, g_in[slot].ry);
+	out[11] = 0x09;  // rumble_input_report echo (some Switch fw expects nonzero)
+}
+
+// ---- factory SPI dumps the host reads for calibration ----------------------
+static const uint8_t SPI_IMU_CAL[24] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					 0x00, 0x40, 0x00, 0x40, 0x00, 0x40,
+					 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+					 0x3B, 0x34, 0x3B, 0x34, 0x3B, 0x34 };
+static const uint8_t SPI_PARAMS1[24] = { 0x50, 0xFD, 0x00, 0x00, 0xC6, 0x0F,
+					 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
+					 0xD4, 0x14, 0x54, 0x41, 0x15, 0x54,
+					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
+static const uint8_t SPI_PARAMS2[18] = { 0x0F, 0x30, 0x61, 0x96, 0x30, 0xF3,
+					 0xD4, 0x14, 0x54, 0x41, 0x15, 0x54,
+					 0xC7, 0x79, 0x9C, 0x33, 0x36, 0x63 };
+static const uint8_t SPI_COLOR[13] = { 0x32, 0x32, 0x32, 0xE6, 0xE6, 0xE6, 0x32,
+				       0x32, 0x32, 0x32, 0x32, 0x32, 0xFF };
+static uint8_t g_spi_stick_cal[18];
+static bool g_stick_cal_built;
+static void jc_pack12(uint8_t *o9, const uint16_t v[6])
+{
+	o9[0] = v[0] & 0xFF;
+	o9[1] = ((v[1] & 0x0F) << 4) | ((v[0] >> 8) & 0x0F);
+	o9[2] = (v[1] >> 4) & 0xFF;
+	o9[3] = v[2] & 0xFF;
+	o9[4] = ((v[3] & 0x0F) << 4) | ((v[2] >> 8) & 0x0F);
+	o9[5] = (v[3] >> 4) & 0xFF;
+	o9[6] = v[4] & 0xFF;
+	o9[7] = ((v[5] & 0x0F) << 4) | ((v[4] >> 8) & 0x0F);
+	o9[8] = (v[5] >> 4) & 0xFF;
+}
+static void jc_build_stick_cal(void)
+{
+	if (g_stick_cal_built) return;
+	const uint16_t C = 2048, R = 1800;
+	uint16_t L[6] = { R, R, C, C, R, R };
+	uint16_t Rr[6] = { C, C, R, R, R, R };
+	jc_pack12(&g_spi_stick_cal[0], L);
+	jc_pack12(&g_spi_stick_cal[9], Rr);
+	g_stick_cal_built = true;
+}
+
+// User-cal SPI mirror (0x8000-0x80FF), RAM only. 0xFF = blank → factory fallback.
+static uint8_t g_user_cal[0x100];
+static bool g_user_cal_init;
+static void jc_spi_write(uint32_t addr, uint8_t len, const uint8_t *data, uint16_t avail)
+{
+	for (uint8_t i = 0; i < len && i < avail; i++) {
+		uint32_t a = addr + i;
+		if (a >= 0x8000 && a < 0x8100)
+			g_user_cal[a - 0x8000] = data[i];
+	}
+}
+static void spi_read(uint32_t addr, uint8_t len, uint8_t *dst)
+{
+	if (!g_user_cal_init) {
+		memset(g_user_cal, 0xFF, sizeof(g_user_cal));
+		g_user_cal_init = true;
+	}
+	for (uint8_t i = 0; i < len; i++) {
+		uint32_t a = addr + i;
+		uint8_t v = 0xFF;
+		if (a >= 0x6020 && a < 0x6020 + 24) v = SPI_IMU_CAL[a - 0x6020];
+		else if (a >= 0x603D && a < 0x603D + 18) v = g_spi_stick_cal[a - 0x603D];
+		else if (a >= 0x6050 && a < 0x6050 + 13) v = SPI_COLOR[a - 0x6050];
+		else if (a >= 0x6080 && a < 0x6080 + 24) v = SPI_PARAMS1[a - 0x6080];
+		else if (a >= 0x6098 && a < 0x6098 + 18) v = SPI_PARAMS2[a - 0x6098];
+		else if (a >= 0x8000 && a < 0x8100) v = g_user_cal[a - 0x8000];
+		dst[i] = v;
+	}
+}
+
+static const uint8_t BT_PAIR_2[31] = {
+	0x02, 0xE5, 0xC8, 0xE4, 0x92, 0x05, 0xFF, 0xC9, 0x8A, 0x7D, 0xEA,
+	0x15, 0xF6, 0x19, 0xBA, 0x82, 0x13, 0x00, 0x00, 0x00, 0x00, 0x00,
+	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
+};
+
+// ---- reply FIFO (single interface) -----------------------------------------
+#define JC_REPLEN 63
+#define JCQ_N 8
+typedef struct { uint8_t rid; uint8_t data[JC_REPLEN]; } jc_rep_t;
+static jc_rep_t g_jcq[JCQ_N];
+static uint8_t g_jcq_h, g_jcq_t;
+static void jc_enq(uint8_t rid, const uint8_t *d, uint8_t len)
+{
+	uint8_t nt = (uint8_t)((g_jcq_t + 1) % JCQ_N);
+	if (nt == g_jcq_h) return;  // full → drop; host re-requests on timeout
+	if (len > JC_REPLEN) len = JC_REPLEN;
+	g_jcq[g_jcq_t].rid = rid;
+	memset(g_jcq[g_jcq_t].data, 0, JC_REPLEN);
+	memcpy(g_jcq[g_jcq_t].data, d, len);
+	g_jcq_t = nt;
+}
+
+// Build the 0x21 reply (input prefix + ACK + echoed subcommand + reply data).
+static void jc_subcmd(int slot, uint8_t sub, const uint8_t *args, uint16_t alen)
+{
+	uint8_t p[JC_REPLEN];
+	memset(p, 0, sizeof(p));
+	jc_input_prefix(slot, p);
+	p[13] = sub;
+	switch (sub) {
+	case 0x01: {  // manual BT pairing: 3-stage key exchange
+		uint8_t ty = (alen >= 1) ? args[0] : 3;
+		p[12] = 0x81;
+		if (ty == 1) {
+			p[14] = 0x01;
+			memcpy(&p[15], g_jcMac, 6);
+			p[21] = 0x00; p[22] = 0x25; p[23] = 0x08;
+			static const uint8_t NAME[14] = {
+				0x50, 0x72, 0x6F, 0x20, 0x43, 0x6F, 0x6E,
+				0x74, 0x72, 0x6F, 0x6C, 0x6C, 0x65, 0x72 };
+			memcpy(&p[24], NAME, 14);
+			p[43] = 0x68;
+		} else if (ty == 2) {
+			memcpy(&p[14], BT_PAIR_2, 31);
+		} else {
+			p[14] = 0x03;
+		}
+		break;
+	}
+	case 0x02:  // device info
+		p[12] = 0x82;
+		p[14] = 0x03;
+		p[15] = 0x48;
+		p[16] = 0x03;  // Pro Controller
+		p[17] = 0x02;
+		memcpy(&p[18], g_jcMac, 6);
+		p[24] = 0x01;
+		p[25] = 0x01;
+		break;
+	case 0x10: {  // SPI flash read → echo [addr][len] then data
+		if (alen < 5) { p[12] = 0x80; break; }
+		uint32_t a = (uint32_t)args[0] | ((uint32_t)args[1] << 8) |
+			     ((uint32_t)args[2] << 16) | ((uint32_t)args[3] << 24);
+		uint8_t rl = args[4];
+		if (rl > 0x1D) rl = 0x1D;
+		p[12] = 0x90;
+		p[14] = args[0]; p[15] = args[1]; p[16] = args[2]; p[17] = args[3];
+		p[18] = rl;
+		spi_read(a, rl, &p[19]);
+		break;
+	}
+	case 0x11:  // SPI flash write → mirror user cal
+		if (alen >= 5) {
+			uint32_t a = (uint32_t)args[0] | ((uint32_t)args[1] << 8) |
+				     ((uint32_t)args[2] << 16) | ((uint32_t)args[3] << 24);
+			jc_spi_write(a, args[4], &args[5],
+				     (alen > 5) ? (uint16_t)(alen - 5) : 0);
+		}
+		p[12] = 0x80;
+		break;
+	case 0x03:  // set input report mode
+		if (alen >= 1 && args[0] == 0x30)
+			g_reportMode = 0x30;
+		p[12] = 0x80;
+		break;
+	case 0x04:  // trigger elapsed time
+		p[12] = 0x83;
+		p[15] = 0xCC; p[17] = 0xEE; p[19] = 0xFF;
+		break;
+	case 0x21:  // NFC/IR config
+		p[12] = 0xA0;
+		break;
+	default:
+		p[12] = 0x80;  // generic ACK
+		break;
+	}
+	jc_enq(0x21, p, JC_REPLEN);
+}
+
+// ---- emu interface ---------------------------------------------------------
+static void swpro_set(int slot, uint8_t rid, uint8_t type, const uint8_t *b, uint16_t n)
+{
+	if (type != PP_HID_OUTPUT || n < 1)
+		return;
+	// OUT endpoint delivers rid=0 with the id in b[0]; control SET_REPORT splits
+	// the id into rid and hands us the body.
+	uint8_t id;
+	const uint8_t *p;
+	uint16_t pn;
+	if (rid == 0) { id = b[0]; p = b + 1; pn = (uint16_t)(n - 1); }
+	else { id = rid; p = b; pn = n; }
+	if (id == 0x80) {  // USB handshake
+		if (pn < 1) return;
+		if (p[0] == 0x01) {
+			uint8_t d[9] = { 0x01, 0x00, 0x03, g_jcMac[0], g_jcMac[1],
+					 g_jcMac[2], g_jcMac[3], g_jcMac[4], g_jcMac[5] };
+			jc_enq(0x81, d, 9);
+		} else if (p[0] == 0x02) {
+			uint8_t d[1] = { 0x02 };
+			jc_enq(0x81, d, 1);
+		} else if (p[0] == 0x03) {
+			uint8_t d[1] = { 0x03 };
+			jc_enq(0x81, d, 1);
+		}
+		return;
+	}
+	if (id == 0x01) {  // [timer][rumble x8][subcmd][args...]
+		if (pn < 10) return;
+		jc_rumble(slot, p, pn);
+		jc_subcmd(slot, p[9], p + 10, (pn > 10) ? (uint16_t)(pn - 10) : 0);
+		return;
+	}
+	if (id == 0x10) {  // rumble-only
+		jc_rumble(slot, p, pn);
+		return;
+	}
+}
+
+static void swpro_build_30(int slot, uint8_t out[63])
+{
+	memset(out, 0, 63);
+	jc_input_prefix(slot, out);
+	// accel X<-+ay, Y<--ax, Z<-+az (÷4 for ±8g cal); gyro roll<-+gy, pitch<--gx,
+	// yaw<-+gz. Same signed permutation across both so the console's accel/gyro
+	// fusion keeps a consistent handedness (see OpenPuck notes).
+	int16_t aX = (int16_t)(g_in[slot].ay / SW_ACCEL_DIV);
+	int16_t aY = (int16_t)((-(int16_t)g_in[slot].ax) / SW_ACCEL_DIV);
+	int16_t aZ = (int16_t)(g_in[slot].az / SW_ACCEL_DIV);
+	int16_t groll = gscale((int16_t)g_in[slot].gy);
+	int16_t gpitch = gscale((int16_t)(-(int16_t)g_in[slot].gx));
+	int16_t gyaw = gscale((int16_t)g_in[slot].gz);
+	for (int k = 0; k < 3; k++) {
+		int o = 12 + k * 12;
+		out[o + 0] = aX & 0xFF;    out[o + 1] = (aX >> 8) & 0xFF;
+		out[o + 2] = aY & 0xFF;    out[o + 3] = (aY >> 8) & 0xFF;
+		out[o + 4] = aZ & 0xFF;    out[o + 5] = (aZ >> 8) & 0xFF;
+		out[o + 6] = groll & 0xFF; out[o + 7] = (groll >> 8) & 0xFF;
+		out[o + 8] = gpitch & 0xFF; out[o + 9] = (gpitch >> 8) & 0xFF;
+		out[o + 10] = gyaw & 0xFF; out[o + 11] = (gyaw >> 8) & 0xFF;
+	}
+}
+
+static uint16_t swpro_build(int slot, uint8_t *out, uint8_t *rid)
+{
+	jc_build_stick_cal();
+	// Drain one queued handshake/subcommand reply per cycle (ordered), ahead of
+	// any streamed input, so a bursty host-init isn't starved.
+	if (g_jcq_h != g_jcq_t) {
+		jc_rep_t *r = &g_jcq[g_jcq_h];
+		*rid = r->rid;
+		memcpy(out, r->data, JC_REPLEN);
+		g_jcq_h = (uint8_t)((g_jcq_h + 1) % JCQ_N);
+		return JC_REPLEN;
+	}
+	if (g_reportMode != 0x30)
+		return 0;  // not until the host selects report mode 0x30
+	// Rate-limit the 0x30 stream by the configured Switch cadence (the console
+	// integrates 3 IMU samples/report at a fixed ~5 ms/sample).
+	static uint32_t last_ms;
+	uint8_t rate = settings()->sw_pro_rate;
+	uint32_t interval = (rate == 2) ? SW_STREAM_MS :
+			    (rate == 1) ? SW_PRO_REPORT_MS_120 : SW_PRO_REPORT_MS;
+	uint32_t t = to_ms_since_boot(get_absolute_time());
+	if (t - last_ms < interval)
+		return 0;
+	last_ms = t;
+	*rid = 0x30;
+	swpro_build_30(slot, out);
+	return 63;
+}
+
+const emu_mode_t emu_switch_pro = {
+	.vid = 0x057E, .pid = 0x2009, .bcd = 0x0220,
+	.product = "Pro Controller",
+	.report_desc = SWPRO_HID_DESC,
+	.report_desc_len = sizeof(SWPRO_HID_DESC),
+	.build = swpro_build,
+	.get_report = NULL,
+	.set_report = swpro_set,
+	.poll_ms = 1,
+};

+ 235 - 0
PicoPuck/src/puck/xinput.c

@@ -0,0 +1,235 @@
+// xinput.c — Xbox 360 wired (XInput) personality (see xinput.h).
+//
+// A custom TinyUSB class driver serves the vendor interface (0xFF/0x5D/0x01)
+// and its interrupt IN/OUT endpoints. The interface descriptor is emitted by
+// usb_descriptors.c; here we claim the endpoints in the driver's open()
+// callback, stream the 20-byte report from g_in, and decode host rumble on the
+// OUT endpoint. Single interface (the active controller) — PicoPuck presents one
+// emulated controller at a time, so the per-slot pool of the OpenPuck original
+// collapses to one state block here.
+//
+// PicoPuck's main loop is cooperative (no separate usbd task), so IN transfers
+// are issued directly from xinput_task() rather than deferred to a SOF drain.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "puck/xinput.h"
+#include "puck/triton.h"
+#include "puck/slots.h"
+#include "puck/relay.h"
+#include "sys/settings.h"
+#include "config/modes.h"
+
+#include <string.h>
+#include "tusb.h"
+#include "device/usbd_pvt.h"
+#include "pico/time.h"
+
+#define ET_XBOX 0  // per-type config index (matches OpenPuck ET_XBOX)
+
+// XInput button bits.
+enum {
+	XB_DUP = 0x0001, XB_DDOWN = 0x0002, XB_DLEFT = 0x0004, XB_DRIGHT = 0x0008,
+	XB_START = 0x0010, XB_BACK = 0x0020, XB_L3 = 0x0040, XB_R3 = 0x0080,
+	XB_LB = 0x0100, XB_RB = 0x0200, XB_GUIDE = 0x0400,
+	XB_A = 0x1000, XB_B = 0x2000, XB_X = 0x4000, XB_Y = 0x8000
+};
+
+static struct {
+	uint8_t itf, ep_in, ep_out;
+	uint8_t in_buf[32], out_buf[32];
+	bool in_use;
+} g_xi;
+
+// ---- custom class driver ---------------------------------------------------
+static void xi_init(void) { g_xi.in_use = false; }
+static bool xi_deinit(void) { return true; }
+static void xi_reset(uint8_t rhport)
+{
+	(void)rhport;
+	g_xi.itf = g_xi.ep_in = g_xi.ep_out = 0;
+	g_xi.in_use = false;
+}
+static uint16_t xi_open(uint8_t rhport, tusb_desc_interface_t const *itf,
+			uint16_t max_len)
+{
+	if (!(itf->bInterfaceClass == 0xFF && itf->bInterfaceSubClass == 0x5D &&
+	      itf->bInterfaceProtocol == 0x01))
+		return 0;  // not the XInput interface → let another driver claim it
+	g_xi.itf = itf->bInterfaceNumber;
+	uint8_t const *p = (uint8_t const *)itf;
+	uint8_t const *end = p + max_len;
+	uint16_t used = itf->bLength;
+	p += itf->bLength;
+	uint8_t opened = 0;
+	while (p < end && opened < itf->bNumEndpoints) {
+		uint8_t blen = p[0], btype = p[1];
+		if (btype == TUSB_DESC_ENDPOINT) {
+			tusb_desc_endpoint_t const *ep = (tusb_desc_endpoint_t const *)p;
+			usbd_edpt_open(rhport, ep);
+			if (tu_edpt_dir(ep->bEndpointAddress) == TUSB_DIR_IN)
+				g_xi.ep_in = ep->bEndpointAddress;
+			else
+				g_xi.ep_out = ep->bEndpointAddress;
+			opened++;
+		}
+		used += blen;
+		p += blen;
+	}
+	g_xi.in_use = true;
+	if (g_xi.ep_out)  // arm OUT (rumble/LED)
+		usbd_edpt_xfer(rhport, g_xi.ep_out, g_xi.out_buf, sizeof(g_xi.out_buf));
+	return used;
+}
+static bool xi_ctrl(uint8_t rhport, uint8_t stage,
+		    tusb_control_request_t const *req)
+{
+	(void)rhport; (void)req;
+	return stage != CONTROL_STAGE_SETUP;
+}
+static bool xi_xfer(uint8_t rhport, uint8_t ep, xfer_result_t res, uint32_t n)
+{
+	(void)res;
+	if (!g_xi.in_use)
+		return true;
+	if (ep == g_xi.ep_out) {
+		// Rumble packet: [00][08][00][big][small][00][00][00]; LED [01][03][led].
+		if (n >= 5 && g_xi.out_buf[0] == 0x00 && g_xi.out_buf[1] == 0x08) {
+			int slot = -1;
+			for (int s = 0; s < PP_NSLOT; s++)
+				if (g_slot[s].connected) { slot = s; break; }
+			if (slot >= 0 && settings()->type[ET_XBOX].rumble)
+				puck_rumble(slot, (uint16_t)g_xi.out_buf[3] * 257u,
+					    (uint16_t)g_xi.out_buf[4] * 257u);
+		}
+		usbd_edpt_xfer(rhport, g_xi.ep_out, g_xi.out_buf, sizeof(g_xi.out_buf));
+	}
+	return true;
+}
+static const usbd_class_driver_t g_xi_driver = {
+#if CFG_TUSB_DEBUG >= 2
+	.name = "XINPUT",
+#endif
+	.init = xi_init,
+	.deinit = xi_deinit,
+	.reset = xi_reset,
+	.open = xi_open,
+	.control_xfer_cb = xi_ctrl,
+	.xfer_cb = xi_xfer,
+	.sof = NULL,
+};
+// TinyUSB calls this once at init to collect app class drivers. The XInput
+// driver's open() only claims 0xFF/0x5D/0x01 interfaces (which exist only in
+// MODE_XBOX), so registering it unconditionally is harmless in other modes.
+const usbd_class_driver_t *usbd_app_driver_get_cb(uint8_t *count)
+{
+	*count = 1;
+	return &g_xi_driver;
+}
+
+// ---- report building -------------------------------------------------------
+// Back-paddle / QAM button code → XInput bit (0=none 1=A..15=Dright).
+static uint16_t code_to_xb(uint8_t c)
+{
+	switch (c) {
+	case 1: return XB_A;
+	case 2: return XB_B;
+	case 3: return XB_X;
+	case 4: return XB_Y;
+	case 5: return XB_LB;
+	case 6: return XB_RB;
+	case 7: return XB_L3;
+	case 8: return XB_R3;
+	case 9: return XB_BACK;
+	case 10: return XB_START;
+	case 11: return XB_GUIDE;
+	case 12: return XB_DUP;
+	case 13: return XB_DDOWN;
+	case 14: return XB_DLEFT;
+	case 15: return XB_DRIGHT;
+	default: return 0;
+	}
+}
+static int active_slot(void)
+{
+	for (int s = 0; s < PP_NSLOT; s++)
+		if (g_slot[s].connected)
+			return s;
+	return 0;
+}
+static void xi_build(int slot, uint8_t *r)
+{
+	const pp_type_cfg_t *t = &settings()->type[ET_XBOX];
+	uint32_t b = g_in[slot].buttons;
+	uint16_t btn = 0;
+	if (b & TB_DUP) btn |= XB_DUP;
+	if (b & TB_DDN) btn |= XB_DDOWN;
+	if (b & TB_DLF) btn |= XB_DLEFT;
+	if (b & TB_DRT) btn |= XB_DRIGHT;
+	if (b & TB_VIEW) btn |= XB_START;
+	if (b & TB_MENU) btn |= XB_BACK;
+	if (b & TB_STEAM) btn |= XB_GUIDE;
+	if (b & TB_LB) btn |= XB_LB;
+	if (b & TB_RB) btn |= XB_RB;
+	if (b & TB_L3) btn |= XB_L3;
+	if (b & TB_R3) btn |= XB_R3;
+	uint16_t fA = t->ab_swap ? XB_B : XB_A, fB = t->ab_swap ? XB_A : XB_B;
+	uint16_t fX = t->ab_swap ? XB_Y : XB_X, fY = t->ab_swap ? XB_X : XB_Y;
+	if (b & TB_A) btn |= fA;
+	if (b & TB_B) btn |= fB;
+	if (b & TB_X) btn |= fX;
+	if (b & TB_Y) btn |= fY;
+	if (b & TB_L4) btn |= code_to_xb(t->back[0]);
+	if (b & TB_R4) btn |= code_to_xb(t->back[1]);
+	if (b & TB_L5) btn |= code_to_xb(t->back[2]);
+	if (b & TB_R5) btn |= code_to_xb(t->back[3]);
+	if (t->qam && (b & TB_QAM)) btn |= code_to_xb(t->qam);
+
+	uint8_t lt = g_in[slot].lt, rt = g_in[slot].rt;
+	if (b & TB_L2) lt = 0xFF;
+	if (b & TB_R2) rt = 0xFF;
+	// Trigger remaps: a back paddle / QAM mapped to code 19 (LT) / 20 (RT) pulls
+	// that analog trigger to full.
+	const uint8_t bc[4] = { t->back[0], t->back[1], t->back[2], t->back[3] };
+	const uint32_t bm[4] = { TB_L4, TB_R4, TB_L5, TB_R5 };
+	for (int i = 0; i < 4; i++) {
+		if (!(b & bm[i])) continue;
+		if (bc[i] == 19) lt = 0xFF;
+		else if (bc[i] == 20) rt = 0xFF;
+	}
+	if (t->qam && (b & TB_QAM)) {
+		if (t->qam == 19) lt = 0xFF;
+		else if (t->qam == 20) rt = 0xFF;
+	}
+
+	int16_t lx = g_in[slot].lx, ly = g_in[slot].ly;
+	int16_t rx = g_in[slot].rx, ry = g_in[slot].ry;
+	r[0] = 0x00;
+	r[1] = 0x14;
+	r[2] = (uint8_t)(btn & 0xFF);
+	r[3] = (uint8_t)(btn >> 8);
+	r[4] = lt;
+	r[5] = rt;
+	r[6] = lx & 0xFF;  r[7] = (lx >> 8) & 0xFF;
+	r[8] = ly & 0xFF;  r[9] = (ly >> 8) & 0xFF;
+	r[10] = rx & 0xFF; r[11] = (rx >> 8) & 0xFF;
+	r[12] = ry & 0xFF; r[13] = (ry >> 8) & 0xFF;
+	memset(r + 14, 0, 6);
+}
+
+void xinput_task(void)
+{
+	if (!g_xi.in_use || g_xi.ep_in == 0 || !tud_mounted())
+		return;
+	static uint32_t last_ms;
+	uint32_t now = to_ms_since_boot(get_absolute_time());
+	if (now - last_ms < 4)  // ~250 Hz cap
+		return;
+	if (usbd_edpt_busy(0, g_xi.ep_in))
+		return;  // a transfer is still in flight — newest state next cycle
+	last_ms = now;
+	xi_build(active_slot(), g_xi.in_buf);
+	if (usbd_edpt_claim(0, g_xi.ep_in)) {
+		if (!usbd_edpt_xfer(0, g_xi.ep_in, g_xi.in_buf, 20))
+			usbd_edpt_release(0, g_xi.ep_in);
+	}
+}

+ 19 - 0
PicoPuck/src/puck/xinput.h

@@ -0,0 +1,19 @@
+// xinput.h — Xbox 360 wired (XInput) personality (MODE_XBOX).
+//
+// Xbox 360 pads are NOT HID: they expose a vendor interface (class 0xFF /
+// subclass 0x5D / protocol 0x01) carrying a 20-byte input report, served by a
+// custom TinyUSB class driver. PicoPuck presents ONE XInput interface for the
+// active controller, alongside the WebUSB vendor interface (so the panel keeps
+// working). The OUT endpoint carries rumble, relayed to the controller.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_XINPUT_H
+#define PICOPUCK_XINPUT_H
+
+#include <stdint.h>
+
+// Stream the active controller's 20-byte XInput report (call from the main loop
+// in MODE_XBOX). No-op when the interface isn't open / the IN endpoint is busy.
+void xinput_task(void);
+
+#endif // PICOPUCK_XINPUT_H

+ 183 - 0
PicoPuck/src/sys/settings.c

@@ -0,0 +1,183 @@
+// settings.c — persisted device settings (see settings.h).
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#include "sys/settings.h"
+#include "config/modes.h"
+
+#include <string.h>
+#include "pico/stdlib.h"
+#include "hardware/flash.h"
+#include "hardware/sync.h"
+
+// MUST sit BELOW the BTstack bond bank on BOTH boards or a settings write erases
+// the bonds (→ no pairing persistence, no reconnect). The SDK's pico_flash_bank
+// occupies the top 2 sectors on RP2040 (Pico W) but SIZE-3*SECTOR .. SIZE-SECTOR
+// on RP2350 (Pico 2 W: its A2 bootrom reserves the final sector). SIZE-4*SECTOR
+// 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)
+
+typedef struct {
+	uint32_t magic;
+	pp_cfg_t cfg;
+} stored_t;
+
+static pp_cfg_t s_cfg;
+
+static void defaults(pp_cfg_t *c)
+{
+	memset(c, 0, sizeof(*c));
+	c->mode = MODE_STEAM;
+	c->m_div = 8;
+	c->m_fric = 40;
+	c->rumble_scale = 100;
+	c->persist_mode = 0;
+	c->chord[0] = MODE_PS5;    // B
+	c->chord[1] = MODE_HIDGYRO;  // X
+	c->chord[2] = MODE_SW_HORI;  // Y
+	c->sw_pro_rate = 2;
+	c->sw_gyro10 = 10;
+	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
+	}
+}
+
+void settings_load(void)
+{
+	const stored_t *f = (const stored_t *)(XIP_BASE + SETTINGS_OFFSET);
+	if (f->magic == SETTINGS_MAGIC && f->cfg.mode <= MODE_MAX)
+		s_cfg = f->cfg;
+	else
+		defaults(&s_cfg);
+}
+
+const pp_cfg_t *settings(void)
+{
+	return &s_cfg;
+}
+
+uint8_t settings_mode(void)
+{
+	return s_cfg.mode;
+}
+
+static void commit(void)
+{
+	stored_t st;
+	st.magic = SETTINGS_MAGIC;
+	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));
+
+	uint32_t ints = save_and_disable_interrupts();
+	flash_range_erase(SETTINGS_OFFSET, FLASH_SECTOR_SIZE);
+	flash_range_program(SETTINGS_OFFSET, page, FLASH_PAGE_SIZE);
+	restore_interrupts(ints);
+}
+
+void settings_set_mode(uint8_t mode)
+{
+	if (mode > MODE_MAX)
+		return;
+	s_cfg.mode = mode;
+	commit();
+}
+
+void settings_set_field(uint8_t field, uint8_t val)
+{
+	// Per-type fields: 40 + et*9 + k (et 0..3, k 0..8) — mirrors OpenPuck.
+	if (field >= 40 && field < 40 + 4 * 9) {
+		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;
+		}
+		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
+	}
+	commit();
+}
+
+void settings_factory_reset(void)
+{
+	defaults(&s_cfg);
+	commit();
+}
+
+// ---- bond label table ------------------------------------------------------
+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)
+			return i;
+	return -1;
+}
+
+void settings_bond_remember(const uint8_t addr[6], uint8_t addr_type,
+			    uint8_t kind, const char name[16])
+{
+	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 (i < 0)
+		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).
+	if (b->used && b->addr_type == addr_type && b->kind == kind &&
+	    memcmp(b->addr, addr, 6) == 0 && memcmp(b->name, name, 16) == 0)
+		return;
+	b->used = 1;
+	memcpy(b->addr, addr, 6);
+	b->addr_type = addr_type;
+	b->kind = kind;
+	memcpy(b->name, name, 16);
+	commit();
+}
+
+bool settings_bond_lookup(const uint8_t addr[6], uint8_t *kind, char name[16])
+{
+	int i = bond_find(addr);
+	if (i < 0)
+		return false;
+	if (kind)
+		*kind = s_cfg.bond[i].kind;
+	if (name)
+		memcpy(name, s_cfg.bond[i].name, 16);
+	return true;
+}
+
+void settings_bond_forget(const uint8_t addr[6])
+{
+	int i = bond_find(addr);
+	if (i < 0)
+		return;
+	memset(&s_cfg.bond[i], 0, sizeof(s_cfg.bond[i]));
+	commit();
+}

+ 64 - 0
PicoPuck/src/sys/settings.h

@@ -0,0 +1,64 @@
+// settings.h — persisted device settings (mirrors OpenPuck's Cfg: mode + the
+// generic tunables the WebUSB panel reads/writes, so the common panel cards work
+// identically). Stored in a flash sector below the BTstack bond bank.
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+#ifndef PICOPUCK_SETTINGS_H
+#define PICOPUCK_SETTINGS_H
+
+#include <stdint.h>
+#include <stdbool.h>
+
+// Per-emulated-type config (Xbox/Switch/DS4/DS5), mirroring OpenPuck's TypeCfg.
+typedef struct {
+	uint8_t back[4];   // back-paddle L4/R4/L5/R5 → button code
+	uint8_t qam;       // QAM (…) → button code
+	uint8_t ab_swap;   // A/B + X/Y swap
+	uint8_t pad_haptics;
+	uint8_t led_bright;
+	uint8_t rumble;    // per-type rumble enable
+} pp_type_cfg_t;
+
+// Persisted per-bond metadata, so a paired controller that is currently offline
+// (e.g. right after a reboot / mode switch) still shows its real name and kind in
+// the panel instead of a generic placeholder. Keyed by the bond identity address.
+// The Bluetooth stack persists the bond itself (keys) in its own flash bank; this
+// only carries the human-facing label the stack doesn't keep.
+typedef struct {
+	uint8_t used;
+	uint8_t addr[6];
+	uint8_t addr_type;
+	uint8_t kind;       // 0 unknown, 1 SC2, 2 BLE pad, 3 Classic pad
+	char name[16];
+} pp_bond_meta_t;
+
+typedef struct {
+	uint8_t mode;             // active USB mode (MODE_*)
+	uint8_t m_div, m_fric;    // mouse sensitivity / friction
+	uint8_t rumble_scale;     // rumble strength % (100 = 1×)
+	uint8_t persist_mode;     // remember last mode across reboots
+	uint8_t chord[3];         // B/X/Y chord target modes
+	uint8_t sw_pro_rate;      // Switch Pro report rate 0/1/2
+	uint8_t sw_gyro10;        // Switch gyro scale ×10
+	uint8_t land_all_87;
+	pp_type_cfg_t type[4];    // ET_XBOX, ET_SWITCH, ET_DS4, ET_DS5
+	pp_bond_meta_t bond[4];   // labels for the (≤4) persisted bonds
+} pp_cfg_t;
+
+void settings_load(void);
+const pp_cfg_t *settings(void);  // read-only view
+
+uint8_t settings_mode(void);
+void settings_set_mode(uint8_t mode);      // persist mode (caller reboots)
+void settings_set_field(uint8_t field, uint8_t val);  // WebUSB 0x02 set-field
+void settings_factory_reset(void);
+
+// Bond label persistence (see pp_bond_meta_t). remember() upserts by address;
+// lookup() fills kind/name for a known address (returns false if unknown);
+// forget() drops one address. Each commits to flash.
+void settings_bond_remember(const uint8_t addr[6], uint8_t addr_type,
+			    uint8_t kind, const char name[16]);
+bool settings_bond_lookup(const uint8_t addr[6], uint8_t *kind, char name[16]);
+void settings_bond_forget(const uint8_t addr[6]);
+
+#endif // PICOPUCK_SETTINGS_H