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

@ -886,6 +886,15 @@ not reset the other, including when physical slot indices are reused. The
existing IMU target mask is side-local and repeats for each pair. Physical
Bluetooth capacity remains four devices: a physical Joy-Con pair uses two links.
GAMEPAD/DUALSENSE builds from 0.101 translate native gameplay vibration into
conventional source-driver rumble, including a Wii Remote used as a GAMEPAD
source. Each virtual R/L half controls only its assigned source's right/weak
or left/strong contribution; mono actuators combine those contributions.
Source profile rumble gains apply. This does not require the optional DualSense
HD-haptics experiment and does not reproduce HD carrier-wave frequencies.
The separate JOYCON2 relay and dedicated Wii-IR builds retain their existing
built-in sample support; this gameplay translation is for full-controller mode.
With the four private capture sets described below prepared, build separately:
```sh
@ -1236,8 +1245,65 @@ region byte-for-byte. Its single hardware capture passed all 16 initialization
exchanges and 20 descriptor/isolation rounds (415 control requests), including
one-, seven- and fifteen-byte version reads on all four children. No host error,
retry or reset occurred; sustained traffic and gameplay remain unqualified.
Gameplay rumble is not implemented in this native output path: HID output
reports are logged, while built-in vibration samples use a separate cue path.
That 0.100 image did not implement gameplay rumble: HID output reports were
logged, while built-in vibration samples used a separate cue path.
**0.101 gameplay-rumble candidate:** native Output Report `0x01` now reaches the
existing source-driver scheduler in GAMEPAD/DUALSENSE mode. Interrupt reports
including the ID and SET_REPORT payloads excluding it are normalized without
copying their padding. The decoder requires a complete 16-byte LRA block and
format `01`, preserves one to three samples, and converts the larger of the two
10-bit amplitudes to a conventional 8-bit magnitude. Count-zero HOLD does not
change output or refresh its watchdog; an explicit zero-amplitude sample stops
only that side. Unknown formats, wrong IDs and truncated frames do not dispatch.
For compatibility playback, samples divide a 12 ms envelope; the last magnitude
holds only until the 50 ms receipt deadline. The existing 5 ms rumble timer skips
missed sample boundaries rather than replaying stale pulses. Physical drivers
receive finite durations. New gameplay and built-in cue requests replace older
work on the same side; local/profile feedback has priority and interrupted game
output cannot resume later. Driver calls run outside the backend lock with
source-generation and output-revision checks. Reset, suspend, disconnect and
source reassignment retire affected work without stopping another pair.
The layout is grounded in [native report research](https://github.com/ndeadly/switch2_controller_research/blob/master/hid_reports.md#output-report-0x01),
[SDL's Switch 2 encoder](https://github.com/libsdl-org/SDL/blob/main/src/joystick/hidapi/SDL_hidapi_switch2.c),
and existing public Pro Controller USB blocks with the shared LRA layout.
Host smoke coverage runs the actual HID callback, decoder and backend against
instrumented source drivers; it checks independent pairs, side stops, watchdog
expiry and reset/suspend cancellation. It does not qualify physical motor
sensation, HD fidelity or console transport timing. The authorized 0.101 flash
preserved persistent bytes; the user subsequently reported working rumble, but
also second-player latency and failure to enumerate on the first cold connection
to the Switch. HD reproduction is deferred. Picotool and management labels are
synchronized from this candidate onward.
**0.102 attach-last startup candidate:** the native initializer previously
forced the physical D+ pull-up on before configuring the controller/EP0,
starting the Core 1 observer, publishing routing and installing the USB IRQ.
The physical override made the later logical SIE pull-up write insufficient
as an attach gate. A warm replug occurs after that initialization has finished.
Startup now forces the physical pull-up off while those dependencies initialize.
After the observer is ready, address routing and IRQ handling are enabled, and
the started/watchdog timestamp state is published, the physical pull-up is
asserted last. Observer timeout leaves the device detached. No retry, arbitrary
startup delay, USB identity replacement or storage change is introduced.
The host register model fails with the old initializer and passes with the new
one for two/four children, traced/untraced builds, immediate/delayed observer
readiness and timeout. It also services a root descriptor, SET_ADDRESS and the
next descriptor from the first attach edge without replugging. This proves the
software attach-before-ready defect; it is not a physical USB timing trace.
After the authorized 0.102 flash preserved persistent bytes, the user reported
that the cold-start connection now works. That is user qualification, not an
instrumented electrical measurement or a long-run reliability claim.
The Pico is running 0.102. Gameplay rumble, USB runtime scheduling and player
routing are unchanged by the startup correction. Second-player latency
remains a separate open investigation: a 20-second concurrent PC capture showed
roughly 231–232 reports/second across all four children, which does not establish
equal physical input-to-display latency on the Switch.
**Neutral two-pair transport experiment (0.91):** the standalone probe can expose
four native children, ordered **A-R, A-L, B-R, B-L** on hub ports 1–4. This is an

View file

@ -17,6 +17,9 @@
#endif
#include "configuration/configuration_service.h"
#include "profile/profile_service.h"
#if SWITCH2_BRIDGE_FULL_INPUT
#include "profile/controller_profile_runtime.h"
#endif
#include <limits.h>
#include <math.h>
#include <stddef.h>
@ -291,6 +294,9 @@ struct NativeGamepadCue {
uint32_t started_ms = 0;
uint8_t slot = 0xff;
uint8_t sample_id = 0;
// Nonzero selects host gameplay instead of the built-in sample vocabulary.
uint8_t rumble_count = 0;
uint8_t rumble_magnitude[3]{};
int result = -1;
bool active = false;
bool consumed = false;
@ -298,7 +304,7 @@ struct NativeGamepadCue {
};
struct NativeGamepadMotorOutput {
uint32_t deadline_ms = 0;
uint32_t deadline_ms[2]{};
uint8_t magnitude[2]{};
bool owned = false;
};
@ -321,6 +327,10 @@ constexpr NativeGamepadCuePattern kNativeGamepadCuePatterns[8] = {
{{120}, 1, 220},
};
constexpr uint32_t kNativeGamepadCueDeadlineMs = 2000;
// One host block spans 12 ms; the last magnitude holds only until the 50 ms
// receipt watchdog. ERM compatibility ignores carrier frequencies, not timing.
constexpr uint32_t kNativeGameplayFrameMs = 12;
constexpr uint32_t kNativeGameplayWatchdogMs = 50;
#endif
@ -498,6 +508,8 @@ struct NativeGamepadBinding {
constexpr uint8_t kNativeChildCount = BLUEPAD32_NATIVE_PAIR_COUNT * 2;
NativeGamepadBinding g_native_bindings[BLUEPAD32_NATIVE_PAIR_COUNT]{};
NativeGamepadCue g_native_cues[kNativeChildCount]{};
uint32_t g_native_output_revision[kNativeChildCount]{};
uint32_t g_native_slot_epoch[kSlotCount]{};
uint64_t g_next_native_token = 1;
uni_hid_device_t* g_native_pending_devices[kSlotCount]{};
NativeGamepadReportIngress g_native_reports[kSlotCount]{};
@ -569,11 +581,26 @@ bool native_rumble_capable(const BackendSlot& slot, uint8_t side) {
}
void cancel_native_cue_locked(NativeGamepadCue& cue) {
if (cue.active || cue.result == 0)
++g_native_output_revision[&cue - g_native_cues];
cue.active = false;
cue.result = -1;
// The slot's last motor output remains owned until the timer replaces it.
}
bool native_feedback_owns(const BackendSlot& slot, uint32_t now_ms) {
return slot.feedback_pending || slot.pending_profile_feedback_count != 0 ||
slot.profile_feedback.active ||
(slot.feedback_until_ms != 0 &&
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0);
}
void cancel_native_gameplay_locked(const BackendSlot& slot) {
for (NativeGamepadCue& cue : g_native_cues)
if (cue.rumble_count != 0 && cue.slot == &slot - g_slots)
cancel_native_cue_locked(cue);
}
void refresh_native_source_locked(uint8_t reselected_pair = 0xff) {
uint8_t candidates[BLUEPAD32_NATIVE_PAIR_COUNT]{};
uint8_t reserved = 0;
@ -671,14 +698,18 @@ void refresh_native_source_locked(uint8_t reselected_pair = 0xff) {
}
void retire_native_slot(uint8_t index) {
++g_native_slot_epoch[index];
for (NativeGamepadBinding& binding : g_native_bindings) {
if (binding.slot != index) continue;
binding.slot = 0xff;
binding.generation = 0;
binding.snapshot = {};
}
for (NativeGamepadCue& cue : g_native_cues)
if (cue.slot == index) cue = {};
for (NativeGamepadCue& cue : g_native_cues) {
if (cue.slot != index) continue;
cancel_native_cue_locked(cue);
cue = {};
}
g_slots[index].native_motion = {};
g_slots[index].native_output = {};
}
@ -2099,6 +2130,9 @@ struct HotkeyDecision {
void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
uint8_t weak_magnitude,
uint8_t strong_magnitude) {
#if SWITCH2_BRIDGE_FULL_INPUT
cancel_native_gameplay_locked(slot);
#endif
slot.feedback_pending = true;
slot.pending_feedback = {
slot.connection_generation, duration_ms, weak_magnitude,
@ -2108,6 +2142,9 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms,
void queue_profile_feedback(BackendSlot& slot,
const ProfileFeedbackEnvelope& feedback) {
#if SWITCH2_BRIDGE_FULL_INPUT
cancel_native_gameplay_locked(slot);
#endif
if (slot.pending_profile_feedback_count < kProfileFeedbackQueueCapacity) {
slot.pending_profile_feedback[
slot.pending_profile_feedback_count++] = feedback;
@ -2925,6 +2962,8 @@ struct NativeGamepadCueDispatch {
uint32_t connection_generation = 0;
uint32_t prepared_ms = 0;
uint64_t token[2]{};
uint32_t revision[2]{};
uint32_t slot_epoch = 0;
uint16_t duration_ms = 0;
uint8_t magnitude[2]{};
uint8_t slot = 0xff;
@ -2948,11 +2987,13 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
NativeGamepadCue& cue = g_native_cues[pair * 2 + side];
if (cue.slot != index) continue;
if (!native_cue_current(pair, cue) ||
(cue.result == 0 && now_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs) ||
(cue.active && now_ms - cue.started_ms >= kNativeGamepadCueDeadlineMs))
(cue.rumble_count != 0
? now_ms - cue.requested_ms >= kNativeGameplayWatchdogMs
: ((cue.result == 0 && now_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs) ||
(cue.active && now_ms - cue.started_ms >= kNativeGamepadCueDeadlineMs))))
cancel_native_cue_locked(cue);
if (local_active || local_dispatch) {
if (cue.active) cancel_native_cue_locked(cue);
if (cue.active || cue.rumble_count != 0) cancel_native_cue_locked(cue);
busy |= cue.result == 0;
continue;
}
@ -2960,6 +3001,19 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
if (cue.result != 0 && !cue.active) continue;
command->token[side] = cue.token;
pending |= cue.result == 0;
if (cue.rumble_count != 0) {
const uint32_t elapsed = now_ms - cue.requested_ms;
const uint32_t phase_ms = kNativeGameplayFrameMs / cue.rumble_count;
const uint8_t phase = elapsed >= kNativeGameplayFrameMs
? cue.rumble_count - 1 : static_cast<uint8_t>(elapsed / phase_ms);
magnitude[side] = cue.rumble_magnitude[phase];
const uint32_t boundary = phase + 1u < cue.rumble_count
? (phase + 1u) * phase_ms : kNativeGameplayWatchdogMs;
const uint16_t remaining = static_cast<uint16_t>(boundary - elapsed);
if (magnitude[side] != 0 && remaining < duration) duration = remaining;
busy = true;
continue;
}
if (cue.sample_id == 0) continue;
const NativeGamepadCuePattern& pattern = kNativeGamepadCuePatterns[cue.sample_id];
uint32_t elapsed = cue.active ? now_ms - cue.started_ms : 0;
@ -2987,7 +3041,8 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
const uint32_t deadline = duration == 0 ? 0 : now_ms + duration;
const bool changed = magnitude[0] != previous.magnitude[0] ||
magnitude[1] != previous.magnitude[1] ||
(duration != 0 && deadline != previous.deadline_ms);
(magnitude[0] != 0 && deadline != previous.deadline_ms[0]) ||
(magnitude[1] != 0 && deadline != previous.deadline_ms[1]);
if (!pending && !changed) return busy || previous.owned;
if (!slot.active || slot.device == nullptr ||
(!native_rumble_capable(slot, 0) && !native_rumble_capable(slot, 1))) {
@ -3005,6 +3060,10 @@ bool prepare_native_cues(uint8_t index, uint32_t now_ms,
command->magnitude[1] = magnitude[1];
command->slot = index;
command->pair = pair;
command->slot_epoch = g_native_slot_epoch[index];
if (pair != 0xff)
for (uint8_t side = 0; side < 2; ++side)
command->revision[side] = g_native_output_revision[pair * 2 + side];
for (uint8_t side = 0; side < 2; ++side)
if (command->token[side] != 0) g_native_cues[pair * 2 + side].in_flight = true;
return true;
@ -3018,6 +3077,8 @@ bool submit_native_rumble(uni_hid_device_t* device, uint16_t duration,
device->report_parser.parse_input_report == uni_hid_parser_ds5_parse_input_report) {
return uni_hid_parser_ds5_bridge_rumble(device, duration, right, left);
}
if (device->controller_type == CONTROLLER_TYPE_WiiController &&
!uni_hid_parser_wii_rumble_ready(device)) return false;
// Existing finite-duration dispatch is the strongest observable result
// most drivers expose. It is not transport acceptance or a remote ACK.
dispatch_rumble(device, duration, right, left);
@ -3034,9 +3095,15 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) {
state_lock_enter();
bool current = slot.active && slot.device == command.device &&
slot.companion == command.companion &&
slot.connection_generation == command.connection_generation;
slot.connection_generation == command.connection_generation &&
g_native_slot_epoch[command.slot] == command.slot_epoch &&
!native_feedback_owns(slot, btstack_run_loop_get_time_ms());
for (uint8_t side = 0; side < 2; ++side) {
if ((paired && side != target) || command.token[side] == 0) continue;
if (paired && side != target) continue;
if (command.pair != 0xff)
current &= command.revision[side] ==
g_native_output_revision[command.pair * 2 + side];
if (command.token[side] == 0) continue;
const NativeGamepadCue& cue = g_native_cues[command.pair * 2 + side];
current &= cue.token == command.token[side] && cue.in_flight &&
cue.result != -1 && native_cue_current(command.pair, cue);
@ -3063,18 +3130,18 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) {
state_lock_enter();
if (slot.active && slot.device == command.device &&
slot.companion == command.companion) {
slot.companion == command.companion &&
g_native_slot_epoch[command.slot] == command.slot_epoch) {
// Retain each actual submission even if USB canceled/reselected
// during its driver call, or the other half cannot be submitted.
NativeGamepadMotorOutput& output = slot.native_output;
if (paired) output.magnitude[target] = command.magnitude[target];
else {
output.magnitude[0] = command.magnitude[0];
output.magnitude[1] = command.magnitude[1];
for (uint8_t side = 0; side < 2; ++side) {
if (paired && side != target) continue;
output.magnitude[side] = command.magnitude[side];
output.deadline_ms[side] = command.magnitude[side] != 0
? command.prepared_ms + command.duration_ms : 0;
}
output.owned = (output.magnitude[0] | output.magnitude[1]) != 0;
output.deadline_ms = output.owned
? command.prepared_ms + command.duration_ms : 0;
}
state_lock_exit();
}
@ -3085,12 +3152,15 @@ void dispatch_native_cues(const NativeGamepadCueDispatch& command) {
if (cue.token != command.token[side]) continue;
cue.in_flight = false;
if (!native_cue_current(command.pair, cue) ||
(cue.result == 0 && dispatch_ms - cue.requested_ms >= kNativeGamepadCueDeadlineMs)) {
(cue.result == 0 && dispatch_ms - cue.requested_ms >=
(cue.rumble_count != 0 ? kNativeGameplayWatchdogMs : kNativeGamepadCueDeadlineMs))) {
cancel_native_cue_locked(cue);
} else if (submitted[side] && cue.result == 0) {
cue.result = 1; // Accepted source submission, never a native ACK.
cue.started_ms = command.prepared_ms;
cue.active = cue.sample_id != 0;
if (cue.rumble_count == 0) {
cue.started_ms = command.prepared_ms;
cue.active = cue.sample_id != 0;
}
}
}
state_lock_exit();
@ -3321,6 +3391,7 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
}
const bool feedback_active =
slot.feedback_until_ms != 0 &&
static_cast<int32_t>(now_ms - slot.feedback_until_ms) < 0;
if (!slot.profile_feedback.active && !feedback_active &&
slot.pending_profile_feedback_count != 0) {
@ -3386,8 +3457,8 @@ void process_rumble_timer(btstack_timer_source_t* timer) {
const bool local_feedback_active =
slot.profile_feedback.active ||
static_cast<int32_t>(
now_ms - slot.feedback_until_ms) < 0;
(slot.feedback_until_ms != 0 && static_cast<int32_t>(
now_ms - slot.feedback_until_ms) < 0);
#ifdef SWITCH2_BRIDGE_WII_INPUT
const bool wii_cue_owns_rumble = prepare_wii_cue(
slot_index, now_ms, local_feedback_active,
@ -4847,6 +4918,75 @@ void bluepad32_input_backend_native_snapshot(
state_lock_exit();
}
bool bluepad32_input_backend_native_rumble_submit(
uint8_t instance, const uint8_t* magnitudes, uint8_t count) {
if (!g_initialized || instance >= kNativeChildCount || magnitudes == nullptr ||
count == 0 || count > 3) return false;
const uint32_t received_ms = btstack_run_loop_get_time_ms();
uint8_t samples[3]{};
memcpy(samples, magnitudes, count);
state_lock_enter();
const NativeGamepadBinding& binding = g_native_bindings[instance / 2];
const uint8_t index = binding.slot;
if (index >= kSlotCount || !g_slots[index].active ||
g_slots[index].connection_generation != binding.generation ||
!native_rumble_capable(g_slots[index], instance & 1u) ||
native_feedback_owns(g_slots[index], received_ms)) {
state_lock_exit();
return false;
}
const BackendSlot& slot = g_slots[index];
const Bluepad32SlotSnapshot snapshot{
slot.active, slot.connection_generation, slot.identity,
slot.pre_hotkey_button_mask, slot.state, slot.accelerometer,
slot.nunchuk_accelerometer};
const uint32_t revision = g_native_output_revision[instance];
state_lock_exit();
// Profile resolution may call services: never hold the backend lock over
// it. Full-scale input obtains the existing weak/strong gain once per block.
const ControllerRumbleOutput gains = controller_profile_runtime_scale_host_rumble(
index, snapshot, ControllerRumbleOutput{UINT8_MAX, UINT8_MAX});
const uint8_t gain = (instance & 1u) == 0
? gains.high_frequency_magnitude : gains.low_frequency_magnitude;
for (uint8_t sample = 0; sample < count; ++sample)
samples[sample] = controller_profile_scale_rumble_magnitude(samples[sample], gain);
state_lock_enter();
const uint32_t now_ms = btstack_run_loop_get_time_ms();
const bool accepted = binding.slot == index && slot.active &&
binding.generation == snapshot.connection_generation &&
slot.connection_generation == snapshot.connection_generation &&
g_native_output_revision[instance] == revision && g_next_native_token != 0 &&
native_rumble_capable(slot, instance & 1u) &&
!native_feedback_owns(slot, now_ms) &&
now_ms - received_ms < kNativeGameplayWatchdogMs;
if (accepted) {
++g_native_output_revision[instance];
NativeGamepadCue& cue = g_native_cues[instance];
cue = {};
cue.token = g_next_native_token++;
cue.slot = index;
cue.connection_generation = snapshot.connection_generation;
cue.requested_ms = received_ms;
cue.rumble_count = count;
memcpy(cue.rumble_magnitude, samples, count);
cue.result = 0;
cue.active = true;
__atomic_add_fetch(&g_host_rumble_requests, 1, __ATOMIC_RELAXED);
}
state_lock_exit();
return accepted;
}
void bluepad32_input_backend_native_rumble_cancel(uint8_t instance) {
if (!g_initialized || instance >= kNativeChildCount) return;
state_lock_enter();
NativeGamepadCue& cue = g_native_cues[instance];
if (cue.rumble_count != 0) cancel_native_cue_locked(cue);
state_lock_exit();
}
bool bluepad32_input_backend_native_sample_request(
uint8_t instance, uint8_t sample_id, uint64_t* token) {
if (token == nullptr) return false;
@ -4857,9 +4997,10 @@ bool bluepad32_input_backend_native_sample_request(
const NativeGamepadBinding& binding = g_native_bindings[instance / 2];
const uint8_t index = binding.slot;
const bool accepted = index < kSlotCount && g_next_native_token != 0 &&
native_rumble_capable(g_slots[index], instance & 1u) &&
!cue.in_flight && (sample_id == 0 || (cue.result != 0 && !cue.active));
g_slots[index].active && g_slots[index].connection_generation == binding.generation &&
native_rumble_capable(g_slots[index], instance & 1u);
if (accepted) {
++g_native_output_revision[instance];
cue = {};
cue.token = g_next_native_token++;
cue.slot = index;
@ -4893,7 +5034,8 @@ int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t toke
void bluepad32_input_backend_native_sample_cancel(uint8_t instance) {
if (!g_initialized || instance >= kNativeChildCount) return;
state_lock_enter();
cancel_native_cue_locked(g_native_cues[instance]);
NativeGamepadCue& cue = g_native_cues[instance];
if (cue.rumble_count == 0) cancel_native_cue_locked(cue);
state_lock_exit();
}
#endif

View file

@ -154,6 +154,15 @@ bool bluepad32_input_backend_native_sample_request(
uint8_t instance, uint8_t sample_id, uint64_t* token);
int bluepad32_input_backend_native_sample_result(uint8_t instance, uint64_t token);
void bluepad32_input_backend_native_sample_cancel(uint8_t instance);
// Gameplay blocks replace older gameplay/cues only on the addressed side.
// Samples divide a 12 ms frame; the last holds up to a 50 ms receipt watchdog.
// Magnitudes use that source profile's weak/right or strong/left host gain.
// Submission copies 1..3 samples; false means invalid/unbound/unsupported,
// higher-priority local feedback, or a source change during profile resolution.
// Cancellation retires gameplay only; callers resetting a child also cancel its cue.
bool bluepad32_input_backend_native_rumble_submit(
uint8_t instance, const uint8_t* magnitudes, uint8_t count);
void bluepad32_input_backend_native_rumble_cancel(uint8_t instance);
#endif
// Side-effect-free raw input snapshot for management telemetry. Unlike the

View file

@ -1294,7 +1294,9 @@ bool native_hub_init(void) {
active_device = 0; addresses[0] = 0; default_device = 0;
usb_hw->muxing = USB_USB_MUXING_TO_PHY_BITS | USB_USB_MUXING_SOFTCON_BITS | USB_USB_MUXING_USBPHY_AS_GPIO_BITS;
sio_hw->gpio_hi_oe_clr = SIO_GPIO_HI_IN_USB_DP_BITS | SIO_GPIO_HI_IN_USB_DM_BITS;
hw_set_bits(&usb_hw->phy_direct,USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS);
// GPIO-observer mode needs the physical override, but asserting it here
// would advertise attachment before the SIE, router and IRQ are ready.
hw_clear_bits(&usb_hw->phy_direct,USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS);
hw_set_bits(&usb_hw->phy_direct_override,USB_USBPHY_DIRECT_OVERRIDE_DP_PULLUP_EN_OVERRIDE_EN_BITS);
usb_hw->pwr = USB_USB_PWR_VBUS_DETECT_BITS | USB_USB_PWR_VBUS_DETECT_OVERRIDE_EN_BITS;
usb_hw->main_ctrl = USB_MAIN_CTRL_CONTROLLER_EN_BITS;
@ -1316,8 +1318,12 @@ bool native_hub_init(void) {
#endif
irq_set_priority(USBCTRL_IRQ,0);
irq_set_enabled(USBCTRL_IRQ,true);
hw_set_bits(&usb_hw->sie_ctrl,USB_SIE_CTRL_PULLUP_EN_BITS);
watchdog_enable(8000,false); started = true; startup_time = time_us_32();
__dmb();
hw_set_bits(&usb_hw->sie_ctrl,USB_SIE_CTRL_PULLUP_EN_BITS);
// The forced physical pull-up is the actual attach edge. Publish it last:
// even an immediate host reset/SETUP now has an initialized receiver.
hw_set_bits(&usb_hw->phy_direct,USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS);
#if CHILDREN == 2
probe_debug_printf("[NATIVE_HUB] stock USB, SIO phase=%u, 240MHz; hub2068 R2066 L2067; isolated EP0/1/2 banks\n",NATIVE_HUB_SAMPLE_PHASE);
#else

View file

@ -192,7 +192,7 @@ size_t encode_transaction(uint8_t* output, size_t output_size) {
size_t encode_info(uint8_t* output, size_t output_size) {
uint8_t payload[8] = {
#if SWITCH2_PROBE_HUB
0, 100, 0, 2,
0, 102, 0, 2,
kNativeHubActiveMode,
USB_OUTPUT_CAPABILITY_INPUT | USB_OUTPUT_CAPABILITY_RUMBLE |
USB_OUTPUT_CAPABILITY_MOTION,

View file

@ -1,8 +1,29 @@
// Reuse the backend's transport/storage fixture; these scenarios exercise only
// the native gamepad contract, not a second implementation of its scheduler.
#include <uni.h>
extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*);
#define profile_service_active_profile_snapshot fixture_active_profile_snapshot
#define main backend_fixture_main
#include "bluepad32_backend_lifecycle_test.cpp"
#undef main
#undef profile_service_active_profile_snapshot
namespace {
void (*during_profile_resolution)() = nullptr;
bool native_wii_ready = true;
}
void profile_service_active_profile_snapshot(
const ControllerIdentity& identity, ProfileServiceActiveProfileSnapshot* output) {
require(state_lock_depth == 0, "profile service callbacks must not hold the backend lock");
if (during_profile_resolution) during_profile_resolution();
fixture_active_profile_snapshot(identity, output);
}
extern "C" bool uni_hid_parser_wii_rumble_ready(uni_hid_device_t*) {
require(state_lock_depth == 0, "Wii readiness must not hold the backend lock");
return native_wii_ready;
}
namespace {
struct SensorFixture {
@ -600,6 +621,316 @@ void mono_rumble() {
"retired mono work must never enter a replacement connection");
}
void gameplay_timeline() {
start_pairing_backend();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "gameplay source must connect");
uint8_t right[] = {40, 80, 120};
const uint8_t left[] = {60, 180};
const uint8_t stop = 0;
require(bluepad32_input_backend_native_rumble_submit(0, right, 3) &&
bluepad32_input_backend_native_rumble_submit(1, left, 2),
"mixed sample counts must be accepted independently");
right[0] = 255;
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 40 && pad.last_low == 60 && pad.last_rumble_duration_ms == 4,
"copied right/weak and left/strong samples share the earliest finite boundary");
now_ms = 5;
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 80 && pad.last_low == 60 && pad.last_rumble_duration_ms == 1,
"5 ms polling skips elapsed time rather than replaying the first sample");
now_ms = 10;
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 120 && pad.last_low == 180 && pad.last_rumble_duration_ms == 40,
"last samples hold only to their original receipt watchdog");
require(bluepad32_input_backend_native_rumble_submit(0, &stop, 1),
"explicit zero magnitude must be a valid side stop");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 0 && pad.last_low == 180 && pad.last_rumble_duration_ms == 40,
"right stop must preserve the left contribution and deadline");
now_ms = 49;
const int calls = pad.rumble_calls;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == calls, "unchanged finite holds must not produce duplicate writes");
now_ms = 50;
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "watchdog expiry must stop without further host packets");
now_ms = 500;
process_rumble_timer(&g_rumble_timer);
const int stopped = pad.rumble_calls;
const uint8_t delayed[] = {21, 42, 84};
require(bluepad32_input_backend_native_rumble_submit(0, delayed, 3), "delayed block must queue");
now_ms = 520;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == stopped + 1 && pad.last_high == 84 &&
pad.last_rumble_duration_ms == 30,
"a stalled timer dispatches only the current sample with its remaining lifetime");
bluepad32_input_backend_native_rumble_cancel(0);
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "explicit cancellation must stop its live hold");
now_ms = UINT32_MAX - 9u;
const uint8_t pulse = 99;
require(bluepad32_input_backend_native_rumble_submit(1, &pulse, 1), "pre-wrap request must queue");
process_rumble_timer(&g_rumble_timer);
require(pad.last_low == 99 && pad.last_rumble_duration_ms == 50,
"finite host lifetime must remain valid before clock wrap");
now_ms = 39;
process_rumble_timer(&g_rumble_timer);
require(pad.last_low == 99, "wrap must not cause early watchdog expiry");
now_ms = 40;
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "watchdog must expire exactly across clock wrap");
}
void gameplay_availability() {
start_pairing_backend();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "busy gameplay source must connect");
const uint8_t old[] = {10, 20, 30};
const uint8_t newest[] = {70, 140};
require(bluepad32_input_backend_native_rumble_submit(0, old, 3), "old block must queue");
dualsense_transport_available = false;
process_rumble_timer(&g_rumble_timer);
now_ms = 5;
require(bluepad32_input_backend_native_rumble_submit(0, newest, 2), "busy source must retain a new block");
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == 0, "driver rejection cannot count as a successful output");
now_ms = 15;
dualsense_transport_available = true;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == 1 && pad.last_high == 140 && pad.last_rumble_duration_ms == 40,
"driver recovery must dispatch only the newest current phase, not old samples");
const uint8_t next = 210;
during_dualsense_dispatch = [] {
const uint8_t replacement = 33;
require(bluepad32_input_backend_native_rumble_submit(0, &replacement, 1),
"gameplay must replace work while an earlier driver call is in flight");
};
require(bluepad32_input_backend_native_rumble_submit(0, &next, 1), "dispatch race must queue");
process_rumble_timer(&g_rumble_timer);
during_dualsense_dispatch = nullptr;
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 33 && pad.last_rumble_duration_ms == 50,
"older dispatch completion cannot consume a newly accepted revision");
bluepad32_input_backend_native_rumble_cancel(0);
process_rumble_timer(&g_rumble_timer);
const int stopped = pad.rumble_calls;
dualsense_transport_available = false;
require(bluepad32_input_backend_native_rumble_submit(0, old, 3), "stale block must queue");
now_ms += 50;
process_rumble_timer(&g_rumble_timer);
dualsense_transport_available = true;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == stopped, "expired rejected work must never replay after driver recovery");
}
void gameplay_priority() {
start_pairing_backend();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "priority source must connect");
report_dualsense(pad);
const uint8_t game = 45;
uint64_t old_cue, new_cue;
require(bluepad32_input_backend_native_sample_request(0, 1, &old_cue) &&
bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"gameplay must replace a pending cue on the same side");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 45 &&
bluepad32_input_backend_native_sample_result(0, old_cue) == -1,
"replaced cue cannot dispatch or complete after gameplay");
require(bluepad32_input_backend_native_sample_request(0, 6, &new_cue),
"a new cue must replace live gameplay");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 96 && pad.last_rumble_duration_ms == 60 &&
bluepad32_input_backend_native_sample_result(0, new_cue) == 1,
"built-in cue completion must retain its driver-dispatch semantics");
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1) &&
bluepad32_input_backend_native_rumble_submit(1, &game, 1),
"fresh gameplay must replace the playing cue");
process_rumble_timer(&g_rumble_timer);
const auto source = bridge_snapshot();
bluepad32_input_backend_queue_profile_feedback(source.slot,
source.controller.connection_generation, 1, ControllerProfileConfirmationPolicy::kRumble);
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"queued higher-priority feedback must not admit gameplay for later replay");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == UINT8_MAX && pad.last_low == UINT8_MAX &&
pad.last_rumble_duration_ms == 75, "profile feedback must own the only motor writer");
now_ms = 150;
process_rumble_timer(&g_rumble_timer);
const int after_feedback = pad.rumble_calls;
now_ms = 155;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == after_feedback, "profile completion must never resurrect interrupted gameplay");
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "fresh post-feedback gameplay must resume");
process_rumble_timer(&g_rumble_timer);
require(bluepad32_input_backend_toggle_motion(source.slot, source.controller.connection_generation),
"motion feedback must queue through the existing local-feedback path");
process_rumble_timer(&g_rumble_timer);
const int local_calls = pad.rumble_calls;
now_ms += 3000;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == local_calls, "local feedback must cancel rather than retain interrupted gameplay");
}
void gameplay_source_epochs() {
start_pairing_backend();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "epoch source must connect");
const uint8_t game = 170;
require(!bluepad32_input_backend_native_rumble_submit(0, nullptr, 1) &&
!bluepad32_input_backend_native_rumble_submit(0, &game, 0) &&
!bluepad32_input_backend_native_rumble_submit(0, &game, 4) &&
!bluepad32_input_backend_native_rumble_submit(PROBE_CONTROLLER_COUNT, &game, 1),
"invalid gameplay frames must fail before dispatch");
pad.report_parser.play_dual_rumble = nullptr;
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"source without a rumble driver must reject gameplay");
pad.report_parser.play_dual_rumble = observe_dualsense_rumble;
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "epoch block must queue");
during_dualsense_dispatch = [] { bluepad32_input_backend_select_native_source(0, nullptr); };
process_rumble_timer(&g_rumble_timer);
during_dualsense_dispatch = nullptr;
process_rumble_timer(&g_rumble_timer);
require(pad.last_rumble_duration_ms == 0, "same-source reselection must stop an in-flight retired epoch");
require(bluepad32_input_backend_native_rumble_submit(1, &game, 1), "disconnect block must queue");
process_rumble_timer(&g_rumble_timer);
platform_on_device_disconnected(&pad);
require(pad.last_rumble_duration_ms == 0 &&
!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"disconnect must retire the driver's finite timer and refuse new work");
pad = dualsense(0);
platform_on_device_connected(&pad);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "same-address slot replacement must connect");
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == 0, "slot memory reuse cannot inherit old samples or stop obligations");
controller_profile_runtime_reset();
during_profile_resolution = [] { bluepad32_input_backend_select_native_source(0, nullptr); };
require(!bluepad32_input_backend_native_rumble_submit(0, &game, 1),
"source generation must be rechecked after unlocked profile callbacks");
during_profile_resolution = nullptr;
process_rumble_timer(&g_rumble_timer);
require(pad.rumble_calls == 0, "a profile-resolution race cannot reach the replacement epoch");
}
void gameplay_profile_gain() {
start_pairing_backend();
initialize_runtime_profile_storage();
auto pad = dualsense(0);
require(platform_on_device_ready(&pad) == UNI_ERROR_SUCCESS, "profile source must connect");
const auto identity = identity_for_device(&pad);
auto profile = controller_profile_default(identity, 0);
profile.weak_rumble_scale = 128;
profile.strong_rumble_scale = 64;
require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk,
"profile must configure independent host motor gains");
const uint8_t maximum = 255;
require(bluepad32_input_backend_native_rumble_submit(0, &maximum, 1) &&
bluepad32_input_backend_native_rumble_submit(1, &maximum, 1), "scaled gameplay must queue");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 128 && pad.last_low == 64,
"right/weak and left/strong magnitudes must use their matching persisted profile gains");
profile.weak_rumble_scale = 0;
profile.strong_rumble_scale = 0;
require(runtime_profile_storage.set(identity, 0, profile) == ProfileStorageResult::kOk,
"profile mute must update its generation");
require(bluepad32_input_backend_native_rumble_submit(0, &maximum, 1) &&
bluepad32_input_backend_native_rumble_submit(1, &maximum, 1), "muted gameplay must queue");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 0 && pad.last_low == 0 && pad.last_rumble_duration_ms == 0,
"muting a profile must stop live host output rather than retaining unscaled samples");
uint64_t cue;
require(bluepad32_input_backend_native_sample_request(0, 7, &cue), "muted profile still permits local cues");
process_rumble_timer(&g_rumble_timer);
require(pad.last_high == 220 && bluepad32_input_backend_native_sample_result(0, cue) == 1,
"host gain must not scale built-in local confirmation cues");
}
void gameplay_two_pairs() {
start_pairing_backend();
auto first = dualsense(0);
auto second = dualsense(1);
require(platform_on_device_ready(&first) == UNI_ERROR_SUCCESS &&
platform_on_device_ready(&second) == UNI_ERROR_SUCCESS, "both gameplay pairs must connect");
const uint8_t values[] = {31, 62, 93, 124};
for (uint8_t instance = 0; instance < 4; ++instance)
require(bluepad32_input_backend_native_rumble_submit(instance, values + instance, 1),
"each gameplay child must bind its own pair");
process_rumble_timer(&g_rumble_timer);
require(first.last_high == 31 && first.last_low == 62 &&
second.last_high == 93 && second.last_low == 124,
"four logical contributions must route to two independent physical sources");
bluepad32_input_backend_native_rumble_cancel(0);
now_ms = 10;
process_rumble_timer(&g_rumble_timer);
require(first.last_high == 0 && first.last_low == 62 && first.last_rumble_duration_ms == 40 &&
second.rumble_calls == 1, "side cancellation cannot dispatch into another pair");
bluepad32_input_backend_select_native_source(0, nullptr);
process_rumble_timer(&g_rumble_timer);
require(first.last_rumble_duration_ms == 0 && second.rumble_calls == 1,
"source reselection must retire only its own pair's output");
platform_on_device_disconnected(&first);
now_ms = 50;
process_rumble_timer(&g_rumble_timer);
require(second.last_rumble_duration_ms == 0 && second.rumble_calls == 2,
"the surviving pair must expire on its original independent watchdog");
}
void gameplay_paired_revision() {
start_pairing_backend();
auto left = switch2_device(0, UNI_SW2_JOYCON_L_PID);
auto right = switch2_device(1, UNI_SW2_JOYCON_R_PID);
ready_switch2(left);
ready_switch2(right);
right.report_parser.play_dual_rumble = [](
uni_hid_device_t* pad, uint16_t delay, uint16_t duration, uint8_t weak, uint8_t strong) {
play_rumble(pad, delay, duration, weak, strong);
const uint8_t fresh = 150;
require(bluepad32_input_backend_native_rumble_submit(1, &fresh, 1),
"right dispatch may accept a newer left revision");
};
const uint8_t game = 75;
require(bluepad32_input_backend_native_rumble_submit(0, &game, 1), "paired gameplay must queue");
const int previous_left = left.rumble_calls;
process_rumble_timer(&g_rumble_timer);
require(right.last_high == 75 && right.last_low == 75 && left.rumble_calls == previous_left,
"new left work must invalidate even a prepared zero-output command before left dispatch");
right.report_parser.play_dual_rumble = play_rumble;
process_rumble_timer(&g_rumble_timer);
require(left.last_high == 150 && left.last_low == 150,
"the newer paired-side revision must remain pending until its real driver dispatch");
const uint8_t stop = 0;
require(bluepad32_input_backend_native_rumble_submit(0, &stop, 1), "paired right stop must queue");
process_rumble_timer(&g_rumble_timer);
require(right.last_rumble_duration_ms == 0 && left.last_high == 150 &&
left.last_rumble_duration_ms == 50, "paired stop must preserve only the live sibling contribution");
}
void gameplay_wii() {
start_pairing_backend();
auto remote = wii_device(0);
remote.report_parser.play_dual_rumble = observe_mono_rumble;
require(platform_on_device_ready(&remote) == UNI_ERROR_SUCCESS, "Wii GAMEPAD source must connect");
const uint8_t right = 70, left = 140;
require(bluepad32_input_backend_native_rumble_submit(0, &right, 1) &&
bluepad32_input_backend_native_rumble_submit(1, &left, 1), "Wii contributions must queue");
native_wii_ready = false;
process_rumble_timer(&g_rumble_timer);
require(remote.rumble_calls == 0, "Wii topology setup must not consume pending output");
native_wii_ready = true;
now_ms = 10;
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 140 && remote.last_rumble_duration_ms == 40,
"Wii compatibility combines both contributions on its finite mono motor");
bluepad32_input_backend_native_rumble_cancel(1);
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 70 && remote.last_rumble_duration_ms == 40,
"Wii side cancellation must not stop its sibling's mono contribution");
now_ms = 50;
process_rumble_timer(&g_rumble_timer);
require(mono_magnitude == 0, "Wii gameplay must stop at its original watchdog");
}
} // namespace
extern "C" void uni_hid_parser_ds5_parse_input_report(uni_hid_device_t*, const uint8_t*, uint16_t) {}
@ -993,6 +1324,14 @@ int main(int argc, char** argv) {
if (scenario == "source-isolation") source_isolation();
else if (scenario == "cue-lifetime") cue_lifetime();
else if (scenario == "cue-races") cue_races();
else if (scenario == "gameplay-timeline") gameplay_timeline();
else if (scenario == "gameplay-availability") gameplay_availability();
else if (scenario == "gameplay-priority") gameplay_priority();
else if (scenario == "gameplay-source-epochs") gameplay_source_epochs();
else if (scenario == "gameplay-profile-gain") gameplay_profile_gain();
else if (scenario == "gameplay-two-pairs") gameplay_two_pairs();
else if (scenario == "gameplay-paired-revision") gameplay_paired_revision();
else if (scenario == "gameplay-wii") gameplay_wii();
else if (scenario == "stable-logical-slot") stable_logical_slot();
else if (scenario == "sensorless-admission") sensorless_admission();
else if (scenario == "independent-motion") independent_motion();

View file

@ -0,0 +1,270 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include "hardware_stub.h"
static void startup_set_bits(volatile uint32_t* address, uint32_t bits);
static void startup_clear_bits(volatile uint32_t* address, uint32_t bits);
static void startup_reset(uint32_t mask);
static void startup_launch(void (*entry)(void));
static void startup_irq_handler(unsigned irq, void (*handler)(void));
static void startup_irq_enable(unsigned irq, bool enabled);
static void startup_wait(void);
#define hw_set_bits(address, bits) startup_set_bits(address, bits)
#define hw_clear_bits(address, bits) startup_clear_bits(address, bits)
#define reset_block(mask) startup_reset(mask)
#define multicore_launch_core1(entry) startup_launch(entry)
#define irq_set_exclusive_handler(irq, handler) startup_irq_handler(irq, handler)
#define irq_set_enabled(irq, enabled) startup_irq_enable(irq, enabled)
#define tight_loop_contents() startup_wait()
#define NATIVE_TEST_EXTERNAL_ROUTER 1
#define NATIVE_TEST_EXTERNAL_IRQ 1
#include "native_hub_transport_fixture.c"
// This is a register/IRQ model, not a physical USB bus or Core1 timing model.
// The physical pull-up follows DIRECT when its override is enabled; otherwise
// it follows SIE_CTRL. In particular, a disabled SIE pull-up cannot mask a
// forced physical pull-up. Register bit writes and startup hardware/router
// boundaries sample this signal. Each scenario starts in fresh process BSS.
static bool attached, observer_initialized, observer_launched, observer_ready;
static bool routing_enabled, addresses_published, irq_enabled, enumeration_done;
static uint8_t routed_addresses[PROBE_ROUTER_SLOTS], routed_default;
static void (*installed_irq)(void);
static uint32_t ready_delay_us, observer_start_us, wait_us, attach_time_us;
static unsigned attach_edges, detach_edges, controller_resets, setup_irqs;
static bool observer_never_ready;
static bool physical_pullup(void) {
if (usb_hw->phy_direct_override & USB_USBPHY_DIRECT_OVERRIDE_DP_PULLUP_EN_OVERRIDE_EN_BITS)
return (usb_hw->phy_direct & USB_USBPHY_DIRECT_DP_PULLUP_EN_BITS) != 0;
return (usb_hw->sie_ctrl & USB_SIE_CTRL_PULLUP_EN_BITS) != 0;
}
static uint8_t route_address(uint8_t address) {
assert(routing_enabled && addresses_published);
if (!address) return routed_default;
for (uint8_t slot = 0; slot < PROBE_ROUTER_SLOTS; ++slot)
if (routed_addresses[slot] == address) return slot;
return NONE;
}
void native_test_service_interrupt(void) {
if (native_test_interrupt_mask || servicing_interrupt || !irq_enabled ||
!installed_irq || !(usb_hw->ints & usb_hw->inte)) return;
servicing_interrupt = true;
if (usb_hw->ints & USB_INTS_SETUP_REQ_BITS) ++setup_irqs;
installed_irq();
usb_hw->ints = 0;
servicing_interrupt = false;
}
static void host_setup(uint8_t address, const tusb_control_request_t* request) {
assert(attached && physical_pullup());
uint8_t slot = route_address(address);
assert(slot == 0 && "root request must route through the published address table");
assert(native_hub_select_device(address,slot,UINT32_MAX / 2u));
memcpy(usb_dpram->setup_packet,request,sizeof(*request));
usb_hw->sie_status = USB_SIE_STATUS_SETUP_REC_BITS;
usb_hw->ints = USB_INTS_SETUP_REQ_BITS;
unsigned previous = setup_irqs;
native_test_service_interrupt();
assert(setup_irqs == previous+1u && "first host SETUP must reach the installed IRQ");
native_hub_task();
assert(!failed);
}
static void receive_descriptor(uint8_t type, uint16_t expected_length) {
uint8_t response[PACKET];
uint16_t length = 0;
assert(native_test_in(0,response,&length,true));
assert(length == expected_length && response[1] == type);
if (type == TUSB_DESC_DEVICE) {
assert(response[0] == 18 && response[4] == 9 && response[7] == PACKET);
} else {
assert(response[0] == 9 && response[2] == expected_length && response[3] == 0);
assert(response[4] == 1 && response[5] == 1);
}
assert(native_test_out(0,NULL,0,true));
assert(devices[0].control.stage == IDLE && !failed);
}
static void enumerate_at_attach(void) {
// Do not reset/reinitialize the transport here: that could repair the very
// startup state being tested. Deliver the first SETUP inside the physical
// attach write, before native_hub_init has even returned to its caller.
const tusb_control_request_t descriptor = {
.bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR,
.wValue = TUSB_DESC_DEVICE << 8, .wLength = 18,
};
host_setup(0,&descriptor);
receive_descriptor(TUSB_DESC_DEVICE,18);
const tusb_control_request_t set_address = {
.bmRequestType = 0, .bRequest = TUSB_REQ_SET_ADDRESS, .wValue = 9,
};
host_setup(0,&set_address);
assert(route_address(0) == 0 && route_address(9) == NONE);
uint8_t response[PACKET];
uint16_t length = 1;
assert(native_test_in(0,response,&length,true) && length == 0);
assert(route_address(9) == 0 && route_address(0) == NONE);
assert(usb_hw->dev_addr_ctrl == 9);
const tusb_control_request_t configuration = {
.bmRequestType = 0x80, .bRequest = TUSB_REQ_GET_DESCRIPTOR,
.wValue = TUSB_DESC_CONFIGURATION << 8, .wLength = 25,
};
host_setup(9,&configuration);
receive_descriptor(TUSB_DESC_CONFIGURATION,25);
enumeration_done = true;
}
static void observe_pullup(void) {
bool visible = physical_pullup();
if (visible == attached) return;
attached = visible;
if (!visible) {
++detach_edges;
return;
}
++attach_edges;
attach_time_us = native_test_time_us;
assert((usb_hw->main_ctrl & USB_MAIN_CTRL_CONTROLLER_EN_BITS) &&
"physical attach preceded controller readiness");
assert((usb_hw->sie_ctrl & USB_SIE_CTRL_EP0_INT_1BUF_BITS) &&
"physical attach preceded EP0 readiness");
assert(observer_ready && routing_enabled && addresses_published &&
"physical attach preceded observer/address routing readiness");
assert(routed_addresses[0] == 0 && routed_default == 0);
for (unsigned slot = 1; slot < PROBE_ROUTER_SLOTS; ++slot)
assert(routed_addresses[slot] == NONE);
assert(irq_enabled && installed_irq && !native_test_interrupt_mask &&
"physical attach preceded IRQ readiness");
assert((usb_hw->inte & (USB_INTS_SETUP_REQ_BITS | USB_INTS_BUFF_STATUS_BITS |
USB_INTS_BUS_RESET_BITS)) ==
(USB_INTS_SETUP_REQ_BITS | USB_INTS_BUFF_STATUS_BITS | USB_INTS_BUS_RESET_BITS));
assert(started && "physical attach preceded foreground/pending-IRQ readiness");
enumerate_at_attach();
}
static void startup_set_bits(volatile uint32_t* address, uint32_t bits) {
(hw_set_bits)(address,bits);
observe_pullup();
}
static void startup_clear_bits(volatile uint32_t* address, uint32_t bits) {
(hw_clear_bits)(address,bits);
observe_pullup();
}
static void startup_reset(uint32_t mask) {
assert(mask == RESETS_RESET_USBCTRL_BITS);
++controller_resets;
memset(usb_hw,0,sizeof(*usb_hw));
observe_pullup();
}
static void startup_launch(void (*entry)(void)) {
observe_pullup();
assert(observer_initialized && entry == probe_router_core1);
observer_launched = true;
}
static void startup_irq_handler(unsigned irq, void (*handler)(void)) {
observe_pullup();
assert(irq == USBCTRL_IRQ);
installed_irq = handler;
}
static void startup_irq_enable(unsigned irq, bool enabled) {
observe_pullup();
assert(irq == USBCTRL_IRQ);
irq_enabled = enabled;
native_test_service_interrupt();
}
static void startup_wait(void) {
observe_pullup();
assert(!attached && "host must remain detached throughout observer startup");
native_test_time_us += 1000u;
wait_us += 1000u;
assert(wait_us <= 100000u && "observer startup must have a bounded timeout");
}
void probe_router_init(uint32_t hz) {
observe_pullup();
assert(hz == 240000000u);
observer_initialized = true;
observer_start_us = native_test_time_us;
memset(routed_addresses,NONE,sizeof(routed_addresses));
routed_default = NONE;
}
void probe_router_core1(void) {}
void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot) {
observe_pullup();
memcpy(routed_addresses,values,sizeof(routed_addresses));
routed_default = slot;
addresses_published = true;
}
void probe_router_enable(bool enabled) {
observe_pullup();
routing_enabled = enabled;
}
bool probe_router_set_phase(uint32_t phase) {
(void)phase;
observe_pullup();
return true;
}
void probe_router_snapshot(probe_router_stats* snapshot) {
observe_pullup();
assert(observer_initialized && observer_launched);
if (!observer_never_ready && native_test_time_us-observer_start_us >= ready_delay_us)
observer_ready = true;
memset(snapshot,0,sizeof(*snapshot));
snapshot->ready = observer_ready;
}
bool tud_vendor_control_xfer_cb(uint8_t slot, uint8_t stage, const tusb_control_request_t* request) {
(void)slot; (void)stage; (void)request;
assert(false && "root standard enumeration must not invoke vendor handling");
return false;
}
void reset_usb_boot(uint32_t gpio_mask, uint32_t disable_mask) {
(void)gpio_mask; (void)disable_mask;
assert(false && "cold startup must not enter BOOTSEL");
abort();
}
int main(int argc, char** argv) {
assert(argc == 2);
if (strcmp(argv[1],"delayed") == 0) ready_delay_us = 75000u;
else if (strcmp(argv[1],"timeout") == 0) observer_never_ready = true;
else assert(strcmp(argv[1],"ready") == 0);
// No native_test_initialize/startup helper: call the actual initializer
// from cold BSS, rather than inheriting the transport fixture's ready state.
assert(!physical_pullup());
bool initialized = native_hub_init();
observe_pullup();
assert(controller_resets == 1 && detach_edges == 0);
if (observer_never_ready) {
assert(!initialized && !started && !physical_pullup());
assert(!attach_edges && !enumeration_done && !setup_irqs);
assert(wait_us == 100000u);
} else {
assert(initialized && attached && attach_edges == 1);
assert(enumeration_done && setup_irqs == 3);
assert(wait_us == ready_delay_us && attach_time_us-observer_start_us == ready_delay_us);
assert(startup_time == attach_time_us && !failed);
}
printf("native cold startup %s passed for %u children\n",argv[1],CHILDREN);
return 0;
}

View file

@ -15,6 +15,7 @@ uint16_t native_test_received_length[CHILDREN][2];
uint8_t native_test_received_data[CHILDREN][2][PACKET];
static bool servicing_interrupt;
#ifndef NATIVE_TEST_EXTERNAL_IRQ
void native_test_service_interrupt(void) {
if (native_test_interrupt_mask || servicing_interrupt || !usb_hw->ints) return;
servicing_interrupt = true;
@ -22,13 +23,16 @@ void native_test_service_interrupt(void) {
usb_hw->ints = 0;
servicing_interrupt = false;
}
#endif
#ifndef NATIVE_TEST_EXTERNAL_ROUTER
void probe_router_init(uint32_t hz) { (void)hz; }
void probe_router_core1(void) {}
void probe_router_publish(const uint8_t values[PROBE_ROUTER_SLOTS], uint8_t slot) { (void)values; (void)slot; }
void probe_router_enable(bool enabled) { (void)enabled; }
bool probe_router_set_phase(uint32_t phase) { (void)phase; return true; }
void probe_router_snapshot(probe_router_stats* snapshot) { memset(snapshot,0,sizeof(*snapshot)); snapshot->ready = 1; }
#endif
#ifndef NATIVE_TEST_EXTERNAL_LOG
int probe_debug_printf(const char* format, ...) { (void)format; return 0; }
#endif

View file

@ -76,6 +76,13 @@ void bluepad32_input_backend_native_sample_cancel(uint8_t instance) {
assert(instance < PROBE_CONTROLLER_COUNT);
cue_tokens[instance] = 0;
}
bool bluepad32_input_backend_native_rumble_submit(uint8_t, const uint8_t*, uint8_t) {
assert(false && "gameplay motor dispatch belongs to the native backend fixture");
return false;
}
void bluepad32_input_backend_native_rumble_cancel(uint8_t) {
assert(false && "gameplay motor cancellation belongs to the native backend fixture");
}
void bluepad32_input_backend_queue_profile_feedback(uint8_t, uint32_t, uint8_t, ControllerProfileConfirmationPolicy) {}
void controller_profile_runtime_reset() {
for (ControllerProfile& value : profiles)

View file

@ -680,7 +680,122 @@ static void test_indexed_memory(void) {
}
}
static void expect_invalid_rumble(uint8_t report_id, const uint8_t* data, size_t length) {
probe_rumble_frame output = {.count = 3, .magnitude = {17, 93, 241}};
assert(!probe_protocol_decode_rumble(report_id, data, length, &output));
assert(output.count == 3);
assert(output.magnitude[0] == 17 && output.magnitude[1] == 93 && output.magnitude[2] == 241);
}
static void test_native_rumble(void) {
// Independent block bytes from public rumble-procon-gccon.pcapng.gz,
// packets 9970 and 257200. These are Pro Controller report 02 LRA blocks;
// the report 01 wrapper below is synthetic, not a captured Joy-Con packet.
static const uint8_t captured_blocks[][16] = {
{0x50, 0x81, 0x01, 0x10, 0x1e, 0x00},
{0x52, 0x9f, 0x19, 0xe0, 0x9d, 0x00},
};
probe_rumble_frame output;
uint8_t wire[65];
for (unsigned i = 0; i < 2; ++i) {
memset(wire, 0xa5, sizeof(wire));
wire[0] = 0x01;
memcpy(wire + 1, captured_blocks[i], sizeof(captured_blocks[i]));
assert(probe_protocol_decode_rumble(0, wire, 64, &output));
assert(output.count == 1 && output.magnitude[0] == i);
assert(probe_protocol_decode_rumble(1, wire + 1, 63, &output));
assert(output.count == 1 && output.magnitude[0] == i);
}
// Manually specified byte boundaries, not an encoder/decoder roundtrip.
// Frequencies are both 1023: they must not leak into either amplitude,
// nor be rejected merely because this compatibility decoder ignores them.
static const struct {
uint8_t sample[5];
uint8_t expected;
} boundaries[] = {
{{0xff, 0x03, 0xf0, 0x3f, 0x00}, 0}, // amplitudes 0, 0
{{0xff, 0x0b, 0xf0, 0x3f, 0x00}, 0}, // 2, 0 rounds down
{{0xff, 0x03, 0xf0, 0xff, 0x00}, 1}, // 0, 3 rounds up
{{0xff, 0xff, 0xf0, 0x3f, 0x00}, 16}, // 63, 0
{{0xff, 0x03, 0xf1, 0x3f, 0x00}, 16}, // 64, 0
{{0xff, 0xff, 0xf7, 0x3f, 0x80}, 128}, // 511, 512
{{0xff, 0x03, 0xf8, 0xff, 0x7f}, 128}, // 512, 511
{{0xff, 0xff, 0xff, 0x3f, 0x00}, 255}, // 1023, 0
{{0xff, 0x03, 0xf0, 0xff, 0xff}, 255}, // 0, 1023
};
wire[1] = 0x5f;
for (unsigned i = 0; i < sizeof(boundaries) / sizeof(boundaries[0]); ++i) {
memcpy(wire + 2, boundaries[i].sample, 5);
assert(probe_protocol_decode_rumble(0, wire, 17, &output));
assert(output.count == 1 && output.magnitude[0] == boundaries[i].expected);
}
// Three distinguishable samples retain wire order; a shorter count ignores
// stale later samples. Both callback envelopes accept minimal/compact/USB sizes.
static const uint8_t ordered[16] = {
0x70,
0x00, 0xfc, 0x0f, 0x00, 0x00, // amplitudes 1023, 0 -> 255
0x00, 0x00, 0x00, 0xc0, 0x3f, // amplitudes 0, 255 -> 64
0xff, 0xff, 0xf7, 0x3f, 0x80, // amplitudes 511, 512 -> 128
};
static const size_t lengths[] = {17, 42, 64};
memcpy(wire + 1, ordered, sizeof(ordered));
for (unsigned count = 1; count <= 3; ++count) {
for (unsigned sequence = 0; sequence < 16; ++sequence) {
wire[1] = (uint8_t)(0x40u | (count << 4) | sequence);
for (unsigned i = 0; i < sizeof(lengths) / sizeof(lengths[0]); ++i) {
for (unsigned form = 0; form < 2; ++form) {
assert(probe_protocol_decode_rumble((uint8_t)form, wire + form,
lengths[i] - form, &output));
assert(output.count == count && output.magnitude[0] == 255);
if (count >= 2) assert(output.magnitude[1] == 64);
if (count == 3) assert(output.magnitude[2] == 128);
}
}
}
}
wire[1] = 0x4f; // HOLD: nonzero stale samples must not become a stop/update.
assert(probe_protocol_decode_rumble(0, wire, 17, &output));
assert(output.count == 0);
assert(probe_protocol_decode_rumble(1, wire + 1, 16, &output));
assert(output.count == 0);
// Complete 16-byte block required even for HOLD or a one-sample update.
for (unsigned count = 0; count <= 3; ++count) {
wire[1] = (uint8_t)(0x40u | (count << 4));
for (size_t length = 0; length < 17; ++length)
expect_invalid_rumble(0, wire, length);
for (size_t length = 0; length < 16; ++length)
expect_invalid_rumble(1, wire + 1, length);
}
expect_invalid_rumble(0, wire, 65);
expect_invalid_rumble(1, wire + 1, 64);
expect_invalid_rumble(0, wire, SIZE_MAX);
expect_invalid_rumble(1, wire + 1, SIZE_MAX);
expect_invalid_rumble(0, NULL, 64);
expect_invalid_rumble(1, NULL, 63);
assert(!probe_protocol_decode_rumble(0, wire, 64, NULL));
assert(!probe_protocol_decode_rumble(1, wire + 1, 63, NULL));
for (unsigned id = 0; id <= UINT8_MAX; ++id) {
if (id != 1) {
wire[0] = (uint8_t)id;
expect_invalid_rumble(0, wire, 64);
}
if (id > 1) expect_invalid_rumble((uint8_t)id, wire + 1, 63);
}
wire[0] = 1;
for (unsigned header = 0; header <= UINT8_MAX; ++header) {
if ((header & 0xc0u) == 0x40u) continue;
wire[1] = (uint8_t)header;
expect_invalid_rumble(0, wire, 64);
expect_invalid_rumble(1, wire + 1, 63);
}
}
int main(void) {
test_native_rumble();
test_indexed_memory();
test_descriptors();
for (unsigned side = 0; side < 2; ++side) {

View file

@ -53,13 +53,37 @@ def test_native_gamepad_backend_native(
check=True,
cwd=root,
)
scenarios = ["stable-logical-slot", "cue-lifetime", "cue-races"]
scenarios = [
"stable-logical-slot",
"cue-lifetime",
"cue-races",
"gameplay-timeline",
"gameplay-availability",
"gameplay-priority",
"gameplay-source-epochs",
"gameplay-profile-gain",
]
if controller_count == 2:
scenarios.append("source-isolation")
else:
scenarios.extend(("two-pair-sources", "two-pair-cues", "explicit-precedence"))
scenarios.extend(
(
"two-pair-sources",
"two-pair-cues",
"explicit-precedence",
"gameplay-two-pairs",
)
)
if source == "GAMEPAD":
scenarios.extend(("paired-source", "pair-cue-races", "mono-rumble"))
scenarios.extend(
(
"paired-source",
"pair-cue-races",
"mono-rumble",
"gameplay-paired-revision",
"gameplay-wii",
)
)
if controller_count == 2:
scenarios.extend(("sensorless-admission", "independent-motion"))
else:

View file

@ -93,3 +93,43 @@ def test_native_hub_management_native(
cwd=root,
)
subprocess.run([str(router_executable)], check=True, cwd=root)
@pytest.mark.parametrize("controller_count", [2, 4], ids=["one-pair", "two-pair"])
@pytest.mark.parametrize("trace_enabled", [False, True], ids=["plain", "trace"])
def test_native_hub_cold_startup(
tmp_path: Path, controller_count: int, trace_enabled: bool
) -> None:
root = Path(__file__).resolve().parents[1]
cc = shutil.which("cc") or shutil.which("gcc")
assert cc is not None, "a host C compiler is required"
executable = tmp_path / "native_hub_startup_test"
flags = [f"-DPROBE_CONTROLLER_COUNT={controller_count}"]
if trace_enabled:
flags.append("-DSWITCH2_PROBE_TRACE_NATIVE_INPUT=1")
subprocess.run(
[
cc,
"-std=c11",
"-Wall",
"-Wextra",
"-Werror",
"-pedantic",
"-ffunction-sections",
"-fdata-sections",
"-DSWITCH2_PROBE_HUB=1",
*flags,
f"-I{root / 'tests' / 'native_hub_stubs'}",
f"-I{root / 'src' / 'firmware'}",
f"-I{root / 'tools' / 'pico_usb_address_probe'}",
f"-I{root / 'tools' / 'switch2_usb_probe'}",
str(root / "tests" / "native_hub_startup_test.c"),
"-Wl,--gc-sections",
"-o",
str(executable),
],
check=True,
cwd=root,
)
for scenario in ("ready", "delayed", "timeout"):
subprocess.run([str(executable), scenario], check=True, cwd=root)

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.