feat(native-usb): route gameplay rumble and attach only after startup readiness

This commit is contained in:
Joey Yakimowich-Payne 2026-09-19 08:20:47 -06:00
commit ae1ca51b9a
18 changed files with 1158 additions and 43 deletions

View file

@ -489,6 +489,17 @@ extern "C" void probe_controller_input_set_full_stick_calibration(
uint8_t instance, const uint8_t calibration[9]) {
probe_native_gamepad_input_set_stick_calibration(instance, calibration);
}
extern "C" bool probe_controller_input_submit_rumble(
uint8_t instance, const uint8_t* magnitudes, uint8_t count) {
return g_flash_ready && instance < PROBE_CONTROLLER_COUNT &&
bluepad32_input_backend_native_rumble_submit(instance, magnitudes, count);
}
extern "C" void probe_controller_input_cancel_rumble(uint8_t instance) {
if (instance < PROBE_CONTROLLER_COUNT)
bluepad32_input_backend_native_rumble_cancel(instance);
}
#endif
extern "C" void probe_controller_input_set_native_stream(uint8_t instance, bool enabled) {

View file

@ -51,6 +51,11 @@ void probe_controller_input_set_native_features(uint8_t features);
// Supply each child's advertised, validated nine-byte stick record. Native
// output stays unavailable until that child's calibration has been supplied.
void probe_controller_input_set_full_stick_calibration(uint8_t instance, const uint8_t calibration[9]);
// Native single-actuator gameplay envelope for this virtual half. Backend owns
// binding/generation checks, profile gain, finite playback and stale-stream stop.
bool probe_controller_input_submit_rumble(uint8_t instance, const uint8_t* magnitudes,
uint8_t count);
void probe_controller_input_cancel_rumble(uint8_t instance);
#endif
// Core0 native07/08 output. Disable discards queued/prepared data; repeated
// enable preserves it. Joy-Con mode relays its bounded FIFO; right-only Wii
@ -66,7 +71,7 @@ uint32_t probe_controller_input_peek_native_report(uint8_t instance, uint32_t no
// 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(uint8_t instance, uint32_t serial);
// Built-in vibration samples only; raw HD-rumble output is not forwarded.
// Built-in vibration samples; separate from full-controller gameplay envelopes.
// A nonzero token means queued, not completed. Result:0 pending,1 completion,
// -1 failed/stale. Joy-Con completion is its application ACK; Wii/DualSense
// completion is actual bounded rumble-driver dispatch, not a source application

View file

@ -79,6 +79,9 @@ typedef struct {
bool native_stream_ready;
uint32_t last_hid_complete_ms;
bool hid_completion_seen;
#if SWITCH2_BRIDGE_FULL_INPUT
bool gameplay_rumble_seen;
#endif
uint32_t mouse_delivered_reports, mouse_logged_reports;
int64_t mouse_delivered_x, mouse_delivered_y;
#ifdef SWITCH2_PROBE_TRACE_NATIVE_INPUT
@ -298,6 +301,21 @@ 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;
#if defined(SWITCH2_PROBE_USB_INIT) && SWITCH2_BRIDGE_FULL_INPUT
if (instance < PROBE_CONTROLLER_COUNT && report_type == HID_REPORT_TYPE_OUTPUT &&
controllers[instance].protocol.initialized &&
probe_transport_mounted(instance) && !probe_transport_suspended(instance)) {
probe_rumble_frame frame;
if (probe_protocol_decode_rumble(report_id, buffer, length, &frame)) {
// Count-zero HOLD leaves both motor state and watchdog untouched.
// Valid gameplay traffic must not fill the slow UART log ring.
if (frame.count && probe_controller_input_submit_rumble(
instance, frame.magnitude, frame.count))
controllers[instance].gameplay_rumble_seen = true;
return;
}
}
#endif
probe_debug_printf("[PROBE] HID_REPORT_TYPE=%u\n", report_type);
log_packet("HID_OUT", instance, report_id, buffer, length);
}
@ -329,6 +347,9 @@ static void reset_controller_protocol(uint8_t instance) {
probe_usb_controller* controller = &controllers[instance];
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_controller_input_cancel_sample(instance);
#if SWITCH2_BRIDGE_FULL_INPUT
probe_controller_input_cancel_rumble(instance);
#endif
probe_controller_input_set_native_stream(instance, false);
#endif
memset(controller, 0, sizeof(*controller));
@ -483,8 +504,16 @@ static void controller_input_task(probe_usb_controller* controller, uint32_t now
probe_protocol_state* protocol = &controller->protocol;
probe_controller_input source = {0};
probe_controller_input_poll(instance, now, &source);
const bool output_active = source.active && probe_transport_mounted(instance) &&
!probe_transport_suspended(instance);
const bool usb_active = probe_transport_mounted(instance) &&
!probe_transport_suspended(instance);
const bool output_active = source.active && usb_active;
#if SWITCH2_BRIDGE_FULL_INPUT
if (!usb_active) probe_controller_input_cancel_sample(instance);
if (controller->gameplay_rumble_seen && (!output_active || !protocol->initialized)) {
probe_controller_input_cancel_rumble(instance);
controller->gameplay_rumble_seen = false;
}
#endif
if (protocol->controller_active != output_active)
probe_debug_printf("[PROBE] Controller input itf=%u %s\n",
instance, output_active ? "active" : "neutral (disconnected/stale)");
@ -783,6 +812,11 @@ void tud_suspend_cb(bool remote_wakeup_en) {
controller->protocol.controller_active = false;
#ifdef SWITCH_PICO_SWITCH2_USB_BRIDGE
probe_controller_input_set_native_stream(instance, false);
#if SWITCH2_BRIDGE_FULL_INPUT
probe_controller_input_cancel_sample(instance);
probe_controller_input_cancel_rumble(instance);
controller->gameplay_rumble_seen = false;
#endif
controller->native_stream_ready = false;
controller->hid_completion_seen = false;
#endif

View file

@ -417,27 +417,27 @@ function(switch2_usb_probe_configure target)
endif()
if(SWITCH2_PROBE_NEUTRAL_INPUT)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.97-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace")
pico_set_program_version(${target} "0.102-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair-trace")
else()
pico_set_program_version(${target} "0.97-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair")
pico_set_program_version(${target} "0.102-neutral-hub-${SWITCH2_PROBE_PAIR_COUNT}pair")
endif()
elseif(SWITCH2_PROBE_HUB AND SWITCH2_BRIDGE_FULL_INPUT)
if(probe_controller_count GREATER 2)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.97-live-two-pair-trace")
pico_set_program_version(${target} "0.102-live-two-pair-trace")
else()
pico_set_program_version(${target} "0.97-live-two-pair")
pico_set_program_version(${target} "0.102-live-two-pair")
endif()
elseif(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.97-native-digital-stick-trace")
pico_set_program_version(${target} "0.102-native-digital-stick-trace")
else()
pico_set_program_version(${target} "0.97-native-digital-stick")
pico_set_program_version(${target} "0.102-native-digital-stick")
endif()
elseif(SWITCH2_PROBE_HUB)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)
pico_set_program_version(${target} "0.97-native-hub-profiles-trace")
pico_set_program_version(${target} "0.102-native-hub-profiles-trace")
else()
pico_set_program_version(${target} "0.97-native-hub-profiles")
pico_set_program_version(${target} "0.102-native-hub-profiles")
endif()
elseif(SWITCH2_PROBE_JOIN_CHORD_GATE)
if(SWITCH2_PROBE_TRACE_NATIVE_INPUT)

View file

@ -407,3 +407,34 @@ void probe_protocol_gate_native_report(const probe_protocol_state* state,
memset(input + imu_length_offset, 0, 41);
#endif
}
bool probe_protocol_decode_rumble(uint8_t report_id, const uint8_t* data,
size_t length, probe_rumble_frame* output) {
if (!data || !output) return false;
if (report_id == 0) {
if (length < 17 || length > 64 || data[0] != 0x01) return false;
++data;
} else if (report_id != 0x01 || length < 16 || length > 63) {
return false;
}
if ((data[0] & 0xc0u) != 0x40u) return false;
// Wire block: ndeadly/switch2_controller_research hid_reports.md#output-report-0x01.
// SDL src/joystick/hidapi/SDL_hidapi_switch2.c (EncodeHDRumble / UpdateRumble)
// packs frequency/amplitude/frequency/amplitude as four 10-bit LE fields.
// Header: format 01 [7:6], sample count [5:4], sequence [3:0].
// Sequence is informational; unused sample bytes and USB padding may be stale.
probe_rumble_frame decoded = {.count = (data[0] >> 4) & 3u};
for (unsigned i = 0; i < decoded.count; ++i) {
const uint8_t* sample = data + 1u + 5u * i;
const unsigned first = (sample[1] >> 2) | ((sample[2] & 0x0fu) << 6);
const unsigned second = (sample[3] >> 6) | ((unsigned)sample[4] << 2);
const unsigned amplitude = first > second ? first : second;
// ERM compatibility, not HD waveform reproduction: ignore frequencies
// and round max(amplitudes) across the full 10-bit range to 0..255.
// SDL's conservative outbound clamp is not an inbound validity limit.
decoded.magnitude[i] = (uint8_t)((amplitude * 255u + 511u) / 1023u);
}
*output = decoded;
return true;
}

View file

@ -50,6 +50,18 @@ typedef struct {
uint32_t report_counter;
} probe_protocol_state;
typedef struct {
uint8_t count;
uint8_t magnitude[3];
} probe_rumble_frame;
// Stateless Output 01 compatibility rumble: report_id 0 includes the leading
// wire ID (17..64 bytes); report_id 1 omits it (16..63 bytes).
// Count zero means HOLD/no update, not stop or watchdog refresh. A nonempty
// zero-amplitude sample is stop. Malformed input leaves output unchanged.
bool probe_protocol_decode_rumble(uint8_t report_id, const uint8_t* data,
size_t length, probe_rumble_frame* output);
void probe_protocol_reset(probe_protocol_state* state, bool is_left);
// Blob: own address[6], count[1], zero-padded host addresses[42][6], AES key[16].
// Rejects other identities, invalid counts/padding/lengths without mutation.