diff --git a/ADAPTER_PARITY_PLAN.md b/ADAPTER_PARITY_PLAN.md index a482943..f8aa9ee 100644 --- a/ADAPTER_PARITY_PLAN.md +++ b/ADAPTER_PARITY_PLAN.md @@ -67,20 +67,20 @@ The Bluetooth, UART, Switch, and XInput paths now share `ControllerState`: |---|---|---| | Bluetooth Classic and BLE | Complete | Bluepad32 supports both transports; bonds persist across reboot. | | Pairing gate, reconnect, list, and clear | Complete | Physical BOOTSEL flow and `switch-pico-config pairings` use the versioned management protocol. | -| Four concurrent controllers | Complete for Switch mode | Four independent USB interfaces and Bluetooth slots are implemented. | +| Four concurrent controllers | Complete for current output modes | Switch, XInput, DInput, and Mac expose four isolated interfaces; current host-specific limits remain qualification items. | | Switch input | Complete | Buttons, sticks, lifecycle, colors, and per-slot isolation are hardware-tested. | | Switch motion | Complete for supported parsers | DualSense, Switch-family, Wii accelerometer, PS Move, and compatible 8BitDo modes are normalized. | | Switch rumble | Complete for tested controllers | DualSense and 8BitDo Ultimate Bluetooth are hardware-tested; Ultimate requires enable, fixed LRA frequencies, and refresh. | | 8BitDo Ultimate reconnect | Complete | Bond preservation, scan restart, and four-second supervision timeout are implemented. | -| XInput descriptors and reports | Feasibility complete | Four-interface prototype works on Windows and is covered by native descriptor/report tests. | -| Automatic Windows/Switch selection | Feasibility complete | Windows enumeration fix is in `db4a860`; real Windows transition and rumble were reported working. | -| Windows feasibility test | Complete | `tools/Test-AdapterFeasibility.ps1` checks transition, PnP health, four XInput slots, controls, and rumble isolation. | +| XInput descriptors and reports | Complete with development identity | Production-neutral XInput driver and reconnect-safe XUSB binding pass four-slot Windows hardware tests; replace `CAFE:4010` before release. | +| Automatic Windows/Switch selection | Complete | `auto` preserves the verified Switch probe → XInput reboot and manual modes bypass probing. | +| Windows output tests | Complete for XInput and DInput | Real Windows passed four XInput slots with controls/rumble isolation and four DInput interfaces with complete input. | | Protocol-neutral controller state | Complete | `ControllerState` is shared by Bluetooth, UART, Switch, and XInput paths; analog trigger precision is retained. | | Persistent configuration protocol | Complete | Adapter settings and identity-keyed profiles use separate two-copy CRC/generation stores with bounded transactions and recovery. | | Production USB VID/PID | Missing | Prototype uses `CAFE:4010`; obtain an appropriate project VID/PID and repeat Windows binding tests. | -| DInput output | Missing | Add generic HID descriptor and report driver. | -| Mac output mode | Missing | Capture/define compatible descriptor and report semantics. | -| Manual output-mode selection | Missing | Add persistent PC command and controller chord. | +| DInput output | Complete, input-only | Four generic HID interfaces pass Linux and Windows controls; USB HID does not provide generic rumble. | +| Mac output mode | Complete, input-only | Four generic HID interfaces pass real macOS sticks, buttons, D-pad, and independent analog DualSense triggers. | +| Manual output-mode selection | Complete | Persistent PC command and three-second controller chord select auto/Switch/XInput/DInput/Mac; ten-second BOOTSEL reset restores auto. | | General button remapping | Complete | Sixteen positional logical inputs map directly to supported logical outputs per profile. | | Stick sensitivity | Complete | Per-stick center calibration, inner deadzone, outer saturation, fixed-point curve, and inversion run before every output serializer. | | Trigger ranges | Complete for current outputs | Per-trigger deadzone, saturation, curve, and digital threshold preserve analog XInput values and configured Switch thresholds. | @@ -415,7 +415,7 @@ Completion evidence: profile commits, button remapping, stick tuning, 15 Hz Turbo, a releasing macro, and transient profile-to-slot LED restoration -### Phase 4 — Production USB output modes +### Phase 4 — Production USB output modes — Complete This is shorter than Phase 3 because Switch and XInput already work, but it is not a small change: USB descriptors are fixed before `tusb_init()`, mode changes @@ -478,6 +478,36 @@ Final acceptance: supported mode - each mode passes real-target enumeration and complete input checks +Completion evidence: + +- all TinyUSB callbacks are centralized in one static output-driver boundary; + Switch, XInput, DInput, and Mac serializers consume the same Phase 3 output +- adapter configuration schema v2 migrates v1 to `auto`; selection is loaded + before `tusb_init()`, persists across power cycles, and reboots only after a + correlated atomic commit +- physical recovery reserves configuration ownership, clears pairings with an + exact monotonic completion token, restores `auto`, and avoids stale reboots +- XInput was promoted from feasibility naming and its Windows XUSB binding was + fixed for reconnect; real Windows passed four slots, controls, analog + triggers, and rumble isolation +- real Windows DInput passed four interfaces, buttons, D-pad, both sticks, + independent triggers, and reconnect; generic HID intentionally has no rumble +- real macOS passed four interfaces, both sticks, buttons, D-pad, and + independent analog DualSense triggers under project identity `CAFE:4021` +- DualSense/DS4 analog brake/throttle now outrank simultaneous digital trigger + bits; digital-only controllers retain full-scale fallback +- XInput carries Home as Guide `0x0400` and Capture as de-facto Share `0x0800`; + standard `XInputGetState` does not expose either portably +- the CYW43 HCI drain is bounded to 16 packets per poll so continuous + multi-controller traffic cannot starve application or controller-parser + timers; live diagnostics expose timer, report, queue, and dispatch counters +- hardware verified that DualSense and 8BitDo rumble both start and stop under + two-controller traffic, with timer counters continuing to advance +- the final cold Switch boot passed controls, motion, profiles, and rumble in + `auto` / Switch-probe mode +- 107 tests passed; UART, AIO, and feasibility artifacts linked and published +- release remains blocked on an appropriate production project VID/PID + ### Phase 5 — Controller compatibility Target hardware families: @@ -576,7 +606,7 @@ Do not mark a host/controller combination complete from descriptor inspection or ## Next action -Begin Phase 4 by placing the existing Switch and verified XInput -implementations behind one production output-driver boundary. Preserve the -Phase 3 transform/runtime seam before serialization, then add DInput and -Mac-compatible HID in that order. +Complete the final cold Switch regression with requested mode `auto`, then +begin Phase 5 model-level controller qualification. Before release, replace the +three `CAFE` development identities with an appropriate project VID/PID and +repeat Windows/macOS binding tests. diff --git a/CMakeLists.txt b/CMakeLists.txt index 961bb33..61e4f38 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -125,6 +125,7 @@ if(SWITCH_PICO_INPUT_BACKEND STREQUAL "BLUEPAD32") SWITCH_PICO_HID_INSTANCE_COUNT=4 SWITCH_PICO_USB_OUTPUT_MODES=1 PICO_FLASH_ASSUME_CORE1_SAFE=0 + PICO_BTSTACK_CYW43_MAX_HCI_PROCESS_LOOP_COUNT=16 ) else() target_compile_definitions(switch-pico PRIVATE diff --git a/README.md b/README.md index 1f182a1..edfa369 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,15 @@ Connect the Pico 2 W to the PC while the AIO firmware is running normally; do no ```sh uv run switch-pico-config status +uv run switch-pico-config diagnostics uv run switch-pico-config config show uv run switch-pico-config config set --pairing-window-seconds 90 uv run switch-pico-config config reset --yes +uv run switch-pico-config mode auto +uv run switch-pico-config mode switch +uv run switch-pico-config mode xinput +uv run switch-pico-config mode dinput +uv run switch-pico-config mode mac uv run switch-pico-config profiles list uv run switch-pico-config profiles export 1 profile.json --identity 0 uv run switch-pico-config profiles import 2 profile.json --identity 0 @@ -105,10 +111,21 @@ uv run switch-pico-config pairings clear --yes Adapter configuration records use version, size, generation, and CRC fields in two dedicated flash sectors. Profiles use a separate two-bank atomic store before the adapter and Bluepad32 bond regions. Profile writes are chunked, verified before the new bank is committed, recover the previous generation after interruption or corruption, skip unchanged data, and are rate-limited. +Output mode is selected before TinyUSB starts and never changes while mounted. A mode command atomically stores the selection, resets synthetic input, reboots, follows the same physical USB port through re-enumeration, and verifies requested versus active mode. `auto` uses the verified Switch probe → Windows XInput transition; manual modes bypass probing. The controller chord **L + R + Select + Start + System** held for three seconds cycles `auto → switch → xinput → dinput → mac → auto`. The destructive ten-second BOOTSEL pairing reset also restores `auto` before reboot, providing physical recovery. + +Development USB identities are `CAFE:4010` (XInput), `CAFE:4020` (DInput), and `CAFE:4021` (Mac). DInput and Mac expose four input-only generic HID interfaces and no rumble. Mac uses X/Y/Z/Rx sticks plus Simulation Brake/Accelerator triggers. Switch reports input, rumble, and motion capability; XInput reports input and rumble. + `profiles list` prints identity index `0` for the global fallback plus each stable Bluetooth identity observed by the firmware. Each identity owns four persistent profiles and one active index. Exported JSON contains direct logical button mappings, independent stick and trigger calibration/curves, digital trigger thresholds, weak/strong rumble scales, a profile-switching chord, one bounded eight-step macro, and per-button Turbo modes. Profile numbers shown to users are `1` through `4`; `--identity` uses the zero-based index from `profiles list`. `pairings list` refreshes and prints stored Bluetooth Classic and BLE addresses. `pairings clear --yes` deletes all bonds, disconnects active controllers, closes new authentication, and resumes discovery because no controllers remain. Destructive commands require `--yes`. If multiple compatible Picos are attached, select one with `--bus N --address N`; the error lists their locations. USB access errors require permission to the matching `/dev/bus/usb` device. +`diagnostics` reports Bluetooth initialization stage, real BTstack timer +callbacks, controller report traffic, host/local rumble requests and +dispatches, active/rumble-capable slot counts, and pending feedback. The AIO +build bounds each CYW43 HCI drain to 16 packets so continuous multi-controller +traffic returns to BTstack timers instead of starving rumble stop/refresh, +configuration, pairing, and profile work. + ### Per-controller profiles The default profile-switching chord is **L + R + Select + Start**. On DualSense, use **L1 + R1 + Create + Options**. A profile can replace this chord with any nonzero logical-button mask; a stored zero selects the default chord. @@ -195,6 +212,7 @@ Bluepad32 is Apache-2.0. BTstack use on Pico W/Pico 2 W is covered by Raspberry - No NFC/amiibo/IR support. - Rumble is best-effort: the UART build depends on SDL3 haptics; the AIO build depends on the connected controller's Bluepad32 rumble implementation. - The UART firmware requires a host computer running the bridge. The Pico 2 W AIO firmware does not; it hosts controllers over Bluetooth, not USB. +- In XInput output mode, Home/System is carried in the raw XUSB Guide bit `0x0400`, and Capture is carried in the de-facto Share/reserved bit `0x0800` used by modern open XUSB stacks. The standard Microsoft XInput headers define neither Guide nor Share for `XINPUT_GAMEPAD.wButtons`, so `XInputGetState` does not expose either button portably. Guide may be reserved or intercepted by the OS, while Share/Capture support depends on the installed driver or consumers such as GameInput and Steam; qualify the intended controller, driver, and application on real Windows hardware. ## Uses - **Remote couch co-op**: friends connect via Parsec while the host streams the Switch via a low-latency capture device (e.g., Magewell Pro Capture) and runs the bridge (see setup below). @@ -468,6 +486,7 @@ with SwitchUARTClient("/dev/cu.usbserial-0001") as client: ### Linux tips - You may need udev permissions for `/dev/ttyUSB*`/`/dev/ttyACM*` (add user to `dialout`/`uucp` or use `udev` rules). +- For the development XInput/DInput/Mac identities, install `udev/99-switch-pico.rules` into `/etc/udev/rules.d/`, reload udev, and reconnect the Pico so `switch-pico-config` can access endpoint zero without root. ## IMU / Motion Controls diff --git a/bluepad32_input_backend.cpp b/bluepad32_input_backend.cpp index 99ba1fc..bfd521e 100644 --- a/bluepad32_input_backend.cpp +++ b/bluepad32_input_backend.cpp @@ -199,6 +199,13 @@ uint16_t g_status_led_tick = 0; bool g_pairing_window_open = false; bool g_status_led_on = false; Bluepad32PairingSnapshot g_pairing_snapshot{}; +uint32_t g_initialization_stage = 0; +uint32_t g_rumble_timer_ticks = 0; +uint32_t g_configuration_timer_ticks = 0; +uint32_t g_controller_reports = 0; +uint32_t g_host_rumble_requests = 0; +uint32_t g_local_feedback_requests = 0; +uint32_t g_rumble_dispatches = 0; uint16_t host_rumble_duration_ms() { #ifdef SWITCH_PICO_USB_OUTPUT_MODES @@ -817,6 +824,7 @@ void queue_local_feedback(BackendSlot& slot, uint16_t duration_ms, slot.pending_feedback = { slot.connection_generation, duration_ms, weak_magnitude, strong_magnitude}; + __atomic_add_fetch(&g_local_feedback_requests, 1, __ATOMIC_RELAXED); } void reset_slot_hotkeys(BackendSlot& slot) { slot.motion_enabled = kDefaultMotionEnabled; @@ -1265,6 +1273,8 @@ void update_status_led() { } void process_configuration_timer(btstack_timer_source_t* timer) { + __atomic_add_fetch( + &g_configuration_timer_ticks, 1, __ATOMIC_RELAXED); btstack_run_loop_set_timer(timer, kConfigurationPollIntervalMs); btstack_run_loop_add_timer(timer); const uint32_t now_ms = btstack_run_loop_get_time_ms(); @@ -1273,6 +1283,7 @@ void process_configuration_timer(btstack_timer_source_t* timer) { } void process_rumble_timer(btstack_timer_source_t* timer) { + __atomic_add_fetch(&g_rumble_timer_ticks, 1, __ATOMIC_RELAXED); const uint32_t now_ms = btstack_run_loop_get_time_ms(); process_clear_pairings(now_ms); @@ -1446,16 +1457,22 @@ void process_rumble_timer(btstack_timer_source_t* timer) { } if (profile_rumble_dispatch && device != nullptr && device->report_parser.play_dual_rumble != nullptr) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); device->report_parser.play_dual_rumble( device, 0, kProfileFeedbackPhaseDurationMs, kProfileFeedbackWeakMagnitude, kProfileFeedbackStrongMagnitude); } else if (feedback_dispatch) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); device->report_parser.play_dual_rumble( device, 0, feedback.duration_ms, feedback.weak_magnitude, feedback.strong_magnitude); } else if (host_dispatch && device->report_parser.play_dual_rumble != nullptr) { + __atomic_add_fetch( + &g_rumble_dispatches, 1, __ATOMIC_RELAXED); const bool stop = envelope.rumble.low_frequency_magnitude == 0 && envelope.rumble.high_frequency_magnitude == 0; @@ -1503,6 +1520,7 @@ void platform_on_init_complete() { btstack_run_loop_set_timer( &g_configuration_timer, kConfigurationPollIntervalMs); btstack_run_loop_add_timer(&g_configuration_timer); + __atomic_store_n(&g_initialization_stage, 6, __ATOMIC_RELEASE); recompute_connection_status(); } @@ -1649,6 +1667,7 @@ void platform_on_controller_data(uni_hid_device_t* device, controller->klass != UNI_CONTROLLER_CLASS_GAMEPAD) { return; } + __atomic_add_fetch(&g_controller_reports, 1, __ATOMIC_RELAXED); uni_gamepad_t gamepad = controller->gamepad; const uint16_t pre_hotkey_button_mask = @@ -1707,11 +1726,14 @@ uni_platform* get_platform() { if (!flash_safe_execute_core_init()) { halt_wireless_backend(); } + __atomic_store_n(&g_initialization_stage, 2, __ATOMIC_RELEASE); configuration_service_initialize_on_storage_core(); profile_service_initialize_on_storage_core(); + __atomic_store_n(&g_initialization_stage, 3, __ATOMIC_RELEASE); if (cyw43_arch_init() != 0) { halt_wireless_backend(); } + __atomic_store_n(&g_initialization_stage, 4, __ATOMIC_RELEASE); cyw43_arch_gpio_put(CYW43_WL_GPIO_LED_PIN, true); g_status_led_on = true; @@ -1719,6 +1741,7 @@ uni_platform* get_platform() { if (uni_init(0, nullptr) != 0) { halt_wireless_backend(); } + __atomic_store_n(&g_initialization_stage, 5, __ATOMIC_RELEASE); btstack_run_loop_execute(); while (true) { @@ -1763,6 +1786,13 @@ void bluepad32_input_backend_init() { g_pairing_reset_feedback_deadline_ms = 0; g_pairing_window_open = false; g_initialized = true; + __atomic_store_n(&g_initialization_stage, 1, __ATOMIC_RELEASE); + __atomic_store_n(&g_rumble_timer_ticks, 0, __ATOMIC_RELAXED); + __atomic_store_n(&g_configuration_timer_ticks, 0, __ATOMIC_RELAXED); + __atomic_store_n(&g_controller_reports, 0, __ATOMIC_RELAXED); + __atomic_store_n(&g_host_rumble_requests, 0, __ATOMIC_RELAXED); + __atomic_store_n(&g_local_feedback_requests, 0, __ATOMIC_RELAXED); + __atomic_store_n(&g_rumble_dispatches, 0, __ATOMIC_RELAXED); } void bluepad32_input_backend_start() { @@ -1842,6 +1872,47 @@ void bluepad32_input_backend_pairing_snapshot( } +void bluepad32_input_backend_diagnostics( + Bluepad32BackendDiagnostics* out) { + if (out == nullptr) { + return; + } + *out = {}; + out->initialization_stage = + __atomic_load_n(&g_initialization_stage, __ATOMIC_ACQUIRE); + out->rumble_timer_ticks = + __atomic_load_n(&g_rumble_timer_ticks, __ATOMIC_RELAXED); + out->configuration_timer_ticks = + __atomic_load_n(&g_configuration_timer_ticks, __ATOMIC_RELAXED); + out->controller_reports = + __atomic_load_n(&g_controller_reports, __ATOMIC_RELAXED); + out->host_rumble_requests = + __atomic_load_n(&g_host_rumble_requests, __ATOMIC_RELAXED); + out->local_feedback_requests = + __atomic_load_n(&g_local_feedback_requests, __ATOMIC_RELAXED); + out->rumble_dispatches = + __atomic_load_n(&g_rumble_dispatches, __ATOMIC_RELAXED); + + critical_section_enter_blocking(&g_state_lock); + for (const BackendSlot& slot : g_slots) { + if (slot.active) { + ++out->active_slots; + } + if (slot.active && slot.device != nullptr && + slot.device->report_parser.play_dual_rumble != nullptr) { + ++out->rumble_capable_slots; + } + if (slot.feedback_pending || slot.profile_feedback.active || + slot.pending_profile_feedback_count != 0) { + ++out->feedback_pending_slots; + } + if (slot.rumble_pending) { + ++out->rumble_pending_slots; + } + } + critical_section_exit(&g_state_lock); +} + void bluepad32_input_backend_snapshot(uint8_t slot_index, Bluepad32SlotSnapshot* out) { if (out == nullptr) { @@ -1891,6 +1962,8 @@ void bluepad32_input_backend_queue_rumble( duration_ms}; slot.pending_rumble = envelope; slot.rumble_pending = true; + __atomic_add_fetch( + &g_host_rumble_requests, 1, __ATOMIC_RELAXED); if (duration_ms == kXInputHostRumbleDurationMs) { slot.retained_host_rumble = envelope; slot.retained_host_rumble_valid = true; diff --git a/bluepad32_input_backend.h b/bluepad32_input_backend.h index 6d8067e..220ec9d 100644 --- a/bluepad32_input_backend.h +++ b/bluepad32_input_backend.h @@ -62,6 +62,20 @@ struct Bluepad32SlotSnapshot { ControllerState state; }; +struct Bluepad32BackendDiagnostics { + uint32_t initialization_stage; + uint32_t rumble_timer_ticks; + uint32_t configuration_timer_ticks; + uint32_t controller_reports; + uint32_t host_rumble_requests; + uint32_t local_feedback_requests; + uint32_t rumble_dispatches; + uint8_t active_slots; + uint8_t rumble_capable_slots; + uint8_t feedback_pending_slots; + uint8_t rumble_pending_slots; +}; + void bluepad32_input_backend_init(); @@ -75,6 +89,8 @@ void bluepad32_input_backend_snapshot(uint8_t slot, void bluepad32_input_backend_request_pairing_snapshot(); void bluepad32_input_backend_pairing_snapshot( Bluepad32PairingSnapshot* out); +void bluepad32_input_backend_diagnostics( + Bluepad32BackendDiagnostics* out); void bluepad32_input_backend_report_sent(uint8_t slot); void bluepad32_input_backend_queue_rumble( uint8_t slot, const ControllerRumbleOutput& rumble); diff --git a/firmware/switch-pico-adapter-feasibility.elf b/firmware/switch-pico-adapter-feasibility.elf index 5691af9..f8b3088 100755 Binary files a/firmware/switch-pico-adapter-feasibility.elf and b/firmware/switch-pico-adapter-feasibility.elf differ diff --git a/firmware/switch-pico-adapter-feasibility.uf2 b/firmware/switch-pico-adapter-feasibility.uf2 index 4065007..fed55c0 100644 Binary files a/firmware/switch-pico-adapter-feasibility.uf2 and b/firmware/switch-pico-adapter-feasibility.uf2 differ diff --git a/firmware/switch-pico-aio.elf b/firmware/switch-pico-aio.elf index b7db55b..2ef08f6 100755 Binary files a/firmware/switch-pico-aio.elf and b/firmware/switch-pico-aio.elf differ diff --git a/firmware/switch-pico-aio.uf2 b/firmware/switch-pico-aio.uf2 index 84e05c1..fed55c0 100644 Binary files a/firmware/switch-pico-aio.uf2 and b/firmware/switch-pico-aio.uf2 differ diff --git a/firmware/switch-pico.elf b/firmware/switch-pico.elf index caa8707..f283c92 100755 Binary files a/firmware/switch-pico.elf and b/firmware/switch-pico.elf differ diff --git a/firmware/switch-pico.uf2 b/firmware/switch-pico.uf2 index 12157a0..3c906cc 100644 Binary files a/firmware/switch-pico.uf2 and b/firmware/switch-pico.uf2 differ diff --git a/pico_configuration_storage.cpp b/pico_configuration_storage.cpp index ee0b6d1..a63a5b6 100644 --- a/pico_configuration_storage.cpp +++ b/pico_configuration_storage.cpp @@ -15,6 +15,7 @@ constexpr size_t kConfigurationStorageSize = CONFIGURATION_STORAGE_COPY_COUNT * FLASH_SECTOR_SIZE; constexpr uint32_t kConfigurationStorageOffset = PICO_FLASH_BANK_STORAGE_OFFSET - kConfigurationStorageSize; +constexpr uint32_t kFlashSafeExecuteTimeoutMs = 5000; static_assert(PICO_FLASH_BANK_STORAGE_OFFSET >= kConfigurationStorageSize, "configuration storage offset underflows flash"); @@ -77,7 +78,7 @@ bool erase_storage(void*, uint8_t copy) { nullptr, }; return flash_safe_execute(perform_flash_mutation, &mutation, - UINT32_MAX) == PICO_OK; + kFlashSafeExecuteTimeoutMs) == PICO_OK; } bool program_storage(void*, uint8_t copy, size_t offset, @@ -94,7 +95,7 @@ bool program_storage(void*, uint8_t copy, size_t offset, data, }; return flash_safe_execute(perform_flash_mutation, &mutation, - UINT32_MAX) == PICO_OK; + kFlashSafeExecuteTimeoutMs) == PICO_OK; } } // namespace diff --git a/pico_profile_storage.cpp b/pico_profile_storage.cpp index 683270f..5b44551 100644 --- a/pico_profile_storage.cpp +++ b/pico_profile_storage.cpp @@ -18,6 +18,7 @@ constexpr uint32_t kConfigurationStorageOffset = PICO_FLASH_BANK_STORAGE_OFFSET - kConfigurationStorageSize; constexpr uint32_t kProfileStorageOffset = kConfigurationStorageOffset - PROFILE_STORAGE_TOTAL_SIZE; +constexpr uint32_t kFlashSafeExecuteTimeoutMs = 5000; static_assert(FLASH_SECTOR_SIZE == PROFILE_STORAGE_SECTOR_SIZE, "profile storage sector size does not match Pico flash"); @@ -132,7 +133,7 @@ bool replace_storage_bank(void*, uint8_t bank, }; return flash_safe_execute( perform_flash_bank_replacement, &replacement, - UINT32_MAX) == PICO_OK && + kFlashSafeExecuteTimeoutMs) == PICO_OK && replacement.replaced; } diff --git a/profile_service.cpp b/profile_service.cpp index 94e583e..b880900 100644 --- a/profile_service.cpp +++ b/profile_service.cpp @@ -9,6 +9,7 @@ namespace { constexpr uint32_t kMinimumCommitIntervalMs = 1000; +constexpr uint32_t kInternalTransactionIdMask = 0x80000000u; enum class PendingCommandType : uint8_t { kNone = 0, @@ -406,7 +407,9 @@ ConfigurationTransactionStatus profile_service_begin( g_transaction.snapshot.transaction_id = transaction_id; g_transaction.identity = identity; g_transaction.profile_index = profile_index; - if (transaction_id == 0 || !valid_identity(identity) || + if (transaction_id == 0 || + (transaction_id & kInternalTransactionIdMask) != 0 || + !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT || payload_size == 0) { g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; @@ -435,6 +438,9 @@ ConfigurationTransactionStatus profile_service_begin( ConfigurationTransactionStatus profile_service_append( uint32_t transaction_id, size_t offset, const uint8_t* data, size_t size) { + if ((transaction_id & kInternalTransactionIdMask) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } critical_section_enter_blocking(&g_lock); if (g_transaction.snapshot.status != ConfigurationTransactionStatus::kReceiving) { @@ -461,6 +467,9 @@ ConfigurationTransactionStatus profile_service_append( ConfigurationTransactionStatus profile_service_commit( uint32_t transaction_id) { + if ((transaction_id & kInternalTransactionIdMask) != 0) { + return ConfigurationTransactionStatus::kMalformed; + } critical_section_enter_blocking(&g_lock); if (g_transaction.snapshot.status != ConfigurationTransactionStatus::kReceiving || @@ -510,7 +519,9 @@ ConfigurationTransactionStatus profile_service_reset( g_transaction.snapshot.transaction_id = transaction_id; g_transaction.identity = identity; g_transaction.profile_index = profile_index; - if (transaction_id == 0 || !valid_identity(identity) || + if (transaction_id == 0 || + (transaction_id & kInternalTransactionIdMask) != 0 || + !valid_identity(identity) || (profile_index != CONTROLLER_PROFILE_ALL && profile_index >= CONTROLLER_PROFILE_COUNT)) { g_transaction.snapshot.status = @@ -547,7 +558,9 @@ ConfigurationTransactionStatus profile_service_activate( g_transaction.snapshot.transaction_id = transaction_id; g_transaction.identity = identity; g_transaction.profile_index = profile_index; - if (transaction_id == 0 || !valid_identity(identity) || + if (transaction_id == 0 || + (transaction_id & kInternalTransactionIdMask) != 0 || + !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { g_transaction.snapshot.status = ConfigurationTransactionStatus::kMalformed; @@ -579,7 +592,7 @@ ConfigurationTransactionStatus profile_service_activate_internal( critical_section_exit(&g_lock); return ConfigurationTransactionStatus::kBusy; } - if ((transaction_id & 0x80000000u) == 0 || + if ((transaction_id & kInternalTransactionIdMask) == 0 || !valid_identity(identity) || profile_index >= CONTROLLER_PROFILE_COUNT) { critical_section_exit(&g_lock); diff --git a/src/switch_pico_bridge/config_manager.py b/src/switch_pico_bridge/config_manager.py index cbe4405..6bc3397 100755 --- a/src/switch_pico_bridge/config_manager.py +++ b/src/switch_pico_bridge/config_manager.py @@ -47,6 +47,7 @@ OP_TRANSACTION_STATUS = 0x15 OP_PAIRING_READ = 0x20 OP_PAIRING_REFRESH = 0x21 OP_PAIRING_CLEAR = 0x22 +OP_RUNTIME_DIAGNOSTICS = 0x23 OP_PROFILE_LIST = 0x30 OP_PROFILE_SELECT = 0x31 OP_PROFILE_READ = 0x32 @@ -205,6 +206,20 @@ class DeviceInfo: return "input only" if names == ("input",) else ", ".join(names) +@dataclass(frozen=True) +class RuntimeDiagnostics: + initialization_stage: int + rumble_timer_ticks: int + configuration_timer_ticks: int + controller_reports: int + host_rumble_requests: int + local_feedback_requests: int + rumble_dispatches: int + active_slots: int + rumble_capable_slots: int + feedback_pending_slots: int + rumble_pending_slots: int + @dataclass(frozen=True) class AdapterConfiguration: pairing_window_seconds: int @@ -1350,6 +1365,20 @@ def read_info(device: UsbDevice) -> DeviceInfo: )[0], ) +def read_runtime_diagnostics(device: UsbDevice) -> RuntimeDiagnostics: + envelope = _control_in(device, OP_RUNTIME_DIAGNOSTICS) + _raise_status(envelope) + if len(envelope.payload) != 32: + raise ConfigManagerError("invalid runtime-diagnostics payload") + counters = struct.unpack_from("<7I", envelope.payload) + return RuntimeDiagnostics( + *counters, + active_slots=envelope.payload[28], + rumble_capable_slots=envelope.payload[29], + feedback_pending_slots=envelope.payload[30], + rumble_pending_slots=envelope.payload[31], + ) + def read_configuration(device: UsbDevice) -> AdapterConfiguration: envelope = _control_in(device, OP_CONFIGURATION_READ) @@ -1631,7 +1660,7 @@ def write_profile( if not isinstance(profile, ControllerProfile): raise ConfigManagerError("profile must be a ControllerProfile") payload = profile.to_bytes() - transaction_id = secrets.randbits(32) or 1 + transaction_id = _host_transaction_id() begin = ( struct.pack(" TransactionStatus: _validate_profile_index(profile_index) - transaction_id = secrets.randbits(32) or 1 + transaction_id = _host_transaction_id() _control_out( device, OP_PROFILE_ACTIVATE, @@ -2137,6 +2166,9 @@ def build_parser() -> argparse.ArgumentParser: ) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("status", help="show firmware and configuration status") + commands.add_parser( + "diagnostics", help="show live Bluetooth and rumble pipeline counters" + ) mode = commands.add_parser("mode", help="select the persistent USB mode") mode.add_argument("mode", choices=SELECTABLE_MODE_NAMES) @@ -2256,6 +2288,37 @@ def main(argv: Sequence[str] | None = None) -> int: "Pairing window: " f"{configuration.pairing_window_seconds} seconds" ) + elif args.command == "diagnostics": + diagnostics = read_runtime_diagnostics(device) + print(f"Initialization stage: {diagnostics.initialization_stage}") + print(f"Rumble timer ticks: {diagnostics.rumble_timer_ticks}") + print( + "Configuration timer ticks: " + f"{diagnostics.configuration_timer_ticks}" + ) + print(f"Controller reports: {diagnostics.controller_reports}") + print( + "Host rumble requests: " + f"{diagnostics.host_rumble_requests}" + ) + print( + "Local feedback requests: " + f"{diagnostics.local_feedback_requests}" + ) + print(f"Rumble dispatches: {diagnostics.rumble_dispatches}") + print(f"Active slots: {diagnostics.active_slots}") + print( + "Rumble-capable slots: " + f"{diagnostics.rumble_capable_slots}" + ) + print( + "Feedback-pending slots: " + f"{diagnostics.feedback_pending_slots}" + ) + print( + "Rumble-pending slots: " + f"{diagnostics.rumble_pending_slots}" + ) elif args.command == "mode": requested_mode = REQUESTED_MODE_NAMES.index(args.mode) _, changed = configure_mode(device, requested_mode, args.timeout) diff --git a/tests/profile_service_test.cpp b/tests/profile_service_test.cpp index dd132fc..7296fd6 100644 --- a/tests/profile_service_test.cpp +++ b/tests/profile_service_test.cpp @@ -143,7 +143,22 @@ void test_pending_commands_are_not_decoded_as_profile_writes() { active.profile_index == 0, "profile write did not publish one coherent generation"); - constexpr uint32_t kResetTransactionId = 0xa5a55a5a; + constexpr uint32_t kReservedInternalTransactionId = 0x80000019u; + require( + profile_service_begin( + kReservedInternalTransactionId, identity, kProfileIndex, + CONTROLLER_PROFILE_SCHEMA_VERSION, sizeof(encoded), + profile_storage_crc32(encoded, sizeof(encoded))) == + ConfigurationTransactionStatus::kMalformed && + profile_service_reset(kReservedInternalTransactionId, identity, + kProfileIndex) == + ConfigurationTransactionStatus::kMalformed && + profile_service_activate(kReservedInternalTransactionId, identity, + kProfileIndex) == + ConfigurationTransactionStatus::kMalformed, + "host profile mutations admitted the internal transaction namespace"); + + constexpr uint32_t kResetTransactionId = 0x25a55a5a; require(profile_service_reset(kResetTransactionId, identity, kProfileIndex) == ConfigurationTransactionStatus::kPending, @@ -233,6 +248,17 @@ void test_host_and_controller_mutations_are_serialized() { snapshot.transaction.status == ConfigurationTransactionStatus::kReceiving, "busy controller activation replaced the host transaction"); + require( + profile_service_append(kInternalTransactionId, 0, encoded, + sizeof(encoded)) == + ConfigurationTransactionStatus::kMalformed && + profile_service_commit(kInternalTransactionId) == + ConfigurationTransactionStatus::kMalformed && + transaction_snapshot().transaction.transaction_id == + kHostTransactionId && + transaction_snapshot().transaction.status == + ConfigurationTransactionStatus::kReceiving, + "reserved internal IDs corrupted a receiving host transaction"); require(profile_service_append( kHostTransactionId, 0, encoded, sizeof(encoded)) == diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index c44176a..ed3bfe7 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -226,6 +226,12 @@ class FakeDevice: ] ), ) + if request == config_manager.OP_RUNTIME_DIAGNOSTICS: + return make_response( + request, + struct.pack("<7I4B", 6, 1200, 120, 5000, 8, 2, 10, + 2, 2, 1, 1), + ) if request == config_manager.OP_CONFIGURATION_READ: return make_response( request, @@ -1010,6 +1016,20 @@ def test_requested_and_active_mode_response_validation() -> None: "rumble", "motion", ) + diagnostics = config_manager.read_runtime_diagnostics(device) + assert diagnostics == config_manager.RuntimeDiagnostics( + initialization_stage=6, + rumble_timer_ticks=1200, + configuration_timer_ticks=120, + controller_reports=5000, + host_rumble_requests=8, + local_feedback_requests=2, + rumble_dispatches=10, + active_slots=2, + rumble_capable_slots=2, + feedback_pending_slots=1, + rumble_pending_slots=1, + ) device.configuration = struct.pack(" None: device = FakeDevice() - generated_ids = iter((0, 0xA5A55A5A)) + generated_ids = iter((0, 0xFFFFFFFF)) monkeypatch.setattr( config_manager.secrets, "randbits", lambda _bits: next(generated_ids) ) @@ -1296,13 +1316,13 @@ def test_profile_reset_and_activate_wait_for_correlated_transactions( assert ( activated.transaction_id == device.profile_transaction_id - == 0xA5A55A5A + == config_manager.HOST_TRANSACTION_ID_MASK ) assert activated.status == config_manager.STATUS_OK assert activated.stored_generation == 9 assert device.profile_status_responses == [ - (0xA5A55A5A, config_manager.STATUS_PENDING), - (0xA5A55A5A, config_manager.STATUS_OK), + (config_manager.HOST_TRANSACTION_ID_MASK, config_manager.STATUS_PENDING), + (config_manager.HOST_TRANSACTION_ID_MASK, config_manager.STATUS_OK), ] assert device.active_profiles[identity.to_bytes()] == 3 diff --git a/tests/usb_configuration_management_test.cpp b/tests/usb_configuration_management_test.cpp index 2b3cfa7..2c23479 100644 --- a/tests/usb_configuration_management_test.cpp +++ b/tests/usb_configuration_management_test.cpp @@ -32,6 +32,7 @@ uint32_t reboot_transaction_id = 0; uint32_t reboot_call_count = 0; bool refresh_requested = false; bool clear_requested = false; +Bluepad32BackendDiagnostics current_diagnostics{}; std::vector control_payload; std::vector next_out_payload; uint32_t begin_transaction_id = 0; @@ -200,6 +201,23 @@ void test_vendor_requests() { control_payload[12] == 7, "pairing read did not use the versioned envelope"); + current_diagnostics = { + 6, 1200, 120, 5000, 8, 2, 10, 2, 2, 1, 1, + }; + request = setup_request( + Operation::kRuntimeDiagnostics, TUSB_DIR_IN, + kMaximumResponseSize); + require( + usb_configuration_management_vendor_control( + 0, CONTROL_STAGE_SETUP, &request) && + control_payload[5] == + static_cast(Operation::kRuntimeDiagnostics) && + read_u32(control_payload, kResponseHeaderSize) == 6 && + read_u32(control_payload, kResponseHeaderSize + 4) == 1200 && + control_payload[kResponseHeaderSize + 28] == 2 && + control_payload[kResponseHeaderSize + 31] == 1, + "runtime diagnostics did not expose backend counters"); + perform_out(Operation::kPairingRefresh, {}); require(refresh_requested, "pairing refresh was not dispatched"); @@ -733,6 +751,11 @@ void bluepad32_input_backend_pairing_snapshot( *out = current_pairings; } +void bluepad32_input_backend_diagnostics( + Bluepad32BackendDiagnostics* out) { + *out = current_diagnostics; +} + bool adapter_host_probe_vendor_control( uint8_t, uint8_t, const tusb_control_request_t*) { return false; diff --git a/tests/usb_output_driver_test.cpp b/tests/usb_output_driver_test.cpp index 319d75f..7ffdb78 100644 --- a/tests/usb_output_driver_test.cpp +++ b/tests/usb_output_driver_test.cpp @@ -172,6 +172,39 @@ void test_input_report_mapping() { report.right_y == 0, "neutral axes mismatch"); + ControllerState system_only{}; + system_only.button_system = true; + const auto system_report = XInput::build_input_report(system_only); + const std::array system_golden{ + 0x00, 0x14, 0x00, 0x04}; + expect(std::memcmp(&system_report, system_golden.data(), + system_golden.size()) == 0 && + system_report.buttons == XInput::kGuide, + "system button did not serialize as the Guide bit"); + + ControllerState capture_only{}; + capture_only.button_capture = true; + const auto capture_report = XInput::build_input_report(capture_only); + const std::array capture_golden{ + 0x00, 0x14, 0x00, 0x08}; + expect(std::memcmp(&capture_report, capture_golden.data(), + capture_golden.size()) == 0 && + capture_report.buttons == XInput::kShare, + "capture button did not serialize as the Share bit"); + + ControllerState system_and_capture{}; + system_and_capture.button_system = true; + system_and_capture.button_capture = true; + const auto combined_report = + XInput::build_input_report(system_and_capture); + const std::array combined_golden{ + 0x00, 0x14, 0x00, 0x0c}; + expect(std::memcmp(&combined_report, combined_golden.data(), + combined_golden.size()) == 0 && + combined_report.buttons == + static_cast(XInput::kGuide | XInput::kShare), + "Guide and Share bits collided in the serialized report"); + state.dpad_up = true; state.button_south = true; state.button_east = true; @@ -470,6 +503,8 @@ void test_xinput_boundary_dispatch() { ControllerState state{}; state.button_south = (instance & 1u) == 0; state.button_north = (instance & 1u) != 0; + state.button_system = true; + state.button_capture = true; state.left_trigger = static_cast(0x1000u * (instance + 1u)); state.right_trigger = @@ -490,6 +525,9 @@ void test_xinput_boundary_dispatch() { std::memcmp(input_endpoint.last_transfer.data(), &expected, sizeof(expected)) == 0, "XInput boundary changed an input report"); + expect((read_le16(input_endpoint.last_transfer.data() + 2) & + 0x0c00u) == 0x0c00u, + "XInput boundary dropped Guide or Share from wButtons"); } expect(!usb_output_driver_task(kInvalidInstance), "XInput task accepted an invalid instance"); diff --git a/tools/Test-AdapterFeasibility.ps1 b/tools/Test-AdapterFeasibility.ps1 index 5e94f03..d459ec0 100644 --- a/tools/Test-AdapterFeasibility.ps1 +++ b/tools/Test-AdapterFeasibility.ps1 @@ -247,6 +247,7 @@ if (-not $SkipInteractive) { Write-Host "" Write-Host ("For {0} seconds, exercise physical controller slot {1}:" -f $InputTestSeconds, ($index + 1)) -ForegroundColor Yellow Write-Host " D-pad, face buttons, both shoulders, both triggers, and both sticks." + Write-Host " Also press Home/Guide and Capture/Share to observe their raw, non-portable XUSB bits." [void](Read-Host "Press Enter to start capture") $before = Get-XInputState -Index $index @@ -261,6 +262,10 @@ if (-not $SkipInteractive) { LeftStick = $false RightStick = $false } + $observedRawSystemButtons = [ordered]@{ + GuideBit0x0400 = $false + ShareBit0x0800 = $false + } $captureDeadline = (Get-Date).AddSeconds($InputTestSeconds) while ((Get-Date) -lt $captureDeadline) { $after = Get-XInputState -Index $index @@ -270,6 +275,8 @@ if (-not $SkipInteractive) { if (($after.Buttons -band 0x000F) -ne 0) { $observedControls.DPad = $true } if (($after.Buttons -band 0xF000) -ne 0) { $observedControls.FaceButtons = $true } if (($after.Buttons -band 0x0300) -ne 0) { $observedControls.Shoulders = $true } + if (($after.Buttons -band 0x0400) -ne 0) { $observedRawSystemButtons.GuideBit0x0400 = $true } + if (($after.Buttons -band 0x0800) -ne 0) { $observedRawSystemButtons.ShareBit0x0800 = $true } if ($after.LeftTrigger -gt 0) { $observedControls.LeftTrigger = $true } if ($after.RightTrigger -gt 0) { $observedControls.RightTrigger = $true } if ([math]::Abs([int]$after.LeftThumbX) -gt 8000 -or @@ -295,10 +302,13 @@ if (-not $SkipInteractive) { LastPacket = $before.PacketNumber Observed = $observedControls Missing = $missingControls + RawSystemButtonBits = $observedRawSystemButtons } Add-Result -Name ("XInput slot {0} complete input" -f ($index + 1)) ` -Passed $inputPassed ` -Detail ("samples={0}, packet={1}, missing=[{2}]" -f $samples, $before.PacketNumber, ($missingControls -join ", ")) + Write-Host (" Raw wButtons observations (not portable XInput qualification): Guide 0x0400={0}, Share 0x0800={1}" -f ` + $observedRawSystemButtons.GuideBit0x0400, $observedRawSystemButtons.ShareBit0x0800) -ForegroundColor DarkCyan } for ($index = 0; $index -lt $ExpectedActiveControllers; $index++) { diff --git a/usb_configuration_management.cpp b/usb_configuration_management.cpp index 68be855..661173e 100644 --- a/usb_configuration_management.cpp +++ b/usb_configuration_management.cpp @@ -162,6 +162,26 @@ size_t encode_info(uint8_t* output, size_t output_size) { payload, sizeof(payload), output, output_size); } +size_t encode_runtime_diagnostics(uint8_t* output, size_t output_size) { + Bluepad32BackendDiagnostics diagnostics{}; + bluepad32_input_backend_diagnostics(&diagnostics); + uint8_t payload[32]{}; + write_u32(&payload[0], diagnostics.initialization_stage); + write_u32(&payload[4], diagnostics.rumble_timer_ticks); + write_u32(&payload[8], diagnostics.configuration_timer_ticks); + write_u32(&payload[12], diagnostics.controller_reports); + write_u32(&payload[16], diagnostics.host_rumble_requests); + write_u32(&payload[20], diagnostics.local_feedback_requests); + write_u32(&payload[24], diagnostics.rumble_dispatches); + payload[28] = diagnostics.active_slots; + payload[29] = diagnostics.rumble_capable_slots; + payload[30] = diagnostics.feedback_pending_slots; + payload[31] = diagnostics.rumble_pending_slots; + return encode_response(Operation::kRuntimeDiagnostics, Status::kOk, + 0, 0, 0, payload, sizeof(payload), output, + output_size); +} + } // namespace bool decode_request(Operation setup_operation, const uint8_t* input, @@ -581,6 +601,10 @@ bool usb_configuration_management_vendor_control( snapshot, response, sizeof(response)); break; } + case Operation::kRuntimeDiagnostics: + response_size = + encode_runtime_diagnostics(response, sizeof(response)); + break; case Operation::kProfileList: { ProfileServiceListSnapshot snapshot{}; profile_service_list_snapshot(&snapshot); diff --git a/usb_configuration_management.h b/usb_configuration_management.h index bb223dc..15c7562 100644 --- a/usb_configuration_management.h +++ b/usb_configuration_management.h @@ -41,6 +41,7 @@ enum class Operation : uint8_t { kPairingRead = 0x20, kPairingRefresh = 0x21, kPairingClear = 0x22, + kRuntimeDiagnostics = 0x23, kProfileList = 0x30, kProfileSelect = 0x31, kProfileRead = 0x32, diff --git a/xinput_protocol.h b/xinput_protocol.h index 03ca0a9..ca49c54 100644 --- a/xinput_protocol.h +++ b/xinput_protocol.h @@ -18,6 +18,7 @@ constexpr uint16_t kRightThumb = 0x0080; constexpr uint16_t kLeftShoulder = 0x0100; constexpr uint16_t kRightShoulder = 0x0200; constexpr uint16_t kGuide = 0x0400; +constexpr uint16_t kShare = 0x0800; constexpr uint16_t kButtonA = 0x1000; constexpr uint16_t kButtonB = 0x2000; constexpr uint16_t kButtonX = 0x4000; @@ -60,6 +61,7 @@ inline InputReport build_input_report(const ControllerState& state) { (state.button_left_shoulder ? kLeftShoulder : 0) | (state.button_right_shoulder ? kRightShoulder : 0) | (state.button_system ? kGuide : 0) | + (state.button_capture ? kShare : 0) | (state.button_south ? kButtonA : 0) | (state.button_east ? kButtonB : 0) | (state.button_west ? kButtonX : 0) |