Add verified Joy-Con 2 USB bridge with native mouse support

Implement the standalone USB protocol probe and Bluetooth-backed right Joy-Con bridge with its own persistent virtual pairing identity. Preserve complete ordered native reports, including opaque motion data, and match the console feature set. Relay built-in vibration cues only after genuine source acknowledgement and expose safe BOOTSEL pairing control. Include native capture diagnostics and focused protocol, packet-lifecycle, and cue regressions. Native mouse operation confirmed on Switch with bridge 0.24; private captures and firmware backups remain outside the commit.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-10 17:46:03 -06:00
commit 3040c9d294
34 changed files with 3777 additions and 15 deletions

View file

@ -0,0 +1,15 @@
cmake_minimum_required(VERSION 3.13)
set(PICO_BOARD pico2_w CACHE STRING "Target board")
include(${CMAKE_CURRENT_LIST_DIR}/../../pico_sdk_import.cmake)
project(switch2_usb_probe C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
include(${CMAKE_CURRENT_LIST_DIR}/probe_build.cmake)
pico_sdk_init()
add_executable(switch2-usb-probe
../../src/firmware/configuration/configuration_storage.cpp
../../src/firmware/platform/pico/bootsel_pairing_button.cpp)
target_compile_definitions(switch2-usb-probe PRIVATE
PICO_FLASH_ASSUME_CORE1_SAFE=1 PICO_FLASH_ASSERT_ON_UNSAFE=0)
switch2_usb_probe_configure(switch2-usb-probe)
pico_add_extra_outputs(switch2-usb-probe)

View file

@ -0,0 +1,17 @@
#include "button_test.h"
bool probe_button_update(probe_button_state* state, int sample, bool ready) {
if (!ready || (sample != 0 && sample != 1)) {
state->armed = false;
state->pressed_samples = 0;
return false;
}
if (sample == 0) {
state->armed = true;
state->pressed_samples = 0;
return false;
}
if (!state->armed) return false;
if (state->pressed_samples < 2) ++state->pressed_samples;
return state->pressed_samples == 2;
}

View file

@ -0,0 +1,12 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
typedef struct {
bool armed;
uint8_t pressed_samples;
} probe_button_state;
// Sample at 10 ms intervals. Require a release after reset/unavailability,
// then two pressed samples. Release/error/not-ready clears output immediately.
bool probe_button_update(probe_button_state* state, int sample, bool ready);

View file

@ -0,0 +1,149 @@
#include "controller_input.h"
#include <string.h>
#include "input/bluepad32_input_backend.h"
#include "input/switch2_mouse_capture.h"
#include "platform/pico/bootsel_pairing_button.h"
#include "platform/pico/system_clock.h"
#include "profile/controller_profile_runtime.h"
#include "pico/stdlib.h"
#if !SWITCH_PICO_SWITCH2_USB_BRIDGE || !SWITCH_PICO_BLUEPAD32 || \
!SWITCH_PICO_ENABLE_BLE || !SWITCH_PICO_SWITCH2_MOUSE_CAPTURE || \
!SWITCH_PICO_SWITCH2_MOUSE_CAPTURE_NATIVE
#error "The controller bridge requires Bluepad32 BLE and native Switch 2 capture"
#endif
namespace {
constexpr uint8_t kSourceAddress[] = {SWITCH2_BRIDGE_SOURCE_ADDRESS_BYTES};
static_assert(sizeof(kSourceAddress) == 6, "Select one physical Bluetooth address");
constexpr uint32_t kInputDeadlineMs = 500;
constexpr uint32_t kFlashCoordinationTimeoutMs = 1000;
// The backend publishes stage 2 only after Core 1's flash-safe registration;
// reaching Core 1 already required successful Core 0 registration in start().
constexpr uint32_t kFlashCoordinationStage = 2;
bool g_initialized;
bool g_start_attempted;
bool g_flash_ready;
probe_controller_input g_input;
uint32_t g_received_ms;
} // namespace
extern "C" void probe_controller_input_clock_init(void) {
system_clock_initialize();
}
extern "C" void probe_controller_input_init(void) {
if (g_initialized) return;
switch2_mouse_capture_init();
switch2_mouse_capture_select_input(kSourceAddress);
// Prepare the existing storage services without initializing legacy USB.
// Core 1 loads their persisted state during the normal backend startup.
bluepad32_input_backend_init();
controller_profile_runtime_reset();
g_initialized = true;
}
extern "C" bool probe_controller_input_start(void) {
if (!g_initialized) probe_controller_input_init();
if (g_start_attempted) return g_flash_ready;
g_start_attempted = true;
bluepad32_input_backend_start();
const absolute_time_t deadline = make_timeout_time_ms(kFlashCoordinationTimeoutMs);
do {
Bluepad32BackendDiagnostics diagnostics;
bluepad32_input_backend_diagnostics(&diagnostics);
if (diagnostics.initialization_stage >= kFlashCoordinationStage) {
g_flash_ready = true;
return true;
}
sleep_ms(1);
} while (!time_reached(deadline));
// Do not reset Core 1 or retry a partially launched backend. It may still
// be running; a false return keeps USB and its flash writes fail-closed.
return false;
}
extern "C" bool probe_controller_input_pairing_task(void) {
if (!g_flash_ready) return false;
// Use the shared sampler/hold policy, but deliberately do not route its
// kClearPairings event to recovery or any storage-clearing operation.
if (bootsel_pairing_button_task() != BootselPairingButtonEvent::kOpenPairing)
return false;
bluepad32_input_backend_open_pairing_window();
return true;
}
extern "C" void probe_controller_input_set_native_stream(bool enabled) {
switch2_mouse_capture_set_native_stream(g_flash_ready && enabled);
}
extern "C" uint32_t probe_controller_input_peek_native_report(
uint32_t now_ms, uint8_t report[63]) {
if (!g_flash_ready) return 0;
return switch2_mouse_capture_peek_native_report(now_ms, report);
}
extern "C" bool probe_controller_input_commit_native_report(uint32_t serial) {
if (!g_flash_ready) return false;
return switch2_mouse_capture_commit_native_report(serial);
}
extern "C" bool probe_controller_input_play_sample(uint8_t sample_id, uint64_t* token) {
if (!g_flash_ready) {
if (token != nullptr) *token = 0;
return false;
}
return switch2_mouse_capture_request_sample(
sample_id, to_ms_since_boot(get_absolute_time()), token);
}
extern "C" int probe_controller_input_sample_result(uint64_t token, uint32_t now_ms) {
if (!g_flash_ready) return -1;
return switch2_mouse_capture_sample_result(token, now_ms);
}
extern "C" void probe_controller_input_cancel_sample(void) {
switch2_mouse_capture_cancel_sample();
}
extern "C" void probe_controller_input_poll(uint32_t now_ms,
probe_controller_input* out) {
if (out == nullptr) return;
if (!g_flash_ready) {
*out = {};
return;
}
Switch2MouseCaptureInput sample;
if (switch2_mouse_capture_latest_input(g_input.serial, &sample)) {
g_input.serial = sample.serial;
g_input.active = sample.active;
g_received_ms = sample.received_ms;
// Preserve physical byte meaning: never route through the generic
// solo Joy-Con rotation/mapping. Teardown fields are already zeroed.
memcpy(g_input.buttons, sample.buttons, sizeof(g_input.buttons));
memcpy(g_input.stick, sample.stick, sizeof(g_input.stick));
g_input.native_status = sample.native_status;
g_input.mouse_epoch = sample.mouse_epoch;
g_input.mouse_total_x = sample.mouse_total_x;
g_input.mouse_total_y = sample.mouse_total_y;
g_input.mouse_surface = sample.mouse_surface;
}
// The producer can be a millisecond ahead of the caller's pre-poll clock.
// Signed elapsed time tolerates that race and ordinary uint32_t rollover.
// Expiration latches inactive until a newer capture serial arrives.
if (g_input.active &&
static_cast<int32_t>(now_ms - g_received_ms) >=
static_cast<int32_t>(kInputDeadlineMs)) {
g_input.active = false;
memset(g_input.buttons, 0, sizeof(g_input.buttons));
memset(g_input.stick, 0, sizeof(g_input.stick));
g_input.native_status = 0;
g_input.mouse_epoch = 0;
g_input.mouse_total_x = 0;
g_input.mouse_total_y = 0;
g_input.mouse_surface = 0;
}
*out = g_input;
}

View file

@ -0,0 +1,67 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
bool active;
uint32_t serial;
uint8_t buttons[2];
uint8_t stick[3];
// Latest opaque native 08 byte 8.
uint8_t native_status;
// Cumulative signed relative totals within mouse_epoch, not per-poll
// deltas. Cached polls repeat these totals without consuming motion.
// Epoch changes on reconnect, including a teardown missed between polls.
uint32_t mouse_epoch;
int64_t mouse_total_x;
int64_t mouse_total_y;
// Latest opaque native 08 byte 13.
uint8_t mouse_surface;
} probe_controller_input;
// Core 0, before stdio/peripheral initialization.
void probe_controller_input_clock_init(void);
// Core 0, after stdio and before protocol reset or USB startup.
void probe_controller_input_init(void);
// True means both cores are registered for flash coordination, not that the
// radio is ready or a controller is connected. Failure is latched: keep USB
// and flash-writing protocol operations disabled rather than retrying startup.
bool probe_controller_input_start(void);
// Core 0 after start(): polls the existing two-second BOOTSEL hold gesture.
// True means a Bluetooth pairing-window request was queued. Long holds NEVER
// clear pairings in this bridge, and this does not inject USB controller input.
bool probe_controller_input_pairing_task(void);
// Core 0 native 08 relay: disabled until explicitly enabled after flash-ready
// startup. Disable clears queued data, repeated enable preserves it. Resets
// must disable the stream; this never changes Bluetooth bonds or pairing.
// Only subsequent selected-source packets enter the separate 32-entry FIFO.
// Overflow drops queued history and retains only the arriving packet.
void probe_controller_input_set_native_stream(bool enabled);
// Copy a full opaque 63-byte payload (without report ID), oldest first. Returns
// its never-reused boot-lifetime serial; 0 leaves report untouched. Latest source
// or head >=500 ms old discards the FIFO; now_ms is the Pico boot-ms clock.
// Nondestructive until successful HID submission followed by commit.
uint32_t probe_controller_input_peek_native_report(uint32_t now_ms, uint8_t report[63]);
// Remove only the exact current head once. A stale/replaced token cannot pop a
// new stream's packet. Before flash-ready startup peek/commit return 0/false.
bool probe_controller_input_commit_native_report(uint32_t serial);
// Built-in vibration samples only; raw HD-rumble output is not forwarded.
// A nonzero token means queued, not acknowledged. Result: 0 pending, 1 real
// source ACK, -1 failed/stale. Reset cancels the request, never stored pairing.
bool probe_controller_input_play_sample(uint8_t sample_id, uint64_t* token);
int probe_controller_input_sample_result(uint64_t token, uint32_t now_ms);
void probe_controller_input_cancel_sample(void);
// Core 0 at 250 Hz; now_ms uses the Pico boot-millisecond clock. Only fresh
// native 08 buttons/stick and cumulative mouse totals are exposed. Inactive
// fields are zero except serial; the USB protocol must supply its calibrated
// stick center rather than forwarding inactive stick bytes.
void probe_controller_input_poll(uint32_t now_ms, probe_controller_input* out);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,33 @@
#pragma once
#include <stdint.h>
// Published Joy-Con 2 (R) USB descriptors, reproduced for enumeration capture.
// https://github.com/ndeadly/switch2_controller_research/blob/master/descriptors.md
static const uint8_t probe_device_descriptor[] = {
0x12, 0x01, 0x00, 0x02, 0xef, 0x02, 0x01, 0x40, 0x7e, 0x05, 0x66, 0x20,
0x00, 0x01, 0x01, 0x02, 0x03, 0x01,
};
static const uint8_t probe_configuration_descriptor[] = {
0x09, 0x02, 0x50, 0x00, 0x02, 0x01, 0x04, 0xc0, 0xfa, 0x08, 0x0b, 0x00,
0x01, 0x03, 0x00, 0x00, 0x00, 0x09, 0x04, 0x00, 0x00, 0x02, 0x03, 0x00,
0x00, 0x05, 0x09, 0x21, 0x11, 0x01, 0x00, 0x01, 0x22, 0x64, 0x00, 0x07,
0x05, 0x81, 0x03, 0x40, 0x00, 0x04, 0x07, 0x05, 0x01, 0x03, 0x40, 0x00,
0x04, 0x08, 0x0b, 0x01, 0x01, 0xff, 0x00, 0x00, 0x00, 0x09, 0x04, 0x01,
0x00, 0x02, 0xff, 0x00, 0x00, 0x06, 0x07, 0x05, 0x02, 0x02, 0x40, 0x00,
0x00, 0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00,
};
static const uint8_t probe_hid_report_descriptor[] = {
0x05, 0x01, 0x09, 0x05, 0xa1, 0x01, 0x85, 0x05, 0x05, 0xff, 0x09, 0x01,
0x15, 0x00, 0x26, 0xff, 0x00, 0x95, 0x3f, 0x75, 0x08, 0x81, 0x02, 0x85,
0x08, 0x09, 0x01, 0x95, 0x02, 0x81, 0x02, 0x05, 0x09, 0x19, 0x01, 0x29,
0x10, 0x25, 0x01, 0x95, 0x10, 0x75, 0x01, 0x81, 0x02, 0x05, 0xff, 0x09,
0x01, 0x26, 0xff, 0x00, 0x95, 0x01, 0x75, 0x08, 0x81, 0x02, 0x05, 0x01,
0x09, 0x01, 0xa1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x26, 0xff, 0x0f, 0x95,
0x02, 0x75, 0x0c, 0x81, 0x02, 0xc0, 0x05, 0xff, 0x09, 0x02, 0x26, 0xff,
0x00, 0x95, 0x37, 0x75, 0x08, 0x81, 0x02, 0x85, 0x01, 0x09, 0x01, 0x95,
0x3f, 0x91, 0x02, 0xc0,
};

View file

@ -0,0 +1,684 @@
// Joy-Con 2 (R) USB instrument and optional Bluetooth controller/mouse bridge.
// Only documented/observed transactions are implemented. Built-in vibration
// cues wait for the source ACK; native sensor packets are relayed without decoding.
// Only virtual pairing storage is writable here.
#include <inttypes.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
#include "controller_input.h"
#else
#include "platform/pico/bootsel_button_sample.h"
#include "button_test.h"
#endif
#include "pico/stdlib.h"
#include "hardware/sync.h"
#include "hardware/uart.h"
#include "tusb.h"
#include "descriptors.h"
#include "protocol.h"
#include "storage.h"
#ifdef SWITCH2_PROBE_MEMORY
#include "memory.h"
#endif
#ifdef SWITCH2_PROBE_IDENTITY_REPLY
#include "probe_identity.h"
_Static_assert(sizeof(probe_identity_reply) == 64, "factory identity response size");
#endif
#ifdef SWITCH2_PROBE_VERSION_REPLY
#include "probe_version.h"
_Static_assert(sizeof(probe_version_reply) == 16, "version/address response size");
#endif
#define LOG_CAPACITY 8192u
static char log_bytes[LOG_CAPACITY];
static uint32_t log_written, log_read, log_dropped;
static uint32_t bulk_packets, hid_packets;
static uint32_t identity_requests, version_requests, setup_completions;
static uint16_t string_descriptor[64];
static uint32_t input_reports, command_drops;
#ifdef SWITCH2_PROBE_USB_INIT
#define REPLY_CAPACITY 4u
typedef struct {
uint8_t data[PROBE_REPLY_MAX_SIZE];
uint8_t length;
uint64_t deferred_token;
} queued_reply;
static probe_protocol_state protocol;
static queued_reply replies[REPLY_CAPACITY];
static uint8_t reply_head, reply_count;
static bool reply_inflight;
static uint16_t reply_remaining;
static uint8_t command_frame[PROBE_COMMAND_MAX_SIZE];
static uint16_t command_used, command_expected = 8;
static uint32_t last_input_ms;
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
static uint32_t last_controller_poll_ms;
static uint16_t last_delivered_buttons;
static bool native_stream_ready;
static uint32_t last_hid_complete_ms;
static bool hid_completion_seen;
static uint32_t mouse_delivered_reports, mouse_logged_reports;
static int64_t mouse_delivered_x, mouse_delivered_y;
#endif
#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE
static probe_button_state button_test;
static uint32_t last_button_ms;
static bool button_sample_error;
#endif
static uint8_t last_delivered_rails;
#endif
_Static_assert(sizeof(probe_device_descriptor) == 18, "device descriptor size");
_Static_assert(sizeof(probe_configuration_descriptor) == 80, "configuration descriptor size");
_Static_assert(sizeof(probe_hid_report_descriptor) == 100, "HID descriptor size");
int probe_debug_printf(const char* format, ...) {
char message[512];
va_list args;
va_start(args, format);
const int result = vsnprintf(message, sizeof(message), format, args);
va_end(args);
if (result <= 0) return result;
const size_t size = (size_t)result < sizeof(message) ? (size_t)result : sizeof(message) - 1;
const uint32_t interrupts = save_and_disable_interrupts();
if (LOG_CAPACITY - (log_written - log_read) >= size) {
for (size_t i = 0; i < size; ++i)
log_bytes[(log_written + i) % LOG_CAPACITY] = message[i];
log_written += (uint32_t)size;
log_dropped += (uint32_t)result - (uint32_t)size;
} else {
log_dropped += (uint32_t)result;
}
restore_interrupts(interrupts);
return result;
}
static void drain_log(void) {
while (uart_is_writable(uart0)) {
const uint32_t interrupts = save_and_disable_interrupts();
if (log_read == log_written) {
restore_interrupts(interrupts);
break;
}
const char value = log_bytes[log_read++ % LOG_CAPACITY];
restore_interrupts(interrupts);
uart_putc_raw(uart0, value);
}
}
static void log_packet(const char* kind, uint8_t instance, uint8_t report_id,
const uint8_t* data, uint16_t length) {
if (length >= 4 && data[0] == 0x15 && (data[3] == 0x02 || data[3] == 0x04)) {
probe_debug_printf("[PROBE] %s itf=%u command=15/%02x len=%u [pairing payload redacted]\n",
kind, instance, data[3], length);
return;
}
static const char hex[] = "0123456789abcdef";
char message[288];
size_t at = (size_t)snprintf(message, sizeof(message),
"[PROBE %" PRIu32 "] %s itf=%u report=%02x len=%u:",
to_ms_since_boot(get_absolute_time()), kind, instance, report_id, length);
const uint16_t count = length < 64 ? length : 64;
for (uint16_t i = 0; i < count && at + 4 < sizeof(message); ++i) {
message[at++] = ' ';
message[at++] = hex[data[i] >> 4];
message[at++] = hex[data[i] & 15];
}
message[at] = 0;
probe_debug_printf("%s%s\n", message, length > count ? " [truncated]" : "");
}
uint8_t const* tud_descriptor_device_cb(void) {
probe_debug_printf("[PROBE] DEVICE_DESCRIPTOR 057e:2066\n");
return probe_device_descriptor;
}
uint8_t const* tud_descriptor_configuration_cb(uint8_t index) {
probe_debug_printf("[PROBE] CONFIG_DESCRIPTOR index=%u\n", index);
return index == 0 ? probe_configuration_descriptor : NULL;
}
uint8_t const* tud_hid_descriptor_report_cb(uint8_t instance) {
probe_debug_printf("[PROBE] HID_DESCRIPTOR itf=%u\n", instance);
return instance == 0 ? probe_hid_report_descriptor : NULL;
}
uint16_t const* tud_descriptor_string_cb(uint8_t index, uint16_t langid) {
// Manufacturer/product/serial match the published reference. Remaining
// descriptor labels describe the probe; their genuine strings are unknown.
static const char* const strings[] = {
"", "Nintendo", "Joy-Con 2 (R)", "00", "USB configuration", "HID", "Commands"};
probe_debug_printf("[PROBE] STRING_DESCRIPTOR index=%u lang=%04x\n", index, langid);
if (index == 0) {
string_descriptor[0] = (TUSB_DESC_STRING << 8) | 4;
string_descriptor[1] = 0x0409;
return string_descriptor;
}
if (index >= sizeof(strings) / sizeof(strings[0])) return NULL;
size_t count = strlen(strings[index]);
if (count > 63) count = 63;
string_descriptor[0] = (uint16_t)((TUSB_DESC_STRING << 8) | (2 + count * 2));
for (size_t i = 0; i < count; ++i)
string_descriptor[i + 1] = (uint8_t)strings[index][i];
return string_descriptor;
}
uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id,
hid_report_type_t report_type, uint8_t* buffer,
uint16_t requested_length) {
#ifdef SWITCH2_PROBE_USB_INIT
uint8_t input[PROBE_INPUT_SIZE];
if (instance == 0 && report_type == HID_REPORT_TYPE_INPUT &&
probe_protocol_report(&protocol, report_id, input, sizeof(input))) {
const uint16_t size = requested_length < sizeof(input) ? requested_length : sizeof(input);
memcpy(buffer, input, size);
probe_debug_printf("[PROBE] GET_REPORT id=%02x diagnostic length=%u\n", report_id, size);
return size;
}
#else
(void)buffer;
#endif
probe_debug_printf("[PROBE] GET_REPORT itf=%u report=%02x type=%u length=%u -> STALL\n",
instance, report_id, report_type, requested_length);
return 0;
}
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id,
hid_report_type_t report_type, const uint8_t* buffer,
uint16_t length) {
++hid_packets;
probe_debug_printf("[PROBE] HID_REPORT_TYPE=%u\n", report_type);
log_packet("HID_OUT", instance, report_id, buffer, length);
}
#ifdef SWITCH2_PROBE_USB_INIT
static bool save_pairing(const uint8_t* data, size_t length) {
const bool saved = probe_storage_save(data, length);
probe_debug_printf("[PROBE] Virtual pairing persistence %s\n", saved ? "verified" : "failed");
return saved;
}
static void reset_protocol(void) {
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_controller_input_cancel_sample();
#endif
probe_protocol_reset(&protocol);
memcpy(protocol.controller_address, probe_version_reply + 10, sizeof(protocol.controller_address));
protocol.firmware_version = probe_firmware_version;
protocol.save_pairing = save_pairing;
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
protocol.play_sample = probe_controller_input_play_sample;
#endif
#ifdef SWITCH2_PROBE_MEMORY
if (!probe_memory_right_stick_center(protocol.right_stick_center))
panic("Invalid captured Joy-Con stick calibration");
protocol.read_memory = probe_memory_read;
#endif
uint8_t pairing[PROBE_PAIRING_BLOB_SIZE];
if (probe_storage_load(pairing, sizeof(pairing)) &&
probe_protocol_restore_pairing(&protocol, pairing, sizeof(pairing))) {
probe_debug_printf("[PROBE] Restored own virtual pairing record\n");
}
reply_head = reply_count = 0;
reply_inflight = false;
reply_remaining = 0;
command_used = 0;
command_expected = 8;
last_input_ms = 0;
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
last_controller_poll_ms = 0;
last_delivered_buttons = 0;
probe_controller_input_set_native_stream(false);
native_stream_ready = false;
last_hid_complete_ms = 0;
hid_completion_seen = false;
#endif
#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE
(void)probe_button_update(&button_test, -1, false);
last_button_ms = 0;
button_sample_error = false;
#endif
last_delivered_rails = 0;
}
static void complete_command(void) {
// Log complete frames rather than fragments so key material can be redacted.
log_packet("BULK_OUT", 0, 0, command_frame, command_used);
if (reply_count == REPLY_CAPACITY) {
++command_drops;
probe_debug_printf("[PROBE] Command captured but not executed: reply queue full\n");
return;
}
queued_reply* reply = &replies[(reply_head + reply_count) % REPLY_CAPACITY];
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
const uint8_t previous_report_id = protocol.report_id;
const uint8_t previous_features = protocol.enabled_features;
#endif
const size_t length = probe_protocol_command(
&protocol, command_frame, command_used, reply->data, sizeof(reply->data),
&reply->deferred_token);
if (length) {
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
if (previous_report_id != protocol.report_id ||
previous_features != protocol.enabled_features) {
probe_controller_input_set_native_stream(false);
native_stream_ready = false;
}
#endif
reply->length = (uint8_t)length;
++reply_count;
if (reply->deferred_token) {
probe_debug_printf("[PROBE] Sample %u awaiting source ACK token=%" PRIu64 "\n",
command_frame[8], reply->deferred_token);
} else {
log_packet("BULK_REPLY_QUEUED", 0, 0, reply->data, reply->length);
}
if (command_frame[0] == 0x09) {
probe_debug_printf("[PROBE] Virtual player LEDs mask=%x flashing=%u\n",
protocol.player_leds, protocol.player_leds_flashing);
}
if (command_frame[0] == 0x0c) {
probe_debug_printf("[PROBE] Virtual features mask=%02x enabled=%02x\n",
protocol.feature_mask, protocol.enabled_features);
}
if (command_frame[0] == 0x0a && command_frame[3] == 8)
probe_debug_printf("[PROBE] Virtual vibration parameters stored; no motor output\n");
if (command_frame[0] == 0x03 && command_frame[3] == 0x0c)
probe_debug_printf("[PROBE] Runtime 03/0C value=%u\n", protocol.runtime03_0c);
} else {
probe_debug_printf("[PROBE] Command %02x/%02x length=%u capture-only or malformed\n",
command_frame[0], command_frame[3], command_used);
}
}
static void consume_bulk_packet(const uint8_t* buffer, uint16_t length) {
for (uint16_t i = 0; i < length; ++i) {
command_frame[command_used++] = buffer[i];
if (command_used == 8) command_expected = (uint16_t)(8 + command_frame[5]);
if (command_used == command_expected) {
complete_command();
command_used = 0;
command_expected = 8;
}
}
// A short USB packet/ZLP ends an OUT transfer. Never let a malformed
// truncated frame consume a subsequent independent host command.
if (length < CFG_TUD_VENDOR_EPSIZE && command_used) {
probe_debug_printf("[PROBE] Truncated command discarded: received=%u expected=%u\n",
command_used, command_expected);
command_used = 0;
command_expected = 8;
}
}
#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE
static void button_test_task(uint32_t now) {
const bool ready = tud_mounted() && !tud_suspended() &&
protocol.initialized && (protocol.enabled_features & 1);
bool pressed;
if (!ready) {
pressed = probe_button_update(&button_test, -1, false);
last_button_ms = now;
} else {
if (now - last_button_ms < 10) return;
last_button_ms = now;
const int sample = bootsel_button_sample();
if (sample < 0 && !button_sample_error)
probe_debug_printf("[PROBE] BOOTSEL sampling unavailable; test buttons released and disarmed\n");
button_sample_error = sample < 0;
pressed = probe_button_update(&button_test, sample, true);
}
if (protocol.test_rail_buttons != pressed) {
protocol.test_rail_buttons = pressed;
probe_debug_printf("[PROBE] TEST_BUTTON SL+SR %s\n", pressed ? "pressed" : "released");
}
}
#endif
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
static void controller_input_task(uint32_t now) {
if (now - last_controller_poll_ms < 4) return;
last_controller_poll_ms = now;
probe_controller_input source = {0};
probe_controller_input_poll(now, &source);
const bool output_active = source.active && tud_mounted() && !tud_suspended();
if (protocol.controller_active != output_active)
probe_debug_printf("[PROBE] Controller input %s\n", output_active ? "active" : "neutral (disconnected/stale)");
protocol.controller_active = output_active;
if (output_active) {
memcpy(protocol.controller_buttons, source.buttons, sizeof(source.buttons));
memcpy(protocol.controller_stick, source.stick, sizeof(source.stick));
} else {
memset(protocol.controller_buttons, 0, sizeof(protocol.controller_buttons));
}
// Bootstrap with diagnostic reports until the host polls HID. Then consume
// complete source packets once, including opaque packed motion samples.
native_stream_ready = tud_mounted() && !tud_suspended() && protocol.initialized &&
protocol.report_id == 0x08 && hid_completion_seen &&
(uint32_t)(now - last_hid_complete_ms) < 500;
probe_controller_input_set_native_stream(native_stream_ready);
}
static void gate_native_report(uint8_t input[PROBE_INPUT_SIZE]) {
if (!(protocol.enabled_features & 1)) memset(input + 2, 0, 2);
if (!(protocol.enabled_features & 2))
memcpy(input + 5, protocol.right_stick_center, sizeof(protocol.right_stick_center));
if (!(protocol.enabled_features & 0x10)) memset(input + 9, 0, 5);
if (!(protocol.enabled_features & 4)) memset(input + 15, 0, 41);
}
#endif
static void protocol_task(uint32_t now) {
if (!tud_mounted() || tud_suspended()) return;
if (reply_count && !reply_inflight) {
queued_reply* reply = &replies[reply_head];
bool ready = reply->deferred_token == 0;
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
if (!ready) {
const int result = probe_controller_input_sample_result(reply->deferred_token, now);
if (result > 0) {
probe_debug_printf("[PROBE] Source sample ACK token=%" PRIu64 "\n",
reply->deferred_token);
reply->deferred_token = 0;
ready = true;
log_packet("BULK_REPLY_QUEUED", 0, 0, reply->data, reply->length);
} else if (result < 0) {
probe_debug_printf("[PROBE] Source sample failed or expired token=%" PRIu64
"; no USB ACK\n", reply->deferred_token);
reply_head = (uint8_t)((reply_head + 1) % REPLY_CAPACITY);
--reply_count;
++command_drops;
}
}
#endif
if (ready && tud_vendor_n_write_available(0) >= reply->length) {
if (tud_vendor_n_write(0, reply->data, reply->length) == reply->length) {
reply_inflight = true;
reply_remaining = reply->length;
tud_vendor_n_write_flush(0);
reply_head = (uint8_t)((reply_head + 1) % REPLY_CAPACITY);
--reply_count;
}
}
}
if (protocol.initialized && now - last_input_ms >= 4 && tud_hid_n_ready(0)) {
uint8_t input[PROBE_INPUT_SIZE];
size_t length = 0;
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
uint32_t native_serial = 0;
if (protocol.report_id == 0x08 && protocol.controller_active && native_stream_ready) {
native_serial = probe_controller_input_peek_native_report(now, input);
if (!native_serial) return; // Never replay a packet's mouse or motion samples.
length = sizeof(input);
gate_native_report(input);
}
#endif
if (!length) {
length = probe_protocol_report(&protocol, protocol.report_id, input, sizeof(input));
}
if (length && tud_hid_n_report(0, protocol.report_id, input, (uint16_t)length)) {
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
if (native_serial) {
if (!probe_controller_input_commit_native_report(native_serial))
probe_debug_printf("[PROBE] Native stream changed during HID submission\n");
protocol.report_counter = input[0];
}
#endif
++protocol.report_counter;
++input_reports;
last_input_ms = now;
}
}
}
#endif
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
static int32_t signed_mouse_delta(const uint8_t* data) {
const uint16_t raw = (uint16_t)(data[0] | ((uint16_t)data[1] << 8));
return raw >= 0x8000 ? (int32_t)raw - 0x10000 : raw;
}
void tud_hid_report_failed_cb(uint8_t instance, hid_report_type_t report_type,
const uint8_t* report, uint16_t length) {
(void)report;
(void)length;
if (instance == 0 && report_type == HID_REPORT_TYPE_INPUT) {
probe_controller_input_set_native_stream(false);
native_stream_ready = false;
hid_completion_seen = false;
probe_debug_printf("[PROBE] HID input transfer failed; pending native packets discarded\n");
}
}
#endif
void tud_hid_report_complete_cb(uint8_t instance, const uint8_t* report, uint16_t length) {
#ifdef SWITCH2_PROBE_USB_INIT
if (instance != 0 || length != PROBE_INPUT_SIZE + 1) return;
uint8_t rails;
if (report[0] == 0x08) rails = (report[4] >> 6) & 3;
else if (report[0] == 0x05) rails = (report[5] >> 4) & 3;
else return;
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
last_hid_complete_ms = to_ms_since_boot(get_absolute_time());
hid_completion_seen = true;
if (report[0] == 0x08) {
const int32_t dx = signed_mouse_delta(report + 10);
const int32_t dy = signed_mouse_delta(report + 12);
if (dx || dy) {
++mouse_delivered_reports;
mouse_delivered_x += dx;
mouse_delivered_y += dy;
}
}
const uint16_t buttons = report[0] == 0x08 ?
(uint16_t)(report[3] | ((uint16_t)report[4] << 8)) :
(uint16_t)(report[5] | ((uint16_t)report[6] << 8));
if (buttons != last_delivered_buttons) {
last_delivered_buttons = buttons;
probe_debug_printf("[PROBE] CONTROLLER_REPORT delivered id=%02x buttons=%04x\n", report[0], buttons);
}
#endif
if (rails != last_delivered_rails) {
last_delivered_rails = rails;
probe_debug_printf("[PROBE] TEST_REPORT delivered id=%02x SL=%u SR=%u\n",
report[0], (rails >> 1) & 1, rails & 1);
}
#else
(void)instance;
(void)report;
(void)length;
#endif
}
void tud_vendor_rx_cb(uint8_t instance, const uint8_t* buffer, uint16_t length) {
++bulk_packets;
#ifdef SWITCH2_PROBE_USB_INIT
if (instance == 0) consume_bulk_packet(buffer, length);
#else
log_packet("BULK_OUT", instance, 0, buffer, length);
#endif
// Drain TinyUSB's receive FIFO; raw packet data above is consumed once.
uint8_t discarded[64];
while (tud_vendor_n_available(instance)) {
if (!tud_vendor_n_read(instance, discarded, sizeof(discarded))) break;
}
}
void tud_vendor_tx_cb(uint8_t instance, uint32_t length) {
#ifdef SWITCH2_PROBE_USB_INIT
if (instance == 0 && reply_inflight) {
if (length <= reply_remaining) {
reply_remaining -= (uint16_t)length;
// TinyUSB calls this per packet. Exact packet multiples finish only
// after its automatic ZLP, not after the last full-size packet.
if (reply_remaining == 0 && length < CFG_TUD_VENDOR_EPSIZE)
reply_inflight = false;
} else {
probe_debug_printf("[PROBE] Unexpected bulk completion; pending=%u\n", reply_remaining);
}
}
#endif
probe_debug_printf("[PROBE] BULK_TX_COMPLETE itf=%u length=%" PRIu32 "\n", instance, length);
}
bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage,
const tusb_control_request_t* request) {
if (stage == CONTROL_STAGE_SETUP)
log_packet("VENDOR_CONTROL", rhport, 0, (const uint8_t*)request, sizeof(*request));
#ifdef SWITCH2_PROBE_IDENTITY_REPLY
if (request->bmRequestType == 0xc0 && request->bRequest == 0x03 &&
request->wValue == 0 && request->wIndex == 0) {
if (stage == CONTROL_STAGE_SETUP) {
++identity_requests;
log_packet("IDENTITY_REPLY", 0, 3, probe_identity_reply, sizeof(probe_identity_reply));
return tud_control_xfer(rhport, request, (void*)probe_identity_reply,
sizeof(probe_identity_reply));
}
return true;
}
#endif
#ifdef SWITCH2_PROBE_VERSION_REPLY
if (request->bmRequestType == 0xc0 && request->bRequest == 0x02 &&
request->wValue == 0 && request->wIndex == 0) {
if (stage == CONTROL_STAGE_SETUP) {
++version_requests;
log_packet("VERSION_REPLY", 0, 2, probe_version_reply, sizeof(probe_version_reply));
return tud_control_xfer(rhport, request, (void*)probe_version_reply,
sizeof(probe_version_reply));
}
return true;
}
#endif
#ifdef SWITCH2_PROBE_ACK_SETUP04
// Exact control-transfer contract observed on the console and on two
// genuine controllers. The meaning of 0x0276 is unresolved: do not infer
// UART baud, USB speed, or any flash operation from it.
if (request->bmRequestType == 0x40 && request->bRequest == 0x04 &&
request->wValue == 0x0276 && request->wIndex == 0 && request->wLength == 0) {
if (stage == CONTROL_STAGE_SETUP)
return tud_control_status(rhport, request);
if (stage == CONTROL_STAGE_ACK) {
++setup_completions;
probe_debug_printf("[PROBE] SETUP04 acknowledged value=%04x (parameter semantics unresolved)\n",
request->wValue);
}
return true;
}
#endif
return false;
}
void tud_mount_cb(void) {
#ifdef SWITCH2_PROBE_USB_INIT
reset_protocol();
#endif
probe_debug_printf("[PROBE] MOUNT\n");
}
void tud_umount_cb(void) {
#ifdef SWITCH2_PROBE_USB_INIT
reset_protocol();
#endif
probe_debug_printf("[PROBE] UNMOUNT\n");
}
void tud_suspend_cb(bool remote_wakeup_en) {
probe_debug_printf("[PROBE] SUSPEND wake=%u\n", remote_wakeup_en);
#ifdef SWITCH2_PROBE_USB_INIT
#ifndef SWITCH_PICO_SWITCH2_USB_BRIDGE
(void)probe_button_update(&button_test, -1, false);
#endif
protocol.test_rail_buttons = false;
protocol.controller_active = false;
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_controller_input_set_native_stream(false);
native_stream_ready = false;
hid_completion_seen = false;
#endif
#endif
}
void tud_resume_cb(void) { probe_debug_printf("[PROBE] RESUME\n"); }
int main(void) {
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_controller_input_clock_init();
#endif
stdio_init_all();
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_debug_printf("\n[PROBE] Joy-Con 2 (R) Bluetooth-to-USB controller/native mouse bridge\n");
#else
probe_debug_printf("\n[PROBE] Joy-Con 2 (R) USB enumeration recorder\n");
#endif
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_controller_input_init();
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; selected right Joy-Con Bluetooth source enabled\n");
#else
probe_debug_printf("[PROBE] UART0 GP0=TX, 115200 8N1; Bluetooth disabled\n");
#endif
#ifdef SWITCH2_PROBE_IDENTITY_REPLY
probe_debug_printf("[PROBE] Configured factory-format identity reply enabled for vendor read 03\n");
#else
probe_debug_printf("[PROBE] Factory identity reply disabled\n");
#endif
#ifdef SWITCH2_PROBE_VERSION_REPLY
probe_debug_printf("[PROBE] Captured firmware version with configured controller address enabled\n");
#endif
#ifdef SWITCH2_PROBE_ACK_SETUP04
probe_debug_printf("[PROBE] Observed vendor-04 setup acknowledgement enabled\n");
#endif
#ifdef SWITCH2_PROBE_USB_INIT
reset_protocol();
probe_debug_printf("[PROBE] Own virtual pairing storage offset=%08" PRIx32 "\n",
probe_storage_offset());
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_debug_printf("[PROBE] Live right Joy-Con buttons/stick/native mouse; hold BOOTSEL 2s for Bluetooth pairing (never clears pairings)\n");
#else
probe_debug_printf("[PROBE] Manual input test: hold BOOTSEL for SL+SR, release for neutral; no controller forwarding\n");
#endif
#else
probe_debug_printf("[PROBE] Bulk commands are capture-only; no input reports\n");
#endif
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
if (!probe_controller_input_start())
panic("Bluetooth source could not establish multicore flash coordination");
#endif
tud_init(0);
uint32_t last_heartbeat = 0;
while (true) {
tud_task();
drain_log();
const uint32_t now = to_ms_since_boot(get_absolute_time());
#ifdef SWITCH2_PROBE_USB_INIT
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
if (probe_controller_input_pairing_task())
probe_debug_printf("[PROBE] Bluetooth pairing window requested; put the right Joy-Con in SYNC pairing mode\n");
controller_input_task(now);
#else
button_test_task(now);
#endif
protocol_task(now);
#endif
if (now - last_heartbeat >= 1000) {
last_heartbeat = now;
probe_debug_printf("[PROBE %" PRIu32 "] alive mounted=%u bulk=%" PRIu32
" hid=%" PRIu32 " identity=%" PRIu32
" version=%" PRIu32 " setup=%" PRIu32
" inputs=%" PRIu32 " command_drops=%" PRIu32
" log_dropped_bytes=%" PRIu32 "\n",
now, tud_mounted(), bulk_packets, hid_packets,
identity_requests, version_requests, setup_completions,
input_reports, command_drops, log_dropped);
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
if (mouse_delivered_reports != mouse_logged_reports) {
mouse_logged_reports = mouse_delivered_reports;
probe_debug_printf("[PROBE] MOUSE_REPORT delivered packets=%" PRIu32
" total_x=%" PRId64 " total_y=%" PRId64 "\n",
mouse_delivered_reports, mouse_delivered_x, mouse_delivered_y);
}
#endif
}
sleep_us(100);
}
}

View file

@ -0,0 +1,5 @@
#pragma once
// Pairing uses one AES-128 ECB block; no TLS, entropy service, or heap-backed cipher API.
#define MBEDTLS_AES_C
#define MBEDTLS_AES_ROM_TABLES

View file

@ -0,0 +1,57 @@
#include "memory.h"
#include "probe_memory_data.h"
#include <string.h>
_Static_assert(sizeof(probe_factory_memory) == 8192, "factory capture size");
_Static_assert(sizeof(probe_user_calibration) == 4096, "user calibration capture size");
bool probe_memory_read(uint32_t address, uint8_t* output, size_t length) {
if (!output) return false;
const uint8_t* source;
size_t offset, available;
if (address >= 0x13000 && address < 0x15000) {
offset = address - 0x13000;
source = probe_factory_memory;
available = sizeof(probe_factory_memory) - offset;
} else if (address >= 0x1fc000 && address < 0x1fd000) {
offset = address - 0x1fc000;
source = probe_user_calibration;
available = sizeof(probe_user_calibration) - offset;
} else {
return false; // No fabricated erased bytes, pairing keys, or firmware reads.
}
if (length > available) return false;
memcpy(output, source + offset, length);
return true;
}
static void unpack_pair(const uint8_t* data, uint16_t values[2]) {
values[0] = data[0] | ((uint16_t)(data[1] & 15) << 8);
values[1] = (data[1] >> 4) | ((uint16_t)data[2] << 4);
}
static bool valid_calibration(const uint8_t* data) {
uint16_t center[2], positive[2], negative[2];
unpack_pair(data, center);
unpack_pair(data + 3, positive);
unpack_pair(data + 6, negative);
// Same bounds as the existing Bluepad32 Switch 2 calibration decoder.
for (unsigned i = 0; i < 2; ++i) {
if (!center[i] || center[i] == 4095 || !positive[i] || !negative[i] ||
positive[i] > 4095 - center[i] || negative[i] > center[i]) return false;
}
return true;
}
bool probe_memory_right_stick_center(uint8_t output[3]) {
if (!output) return false;
// A solo Joy-Con uses the primary calibration record, even for the right
// controller. User magic precedes its 9-byte record; factory has no magic.
const uint8_t* selected = probe_factory_memory + 0xa8;
const uint8_t* user = probe_user_calibration + 0x40;
if (user[0] == 0xb2 && user[1] == 0xa1 && valid_calibration(user + 2))
selected = user + 2;
if (!valid_calibration(selected)) return false;
memcpy(output, selected, 3);
return true;
}

View file

@ -0,0 +1,7 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
bool probe_memory_read(uint32_t address, uint8_t* output, size_t length);
bool probe_memory_right_stick_center(uint8_t output[3]);

View file

@ -0,0 +1,139 @@
# Shared by the standalone USB probe and the Bluetooth-backed root target.
# Include before pico_sdk_init() so every mbedTLS source uses the same config.
set(SWITCH2_USB_PROBE_DIR "${CMAKE_CURRENT_LIST_DIR}")
set(PICO_MBEDTLS_CONFIG_FILE "${SWITCH2_USB_PROBE_DIR}/mbedtls_config.h")
function(switch2_usb_probe_configure target)
set(probe_sources
${SWITCH2_USB_PROBE_DIR}/main.c
${SWITCH2_USB_PROBE_DIR}/protocol.c
${SWITCH2_USB_PROBE_DIR}/storage.cpp
${SWITCH2_USB_PROBE_DIR}/button_test.c)
target_compile_features(${target} PRIVATE c_std_11 cxx_std_17)
target_include_directories(${target} PRIVATE
${SWITCH2_USB_PROBE_DIR}
${SWITCH2_USB_PROBE_DIR}/../../src/firmware
${CMAKE_CURRENT_BINARY_DIR}
${PICO_SDK_PATH}/src/rp2_common/pico_btstack/include
${PICO_SDK_PATH}/lib/btstack/platform/embedded)
target_compile_definitions(${target} PRIVATE
CFG_TUSB_CONFIG_FILE="${SWITCH2_USB_PROBE_DIR}/tusb_config.h")
set(SWITCH2_PROBE_IDENTITY_FILE "" CACHE FILEPATH "64-byte Joy-Con 2 (R) factory-format identity block")
if(SWITCH2_PROBE_IDENTITY_FILE)
file(READ "${SWITCH2_PROBE_IDENTITY_FILE}" identity_hex LIMIT 65 HEX)
string(LENGTH "${identity_hex}" identity_length)
if(NOT identity_length EQUAL 128)
message(FATAL_ERROR "Identity capture must contain exactly 64 bytes")
endif()
string(TOLOWER "${identity_hex}" identity_hex)
string(SUBSTRING "${identity_hex}" 36 8 identity_vid_pid)
if(NOT identity_vid_pid STREQUAL "7e056620")
message(FATAL_ERROR "Identity capture must match Joy-Con 2 (R), 057e:2066")
endif()
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," identity_bytes "${identity_hex}")
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/probe_identity.h"
"// Generated from a private, read-only controller capture; do not commit.\n#include <stdint.h>\nstatic const uint8_t probe_identity_reply[64] = {${identity_bytes}};\n")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${SWITCH2_PROBE_IDENTITY_FILE}")
target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_IDENTITY_REPLY=1)
endif()
set(SWITCH2_PROBE_VERSION_FILE "" CACHE FILEPATH "Captured 12-byte Joy-Con 2 (R) firmware-version reply")
set(SWITCH2_PROBE_CONTROLLER_ADDRESS "" CACHE STRING "Advertised controller address (captured or distinct virtual identity)")
if(SWITCH2_PROBE_VERSION_FILE)
if(NOT SWITCH2_PROBE_IDENTITY_FILE)
message(FATAL_ERROR "Version response requires the matching identity capture")
endif()
file(READ "${SWITCH2_PROBE_VERSION_FILE}" version_hex LIMIT 13 HEX)
string(LENGTH "${version_hex}" version_length)
if(NOT version_length EQUAL 24)
message(FATAL_ERROR "Firmware version capture must contain exactly 12 bytes")
endif()
string(TOLOWER "${version_hex}" version_hex)
string(SUBSTRING "${version_hex}" 6 2 firmware_type)
if(NOT firmware_type STREQUAL "01")
message(FATAL_ERROR "Firmware version capture must describe Joy-Con 2 (R)")
endif()
string(REPLACE ":" "" address_hex "${SWITCH2_PROBE_CONTROLLER_ADDRESS}")
string(TOLOWER "${address_hex}" address_hex)
string(LENGTH "${address_hex}" address_length)
if(NOT address_length EQUAL 12 OR NOT address_hex MATCHES "^[0-9a-f]+$")
message(FATAL_ERROR "Provide a six-byte advertised controller Bluetooth address")
endif()
set(address_reversed "")
foreach(byte RANGE 0 5)
math(EXPR position "10 - 2 * ${byte}")
string(SUBSTRING "${address_hex}" ${position} 2 octet)
string(APPEND address_reversed "${octet}")
endforeach()
string(SUBSTRING "${version_hex}" 0 6 main_version)
string(SUBSTRING "${version_hex}" 8 6 bluetooth_version)
# Layout corroborated against two genuine USB vendor-02 responses and their
# matching command-10 version and command-15 address responses.
set(status_hex "${main_version}000000${bluetooth_version}00${address_reversed}")
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," status_bytes "${status_hex}")
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," firmware_bytes "${version_hex}")
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/probe_version.h"
"// Generated from private controller captures; do not commit.\n#include <stdint.h>\nstatic const uint8_t probe_version_reply[16] = {${status_bytes}};\nstatic const uint8_t probe_firmware_version[12] = {${firmware_bytes}};\n")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${SWITCH2_PROBE_VERSION_FILE}")
target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_VERSION_REPLY=1)
endif()
option(SWITCH2_PROBE_ACK_SETUP04 "Acknowledge the observed vendor-04 setup transaction" OFF)
if(SWITCH2_PROBE_ACK_SETUP04)
if(NOT SWITCH2_PROBE_IDENTITY_FILE OR NOT SWITCH2_PROBE_VERSION_FILE)
message(FATAL_ERROR "Setup acknowledgement requires both verified controller replies")
endif()
target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_ACK_SETUP04=1)
endif()
option(SWITCH2_PROBE_USB_INIT "Implement documented USB init and stream neutral diagnostic reports" OFF)
if(SWITCH2_PROBE_USB_INIT)
if(NOT SWITCH2_PROBE_ACK_SETUP04)
message(FATAL_ERROR "USB initialization probe requires the verified setup acknowledgement")
endif()
target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_USB_INIT=1)
endif()
set(SWITCH2_PROBE_FACTORY_FILE "" CACHE FILEPATH "8192-byte captured factory region with configured virtual identity")
set(SWITCH2_PROBE_USER_CALIBRATION_FILE "" CACHE FILEPATH "4096-byte captured user calibration region")
if(SWITCH2_PROBE_FACTORY_FILE OR SWITCH2_PROBE_USER_CALIBRATION_FILE)
if(NOT SWITCH2_PROBE_USB_INIT OR NOT SWITCH2_PROBE_FACTORY_FILE OR NOT SWITCH2_PROBE_USER_CALIBRATION_FILE)
message(FATAL_ERROR "Memory replies require initialized USB and both calibration captures")
endif()
file(READ "${SWITCH2_PROBE_FACTORY_FILE}" factory_hex LIMIT 8193 HEX)
file(READ "${SWITCH2_PROBE_USER_CALIBRATION_FILE}" user_calibration_hex LIMIT 4097 HEX)
string(LENGTH "${factory_hex}" factory_length)
string(LENGTH "${user_calibration_hex}" user_calibration_length)
if(NOT factory_length EQUAL 16384 OR NOT user_calibration_length EQUAL 8192)
message(FATAL_ERROR "Factory/user captures must contain exactly 8192/4096 bytes")
endif()
string(SUBSTRING "${factory_hex}" 0 128 factory_identity_hex)
if(NOT factory_identity_hex STREQUAL identity_hex)
message(FATAL_ERROR "Factory memory identity must match the vendor-control identity")
endif()
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," factory_bytes "${factory_hex}")
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," user_calibration_bytes "${user_calibration_hex}")
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/probe_memory_data.h"
"// Generated from private calibration captures; do not commit.\n#include <stdint.h>\nstatic const uint8_t probe_factory_memory[8192] = {${factory_bytes}};\nstatic const uint8_t probe_user_calibration[4096] = {${user_calibration_bytes}};\n")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${SWITCH2_PROBE_FACTORY_FILE}" "${SWITCH2_PROBE_USER_CALIBRATION_FILE}")
list(APPEND probe_sources ${SWITCH2_USB_PROBE_DIR}/memory.c)
target_compile_definitions(${target} PRIVATE SWITCH2_PROBE_MEMORY=1)
endif()
target_sources(${target} PRIVATE ${probe_sources})
set(probe_compile_options -Wall -Wextra -Werror)
if(SWITCH_PICO_SWITCH2_USB_BRIDGE)
# Preserve the root firmware's warning policy outside the probe sources.
set_source_files_properties(${probe_sources} PROPERTIES
COMPILE_OPTIONS "${probe_compile_options}")
else()
target_compile_options(${target} PRIVATE ${probe_compile_options})
endif()
target_link_libraries(${target} PRIVATE pico_stdlib hardware_uart hardware_sync
hardware_flash pico_flash pico_mbedtls_crypto pico_mbedtls_headers tinyusb_device)
pico_enable_stdio_usb(${target} 0)
pico_enable_stdio_uart(${target} 1)
if(SWITCH_PICO_SWITCH2_USB_BRIDGE)
pico_set_program_name(${target} "Switch 2 right Joy-Con Bluetooth bridge")
else()
pico_set_program_name(${target} "Switch 2 USB initialization capture")
endif()
pico_set_program_version(${target} "0.24")
endfunction()

View file

@ -0,0 +1,374 @@
#include "protocol.h"
#include "mbedtls/aes.h"
#include <string.h>
// Wire contracts: ndeadly/switch2_controller_research commands.md (03/0D,
// 03/0A, 07/01, 09/01-08, 16/01, 15/01-04) and hid_reports.md (05/08). USB reply headers
// and status payloads match captures/usb/rumble-procon-gccon.pcapng.gz.
// This public component is not a pairing key. The host supplies the other half.
static const uint8_t device_key_component[16] = {
0x5c, 0xf6, 0xee, 0x79, 0x2c, 0xdf, 0x05, 0xe1,
0xba, 0x2b, 0x63, 0x25, 0xc4, 0x1a, 0x5f, 0x10,
};
// Identical 11/03 payload in five genuine Joy-Con BLE captures; also present
// in the GameCube USB capture. Full semantics remain undocumented.
static const uint8_t joycon_info11_03[] = {
0x01, 0x20, 0x03, 0x00, 0x00, 0x0a, 0xe8, 0x1c,
0x3b, 0x79, 0x7d, 0x8b, 0x3a, 0x0a, 0xe8, 0x9c,
0x42, 0x58, 0xa0, 0x0b, 0x42, 0x0a, 0xe8, 0x9c,
0x41, 0x58, 0xa0, 0x0b, 0x41,
};
static void clear_pending_pairing(probe_protocol_state* state) {
state->pending_host_count = 0;
memset(state->pending_host_addresses, 0, sizeof(state->pending_host_addresses));
state->pending_key_valid = false;
memset(state->pending_key, 0, sizeof(state->pending_key));
state->challenge_confirmed = false;
}
static const uint8_t* pairing_key_for_context(const probe_protocol_state* state) {
if (!state->pending_host_count) return NULL;
if (state->pending_key_valid) return state->pending_key;
// Host addresses are key associations: order and subset size may change,
// but no new host may borrow the committed key without its own exchange.
for (unsigned i = 0; i < state->pending_host_count; ++i) {
bool stored = false;
for (unsigned j = 0; j < state->committed_host_count; ++j) {
if (memcmp(state->pending_host_addresses[i],
state->committed_host_addresses[j], 6) == 0) {
stored = true;
break;
}
}
if (!stored) return NULL;
}
return state->committed_key;
}
static bool challenge_response(const uint8_t* key, const uint8_t* wire_challenge,
uint8_t* response) {
uint8_t challenge[16];
for (unsigned i = 0; i < sizeof(challenge); ++i) {
challenge[i] = wire_challenge[sizeof(challenge) - 1u - i];
}
mbedtls_aes_context aes;
mbedtls_aes_init(&aes);
int result = mbedtls_aes_setkey_enc(&aes, key, 128);
if (result == 0) {
// Genuine challenge capture: reverse key/input, but NOT ciphertext.
result = mbedtls_aes_crypt_ecb(&aes, MBEDTLS_AES_ENCRYPT, challenge, response);
}
mbedtls_aes_free(&aes);
return result == 0;
}
static bool finalize_pairing(probe_protocol_state* state, const uint8_t* key) {
uint8_t blob[PROBE_PAIRING_BLOB_SIZE] = {0};
memcpy(blob, state->controller_address, sizeof(state->controller_address));
blob[6] = state->pending_host_count;
memcpy(blob + 7, state->pending_host_addresses, 6u * state->pending_host_count);
memcpy(blob + sizeof(blob) - 16u, key, 16);
// Preserve both the old committed key and pending retry on any save failure.
if (!state->save_pairing(blob, sizeof(blob))) return false;
state->committed_host_count = blob[6];
memcpy(state->committed_host_addresses, blob + 7, sizeof(state->committed_host_addresses));
memcpy(state->committed_key, blob + sizeof(blob) - 16u, sizeof(state->committed_key));
state->pending_key_valid = false;
memset(state->pending_key, 0, sizeof(state->pending_key));
// Keep this context confirmed so a repeated finalize can safely retry its ACK.
return true;
}
void probe_protocol_reset(probe_protocol_state* state) {
memset(state, 0, sizeof(*state));
state->report_id = 0x08;
state->right_stick_center[1] = 0x08;
state->right_stick_center[2] = 0x80;
}
bool probe_protocol_restore_pairing(probe_protocol_state* state,
const uint8_t* blob, size_t length) {
if (!state || !blob || length != PROBE_PAIRING_BLOB_SIZE ||
memcmp(state->controller_address, blob, sizeof(state->controller_address)) != 0 ||
blob[6] == 0 || blob[6] > PROBE_HOST_MAX_ADDRESSES) return false;
for (size_t i = 7u + 6u * blob[6]; i < length - 16u; ++i) {
if (blob[i] != 0) return false;
}
// Validate the whole record before changing either committed or pending state.
state->committed_host_count = blob[6];
memcpy(state->committed_host_addresses, blob + 7, sizeof(state->committed_host_addresses));
memcpy(state->committed_key, blob + length - 16u, sizeof(state->committed_key));
clear_pending_pairing(state);
return true;
}
size_t probe_protocol_command(probe_protocol_state* state, const uint8_t* command,
size_t length, uint8_t* reply, size_t capacity,
uint64_t* deferred_token) {
if (deferred_token) *deferred_token = 0;
if (!state || !command || !reply || length < 8 ||
command[1] != 0x91 || command[2] != 0 ||
command[4] != 0 || command[6] != 0 || command[7] != 0 ||
length != 8u + command[5]) return 0;
const bool initialize = command[0] == 0x03 && command[3] == 0x0d;
const bool select_report = command[0] == 0x03 && command[3] == 0x0a;
const bool status_query = (command[0] == 0x07 || command[0] == 0x16) && command[3] == 1;
const bool exchange_addresses = command[0] == 0x15 && command[3] == 1;
const bool confirm_key = command[0] == 0x15 && command[3] == 2;
const bool finalize = command[0] == 0x15 && command[3] == 3;
const bool exchange_keys = command[0] == 0x15 && command[3] == 4;
const bool power07 = command[0] == 0x0b && command[3] == 0x07;
const bool player_leds = command[0] == 0x09 && command[3] >= 1 && command[3] <= 8;
const bool features = command[0] == 0x0c && command[3] >= 1 && command[3] <= 5;
const bool memory_read = command[0] == 0x02 && (command[3] == 1 || command[3] == 4);
const bool info11_03 = command[0] == 0x11 && command[3] == 3;
const bool info11_01 = command[0] == 0x11 && command[3] == 1;
const bool vibration_setup = command[0] == 0x0a && command[3] == 8;
const bool vibration_sample = command[0] == 0x0a && command[3] == 2;
const bool joycon_query = command[0] == 0x13 && command[3] >= 1 && command[3] <= 3;
const bool firmware_info = command[0] == 0x10 && command[3] == 1;
const bool nfc_info = command[0] == 0x01 && command[3] == 0x0c;
const bool runtime_toggle = command[0] == 0x03 && command[3] == 0x0c;
uint32_t memory_address = 0;
uint8_t memory_length = 0;
size_t reply_length;
const uint8_t* pairing_key = NULL;
if (initialize) {
if (length != 16 || command[8] != 1 || command[9] != 0) return 0;
reply_length = 12;
} else if (select_report) {
if (length != 12) return 0;
reply_length = 8;
} else if (status_query) {
if (length != 8) return 0;
reply_length = command[0] == 0x07 ? 9 : 32;
} else if (exchange_addresses) {
if (length != 8 &&
(length < 10 || command[8] != 0 ||
command[9] > PROBE_HOST_MAX_ADDRESSES ||
length != 10u + 6u * command[9])) return 0;
reply_length = 17;
} else if (exchange_keys) {
if (length != 25 || command[8] != 0 || !state->pending_host_count) return 0;
reply_length = 25;
} else if (confirm_key) {
if (length != 25 || command[8] != 0) return 0;
pairing_key = pairing_key_for_context(state);
if (!pairing_key) return 0;
reply_length = 25;
} else if (finalize) {
if (length != 9 || command[8] != 0 || !state->challenge_confirmed ||
!state->save_pairing) return 0;
pairing_key = pairing_key_for_context(state);
if (!pairing_key) return 0;
reply_length = 9;
} else if (power07) {
// Donor BLE capture: 0b 01 01 07 10 78 00 00. Apply the previously
// corroborated USB header mapping. Semantics remain unknown: only the
// console's observed four-zero argument has been queried on the donor.
if (length != 12 || command[8] || command[9] || command[10] || command[11]) return 0;
reply_length = 8;
} else if (player_leds) {
if (command[3] <= 6) {
if (length != 8) return 0;
} else {
// Current console sends four payload bytes; published captures use
// eight. Only the first byte carries the mask/flashing setting.
if (length != 12 && length != 16) return 0;
if (command[3] == 8 && command[8] > 1) return 0;
}
reply_length = 8;
} else if (features) {
if (length != 12) return 0;
reply_length = command[3] == 1 ? 20 : 12;
} else if (memory_read) {
if (length != 16 || !state->read_memory || command[10] || command[11]) return 0;
if (command[3] == 1) {
if (command[8] || command[9]) return 0;
memory_length = 64;
} else {
if (command[9] != 0x7e || command[8] > 80) return 0;
memory_length = command[8];
}
for (unsigned i = 0; i < 4; ++i)
memory_address |= (uint32_t)command[12 + i] << (8 * i);
reply_length = 16u + memory_length;
} else if (info11_03 || info11_01) {
if (length != 8) return 0;
reply_length = info11_03 ? 8 + sizeof(joycon_info11_03) : 12;
} else if (vibration_setup) {
// Five genuine Joy-Con traces acknowledge this 20-byte parameter block
// with no response payload. Its first byte is consistently 1.
if (length != 28 || command[8] != 1) return 0;
reply_length = 8;
} else if (vibration_sample) {
if (length != 12 || command[8] > 7 ||
command[9] || command[10] || command[11] ||
!state->play_sample || !deferred_token) return 0;
reply_length = 8;
} else if (joycon_query) {
if (length != 8) return 0;
reply_length = command[3] == 1 ? 12 : 16;
} else if (firmware_info) {
if (length != 8 || !state->firmware_version) return 0;
reply_length = 20;
} else if (nfc_info) {
if (length != 8) return 0;
reply_length = 12;
} else if (runtime_toggle) {
if (length != 12 || command[8] > 1 || command[9] || command[10] || command[11]) return 0;
reply_length = 8;
} else {
return 0;
}
if (capacity < reply_length) return 0;
if (vibration_sample) {
uint64_t token = 0;
if (!state->play_sample(command[8], &token) || !token) return 0;
*deferred_token = token;
}
uint8_t encrypted_challenge[16];
if (confirm_key && !challenge_response(pairing_key, command + 9, encrypted_challenge)) return 0;
if (finalize && !finalize_pairing(state, pairing_key)) return 0;
if (memory_read &&
!state->read_memory(memory_address, reply + 16, memory_length)) return 0;
const uint8_t header[] = {command[0], 0x01, 0, command[3], 0, 0xf8, 0, 0};
memcpy(reply, header, sizeof(header));
if (info11_03) {
memcpy(reply + 8, joycon_info11_03, sizeof(joycon_info11_03));
} else if (firmware_info) {
memcpy(reply + 8, state->firmware_version, 12);
} else if (nfc_info) {
// Identical in five Joy-Con captures; Pro's last byte differs.
const uint8_t info[] = {0x61, 0x12, 0x50, 0x0d};
memcpy(reply + 8, info, sizeof(info));
} else {
// Memory payload was supplied directly into the reply; initialize only its metadata.
memset(reply + 8, 0, memory_read ? 8 : reply_length - 8);
}
if (initialize) {
memcpy(state->host_address, command + 10, sizeof(state->host_address));
state->initialized = true;
// Retransmission is idempotent: do not reset an already-running counter.
reply[8] = 1;
} else if (select_report) {
// The real controller acknowledges but ignores unsupported report IDs.
if (command[8] == 0x05 || command[8] == 0x08) state->report_id = command[8];
} else if (exchange_addresses) {
if (length != 8) {
clear_pending_pairing(state);
state->pending_host_count = command[9];
memcpy(state->pending_host_addresses, command + 10, 6u * command[9]);
}
reply[8] = 1;
reply[9] = 4; // Observed address-response field; semantics unresolved.
reply[10] = 1;
memcpy(reply + 11, state->controller_address, sizeof(state->controller_address));
} else if (exchange_keys) {
for (unsigned i = 0; i < sizeof(state->pending_key); ++i) {
const unsigned wire_index = sizeof(state->pending_key) - 1u - i;
state->pending_key[i] = command[9 + wire_index] ^ device_key_component[wire_index];
}
state->pending_key_valid = true;
state->challenge_confirmed = false;
reply[8] = 1;
memcpy(reply + 9, device_key_component, sizeof(device_key_component));
} else if (confirm_key) {
reply[8] = 1;
memcpy(reply + 9, encrypted_challenge, sizeof(encrypted_challenge));
state->challenge_confirmed = true;
} else if (finalize) {
reply[8] = 1;
} else if (player_leds) {
if (command[3] <= 4) {
state->player_leds = (uint8_t)(1u << (command[3] - 1));
} else if (command[3] == 5) {
state->player_leds = 0x0f;
} else if (command[3] == 6) {
state->player_leds = 0;
} else if (command[3] == 7) {
state->player_leds = command[8] & 0x0f;
} else {
state->player_leds_flashing = command[8] != 0;
}
} else if (features) {
const uint8_t flags = command[8] & 0xb7; // Bits 3 and 6 are unused.
if (command[3] == 1) {
// Published Joy-Con-specific feature-info encoding; first four
// response bytes and final two feature-info bytes remain zero.
reply[12] = flags & 0x01 ? 7 : 0;
reply[13] = flags & 0x02 ? 7 : 0;
reply[14] = flags & 0x04 ? 3 : 0;
reply[15] = flags & 0x80 ? 3 : 0;
reply[16] = flags & 0x10 ? 3 : 0;
reply[17] = flags & 0x20 ? 3 : 0;
} else if (command[3] == 2) {
state->feature_mask = flags;
state->enabled_features &= flags;
} else if (command[3] == 3) {
state->feature_mask = state->enabled_features = 0;
} else if (command[3] == 4) {
state->enabled_features |= flags & state->feature_mask;
} else {
state->enabled_features &= (uint8_t)~(flags & state->feature_mask);
}
} else if (memory_read) {
reply[8] = memory_length;
for (unsigned i = 0; i < 4; ++i)
reply[12 + i] = (uint8_t)(memory_address >> (8 * i));
} else if (vibration_setup) {
memcpy(state->vibration_parameters, command + 8, sizeof(state->vibration_parameters));
state->vibration_parameters_set = true;
} else if (info11_01) {
// Identical 01 00 00 00 payload in all five genuine Joy-Con captures.
reply[8] = 1;
} else if (joycon_query) {
// Published 13/01-03 replies: leading 1, then reserved zero bytes.
// These queries' full semantics are still undocumented.
reply[8] = 1;
} else if (runtime_toggle) {
state->runtime03_0c = command[8] != 0;
}
return reply_length;
}
size_t probe_protocol_report(const probe_protocol_state* state, uint8_t report_id,
uint8_t* output, size_t capacity) {
if (!state || !state->initialized || !output || capacity < PROBE_INPUT_SIZE ||
(report_id != 0x05 && report_id != 0x08)) return 0;
memset(output, 0, PROBE_INPUT_SIZE);
const bool buttons_enabled = (state->enabled_features & 1) != 0;
const uint8_t buttons0 = state->controller_active && buttons_enabled ? state->controller_buttons[0] : 0;
const uint8_t buttons1 = state->controller_active && buttons_enabled ? state->controller_buttons[1] & 0xd1 : 0;
const uint8_t* stick = state->controller_active && (state->enabled_features & 2) ?
state->controller_stick : state->right_stick_center;
if (report_id == 0x08) {
output[0] = (uint8_t)state->report_counter;
output[1] = 0x25; // Virtual full battery, external USB power.
output[2] = buttons0;
output[3] = buttons1;
if (state->test_rail_buttons && (state->enabled_features & 1))
output[3] |= 0xc0; // Joy-Con R native SL + SR.
output[4] = 0x07;
memcpy(output + 5, stick, 3);
// Diagnostic snapshot only; complete live native packets bypass this generator.
} else {
for (unsigned i = 0; i < 4; ++i) output[i] = (uint8_t)(state->report_counter >> (8 * i));
output[4] = (uint8_t)(((buttons0 & 0x03) << 2) | ((buttons0 & 0x0c) >> 2) |
((buttons0 & 0x30) << 2) | ((buttons1 & 0xc0) >> 2));
output[5] = (uint8_t)(((buttons0 & 0xc0) >> 5) | ((buttons1 & 0x01) << 4) |
((buttons1 & 0x10) << 2));
if (state->test_rail_buttons && (state->enabled_features & 1))
output[4] |= 0x30; // Common report: right SL + SR.
output[11] = 0x08;
output[12] = 0x80;
memcpy(output + 13, stick, 3);
output[31] = 0xa0;
output[32] = 0x0f; // Virtual battery voltage 4000mV.
output[33] = 0x20;
output[41] = 1;
}
return PROBE_INPUT_SIZE;
}

View file

@ -0,0 +1,68 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#define PROBE_COMMAND_MAX_SIZE 263u
#define PROBE_REPLY_MAX_SIZE 96u
#define PROBE_HOST_MAX_ADDRESSES ((255u - 2u) / 6u)
#define PROBE_PAIRING_BLOB_SIZE (6u + 1u + PROBE_HOST_MAX_ADDRESSES * 6u + 16u)
#define PROBE_INPUT_SIZE 63u
typedef struct {
bool initialized;
uint8_t report_id;
bool test_rail_buttons;
bool runtime03_0c; // Observed USB toggle; full semantics remain unknown.
uint8_t right_stick_center[3];
bool controller_active;
uint8_t controller_buttons[2]; // Native right Joy-Con button ordering.
uint8_t controller_stick[3]; // Raw packed 12-bit axes from the selected donor.
uint8_t player_leds; // Virtual four-LED mask, exposed through UART diagnostics.
bool player_leds_flashing;
// Negotiated virtual features; relative mouse events belong to the USB sender.
uint8_t feature_mask;
uint8_t enabled_features;
bool vibration_parameters_set;
uint8_t vibration_parameters[20]; // Captured 0A/08 format; no motor output.
uint8_t host_address[6];
uint8_t controller_address[6]; // Own virtual identity, wire byte order (LE).
const uint8_t* firmware_version; // Twelve captured bytes; caller retains their lifetime.
// Every payload-bearing address exchange starts a fresh pending context.
uint8_t pending_host_count;
uint8_t pending_host_addresses[PROBE_HOST_MAX_ADDRESSES][6];
bool pending_key_valid;
uint8_t pending_key[16]; // Standard AES byte order, not wire byte order.
bool challenge_confirmed;
// A zero count means no committed pairing record.
uint8_t committed_host_count;
uint8_t committed_host_addresses[PROBE_HOST_MAX_ADDRESSES][6];
uint8_t committed_key[16]; // Standard AES byte order.
// Synchronous durable save; NULL disables successful finalization.
bool (*save_pairing)(const uint8_t* blob, size_t length);
bool (*read_memory)(uint32_t address, uint8_t* output, size_t length);
// Queue a physical sample, returning true only with a nonzero completion token.
// Acceptance is not a Bluetooth application ACK.
bool (*play_sample)(uint8_t sample_id, uint64_t* token);
uint32_t report_counter;
} probe_protocol_state;
void probe_protocol_reset(probe_protocol_state* state);
// Blob: own address[6], count[1], zero-padded host addresses[42][6], AES key[16].
// Rejects other identities, invalid counts/padding/lengths without mutation.
// A successful restore replaces the committed record and clears pending state.
bool probe_protocol_restore_pairing(probe_protocol_state* state,
const uint8_t* blob, size_t length);
// Complete command frames only. Unsupported/malformed commands return zero
// and do not mutate state. Pairing finalization requires a successful save.
// When non-NULL, deferred_token is cleared before validation. Synchronous replies
// leave it zero. Sample 0A/02 requires this output and play_sample; acceptance
// prepares an 8-byte reply and returns its nonzero source completion token.
// Those bytes MUST NOT be transmitted until that token has a genuine positive
// Bluetooth application ACK. Failure, cancellation or expiry must discard them.
size_t probe_protocol_command(probe_protocol_state* state, const uint8_t* command,
size_t length, uint8_t* reply, size_t capacity,
uint64_t* deferred_token);
// Button/stick snapshot without relative mouse events; safe for GET_REPORT.
size_t probe_protocol_report(const probe_protocol_state* state, uint8_t report_id,
uint8_t* output, size_t capacity);

View file

@ -0,0 +1,373 @@
#include "storage.h"
#include <string.h>
#include "configuration/configuration_storage.h"
#include "hardware/flash.h"
#include "pico/btstack_flash_bank.h"
#include "pico/flash.h"
#include "pico/platform.h"
#include "profile/profile_storage.h"
extern "C" char __flash_binary_end;
namespace {
constexpr size_t kSlotCount = 2;
constexpr size_t kMaximumPayloadSize = 512;
constexpr size_t kStorageSize = kSlotCount * FLASH_SECTOR_SIZE;
constexpr size_t kConfigurationStorageSize =
CONFIGURATION_STORAGE_COPY_COUNT * FLASH_SECTOR_SIZE;
constexpr size_t kConfigurationStorageOffset =
PICO_FLASH_BANK_STORAGE_OFFSET - kConfigurationStorageSize;
constexpr size_t kProfileStorageOffset =
kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE;
constexpr uint32_t kStorageOffset = kProfileStorageOffset - kStorageSize;
constexpr uint32_t kFlashSafeTimeoutMs = 5000;
constexpr uint32_t kFormatVersion = 1;
// Each sector owns one record. Integers are little-endian; all padding is ff.
// Page 0: magic[16], version:u32, absolute sector offset:u32, maximum payload:u32,
// page size:u32, sector size:u32, CRC32(bytes 0..35):u32, padding.
// Page 1 starts the body: magic[8], generation:u32, ~generation:u32,
// length:u32, payload CRC32:u32, header size:u32, header CRC32:u32,
// payload[length], padding to the fixed body-area boundary.
// The separate commit page is programmed LAST: magic[16], generation:u32,
// header CRC32:u32, payload CRC32:u32, length:u32, sector offset:u32,
// CRC32(bytes 0..35):u32, padding. The rest of the sector stays erased.
// CRC32 is the project's IEEE CRC32 (also compatible with zlib.crc32).
constexpr uint8_t kOwnerMagic[16] = {
'S', '2', 'P', 'R', 'O', 'B', 'E', '-',
'P', 'A', 'I', 'R', 'I', 'N', 'G', 0,
};
constexpr uint8_t kBodyMagic[8] = {'S', '2', 'P', 'A', 'I', 'R', '0', '1'};
constexpr uint8_t kCommitMagic[16] = {
'S', '2', 'P', 'A', 'I', 'R', '-', 'C',
'O', 'M', 'M', 'I', 'T', 'T', 'E', 'D',
};
constexpr size_t kDescriptorCrcOffset = 36;
constexpr size_t kDescriptorSize = kDescriptorCrcOffset + sizeof(uint32_t);
constexpr size_t kBodyOffset = FLASH_PAGE_SIZE;
constexpr size_t kBodyHeaderCrcOffset = 28;
constexpr size_t kBodyHeaderSize = kBodyHeaderCrcOffset + sizeof(uint32_t);
constexpr size_t kBodySize =
((kBodyHeaderSize + kMaximumPayloadSize + FLASH_PAGE_SIZE - 1) /
FLASH_PAGE_SIZE) * FLASH_PAGE_SIZE;
constexpr size_t kPayloadOffset = kBodyOffset + kBodyHeaderSize;
constexpr size_t kCommitOffset = kBodyOffset + kBodySize;
constexpr size_t kRecordFootprint = kCommitOffset + FLASH_PAGE_SIZE;
static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE);
static_assert(FLASH_PAGE_SIZE == PROFILE_STORAGE_PAGE_SIZE);
static_assert(FLASH_SECTOR_SIZE % FLASH_PAGE_SIZE == 0);
static_assert(kDescriptorSize <= FLASH_PAGE_SIZE);
static_assert(kRecordFootprint <= FLASH_SECTOR_SIZE);
static_assert(PROFILE_STORAGE_TOTAL_SIZE % FLASH_SECTOR_SIZE == 0);
static_assert(PICO_FLASH_BANK_STORAGE_OFFSET % FLASH_SECTOR_SIZE == 0);
static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >=
kConfigurationStorageSize + PROFILE_STORAGE_TOTAL_SIZE +
kStorageSize,
"pairing storage offset underflows flash");
static_assert(kStorageOffset + kStorageSize == kProfileStorageOffset);
static_assert(kProfileStorageOffset + PROFILE_STORAGE_TOTAL_SIZE ==
kConfigurationStorageOffset);
static_assert(kConfigurationStorageOffset + kConfigurationStorageSize ==
PICO_FLASH_BANK_STORAGE_OFFSET);
static_assert(PICO_FLASH_BANK_STORAGE_OFFSET <= PICO_FLASH_SIZE_BYTES);
static_assert(PICO_FLASH_BANK_TOTAL_SIZE <=
PICO_FLASH_SIZE_BYTES - PICO_FLASH_BANK_STORAGE_OFFSET,
"BTstack storage exceeds flash");
// Avoid a large USB callback stack frame. All program sources, including any
// input originally backed by XIP, are staged before the first flash mutation.
alignas(FLASH_PAGE_SIZE) uint8_t staging[kRecordFootprint];
enum class SlotKind { Erased, Unknown, OwnedIncomplete, Committed };
struct Slot {
SlotKind kind;
const uint8_t *bytes;
uint32_t generation;
size_t size;
};
struct FlashMutation {
uint32_t offset;
const uint8_t *page; // Null means erase one sector.
};
uint32_t read_u32(const uint8_t *input) {
return static_cast<uint32_t>(input[0]) |
(static_cast<uint32_t>(input[1]) << 8) |
(static_cast<uint32_t>(input[2]) << 16) |
(static_cast<uint32_t>(input[3]) << 24);
}
void write_u32(uint8_t *output, uint32_t value) {
output[0] = static_cast<uint8_t>(value);
output[1] = static_cast<uint8_t>(value >> 8);
output[2] = static_cast<uint8_t>(value >> 16);
output[3] = static_cast<uint8_t>(value >> 24);
}
bool is_erased(const uint8_t *bytes, size_t size) {
for (size_t index = 0; index < size; ++index) {
if (bytes[index] != 0xff) {
return false;
}
}
return true;
}
bool storage_region_available() {
const uintptr_t binary_end = reinterpret_cast<uintptr_t>(&__flash_binary_end);
return binary_end >= XIP_BASE &&
binary_end - XIP_BASE <= kStorageOffset &&
kStorageOffset % FLASH_SECTOR_SIZE == 0 &&
kStorageOffset <= PICO_FLASH_SIZE_BYTES &&
kStorageSize <= PICO_FLASH_SIZE_BYTES - kStorageOffset &&
kStorageOffset + kStorageSize == kProfileStorageOffset;
}
uint32_t slot_offset(size_t slot) {
return static_cast<uint32_t>(kStorageOffset + slot * FLASH_SECTOR_SIZE);
}
const uint8_t *slot_bytes(size_t slot) {
return reinterpret_cast<const uint8_t *>(XIP_BASE + slot_offset(slot));
}
bool owner_valid(const uint8_t *bytes, uint32_t offset) {
return memcmp(bytes, kOwnerMagic, sizeof(kOwnerMagic)) == 0 &&
read_u32(bytes + 16) == kFormatVersion &&
read_u32(bytes + 20) == offset &&
read_u32(bytes + 24) == kMaximumPayloadSize &&
read_u32(bytes + 28) == FLASH_PAGE_SIZE &&
read_u32(bytes + 32) == FLASH_SECTOR_SIZE &&
read_u32(bytes + kDescriptorCrcOffset) ==
configuration_crc32(bytes, kDescriptorCrcOffset) &&
is_erased(bytes + kDescriptorSize,
FLASH_PAGE_SIZE - kDescriptorSize);
}
bool body_valid(const uint8_t *bytes) {
const uint8_t *body = bytes + kBodyOffset;
const size_t size = read_u32(body + 16);
return memcmp(body, kBodyMagic, sizeof(kBodyMagic)) == 0 &&
read_u32(body + 12) == ~read_u32(body + 8) &&
size != 0 && size <= kMaximumPayloadSize &&
read_u32(body + 24) == kBodyHeaderSize &&
read_u32(body + kBodyHeaderCrcOffset) ==
configuration_crc32(body, kBodyHeaderCrcOffset) &&
read_u32(body + 20) ==
configuration_crc32(bytes + kPayloadOffset, size) &&
is_erased(bytes + kPayloadOffset + size,
kBodySize - kBodyHeaderSize - size);
}
bool commit_valid(const uint8_t *bytes, uint32_t offset) {
const uint8_t *body = bytes + kBodyOffset;
const uint8_t *commit = bytes + kCommitOffset;
return memcmp(commit, kCommitMagic, sizeof(kCommitMagic)) == 0 &&
read_u32(commit + 16) == read_u32(body + 8) &&
read_u32(commit + 20) == read_u32(body + kBodyHeaderCrcOffset) &&
read_u32(commit + 24) == read_u32(body + 20) &&
read_u32(commit + 28) == read_u32(body + 16) &&
read_u32(commit + 32) == offset &&
read_u32(commit + kDescriptorCrcOffset) ==
configuration_crc32(commit, kDescriptorCrcOffset) &&
is_erased(commit + kDescriptorSize,
FLASH_PAGE_SIZE - kDescriptorSize);
}
Slot inspect_slot(size_t index) {
const uint8_t *bytes = slot_bytes(index);
Slot slot{SlotKind::Unknown, bytes, 0, 0};
if (!owner_valid(bytes, slot_offset(index))) {
if (is_erased(bytes, FLASH_SECTOR_SIZE)) {
slot.kind = SlotKind::Erased;
}
// A torn ownership page or erase is deliberately NOT guessed to be
// ours. Recovery may need an externally verified backup in that case.
return slot;
}
if (!is_erased(bytes + kRecordFootprint,
FLASH_SECTOR_SIZE - kRecordFootprint)) {
return slot;
}
// A complete ownership page plus an erased tail proves ownership of the
// bounded body/commit area, even if either subsequent write was interrupted.
slot.kind = SlotKind::OwnedIncomplete;
if (body_valid(bytes) && commit_valid(bytes, slot_offset(index))) {
slot.kind = SlotKind::Committed;
slot.generation = read_u32(bytes + kBodyOffset + 8);
slot.size = read_u32(bytes + kBodyOffset + 16);
}
return slot;
}
bool newest_slot(const Slot (&slots)[kSlotCount], int *index) {
*index = -1;
for (size_t candidate = 0; candidate < kSlotCount; ++candidate) {
if (slots[candidate].kind != SlotKind::Committed) {
continue;
}
if (*index < 0) {
*index = static_cast<int>(candidate);
continue;
}
const Slot &current = slots[*index];
const Slot &next = slots[candidate];
const uint32_t difference = next.generation - current.generation;
if (difference == 0) {
if (next.size != current.size ||
memcmp(next.bytes + kPayloadOffset,
current.bytes + kPayloadOffset, next.size) != 0) {
return false; // Conflicting records with no ordering.
}
} else if (difference == 0x80000000u) {
return false; // Exactly half a generation cycle is ambiguous.
} else if (difference < 0x80000000u) {
*index = static_cast<int>(candidate);
}
}
return true;
}
void perform_flash_mutation(void *context) {
const auto *mutation = static_cast<const FlashMutation *>(context);
if (mutation->page == nullptr) {
flash_range_erase(mutation->offset, FLASH_SECTOR_SIZE);
} else {
flash_range_program(mutation->offset, mutation->page, FLASH_PAGE_SIZE);
}
}
// The caller has classified BOTH sectors before permitting any erase. Only
// the inactive, explicitly owned sector is passed here; the active one survives.
bool erase_slot(size_t index) {
if (index >= kSlotCount || !storage_region_available()) {
return false;
}
FlashMutation mutation{slot_offset(index), nullptr};
return flash_safe_execute(perform_flash_mutation, &mutation,
kFlashSafeTimeoutMs) == PICO_OK &&
is_erased(slot_bytes(index), FLASH_SECTOR_SIZE);
}
bool program_page(size_t index, size_t offset, const uint8_t *page) {
if (index >= kSlotCount || offset % FLASH_PAGE_SIZE != 0 ||
offset > kRecordFootprint - FLASH_PAGE_SIZE ||
!storage_region_available() ||
!is_erased(slot_bytes(index) + offset, FLASH_PAGE_SIZE)) {
return false;
}
FlashMutation mutation{
static_cast<uint32_t>(slot_offset(index) + offset), page,
};
return flash_safe_execute(perform_flash_mutation, &mutation,
kFlashSafeTimeoutMs) == PICO_OK &&
memcmp(slot_bytes(index) + offset, page, FLASH_PAGE_SIZE) == 0;
}
void prepare_record(size_t target, uint32_t generation,
const uint8_t *data, size_t size) {
memset(staging, 0xff, sizeof(staging));
memcpy(staging, kOwnerMagic, sizeof(kOwnerMagic));
write_u32(staging + 16, kFormatVersion);
write_u32(staging + 20, slot_offset(target));
write_u32(staging + 24, kMaximumPayloadSize);
write_u32(staging + 28, FLASH_PAGE_SIZE);
write_u32(staging + 32, FLASH_SECTOR_SIZE);
write_u32(staging + kDescriptorCrcOffset,
configuration_crc32(staging, kDescriptorCrcOffset));
uint8_t *body = staging + kBodyOffset;
memcpy(body, kBodyMagic, sizeof(kBodyMagic));
write_u32(body + 8, generation);
write_u32(body + 12, ~generation);
write_u32(body + 16, static_cast<uint32_t>(size));
memcpy(staging + kPayloadOffset, data, size);
const uint32_t payload_crc = configuration_crc32(staging + kPayloadOffset, size);
write_u32(body + 20, payload_crc);
write_u32(body + 24, kBodyHeaderSize);
const uint32_t header_crc = configuration_crc32(body, kBodyHeaderCrcOffset);
write_u32(body + kBodyHeaderCrcOffset, header_crc);
uint8_t *commit = staging + kCommitOffset;
memcpy(commit, kCommitMagic, sizeof(kCommitMagic));
write_u32(commit + 16, generation);
write_u32(commit + 20, header_crc);
write_u32(commit + 24, payload_crc);
write_u32(commit + 28, static_cast<uint32_t>(size));
write_u32(commit + 32, slot_offset(target));
write_u32(commit + kDescriptorCrcOffset,
configuration_crc32(commit, kDescriptorCrcOffset));
}
} // namespace
bool probe_storage_load(uint8_t *output, size_t size) {
if (output == nullptr || size == 0 || size > kMaximumPayloadSize ||
!storage_region_available()) {
return false;
}
const Slot slots[kSlotCount] = {inspect_slot(0), inspect_slot(1)};
int active;
if (!newest_slot(slots, &active) || active < 0 || slots[active].size != size) {
return false;
}
memcpy(output, slots[active].bytes + kPayloadOffset, size);
return true;
}
bool probe_storage_save(const uint8_t *data, size_t size) {
if (data == nullptr || size == 0 || size > kMaximumPayloadSize ||
!storage_region_available()) {
return false;
}
const Slot slots[kSlotCount] = {inspect_slot(0), inspect_slot(1)};
if (slots[0].kind == SlotKind::Unknown || slots[1].kind == SlotKind::Unknown) {
return false; // Never erase through an unrecognized region.
}
int active;
if (!newest_slot(slots, &active)) {
return false;
}
if (active >= 0 && slots[active].size == size &&
memcmp(slots[active].bytes + kPayloadOffset, data, size) == 0) {
return true;
}
const size_t target = active >= 0
? static_cast<size_t>(active) ^ 1u
: (slots[0].kind == SlotKind::Erased ? 0u : 1u);
const uint32_t generation = active >= 0 ? slots[active].generation + 1u : 1u;
prepare_record(target, generation, data, size);
if (slots[target].kind != SlotKind::Erased && !erase_slot(target)) {
return false;
}
if (!program_page(target, 0, staging)) {
return false;
}
for (size_t offset = kBodyOffset; offset < kCommitOffset;
offset += FLASH_PAGE_SIZE) {
if (!is_erased(staging + offset, FLASH_PAGE_SIZE) &&
!program_page(target, offset, staging + offset)) {
return false;
}
}
if (!body_valid(slot_bytes(target)) ||
!program_page(target, kCommitOffset, staging + kCommitOffset)) {
return false;
}
const Slot committed = inspect_slot(target);
return committed.kind == SlotKind::Committed &&
committed.generation == generation && committed.size == size &&
memcmp(committed.bytes + kPayloadOffset,
staging + kPayloadOffset, size) == 0;
}
uint32_t probe_storage_offset(void) {
return kStorageOffset;
}

View file

@ -0,0 +1,25 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// Synchronous, main-loop-only API for the single-core probe. Serialize calls.
// Blobs are opaque, nonempty, and at most 512 bytes. Load requires an exact
// length match and leaves output unchanged on failure; it never writes flash.
bool probe_storage_load(uint8_t *output, size_t size);
// Success means an identical blob was already committed, or a replacement was
// committed and read back. Failure never authorizes a protocol acknowledgement.
bool probe_storage_save(const uint8_t *data, size_t size);
// Flash-relative offset of the two sectors immediately below profile storage.
uint32_t probe_storage_offset(void);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,31 @@
#pragma once
#define CFG_TUSB_RHPORT0_MODE (OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED)
#ifndef CFG_TUSB_OS
#define CFG_TUSB_OS OPT_OS_NONE
#endif
#define CFG_TUD_ENDPOINT0_SIZE 64
#define CFG_TUD_HID 1
#define CFG_TUD_HID_EP_BUFSIZE 64
#define CFG_TUD_CDC 0
#define CFG_TUD_MSC 0
#define CFG_TUD_MIDI 0
#define CFG_TUD_VENDOR 1
#define CFG_TUD_VENDOR_EPSIZE 64
#define CFG_TUD_VENDOR_RX_BUFSIZE 256
#define CFG_TUD_VENDOR_TX_BUFSIZE 256
#ifdef CFG_TUSB_DEBUG
#undef CFG_TUSB_DEBUG
#endif
// Packet-level SDK logging would overflow 115200 baud while streaming input.
// Requests, command payloads, errors and aggregate counters are logged explicitly.
#define CFG_TUSB_DEBUG 1
#define CFG_TUSB_DEBUG_PRINTF probe_debug_printf
#ifdef __cplusplus
extern "C" {
#endif
int probe_debug_printf(const char* format, ...);
#ifdef __cplusplus
}
#endif