From ae1ca51b9a9bf1f50352efe396324e638b703ff0 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 19 Sep 2026 08:20:47 -0600 Subject: [PATCH] feat(native-usb): route gameplay rumble and attach only after startup readiness --- README.md | 70 +++- .../input/bluepad32_input_backend.cpp | 190 ++++++++-- src/firmware/input/bluepad32_input_backend.h | 9 + src/firmware/usb/native_hub/native_hub.c | 10 +- .../usb/usb_configuration_management.cpp | 2 +- tests/native_gamepad_backend_test.cpp | 339 ++++++++++++++++++ tests/native_hub_startup_test.c | 270 ++++++++++++++ tests/native_hub_transport_fixture.c | 4 + tests/switch2_native_gamepad_bridge_test.cpp | 7 + tests/switch2_usb_probe_protocol_test.c | 115 ++++++ tests/test_native_gamepad_backend_native.py | 30 +- tests/test_native_hub_management_native.py | 40 +++ tools/switch2_usb_probe/controller_input.cpp | 11 + tools/switch2_usb_probe/controller_input.h | 7 +- tools/switch2_usb_probe/main.c | 38 +- tools/switch2_usb_probe/probe_build.cmake | 16 +- tools/switch2_usb_probe/protocol.c | 31 ++ tools/switch2_usb_probe/protocol.h | 12 + 18 files changed, 1158 insertions(+), 43 deletions(-) create mode 100644 tests/native_hub_startup_test.c diff --git a/README.md b/README.md index 5de8b59..6bcb4be 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/firmware/input/bluepad32_input_backend.cpp b/src/firmware/input/bluepad32_input_backend.cpp index 7dc81fa..6277e0a 100644 --- a/src/firmware/input/bluepad32_input_backend.cpp +++ b/src/firmware/input/bluepad32_input_backend.cpp @@ -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 #include #include @@ -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(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(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(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(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( - now_ms - slot.feedback_until_ms) < 0; + (slot.feedback_until_ms != 0 && static_cast( + 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 diff --git a/src/firmware/input/bluepad32_input_backend.h b/src/firmware/input/bluepad32_input_backend.h index 9767e4a..3557fb0 100644 --- a/src/firmware/input/bluepad32_input_backend.h +++ b/src/firmware/input/bluepad32_input_backend.h @@ -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 diff --git a/src/firmware/usb/native_hub/native_hub.c b/src/firmware/usb/native_hub/native_hub.c index 00dadfb..ffaa6d5 100644 --- a/src/firmware/usb/native_hub/native_hub.c +++ b/src/firmware/usb/native_hub/native_hub.c @@ -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 diff --git a/src/firmware/usb/usb_configuration_management.cpp b/src/firmware/usb/usb_configuration_management.cpp index fe741c3..717abcf 100644 --- a/src/firmware/usb/usb_configuration_management.cpp +++ b/src/firmware/usb/usb_configuration_management.cpp @@ -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, diff --git a/tests/native_gamepad_backend_test.cpp b/tests/native_gamepad_backend_test.cpp index 8369dfe..0eb5436 100644 --- a/tests/native_gamepad_backend_test.cpp +++ b/tests/native_gamepad_backend_test.cpp @@ -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 +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(); diff --git a/tests/native_hub_startup_test.c b/tests/native_hub_startup_test.c new file mode 100644 index 0000000..2cb0d46 --- /dev/null +++ b/tests/native_hub_startup_test.c @@ -0,0 +1,270 @@ +#include +#include +#include + +#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; +} diff --git a/tests/native_hub_transport_fixture.c b/tests/native_hub_transport_fixture.c index b4dbf8f..7e67f68 100644 --- a/tests/native_hub_transport_fixture.c +++ b/tests/native_hub_transport_fixture.c @@ -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 diff --git a/tests/switch2_native_gamepad_bridge_test.cpp b/tests/switch2_native_gamepad_bridge_test.cpp index 48b286f..2da1015 100644 --- a/tests/switch2_native_gamepad_bridge_test.cpp +++ b/tests/switch2_native_gamepad_bridge_test.cpp @@ -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) diff --git a/tests/switch2_usb_probe_protocol_test.c b/tests/switch2_usb_probe_protocol_test.c index cc5c5ec..3f6fa1d 100644 --- a/tests/switch2_usb_probe_protocol_test.c +++ b/tests/switch2_usb_probe_protocol_test.c @@ -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) { diff --git a/tests/test_native_gamepad_backend_native.py b/tests/test_native_gamepad_backend_native.py index c21fdcb..2aa50e7 100644 --- a/tests/test_native_gamepad_backend_native.py +++ b/tests/test_native_gamepad_backend_native.py @@ -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: diff --git a/tests/test_native_hub_management_native.py b/tests/test_native_hub_management_native.py index a6b2b43..acf39d8 100644 --- a/tests/test_native_hub_management_native.py +++ b/tests/test_native_hub_management_native.py @@ -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) diff --git a/tools/switch2_usb_probe/controller_input.cpp b/tools/switch2_usb_probe/controller_input.cpp index 490739b..a618ee3 100644 --- a/tools/switch2_usb_probe/controller_input.cpp +++ b/tools/switch2_usb_probe/controller_input.cpp @@ -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) { diff --git a/tools/switch2_usb_probe/controller_input.h b/tools/switch2_usb_probe/controller_input.h index 46e48b5..15b6801 100644 --- a/tools/switch2_usb_probe/controller_input.h +++ b/tools/switch2_usb_probe/controller_input.h @@ -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 diff --git a/tools/switch2_usb_probe/main.c b/tools/switch2_usb_probe/main.c index 1aaeaae..e46a19d 100644 --- a/tools/switch2_usb_probe/main.c +++ b/tools/switch2_usb_probe/main.c @@ -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 diff --git a/tools/switch2_usb_probe/probe_build.cmake b/tools/switch2_usb_probe/probe_build.cmake index 4bd0904..5d55de2 100644 --- a/tools/switch2_usb_probe/probe_build.cmake +++ b/tools/switch2_usb_probe/probe_build.cmake @@ -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) diff --git a/tools/switch2_usb_probe/protocol.c b/tools/switch2_usb_probe/protocol.c index 59864b3..955b629 100644 --- a/tools/switch2_usb_probe/protocol.c +++ b/tools/switch2_usb_probe/protocol.c @@ -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; +} diff --git a/tools/switch2_usb_probe/protocol.h b/tools/switch2_usb_probe/protocol.h index 15ea8d1..a11f678 100644 --- a/tools/switch2_usb_probe/protocol.h +++ b/tools/switch2_usb_probe/protocol.h @@ -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.